Skip to content

Commit 0ca35bb

Browse files
committed
Alternative approach with modification of desired state
Signed-off-by: Yury Tsarev <yury@upbound.io>
1 parent 546a0fc commit 0ca35bb

File tree

8 files changed

+387
-223
lines changed

8 files changed

+387
-223
lines changed

README.md

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ A Crossplane Composition Function for implementing manual approval workflows.
77
The `function-approve` provides a serverless approval mechanism at the Crossplane level that:
88

99
1. Tracks changes to a specified field by computing a hash
10-
2. Pauses reconciliation when changes are detected (using either pause annotation or Synced=False condition)
11-
3. Requires explicit approval before allowing reconciliation to continue
10+
2. When changes need approval, overwrites Desired state with Observed state to prevent changes
11+
3. Requires explicit approval before allowing changes to proceed
1212
4. Prevents drift by storing previously approved state
1313

1414
This function implements the approval workflow using entirely Crossplane-native mechanisms without external dependencies, making it lightweight and reliable.
@@ -30,9 +30,9 @@ spec:
3030
3131
1. When a resource is created or updated, `function-approve` calculates a hash of the monitored field (e.g., `spec.resources`).
3232
2. The function stores this hash in `status.newHash` (or specified field).
33-
3. If there's a previous approved hash (`status.oldHash`) and it doesn't match the new hash, reconciliation is paused.
33+
3. If there's a previous approved hash (`status.oldHash`) and it doesn't match the new hash, the function replaces Desired state with Observed state.
3434
4. An operator must approve the change by setting `status.approved = true`.
35-
5. After approval, the new hash is stored as the approved hash, the approval flag is reset, and reconciliation resumes.
35+
5. After approval, the new hash is stored as the approved hash, the approval flag is reset, and changes are allowed to proceed.
3636
6. If a customer modifies an existing claim after approval, this will generate a new hash, requiring another approval.
3737

3838
## Example
@@ -70,10 +70,8 @@ spec:
7070
| `approvalField` | string | Status field to check for approval. Default: `status.approved` |
7171
| `oldHashField` | string | Status field to store previously approved hash. Default: `status.oldHash` |
7272
| `newHashField` | string | Status field to store current hash. Default: `status.newHash` |
73-
| `pauseAnnotation` | string | Annotation to use for pausing reconciliation. Default: `crossplane.io/paused` |
7473
| `detailedCondition` | bool | Whether to add detailed information to conditions. Default: `true` |
7574
| `approvalMessage` | string | Message to display when approval is required. Default: `Changes detected. Approval required.` |
76-
| `setSyncedFalse` | bool | Use Synced=False condition instead of pause annotation. Default: `false` |
7775

7876
## Using with Custom Resources
7977

@@ -119,16 +117,15 @@ spec:
119117

120118
## Approving Changes
121119

122-
When changes are detected, the resource's reconciliation is paused, and its condition will show `ApprovalRequired` status. To approve the changes, patch the resource's status:
120+
When changes are detected, the Desired state is replaced with Observed state (preventing any changes from being applied), and the resource will show an `ApprovalRequired` condition. To approve the changes, patch the resource's status:
123121

124122
```yaml
125123
kubectl patch xapproval example --type=merge --subresource=status -p '{"status":{"approved":true}}'
126124
```
127125

128126
After approval, the function will:
129127
1. Record the new state as the approved state
130-
2. Remove the pause annotation
131-
3. Allow reconciliation to continue
128+
2. Allow the changes to proceed normally without overwriting the Desired state
132129

133130
## Resetting Approval State
134131

@@ -143,31 +140,20 @@ kubectl patch xapproval example --type=merge --subresource=status -p '{"status":
143140
- Use RBAC to control who can approve changes by restricting access to the status subresource
144141
- Consider implementing additional verification steps or multi-party approval in your workflow
145142

146-
## Pausing Reconciliation Methods
143+
## How Changes Are Prevented
147144

148-
The function supports two methods to pause reconciliation when changes are detected:
145+
The function uses a direct approach to prevent changes when approval is required:
149146

150-
### 1. Pause Annotation (Default)
147+
1. When changes are detected but not yet approved, the function:
148+
- Replaces the Desired composite resource with the Observed resource
149+
- Replaces any Desired composed resources with the Observed resources
150+
- Sets an ApprovalRequired condition for visibility
151151

152-
By default, the function adds the `crossplane.io/paused` annotation (or specified annotation) to pause reconciliation:
153-
154-
```yaml
155-
pauseAnnotation: "crossplane.io/paused"
156-
setSyncedFalse: false # Or omit this field as it defaults to false
157-
```
158-
159-
### 2. Synced=False Condition
160-
161-
Alternatively, the function can set the Synced condition to False instead of using an annotation:
162-
163-
```yaml
164-
setSyncedFalse: true
165-
```
166-
167-
This approach may be preferred in environments where:
168-
- Annotations are subject to stricter validation or policies
169-
- You want to leverage Crossplane's native condition-based reconciliation control
170-
- You need better integration with monitoring systems that use conditions
152+
2. This approach has several benefits:
153+
- Deterministic behavior - changes are physically prevented
154+
- No reliance on pause annotations or condition interpretation by the reconciler
155+
- Works consistently across different Crossplane versions
156+
- Clear separation of approval status and reconciliation mechanics
171157

172158
## Complete Example
173159

@@ -196,7 +182,6 @@ spec:
196182
oldHashField: "status.approvedHash"
197183
detailedCondition: true
198184
approvalMessage: "Cluster changes require admin approval"
199-
setSyncedFalse: true # Use Synced=False condition instead of pause annotation
200185
- step: create-resources
201186
functionRef:
202187
name: function-patch-and-transform

example/composition.yaml

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,6 @@ spec:
88
kind: XApproval
99
mode: Pipeline
1010
pipeline:
11-
- step: require-approval
12-
functionRef:
13-
name: function-approve
14-
input:
15-
apiVersion: approve.fn.crossplane.io/v1alpha1
16-
kind: Input
17-
dataField: "spec.resources"
18-
approvalField: "status.approved"
19-
newHashField: "status.newHash"
20-
oldHashField: "status.oldHash"
21-
pauseAnnotation: "crossplane.io/paused"
22-
detailedCondition: true
23-
approvalMessage: "Changes detected. Administrative approval required."
24-
setSyncedFalse: true
2511
- step: render-resources
2612
functionRef:
2713
name: function-patch-and-transform
@@ -41,3 +27,15 @@ spec:
4127
fromFieldPath: Required
4228
readinessChecks:
4329
- type: None
30+
- step: require-approval
31+
functionRef:
32+
name: function-approve
33+
input:
34+
apiVersion: approve.fn.crossplane.io/v1alpha1
35+
kind: Input
36+
dataField: "spec.resources"
37+
approvalField: "status.approved"
38+
newHashField: "status.newHash"
39+
oldHashField: "status.oldHash"
40+
detailedCondition: true
41+
approvalMessage: "Changes detected. Administrative approval required."

example/functions.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ metadata:
66
annotations:
77
render.crossplane.io/runtime: Development
88
spec:
9-
package: xpkg.upbound.io/upbound/function-approve:v0.1.0
9+
#package: xpkg.upbound.io/upbound/function-approve:v0.1.0
10+
package: xpkg.upbound.io/upbound/function-approve:v0.0.0-20250521103443-546a0fc537a8
1011
---
1112
apiVersion: pkg.crossplane.io/v1beta1
1213
kind: Function

fn.go

Lines changed: 34 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -130,17 +130,17 @@ func (f *Function) handleUnapprovedChanges(req *fnv1.RunFunctionRequest, in *v1b
130130
"Approve this change by setting " + *in.ApprovalField + " to true"
131131
}
132132

133-
// Changes detected and not approved, pause reconciliation
134-
if err := f.pauseReconciliation(req, in, rsp); err != nil {
133+
// Changes detected and not approved, overwrite Desired State with Observed State
134+
if err := f.overwriteDesiredWithObserved(req, rsp); err != nil {
135135
return err
136136
}
137137

138-
// After pauseReconciliation, add ApprovalRequired condition
138+
// Add ApprovalRequired condition for visibility
139139
response.ConditionFalse(rsp, "ApprovalRequired", "WaitingForApproval").
140140
WithMessage(detailedMsg).
141141
TargetCompositeAndClaim()
142142

143-
f.log.Info("Pausing reconciliation asking for approval", "message", msg, "setSyncedFalse", *in.SetSyncedFalse)
143+
f.log.Info("Preventing desired state changes until approval", "message", msg)
144144
return nil
145145
}
146146

@@ -152,10 +152,7 @@ func (f *Function) handleApprovedChanges(req *fnv1.RunFunctionRequest, in *v1bet
152152
return err
153153
}
154154

155-
// Remove pause annotation if it exists
156-
if err := f.resumeReconciliation(req, in, rsp); err != nil {
157-
return err
158-
}
155+
// No need to modify desired state when approved - let changes proceed
159156

160157
// Set success condition
161158
response.ConditionTrue(rsp, "FunctionSuccess", "Success").
@@ -194,21 +191,11 @@ func (f *Function) parseInput(req *fnv1.RunFunctionRequest, rsp *fnv1.RunFunctio
194191
in.NewHashField = &defaultField
195192
}
196193

197-
if in.PauseAnnotation == nil {
198-
defaultAnnotation := "crossplane.io/paused"
199-
in.PauseAnnotation = &defaultAnnotation
200-
}
201-
202194
if in.DetailedCondition == nil {
203195
defaultValue := true
204196
in.DetailedCondition = &defaultValue
205197
}
206198

207-
if in.SetSyncedFalse == nil {
208-
defaultValue := false
209-
in.SetSyncedFalse = &defaultValue
210-
}
211-
212199
return in, nil
213200
}
214201

@@ -409,14 +396,22 @@ func SetNestedValue(root map[string]interface{}, key string, value interface{})
409396
return nil
410397
}
411398

412-
// extractDataToHash extracts the data to hash from the specified field
399+
// extractDataToHash extracts the data to hash from either the desired state or specified field
413400
func (f *Function) extractDataToHash(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) (interface{}, error) {
414-
oxr, err := request.GetObservedCompositeResource(req)
401+
dxr, err := request.GetDesiredCompositeResource(req)
415402
if err != nil {
416-
response.Fatal(rsp, errors.Wrap(err, "cannot get observed composite resource"))
403+
response.Fatal(rsp, errors.Wrap(err, "cannot get desired composite resource"))
417404
return nil, err
418405
}
419406

407+
// Extract from desired resources
408+
// Check if desired composed resources exist first
409+
if drs := req.GetDesired().GetResources(); len(drs) > 0 {
410+
f.log.Debug("Calculating hash of desired composed resources")
411+
return req.GetDesired().GetResources(), nil
412+
}
413+
414+
// Fallback to specific field if desired resources aren't available
420415
// Determine if we're looking in spec or status
421416
parts := strings.SplitN(in.DataField, ".", 2)
422417
if len(parts) != 2 {
@@ -427,8 +422,8 @@ func (f *Function) extractDataToHash(req *fnv1.RunFunctionRequest, in *v1beta1.I
427422
section, field := parts[0], parts[1]
428423

429424
var sectionData map[string]interface{}
430-
if err := oxr.Resource.GetValueInto(section, &sectionData); err != nil {
431-
response.Fatal(rsp, errors.Wrapf(err, "cannot get %s from observed XR", section))
425+
if err := dxr.Resource.GetValueInto(section, &sectionData); err != nil {
426+
response.Fatal(rsp, errors.Wrapf(err, "cannot get %s from desired XR", section))
432427
return nil, err
433428
}
434429

@@ -598,92 +593,29 @@ func (f *Function) checkApprovalStatus(req *fnv1.RunFunctionRequest, in *v1beta1
598593
return boolValue, nil
599594
}
600595

601-
// pauseReconciliation pauses resource reconciliation
602-
func (f *Function) pauseReconciliation(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) error {
603-
// Check if we should use Synced=False condition instead of annotation
604-
if in.SetSyncedFalse != nil && *in.SetSyncedFalse {
605-
// Set Synced condition to False with high priority to pause reconciliation
606-
f.log.Info("Setting Synced=False condition to pause reconciliation")
607-
rsp.Conditions = nil // Clear any existing conditions to ensure ours takes precedence
608-
response.ConditionFalse(rsp, "Synced", "ReconciliationPaused").
609-
WithMessage("Resource synchronization paused due to pending approval").
610-
TargetCompositeAndClaim()
611-
612-
return nil
613-
}
614-
615-
// Otherwise use the pause annotation as before
616-
_, dxr, err := f.getXRAndStatus(req)
617-
if err != nil {
618-
response.Fatal(rsp, err)
619-
return err
620-
}
621-
622-
// Get current annotations
623-
annotations := dxr.Resource.GetAnnotations()
624-
if annotations == nil {
625-
annotations = map[string]string{}
626-
}
627-
628-
// Add pause annotation
629-
annotations[*in.PauseAnnotation] = "true"
630-
dxr.Resource.SetAnnotations(annotations)
631-
632-
// Save the updated desired composite resource
633-
if err := response.SetDesiredCompositeResource(rsp, dxr); err != nil {
634-
response.Fatal(rsp, errors.Wrapf(err, "cannot set desired composite resource in %T", rsp))
635-
return err
636-
}
637-
638-
return nil
639-
}
640-
641-
// resumeReconciliation removes pause mechanisms from the resource
642-
func (f *Function) resumeReconciliation(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) error {
643-
// If using Synced=False condition, set Synced=True to resume
644-
if in.SetSyncedFalse != nil && *in.SetSyncedFalse {
645-
f.log.Info("Setting Synced=True condition to resume reconciliation")
646-
647-
// Find and remove any existing Synced=False conditions
648-
var filteredConditions []*fnv1.Condition
649-
for _, c := range rsp.GetConditions() {
650-
if c.GetType() != "Synced" {
651-
filteredConditions = append(filteredConditions, c)
652-
}
653-
}
654-
rsp.Conditions = filteredConditions
655-
656-
// Set Synced condition to True to resume reconciliation
657-
response.ConditionTrue(rsp, "Synced", "ReconciliationResumed").
658-
WithMessage("Resource synchronization resumed after approval").
659-
TargetCompositeAndClaim()
660-
661-
return nil
662-
}
663-
664-
// Otherwise remove the pause annotation as before
665-
_, dxr, err := f.getXRAndStatus(req)
596+
// overwriteDesiredWithObserved overwrites the desired state with the observed state to prevent changes
597+
func (f *Function) overwriteDesiredWithObserved(req *fnv1.RunFunctionRequest, rsp *fnv1.RunFunctionResponse) error {
598+
// Get observed composite resource
599+
oxr, err := request.GetObservedCompositeResource(req)
666600
if err != nil {
667-
response.Fatal(rsp, err)
668-
return err
601+
return errors.Wrap(err, "cannot get observed composite resource")
669602
}
670603

671-
// Get current annotations
672-
annotations := dxr.Resource.GetAnnotations()
673-
if annotations == nil || annotations[*in.PauseAnnotation] == "" {
674-
// No pause annotation, nothing to do
675-
return nil
604+
// Set the observed state as the desired state
605+
if err := response.SetDesiredCompositeResource(rsp, oxr); err != nil {
606+
return errors.Wrap(err, "cannot set desired composite resource with observed state")
676607
}
677608

678-
// Remove pause annotation
679-
delete(annotations, *in.PauseAnnotation)
680-
dxr.Resource.SetAnnotations(annotations)
609+
// If there are any desired composed resources, clear them out
610+
// We're going to just use the observed state for everything
611+
if len(req.GetDesired().GetResources()) > 0 {
612+
// Get the observed resources map
613+
observedResources := req.GetObserved().GetResources()
681614

682-
// Save the updated desired composite resource
683-
if err := response.SetDesiredCompositeResource(rsp, dxr); err != nil {
684-
response.Fatal(rsp, errors.Wrapf(err, "cannot set desired composite resource in %T", rsp))
685-
return err
615+
// Set the response's desired resources to match observed
616+
rsp.Desired.Resources = observedResources
686617
}
687618

619+
f.log.Info("Overwrote desired state with observed state until approval")
688620
return nil
689621
}

0 commit comments

Comments
 (0)