Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ✨ magical ✨ patch resource #112

Merged
merged 9 commits into from
Apr 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 0 additions & 2 deletions api/v1/config/crd/eno.azure.io_resourceslices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@ spec:
type: boolean
manifest:
type: string
reconcileInterval:
type: string
type: object
type: array
type: object
Expand Down
21 changes: 15 additions & 6 deletions api/v1/config/crd/eno.azure.io_synthesizers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,21 @@ spec:
name: v1
schema:
openAPIV3Schema:
description: |-
Synthesizers are any process that can run in a Kubernetes container that implements the [KRM Functions Specification](https://github.com/kubernetes-sigs/kustomize/blob/master/cmd/config/docs/api-conventions/functions-spec.md).


Synthesizer processes are given some metadata about the composition they are synthesizing, and are expected
to return a set of Kubernetes resources. Essentially they generate the desired state for a set of Kubernetes resources.
description: "Synthesizers are any process that can run in a Kubernetes container
that implements the [KRM Functions Specification](https://github.com/kubernetes-sigs/kustomize/blob/master/cmd/config/docs/api-conventions/functions-spec.md).\n\n\nSynthesizer
processes are given some metadata about the composition they are synthesizing,
and are expected\nto return a set of Kubernetes resources. Essentially they
generate the desired state for a set of Kubernetes resources.\n\n\nA special
resource can be returned from synthesizers: `eno.azure.io/v1.Patch`.\nExample:\n\n\n```yaml\n\n\n\t
# - Nothing will happen if the resource doesn't exist\n\t # - Patches are
only applied when they would result in a change\n\t # - Deleting the Patch
will not delete the referenced resource\n\t\tapiVersion: eno.azure.io/v1\n\t\tkind:
Patch\n\t\tmetadata:\n\t\t\tname: resource-to-be-patched\n\t\t\tnamespace:
default\n\t\tpatch:\n\t\t\tapiVersion: v1\n\t\t\tkind: ConfigMap\n\t\t\tops:
# standard jsonpatch operations\n\t\t\t - { \"op\": \"add\", \"path\":
\"/data/hello\", \"value\": \"world\" }\n\t\t\t - { \"op\": \"add\", \"path\":
\"/metadata/deletionTimestamp\", \"value\": \"anything\" } # setting any
deletion timestamp will delete the resource\n\n\n```"
properties:
apiVersion:
description: |-
Expand Down
2 changes: 0 additions & 2 deletions api/v1/resourceslice.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ type Manifest struct {

// Deleted is true when this manifest represents a "tombstone" - a resource that should no longer exist.
Deleted bool `json:"deleted,omitempty"`

ReconcileInterval *metav1.Duration `json:"reconcileInterval,omitempty"`
}

type ResourceSliceStatus struct {
Expand Down
22 changes: 22 additions & 0 deletions api/v1/synthesizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,28 @@ type SynthesizerList struct {
// Synthesizer processes are given some metadata about the composition they are synthesizing, and are expected
// to return a set of Kubernetes resources. Essentially they generate the desired state for a set of Kubernetes resources.
//
// A special resource can be returned from synthesizers: `eno.azure.io/v1.Patch`.
AYM1607 marked this conversation as resolved.
Show resolved Hide resolved
// Example:
//
// ```yaml
//
// # - Nothing will happen if the resource doesn't exist
// # - Patches are only applied when they would result in a change
// # - Deleting the Patch will not delete the referenced resource
// apiVersion: eno.azure.io/v1
// kind: Patch
// metadata:
// name: resource-to-be-patched
// namespace: default
// patch:
// apiVersion: v1
// kind: ConfigMap
// ops: # standard jsonpatch operations
// - { "op": "add", "path": "/data/hello", "value": "world" }
// - { "op": "add", "path": "/metadata/deletionTimestamp", "value": "anything" } # setting any deletion timestamp will delete the resource
//
// ```
//
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster
Expand Down
9 changes: 1 addition & 8 deletions api/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,32 @@ Synthesizer processes are given some metadata about the composition they are syn
to return a set of Kubernetes resources. Essentially they generate the desired state for a set of Kubernetes resources.


A special resource can be returned from synthesizers: `eno.azure.io/v1.Patch`.
Example:


```yaml


# - Nothing will happen if the resource doesn't exist
# - Patches are only applied when they would result in a change
# - Deleting the Patch will not delete the referenced resource
apiVersion: eno.azure.io/v1
kind: Patch
metadata:
name: resource-to-be-patched
namespace: default
patch:
apiVersion: v1
kind: ConfigMap
ops: # standard jsonpatch operations
- { "op": "add", "path": "/data/hello", "value": "world" }
- { "op": "add", "path": "/metadata/deletionTimestamp", "value": "anything" } # setting any deletion timestamp will delete the resource


```





Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.21
toolchain go1.21.5

require (
github.com/evanphx/json-patch/v5 v5.9.0
github.com/go-logr/logr v1.4.1
github.com/go-logr/zapr v1.3.0
github.com/google/cel-go v0.20.1
Expand Down Expand Up @@ -53,7 +54,6 @@ require (
github.com/docker/go-metrics v0.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.12.0 // indirect
github.com/evanphx/json-patch v5.9.0+incompatible // indirect
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
Expand Down
49 changes: 30 additions & 19 deletions internal/controllers/reconciliation/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,17 +111,16 @@ func (c *Controller) Reconcile(ctx context.Context, req *reconstitution.Request)

// Keep track of the last reconciliation time and report on it relative to the resource's reconcile interval
// This is useful for identifying cases where the loop can't keep up
if resource.Manifest.ReconcileInterval != nil {
if resource.ReconcileInterval != nil {
observation := resource.ObserveReconciliation()
if observation > 0 {
delta := observation - resource.Manifest.ReconcileInterval.Duration
delta := observation - resource.ReconcileInterval.Duration
reconciliationScheduleDelta.Observe(delta.Seconds())
}
}

// Fetch the current resource
apiVersion, _ := resource.GVK.ToAPIVersionAndKind()
current, hasChanged, err := c.getCurrent(ctx, resource, apiVersion)
current, hasChanged, err := c.getCurrent(ctx, resource)
if client.IgnoreNotFound(err) != nil {
return ctrl.Result{}, fmt.Errorf("getting current state: %w", err)
}
Expand Down Expand Up @@ -163,8 +162,8 @@ func (c *Controller) Reconcile(ctx context.Context, req *reconstitution.Request)
if ready == nil {
return ctrl.Result{RequeueAfter: wait.Jitter(c.readinessPollInterval, 0.1)}, nil
}
if resource != nil && !resource.Deleted() && resource.Manifest.ReconcileInterval != nil {
return ctrl.Result{RequeueAfter: wait.Jitter(resource.Manifest.ReconcileInterval.Duration, 0.1)}, nil
if resource != nil && !resource.Deleted() && resource.ReconcileInterval != nil {
return ctrl.Result{RequeueAfter: wait.Jitter(resource.ReconcileInterval.Duration, 0.1)}, nil
}
return ctrl.Result{}, nil
}
Expand All @@ -185,19 +184,20 @@ func (c *Controller) reconcileResource(ctx context.Context, comp *apiv1.Composit
}

reconciliationActions.WithLabelValues("delete").Inc()
obj, err := resource.Parse()
if err != nil {
return false, fmt.Errorf("invalid resource: %w", err)
}
err = c.upstreamClient.Delete(ctx, obj)
err := c.upstreamClient.Delete(ctx, current)
if err != nil {
return false, client.IgnoreNotFound(fmt.Errorf("deleting resource: %w", err))
}
logger.V(0).Info("deleted resource")
return true, nil
}

// Always create the resource when it doesn't exist
if resource.Patch != nil && current == nil {
logger.V(1).Info("resource doesn't exist - skipping patch")
return false, nil
}

// Create the resource when it doesn't exist
if current == nil {
reconciliationActions.WithLabelValues("create").Inc()
obj, err := resource.Parse()
Expand All @@ -218,9 +218,11 @@ func (c *Controller) reconcileResource(ctx context.Context, comp *apiv1.Composit
if err != nil {
return false, fmt.Errorf("building patch: %w", err)
}
patch, err = mungePatch(patch, current.GetResourceVersion())
if err != nil {
return false, fmt.Errorf("adding resource version: %w", err)
if patchType != types.JSONPatchType {
patch, err = mungePatch(patch, current.GetResourceVersion())
if err != nil {
return false, fmt.Errorf("adding resource version: %w", err)
}
}
if len(patch) == 0 {
logger.V(1).Info("skipping empty patch")
Expand All @@ -240,6 +242,14 @@ func (c *Controller) reconcileResource(ctx context.Context, comp *apiv1.Composit
}

func (c *Controller) buildPatch(ctx context.Context, prev, resource *reconstitution.Resource, current *unstructured.Unstructured) ([]byte, types.PatchType, error) {
if resource.Patch != nil {
if !resource.NeedsToBePatched(current) {
return []byte{}, types.JSONPatchType, nil
}
patch, err := json.Marshal(&resource.Patch)
return patch, types.JSONPatchType, err
}

var prevManifest []byte
if prev != nil {
prevManifest = []byte(prev.Manifest.Manifest)
Expand Down Expand Up @@ -270,13 +280,13 @@ func (c *Controller) buildPatch(ctx context.Context, prev, resource *reconstitut
return patch, types.StrategicMergePatchType, err
}

func (c *Controller) getCurrent(ctx context.Context, resource *reconstitution.Resource, apiVersion string) (*unstructured.Unstructured, bool, error) {
func (c *Controller) getCurrent(ctx context.Context, resource *reconstitution.Resource) (*unstructured.Unstructured, bool, error) {
if resource.HasBeenSeen() && !resource.Deleted() {
meta := &metav1.PartialObjectMetadata{}
meta.Name = resource.Ref.Name
meta.Namespace = resource.Ref.Namespace
meta.Kind = resource.Ref.Kind
meta.APIVersion = apiVersion
meta.Kind = resource.GVK.Kind
meta.APIVersion = resource.GVK.GroupVersion().String()
err := c.upstreamClient.Get(ctx, client.ObjectKeyFromObject(meta), meta)
if err != nil {
return nil, false, err
Expand All @@ -291,7 +301,8 @@ func (c *Controller) getCurrent(ctx context.Context, resource *reconstitution.Re
current.SetName(resource.Ref.Name)
current.SetNamespace(resource.Ref.Namespace)
current.SetKind(resource.Ref.Kind)
current.SetAPIVersion(apiVersion)
current.SetKind(resource.GVK.Kind)
current.SetAPIVersion(resource.GVK.GroupVersion().String())
err := c.upstreamClient.Get(ctx, client.ObjectKeyFromObject(current), current)
if err != nil {
return nil, true, err
Expand Down