Skip to content

Commit 0d69580

Browse files
authored
Merge pull request #81 from negz/fatalistic
Don't delete composed resources when we hit an error
2 parents aa352e7 + a7c2fe8 commit 0d69580

12 files changed

+1058
-1261
lines changed

example/composition.yaml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,9 @@ spec:
1919
base:
2020
apiVersion: s3.aws.upbound.io/v1beta1
2121
kind: Bucket
22-
spec:
23-
forProvider:
24-
region: us-east-2
2522
patches:
2623
- type: FromCompositeFieldPath
27-
fromFieldPath: "location"
24+
fromFieldPath: "spec.location"
2825
toFieldPath: "spec.forProvider.region"
2926
transforms:
3027
- type: map

fn.go

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
88

99
"github.com/crossplane/crossplane-runtime/pkg/errors"
10+
"github.com/crossplane/crossplane-runtime/pkg/fieldpath"
1011
"github.com/crossplane/crossplane-runtime/pkg/logging"
1112
"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
1213

@@ -114,10 +115,14 @@ func (f *Function) RunFunction(ctx context.Context, req *fnv1beta1.RunFunctionRe
114115
}
115116

116117
if input.Environment != nil {
117-
// Run all patches that are from the (observed) XR to the environment or from the environment to the (desired) XR.
118-
if err := RenderEnvironmentPatches(env, oxr.Resource, dxr.Resource, input.Environment.Patches); err != nil {
119-
response.Fatal(rsp, errors.Wrapf(err, "cannot render ToEnvironment patches from the composite resource"))
120-
return rsp, nil
118+
// Run all patches that are from the (observed) XR to the environment or
119+
// from the environment to the (desired) XR.
120+
for i := range input.Environment.Patches {
121+
p := &input.Environment.Patches[i]
122+
if err := ApplyEnvironmentPatch(p, env, oxr.Resource, dxr.Resource); err != nil {
123+
response.Fatal(rsp, errors.Wrapf(err, "cannot apply the %q environment patch at index %d", p.GetType(), i))
124+
return rsp, nil
125+
}
121126
}
122127
}
123128

@@ -155,8 +160,8 @@ func (f *Function) RunFunction(ctx context.Context, req *fnv1beta1.RunFunctionRe
155160
}
156161
}
157162

158-
ocd, ok := observed[resource.Name(t.Name)]
159-
if ok {
163+
ocd, exists := observed[resource.Name(t.Name)]
164+
if exists {
160165
existing++
161166
log.Debug("Resource template corresponds to existing composed resource", "metadata-name", ocd.Resource.GetName())
162167

@@ -192,17 +197,56 @@ func (f *Function) RunFunction(ctx context.Context, req *fnv1beta1.RunFunctionRe
192197
"name", ocd.Resource.GetName())
193198
}
194199

195-
errs, store := RenderComposedPatches(ocd.Resource, dcd.Resource, oxr.Resource, dxr.Resource, env, t.Patches)
196-
for _, err := range errs {
197-
response.Warning(rsp, errors.Wrapf(err, "cannot render patches for composed resource %q", t.Name))
198-
log.Info("Cannot render patches for composed resource", "warning", err)
199-
warnings++
200+
// Run all patches that are to a desired composed resource, or from an
201+
// observed composed resource.
202+
skip := false
203+
for i := range t.Patches {
204+
p := &t.Patches[i]
205+
if err := ApplyComposedPatch(p, ocd.Resource, dcd.Resource, oxr.Resource, dxr.Resource, env); err != nil {
206+
if fieldpath.IsNotFound(err) {
207+
// This is a patch from a required field path that does not
208+
// exist. The point of FromFieldPathPolicyRequired is to
209+
// block creation of the new 'to' resource until the 'from'
210+
// field path exists.
211+
//
212+
// The only kind of resource we could be patching to that
213+
// might not exist at this point is a composed resource. So
214+
// if we're patching to a composed resource that doesn't
215+
// exist we want to avoid creating it. Otherwise, we just
216+
// treat the patch from a required field path the same way
217+
// we'd treat a patch from an optional field path and skip
218+
// it.
219+
if p.GetPolicy().GetFromFieldPathPolicy() == v1beta1.FromFieldPathPolicyRequired {
220+
if ToComposedResource(p) && !exists {
221+
response.Warning(rsp, errors.Wrapf(err, "not adding new composed resource %q to desired state because %q patch at index %d has 'policy.fromFieldPath: Required'", t.Name, p.GetType(), i))
222+
223+
// There's no point processing further patches.
224+
// They'll either be from an observed composed
225+
// resource that doesn't exist yet, or to a desired
226+
// composed resource that we'll discard.
227+
skip = true
228+
break
229+
}
230+
response.Warning(rsp, errors.Wrapf(err, "cannot render composed resource %q %q patch at index %d: ignoring 'policy.fromFieldPath: Required' because 'to' resource already exists", t.Name, p.GetType(), i))
231+
}
232+
233+
// If any optional field path isn't found we just skip this
234+
// patch and move on. The path may be populated by a
235+
// subsequent patch.
236+
continue
237+
}
238+
response.Fatal(rsp, errors.Wrapf(err, "cannot render composed resource %q %q patch at index %d", t.Name, p.GetType(), i))
239+
return rsp, nil
240+
}
200241
}
201242

202-
if store {
203-
// Add or replace our desired resource.
204-
desired[resource.Name(t.Name)] = dcd
243+
// Skip adding this resource to the desired state because it doesn't
244+
// exist yet, and a required FromFieldPath was not (yet) found.
245+
if skip {
246+
continue
205247
}
248+
249+
desired[resource.Name(t.Name)] = dcd
206250
}
207251

208252
if err := response.SetDesiredCompositeResource(rsp, dxr); err != nil {

fn_test.go

Lines changed: 156 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -384,17 +384,15 @@ func TestRunFunction(t *testing.T) {
384384
},
385385
},
386386
},
387-
"FailedPatchNotSaved": {
388-
reason: "If we fail to patch a desired resource produced by a previous Function in the pipeline we should return a warning result, and leave the original desired resource untouched.",
387+
"OptionalFieldPathNotFound": {
388+
reason: "If we fail to patch a desired resource because an optional field path was not found we should skip the patch.",
389389
args: args{
390390
req: &fnv1beta1.RunFunctionRequest{
391391
Input: resource.MustStructObject(&v1beta1.Resources{
392392
Resources: []v1beta1.ComposedTemplate{
393393
{
394-
// This template base no base, so we try to
395-
// patch the resource named "cool-resource" in
396-
// the desired resources array.
397394
Name: "cool-resource",
395+
Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD","spec":{}}`)},
398396
Patches: []v1beta1.ComposedPatch{
399397
{
400398
// This patch should work.
@@ -405,19 +403,11 @@ func TestRunFunction(t *testing.T) {
405403
},
406404
},
407405
{
408-
// This patch should return an error,
409-
// because the required path does not
410-
// exist.
406+
// This patch should be skipped, because
407+
// the path is not found
411408
Type: v1beta1.PatchTypeFromCompositeFieldPath,
412409
Patch: v1beta1.Patch{
413410
FromFieldPath: ptr.To[string]("spec.doesNotExist"),
414-
ToFieldPath: ptr.To[string]("spec.explode"),
415-
Policy: &v1beta1.PatchPolicy{
416-
FromFieldPath: func() *v1beta1.FromFieldPathPolicy {
417-
r := v1beta1.FromFieldPathPolicyRequired
418-
return &r
419-
}(),
420-
},
421411
},
422412
},
423413
},
@@ -429,16 +419,94 @@ func TestRunFunction(t *testing.T) {
429419
Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"XR","spec":{"widgets":"10"}}`),
430420
},
431421
},
422+
},
423+
},
424+
want: want{
425+
rsp: &fnv1beta1.RunFunctionResponse{
426+
Meta: &fnv1beta1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)},
432427
Desired: &fnv1beta1.State{
433428
Composite: &fnv1beta1.Resource{
434-
Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"XR","spec":{"widgets":"10"}}`),
429+
Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"XR"}`),
435430
},
436431
Resources: map[string]*fnv1beta1.Resource{
437432
"cool-resource": {
438-
Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"CD","spec":{"watchers":42}}`),
433+
// Watchers becomes "10" because our first patch
434+
// worked. We only skipped the second patch.
435+
Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"CD","spec":{"watchers":"10"}}`),
439436
},
440437
},
441438
},
439+
Context: &structpb.Struct{Fields: map[string]*structpb.Value{fncontext.KeyEnvironment: structpb.NewStructValue(nil)}},
440+
},
441+
},
442+
},
443+
"RequiredFieldPathNotFound": {
444+
reason: "If we fail to patch a desired resource because a required field path was not found, and the resource doesn't exist, we should not add it to desired state (i.e. create it).",
445+
args: args{
446+
req: &fnv1beta1.RunFunctionRequest{
447+
Input: resource.MustStructObject(&v1beta1.Resources{
448+
Resources: []v1beta1.ComposedTemplate{
449+
{
450+
Name: "new-resource",
451+
Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD","spec":{}}`)},
452+
Patches: []v1beta1.ComposedPatch{
453+
{
454+
// This patch will fail because the path
455+
// is not found.
456+
Type: v1beta1.PatchTypeFromCompositeFieldPath,
457+
Patch: v1beta1.Patch{
458+
FromFieldPath: ptr.To[string]("spec.doesNotExist"),
459+
Policy: &v1beta1.PatchPolicy{
460+
FromFieldPath: ptr.To[v1beta1.FromFieldPathPolicy](v1beta1.FromFieldPathPolicyRequired),
461+
},
462+
},
463+
},
464+
},
465+
},
466+
{
467+
Name: "existing-resource",
468+
Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD","spec":{}}`)},
469+
Patches: []v1beta1.ComposedPatch{
470+
{
471+
// This patch should work.
472+
Type: v1beta1.PatchTypeFromCompositeFieldPath,
473+
Patch: v1beta1.Patch{
474+
FromFieldPath: ptr.To[string]("spec.widgets"),
475+
ToFieldPath: ptr.To[string]("spec.watchers"),
476+
},
477+
},
478+
{
479+
// This patch will fail because the path
480+
// is not found.
481+
Type: v1beta1.PatchTypeFromCompositeFieldPath,
482+
Patch: v1beta1.Patch{
483+
FromFieldPath: ptr.To[string]("spec.doesNotExist"),
484+
Policy: &v1beta1.PatchPolicy{
485+
FromFieldPath: ptr.To[v1beta1.FromFieldPathPolicy](v1beta1.FromFieldPathPolicyRequired),
486+
},
487+
},
488+
},
489+
},
490+
},
491+
},
492+
}),
493+
Observed: &fnv1beta1.State{
494+
Composite: &fnv1beta1.Resource{
495+
Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"XR","spec":{"widgets":"10"}}`),
496+
},
497+
Resources: map[string]*fnv1beta1.Resource{
498+
// "existing-resource" exists.
499+
"existing-resource": {},
500+
501+
// Note "new-resource" doesn't appear in the
502+
// observed resources. It doesn't yet exist.
503+
},
504+
},
505+
Desired: &fnv1beta1.State{
506+
Composite: &fnv1beta1.Resource{
507+
Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"XR","spec":{"widgets":"10"}}`),
508+
},
509+
},
442510
},
443511
},
444512
want: want{
@@ -449,20 +517,85 @@ func TestRunFunction(t *testing.T) {
449517
Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"XR","spec":{"widgets":"10"}}`),
450518
},
451519
Resources: map[string]*fnv1beta1.Resource{
452-
"cool-resource": {
453-
// spec.watchers would be "10" if we didn't
454-
// discard the patch that worked.
455-
Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"CD","spec":{"watchers":42}}`),
520+
// Note that the first patch did work. We only
521+
// skipped the patch from the required field path.
522+
"existing-resource": {
523+
Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"CD","spec":{"watchers":"10"}}`),
456524
},
525+
526+
// Note "new-resource" doesn't appear here.
457527
},
458528
},
529+
Context: &structpb.Struct{Fields: map[string]*structpb.Value{fncontext.KeyEnvironment: structpb.NewStructValue(nil)}},
459530
Results: []*fnv1beta1.Result{
460531
{
461532
Severity: fnv1beta1.Severity_SEVERITY_WARNING,
462-
Message: fmt.Sprintf("cannot render patches for composed resource %q: cannot apply the %q patch at index 1: spec.doesNotExist: no such field", "cool-resource", "FromCompositeFieldPath"),
533+
Message: `not adding new composed resource "new-resource" to desired state because "FromCompositeFieldPath" patch at index 0 has 'policy.fromFieldPath: Required': spec.doesNotExist: no such field`,
534+
},
535+
{
536+
Severity: fnv1beta1.Severity_SEVERITY_WARNING,
537+
Message: `cannot render composed resource "existing-resource" "FromCompositeFieldPath" patch at index 1: ignoring 'policy.fromFieldPath: Required' because 'to' resource already exists: spec.doesNotExist: no such field`,
538+
},
539+
},
540+
},
541+
},
542+
},
543+
"PatchErrorIsFatal": {
544+
reason: "If we fail to patch a desired resource we should return a fatal result.",
545+
args: args{
546+
req: &fnv1beta1.RunFunctionRequest{
547+
Input: resource.MustStructObject(&v1beta1.Resources{
548+
Resources: []v1beta1.ComposedTemplate{
549+
{
550+
Name: "cool-resource",
551+
Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD","spec":{}}`)},
552+
Patches: []v1beta1.ComposedPatch{
553+
{
554+
// This patch should work.
555+
Type: v1beta1.PatchTypeFromCompositeFieldPath,
556+
Patch: v1beta1.Patch{
557+
FromFieldPath: ptr.To[string]("spec.widgets"),
558+
ToFieldPath: ptr.To[string]("spec.watchers"),
559+
},
560+
},
561+
{
562+
// This patch should return an error,
563+
// because the path is not an array.
564+
Type: v1beta1.PatchTypeFromCompositeFieldPath,
565+
Patch: v1beta1.Patch{
566+
FromFieldPath: ptr.To[string]("spec.widgets[0]"),
567+
},
568+
},
569+
},
570+
},
571+
},
572+
}),
573+
Observed: &fnv1beta1.State{
574+
Composite: &fnv1beta1.Resource{
575+
Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"XR","spec":{"widgets":"10"}}`),
576+
},
577+
},
578+
Desired: &fnv1beta1.State{
579+
Composite: &fnv1beta1.Resource{
580+
Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"XR","spec":{"widgets":"10"}}`),
581+
},
582+
},
583+
},
584+
},
585+
want: want{
586+
rsp: &fnv1beta1.RunFunctionResponse{
587+
Meta: &fnv1beta1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)},
588+
Desired: &fnv1beta1.State{
589+
Composite: &fnv1beta1.Resource{
590+
Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"XR","spec":{"widgets":"10"}}`),
591+
},
592+
},
593+
Results: []*fnv1beta1.Result{
594+
{
595+
Severity: fnv1beta1.Severity_SEVERITY_FATAL,
596+
Message: fmt.Sprintf("cannot render composed resource %q %q patch at index 1: spec.widgets: not an array", "cool-resource", "FromCompositeFieldPath"),
463597
},
464598
},
465-
Context: &structpb.Struct{Fields: map[string]*structpb.Value{fncontext.KeyEnvironment: structpb.NewStructValue(nil)}},
466599
},
467600
},
468601
},

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ require (
5757
github.com/spf13/afero v1.10.0 // indirect
5858
github.com/spf13/cobra v1.8.0 // indirect
5959
github.com/spf13/pflag v1.0.5 // indirect
60-
github.com/stretchr/testify v1.8.4 // indirect
6160
go.uber.org/multierr v1.11.0 // indirect
6261
go.uber.org/zap v1.26.0 // indirect
6362
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect

0 commit comments

Comments
 (0)