internal/resource/resource.go (280 lines of code) (raw):
package resource
import (
"bytes"
"context"
"encoding/json"
"fmt"
"hash/fnv"
"maps"
"reflect"
"slices"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
apiv1 "github.com/Azure/eno/api/v1"
"github.com/Azure/eno/internal/readiness"
jsonpatch "github.com/evanphx/json-patch/v5"
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/api/equality"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)
var patchGVK = schema.GroupVersionKind{
Group: "eno.azure.io",
Version: "v1",
Kind: "Patch",
}
// Ref refers to a specific synthesized resource.
type Ref struct {
Name, Namespace, Group, Kind string
}
func (r *Ref) String() string {
return fmt.Sprintf("(%s.%s)/%s/%s", r.Group, r.Kind, r.Namespace, r.Name)
}
// ManifestRef references a particular resource manifest within a resource slice.
type ManifestRef struct {
Slice types.NamespacedName
Index int // position of this manifest within the slice
}
// Resource is the controller's internal representation of a single resource out of a ResourceSlice.
type Resource struct {
Ref Ref
ManifestDeleted bool
ManifestRef ManifestRef
ManifestHash []byte
ReconcileInterval *metav1.Duration
GVK schema.GroupVersionKind
ReadinessChecks readiness.Checks
Patch jsonpatch.Patch
DisableUpdates bool
Replace bool
ReadinessGroup int
Labels map[string]string
// DefinedGroupKind is set on CRDs to represent the resource type they define.
DefinedGroupKind *schema.GroupKind
parsed *unstructured.Unstructured
latestKnownState atomic.Pointer[apiv1.ResourceState]
}
func (r *Resource) Deleted(comp *apiv1.Composition) bool {
return (comp.DeletionTimestamp != nil && !comp.ShouldOrphanResources()) || r.ManifestDeleted || (r.Patch != nil && r.patchSetsDeletionTimestamp())
}
func (r *Resource) Unstructured() *unstructured.Unstructured { return r.parsed.DeepCopy() }
func (r *Resource) State() *apiv1.ResourceState { return r.latestKnownState.Load() }
func (r *Resource) NeedsToBePatched(current *unstructured.Unstructured) bool {
if r.Patch == nil || current == nil {
return false
}
curjson, err := current.MarshalJSON()
if err != nil {
return false
}
patchedjson, err := r.Patch.Apply(curjson)
if err != nil {
return false
}
patched := &unstructured.Unstructured{}
err = patched.UnmarshalJSON(patchedjson)
if err != nil {
return false
}
return !equality.Semantic.DeepEqual(current, patched)
}
func (r *Resource) patchSetsDeletionTimestamp() bool {
if r.Patch == nil {
return false
}
// Apply the patch to a minimally-viable unstructured resource.
// This is needed to satisfy the validation logic of the unstructured json parser, which requires a kind/apiVersion.
patchedjson, err := r.Patch.Apply([]byte(`{"apiVersion": "eno.azure.io/v1", "kind":"PatchPlaceholder", "metadata":{}}`))
if err != nil {
return false
}
patched := map[string]any{}
err = json.Unmarshal(patchedjson, &patched)
if err != nil {
return false
}
dt, _, _ := unstructured.NestedString(patched, "metadata", "deletionTimestamp")
return dt != ""
}
func NewResource(ctx context.Context, slice *apiv1.ResourceSlice, index int) (*Resource, error) {
logger := logr.FromContextOrDiscard(ctx)
resource := slice.Spec.Resources[index]
res := &Resource{
ManifestDeleted: resource.Deleted,
ManifestRef: ManifestRef{
Slice: types.NamespacedName{
Namespace: slice.Namespace,
Name: slice.Name,
},
Index: index,
},
}
hash := fnv.New64()
hash.Write([]byte(resource.Manifest))
res.ManifestHash = hash.Sum(nil)
parsed := &unstructured.Unstructured{}
res.parsed = parsed
err := parsed.UnmarshalJSON([]byte(resource.Manifest))
if err != nil {
return nil, fmt.Errorf("invalid json: %w", err)
}
// Prune out the status/creation time.
// This is a pragmatic choice to make Eno behave in expected ways for synthesizers written using client-go structs,
// which set metadata.creationTime=null and status={}.
if parsed.Object != nil {
delete(parsed.Object, "status")
parsed.SetCreationTimestamp(metav1.Time{})
}
gvk := parsed.GroupVersionKind()
res.GVK = gvk
res.Ref.Name = parsed.GetName()
res.Ref.Namespace = parsed.GetNamespace()
res.Ref.Group = parsed.GroupVersionKind().Group
res.Ref.Kind = parsed.GetKind()
logger = logger.WithValues("resourceKind", parsed.GetKind(), "resourceName", parsed.GetName(), "resourceNamespace", parsed.GetNamespace())
if res.Ref.Name == "" || res.Ref.Kind == "" || parsed.GetAPIVersion() == "" {
return nil, fmt.Errorf("missing name, kind, or apiVersion")
}
if res.GVK == patchGVK {
obj := struct {
Patch patchMeta `json:"patch"`
}{}
err = json.Unmarshal([]byte(resource.Manifest), &obj)
if err != nil {
return nil, fmt.Errorf("parsing patch json: %w", err)
}
gv, err := schema.ParseGroupVersion(obj.Patch.APIVersion)
if err != nil {
return nil, fmt.Errorf("parsing patch apiVersion: %w", err)
}
res.GVK.Group = gv.Group
res.GVK.Version = gv.Version
res.GVK.Kind = obj.Patch.Kind
res.Patch = obj.Patch.Ops
}
if res.GVK.Group == "apiextensions.k8s.io" && res.GVK.Kind == "CustomResourceDefinition" {
res.DefinedGroupKind = &schema.GroupKind{}
res.DefinedGroupKind.Group, _, _ = unstructured.NestedString(parsed.Object, "spec", "group")
res.DefinedGroupKind.Kind, _, _ = unstructured.NestedString(parsed.Object, "spec", "names", "kind")
}
res.Labels = maps.Clone(parsed.GetLabels())
anno := parsed.GetAnnotations()
if anno == nil {
anno = map[string]string{}
}
const reconcileIntervalKey = "eno.azure.io/reconcile-interval"
if str, ok := anno[reconcileIntervalKey]; ok {
reconcileInterval, err := time.ParseDuration(str)
if anno[reconcileIntervalKey] != "" && err != nil {
logger.V(0).Info("invalid reconcile interval - ignoring")
}
res.ReconcileInterval = &metav1.Duration{Duration: reconcileInterval}
}
const disableUpdatesKey = "eno.azure.io/disable-updates"
res.DisableUpdates = anno[disableUpdatesKey] == "true"
const replaceKey = "eno.azure.io/replace"
res.Replace = anno[replaceKey] == "true"
const readinessGroupKey = "eno.azure.io/readiness-group"
if str, ok := anno[readinessGroupKey]; ok {
rg, err := strconv.Atoi(str)
if err != nil {
logger.V(0).Info("invalid readiness group - ignoring")
} else {
res.ReadinessGroup = rg
}
}
for key, value := range anno {
if !strings.HasPrefix(key, "eno.azure.io/readiness") || key == readinessGroupKey {
continue
}
name := strings.TrimPrefix(key, "eno.azure.io/readiness-")
if name == "eno.azure.io/readiness" {
name = "default"
}
check, err := readiness.ParseCheck(value)
if err != nil {
logger.Error(err, "invalid cel expression")
continue
}
check.Name = name
res.ReadinessChecks = append(res.ReadinessChecks, check)
}
sort.Slice(res.ReadinessChecks, func(i, j int) bool { return res.ReadinessChecks[i].Name < res.ReadinessChecks[j].Name })
parsed.SetAnnotations(pruneMetadata(parsed.GetAnnotations()))
parsed.SetLabels(pruneMetadata(parsed.GetLabels()))
return res, nil
}
func pruneMetadata(m map[string]string) map[string]string {
maps.DeleteFunc(m, func(key string, value string) bool {
return strings.HasPrefix(key, "eno.azure.io/")
})
if len(m) == 0 {
m = nil
}
return m
}
// Less returns true when r < than.
// Used to establish determinstic ordering for conflicting resources.
func (r *Resource) Less(than *Resource) bool {
return bytes.Compare(r.ManifestHash, than.ManifestHash) < 0
}
type patchMeta struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Ops jsonpatch.Patch `json:"ops"`
}
func NewInputRevisions(obj client.Object, refKey string) *apiv1.InputRevisions {
ir := apiv1.InputRevisions{
Key: refKey,
ResourceVersion: obj.GetResourceVersion(),
}
if rev, _ := strconv.Atoi(obj.GetAnnotations()["eno.azure.io/revision"]); rev != 0 {
ir.Revision = &rev
}
if rev, _ := strconv.ParseInt(obj.GetAnnotations()["eno.azure.io/synthesizer-generation"], 10, 64); rev != 0 {
ir.SynthesizerGeneration = &rev
}
return &ir
}
// MergeEnoManagedFields merges Eno managed fields from the overrides slice onto the current.
// All non-Eno managed fields from the current slice are preserved.
func MergeEnoManagedFields(current, overrides []metav1.ManagedFieldsEntry) (copy []metav1.ManagedFieldsEntry) {
for _, cur := range overrides {
if cur.Manager == "eno" {
copy = append(copy, cur)
break
}
}
for _, cur := range current {
if cur.Manager != "eno" {
copy = append(copy, cur)
}
}
return
}
// CompareEnoManagedFields returns true when the Eno managed fields in both slices are equal.
func CompareEnoManagedFields(a, b []metav1.ManagedFieldsEntry) bool {
cmp := func(cur metav1.ManagedFieldsEntry) bool { return cur.Manager == "eno" }
ai := slices.IndexFunc(a, cmp)
ab := slices.IndexFunc(b, cmp)
if ai == -1 && ab == -1 {
return true
}
if ai == -1 || ab == -1 {
return false
}
return reflect.DeepEqual(a[ai], b[ab])
}
// Compare compares two unstructured resources while ignoring:
// - Managed field metadata not controlled by Eno
// - Resource version
// - Generation
// - Status
//
// This should only be used to compare canonical apiserver representations of the resource
// i.e. the unmodified response from gets or patch/update dryruns.
func Compare(a, b *unstructured.Unstructured) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
if !CompareEnoManagedFields(a.GetManagedFields(), b.GetManagedFields()) {
return false
}
return reflect.DeepEqual(stripInsignificantFields(a), stripInsignificantFields(b))
}
func stripInsignificantFields(u *unstructured.Unstructured) *unstructured.Unstructured {
u = u.DeepCopy()
u.SetManagedFields(nil)
u.SetUID(types.UID(""))
u.SetResourceVersion("")
u.SetGeneration(0)
delete(u.Object, "status")
return u
}