From 8a58a8482d886f49c205334d463f1daaf42b7377 Mon Sep 17 00:00:00 2001 From: Rasheed Abdul-Aziz Date: Fri, 27 May 2022 15:16:11 -0400 Subject: [PATCH] 486 runnable output bug (#876) Runnable stampedObjects without a Succeeded condition should not be treated as suceeded Change the behaviour of runnable's outputs: 1. outputs come from the latest StampedObject with a succeeded:true status. objects without a suceeded status are ignored. 2. not specifying outputs is legal - supports terminal/throwaway tasks --- pkg/realizer/runnable/realizer.go | 6 +- pkg/realizer/runnable/realizer_test.go | 12 +- pkg/templates/cluster_run_template.go | 109 ++-- pkg/templates/cluster_run_template_test.go | 605 +++++++++++++++------ 4 files changed, 492 insertions(+), 240 deletions(-) diff --git a/pkg/realizer/runnable/realizer.go b/pkg/realizer/runnable/realizer.go index 208eb29b0..e68c6e1d0 100644 --- a/pkg/realizer/runnable/realizer.go +++ b/pkg/realizer/runnable/realizer.go @@ -125,7 +125,7 @@ func (r *runnableRealizer) Realize(ctx context.Context, runnable *v1alpha1.Runna log.Error(err, "failed to cleanup runnable stamped objects") } - outputs, evaluatedStampedObject, err := template.GetOutput(allRunnableStampedObjects) + outputs, outputSource, err := template.GetLatestSuccessfulOutput(allRunnableStampedObjects) if err != nil { for _, obj := range allRunnableStampedObjects { log.V(logger.DEBUG).Info("failed to retrieve output from any object", "considered", obj) @@ -138,8 +138,8 @@ func (r *runnableRealizer) Realize(ctx context.Context, runnable *v1alpha1.Runna } } - if evaluatedStampedObject != nil { - log.V(logger.DEBUG).Info("retrieved output from stamped object", "stamped object", evaluatedStampedObject) + if outputSource != nil { + log.V(logger.DEBUG).Info("retrieved output from stamped object", "stamped object", outputSource) } if len(outputs) == 0 { diff --git a/pkg/realizer/runnable/realizer_test.go b/pkg/realizer/runnable/realizer_test.go index 79c70182e..18dc44ff4 100644 --- a/pkg/realizer/runnable/realizer_test.go +++ b/pkg/realizer/runnable/realizer_test.go @@ -80,7 +80,8 @@ var _ = Describe("Realizer", func() { APIVersion: "test.run/v1alpha1", }, ObjectMeta: metav1.ObjectMeta{ - GenerateName: "my-stamped-resource-", + GenerateName: "my-stamped-resource-", + CreationTimestamp: metav1.Now(), }, Spec: resources.TestSpec{ Foo: "is a string", @@ -465,9 +466,10 @@ var _ = Describe("Realizer", func() { Template: runtime.RawExtension{ Raw: []byte(D(`{ "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { "generateName": "my-stamped-resource-" }, - "data": { "has": "is a string" } + "kind": "AThing", + "metadata": { "generateName": "my-stamped-resource-", "creationTimestamp": "2021-09-17T17:02:30Z" }, + "spec": { "has": "is a string" }, + "status": { "conditions": [{"type":"Succeeded", "status":"True"}] } }`, )), }, @@ -489,7 +491,7 @@ var _ = Describe("Realizer", func() { It("returns RetrieveOutputError", func() { _, _, err := rlzr.Realize(ctx, runnable, systemRepo, runnableRepo, discoveryClient) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring(`unable to retrieve outputs from stamped object [my-important-ns/my-stamped-resource-] of type [configmap] for run template [my-template]: failed to evaluate path [data.hasnot]: jsonpath returned empty list: data.hasnot`)) + Expect(err.Error()).To(ContainSubstring(`unable to retrieve outputs from stamped object [my-important-ns/my-stamped-resource-] of type [athing] for run template [my-template]: failed to evaluate path [data.hasnot]: jsonpath returned empty list: data.hasnot`)) Expect(reflect.TypeOf(err).String()).To(Equal("errors.RunnableRetrieveOutputError")) }) }) diff --git a/pkg/templates/cluster_run_template.go b/pkg/templates/cluster_run_template.go index b68fc6dd6..08cbd91c1 100644 --- a/pkg/templates/cluster_run_template.go +++ b/pkg/templates/cluster_run_template.go @@ -26,91 +26,68 @@ import ( "github.com/vmware-tanzu/cartographer/pkg/eval" ) +func NewRunTemplateModel(template *v1alpha1.ClusterRunTemplate) ClusterRunTemplate { + return &runTemplate{ + template: template, + evaluator: eval.EvaluatorBuilder(), + } +} + type Outputs map[string]apiextensionsv1.JSON type ClusterRunTemplate interface { GetName() string GetResourceTemplate() v1alpha1.TemplateSpec - GetOutput(stampedObjects []*unstructured.Unstructured) (Outputs, *unstructured.Unstructured, error) + GetLatestSuccessfulOutput(stampedObjects []*unstructured.Unstructured) (Outputs, *unstructured.Unstructured, error) } type runTemplate struct { - template *v1alpha1.ClusterRunTemplate + template *v1alpha1.ClusterRunTemplate + evaluator eval.Evaluator } -func (t runTemplate) GetOutput(stampedObjects []*unstructured.Unstructured) (Outputs, *unstructured.Unstructured, error) { - var ( - updateError error - everyObjectErrored bool - mostRecentlySubmittedSuccesfulTime *time.Time - evaluatedStampedObject *unstructured.Unstructured - ) +const SuccessStatusPath = `status.conditions[?(@.type=="Succeeded")].status` - outputs := Outputs{} +// GetLatestSuccessfulOutput returns the most recent condition:Succeeded=True stamped object. +// If no output paths are specified, then you only receive the object and empty outputs. +// If the output path is specified but doesn't match anything in the latest "suceeded" object, then an error is returned +// along with the matched object. +// if the output paths are all satisfied, then the outputs from the latest object, and the object itself, are returned. +func (t *runTemplate) GetLatestSuccessfulOutput(stampedObjects []*unstructured.Unstructured) (Outputs, *unstructured.Unstructured, error) { + latestMatchingObject := t.getLatestSuccessfulObject(stampedObjects) - evaluator := eval.EvaluatorBuilder() + if latestMatchingObject == nil { + return Outputs{}, nil, nil + } - everyObjectErrored = true + outputError, outputs := t.getOutputsOfSingleObject(t.evaluator, *latestMatchingObject) - for _, stampedObject := range stampedObjects { - objectErr, provisionalOutputs := t.getOutputsOfSingleObject(evaluator, *stampedObject) + return outputs, latestMatchingObject, outputError +} - statusPath := `status.conditions[?(@.type=="Succeeded")].status` - status, err := evaluator.EvaluateJsonPath(statusPath, stampedObject.UnstructuredContent()) - if err != nil { - updateError = objectErr - continue - } +func (t *runTemplate) getLatestSuccessfulObject(stampedObjects []*unstructured.Unstructured) *unstructured.Unstructured { + var ( + latestTime time.Time // zero value is used for comparison + latestMatchingObject *unstructured.Unstructured + ) - if status == "True" && objectErr == nil { - objectCreationTimestamp, err := getCreationTimestamp(stampedObject, evaluator) - if err != nil { - continue - } - - if mostRecentlySubmittedSuccesfulTime == nil { - mostRecentlySubmittedSuccesfulTime = objectCreationTimestamp - } else if objectCreationTimestamp.After(*mostRecentlySubmittedSuccesfulTime) { - mostRecentlySubmittedSuccesfulTime = objectCreationTimestamp - } else { - continue - } - - outputs = provisionalOutputs - evaluatedStampedObject = stampedObject + for _, stampedObject := range stampedObjects { + status, err := t.evaluator.EvaluateJsonPath(SuccessStatusPath, stampedObject.UnstructuredContent()) + if !(err == nil && status == "True") { + continue } - if objectErr != nil { - updateError = objectErr - } else { - everyObjectErrored = false + currentTime := stampedObject.GetCreationTimestamp().Time + if currentTime.After(latestTime) { + latestMatchingObject = stampedObject + latestTime = currentTime } - } - if everyObjectErrored { - return nil, nil, updateError } - - return outputs, evaluatedStampedObject, nil + return latestMatchingObject } -func getCreationTimestamp(stampedObject *unstructured.Unstructured, evaluator evaluator) (*time.Time, error) { - creationTimestamp, err := evaluator.EvaluateJsonPath("metadata.creationTimestamp", stampedObject.UnstructuredContent()) - if err != nil { - return nil, err - } - creationTimeString, ok := creationTimestamp.(string) - if !ok { - return nil, err - } - creationTime, err := time.Parse(time.RFC3339, creationTimeString) - if err != nil { - return nil, fmt.Errorf("failed to parse creation metadata.creationTimestamp: %w", err) - } - return &creationTime, nil -} - -func (t runTemplate) getOutputsOfSingleObject(evaluator eval.Evaluator, stampedObject unstructured.Unstructured) (error, Outputs) { +func (t *runTemplate) getOutputsOfSingleObject(evaluator eval.Evaluator, stampedObject unstructured.Unstructured) (error, Outputs) { var objectErr error provisionalOutputs := Outputs{} for key, path := range t.template.Spec.Outputs { @@ -133,15 +110,11 @@ func (t runTemplate) getOutputsOfSingleObject(evaluator eval.Evaluator, stampedO return objectErr, provisionalOutputs } -func NewRunTemplateModel(template *v1alpha1.ClusterRunTemplate) ClusterRunTemplate { - return &runTemplate{template: template} -} - -func (t runTemplate) GetName() string { +func (t *runTemplate) GetName() string { return t.template.Name } -func (t runTemplate) GetResourceTemplate() v1alpha1.TemplateSpec { +func (t *runTemplate) GetResourceTemplate() v1alpha1.TemplateSpec { return v1alpha1.TemplateSpec{ Template: &t.template.Spec.Template, } diff --git a/pkg/templates/cluster_run_template_test.go b/pkg/templates/cluster_run_template_test.go index 472209c79..6ae116d27 100644 --- a/pkg/templates/cluster_run_template_test.go +++ b/pkg/templates/cluster_run_template_test.go @@ -19,6 +19,7 @@ import ( . "github.com/onsi/gomega" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer/yaml" "github.com/vmware-tanzu/cartographer/pkg/apis/v1alpha1" @@ -26,225 +27,501 @@ import ( "github.com/vmware-tanzu/cartographer/pkg/utils" ) +func makeTemplate(outputs map[string]string) templates.ClusterRunTemplate { + apiTemplate := &v1alpha1.ClusterRunTemplate{} + apiTemplate.Spec.Outputs = outputs + + return templates.NewRunTemplateModel(apiTemplate) +} + var _ = Describe("ClusterRunTemplate", func() { - Describe("GetOutput", func() { + Describe("GetLatestSuccessfulOutput", func() { var ( - apiTemplate *v1alpha1.ClusterRunTemplate - firstStampedObject, secondStampedObject, unconditionedStampedObject *unstructured.Unstructured - stampedObjects []*unstructured.Unstructured + serializer runtime.Serializer + template templates.ClusterRunTemplate + stampedObjects []*unstructured.Unstructured ) - BeforeEach(func() { - apiTemplate = &v1alpha1.ClusterRunTemplate{} - - firstStampedObject = &unstructured.Unstructured{} - firstStampedObjectManifest := utils.HereYamlF(` - apiVersion: thing/v1 - kind: Thing - metadata: - name: named-thing - namespace: somens - creationTimestamp: "2021-09-17T16:02:30Z" - spec: - simple: is a string - complex: - type: object - name: complex object - only-exists-on-first-object: populated - status: - conditions: - - type: Succeeded - status: "True" - `) - - dec := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) - _, _, err := dec.Decode([]byte(firstStampedObjectManifest), nil, firstStampedObject) - Expect(err).NotTo(HaveOccurred()) - - secondStampedObject = &unstructured.Unstructured{} - secondStampedObjectManifest := utils.HereYamlF(` - apiVersion: thing/v1 - kind: Thing - metadata: - name: named-thing - namespace: somens - creationTimestamp: "2021-09-17T16:02:40Z" - spec: - simple: 2nd-simple - complex: 2nd-complex - status: - conditions: - - type: Succeeded - status: "True" - `) - - dec = yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) - _, _, err = dec.Decode([]byte(secondStampedObjectManifest), nil, secondStampedObject) - Expect(err).NotTo(HaveOccurred()) - - unconditionedStampedObject = &unstructured.Unstructured{} - unconditionedStampedObjectManifest := utils.HereYamlF(` - apiVersion: thing/v1 - kind: Thing - metadata: - name: named-thing - namespace: somens - spec: - simple: val - complex: other-val - `) - - dec = yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) - _, _, err = dec.Decode([]byte(unconditionedStampedObjectManifest), nil, unconditionedStampedObject) - Expect(err).NotTo(HaveOccurred()) + serializer = yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) }) - Context("when there is one object", func() { + + Context("No stamped objects", func() { BeforeEach(func() { - stampedObjects = []*unstructured.Unstructured{firstStampedObject} + template = makeTemplate(map[string]string{ + "an-output": "status.simple-result", + }) + stampedObjects = []*unstructured.Unstructured{} }) - Context("with no outputs", func() { - It("returns an empty list", func() { - template := templates.NewRunTemplateModel(apiTemplate) - outputs, evaluatedStampedObject, err := template.GetOutput(stampedObjects) + It("returns no output", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).NotTo(HaveOccurred()) + Expect(outputs).To(BeEmpty()) + Expect(outputSourceObject).To(BeNil()) + }) + }) + + Context("One stamped object", func() { + var stampedObject *unstructured.Unstructured + + BeforeEach(func() { + template = makeTemplate(map[string]string{ + "an-output": "status.simple-result", + }) + }) + + Context("with no succeeded condition", func() { + BeforeEach(func() { + stampedObject = &unstructured.Unstructured{} + stampedObjectYaml := utils.HereYamlF(` + apiVersion: thing/v1 + kind: Thing + metadata: + name: named-thing + namespace: somens + creationTimestamp: "2021-09-17T16:02:30Z" + status: + conditions: {} + `) + _, _, err := serializer.Decode([]byte(stampedObjectYaml), nil, stampedObject) + Expect(err).NotTo(HaveOccurred()) + stampedObjects = []*unstructured.Unstructured{stampedObject} + }) + + It("returns no output", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) Expect(err).NotTo(HaveOccurred()) Expect(outputs).To(BeEmpty()) - Expect(evaluatedStampedObject).To(Equal(firstStampedObject)) + Expect(outputSourceObject).To(BeNil()) }) }) - Context("with valid output paths defined", func() { + Context("with a succeeded:false condition", func() { BeforeEach(func() { - apiTemplate.Spec.Outputs = map[string]string{ - "simplistic": "spec.simple", - "complexish": "spec.complex", - } + stampedObject = &unstructured.Unstructured{} + stampedObjectYaml := utils.HereYamlF(` + apiVersion: thing/v1 + kind: Thing + metadata: + name: named-thing + namespace: somens + creationTimestamp: "2021-09-17T16:02:30Z" + status: + conditions: + - type: Succeeded + status: "False" + `) + + _, _, err := serializer.Decode([]byte(stampedObjectYaml), nil, stampedObject) + Expect(err).NotTo(HaveOccurred()) + stampedObjects = []*unstructured.Unstructured{stampedObject} + }) + + It("returns no output", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).NotTo(HaveOccurred()) + Expect(outputs).To(BeEmpty()) + Expect(outputSourceObject).To(BeNil()) + }) + }) + + Context("with a succeeded:true condition", func() { + BeforeEach(func() { + stampedObject = &unstructured.Unstructured{} + stampedObjectYaml := utils.HereYamlF(` + apiVersion: thing/v1 + kind: Thing + metadata: + name: named-thing + namespace: somens + creationTimestamp: "2021-09-17T16:02:30Z" + status: + conditions: + - type: Succeeded + status: "True" + simple-result: a thing + `) + + _, _, err := serializer.Decode([]byte(stampedObjectYaml), nil, stampedObject) + Expect(err).NotTo(HaveOccurred()) + stampedObjects = []*unstructured.Unstructured{stampedObject} }) - Context("when the object has not succeeded", func() { + Context("that does not match the outputs", func() { BeforeEach(func() { - Expect(utils.AlterFieldOfNestedStringMaps(firstStampedObject.Object, "status.conditions.[0]status", "False")).To(Succeed()) // TODO: fix this notation or start using a jsonpath parser + template = makeTemplate(map[string]string{ + "an-output": "status.nonexistant", + }) }) - It("returns empty outputs", func() { - template := templates.NewRunTemplateModel(apiTemplate) - outputs, evaluatedStampedObject, err := template.GetOutput(stampedObjects) - Expect(err).NotTo(HaveOccurred()) + + It("returns no output, the matching object and an error", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).To(MatchError("failed to evaluate path [status.nonexistant]: jsonpath returned empty list: status.nonexistant")) Expect(outputs).To(BeEmpty()) - Expect(evaluatedStampedObject).To(BeNil()) + Expect(outputSourceObject).To(Equal(stampedObjects[0])) }) }) - It("returns the new outputs", func() { - template := templates.NewRunTemplateModel(apiTemplate) - outputs, evaluatedStampedObject, err := template.GetOutput(stampedObjects) - Expect(err).NotTo(HaveOccurred()) - Expect(outputs["simplistic"]).To(Equal(apiextensionsv1.JSON{Raw: []byte(`"is a string"`)})) - Expect(outputs["complexish"]).To(Equal(apiextensionsv1.JSON{Raw: []byte(`{"name":"complex object","type":"object"}`)})) - Expect(evaluatedStampedObject).To(Equal(firstStampedObject)) + Context("no output specified in the template", func() { + BeforeEach(func() { + template = makeTemplate(map[string]string{}) + }) + It("returns an empty output and the matched object", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).NotTo(HaveOccurred()) + Expect(outputs).To(BeEmpty()) + Expect(outputSourceObject).To(Equal(stampedObjects[0])) + }) }) - }) - Context("with invalid output paths defined", func() { - BeforeEach(func() { - apiTemplate.Spec.Outputs = map[string]string{ - "complexish": "spec.nonexistant", - } - }) - It("returns an error", func() { - template := templates.NewRunTemplateModel(apiTemplate) - _, _, err := template.GetOutput(stampedObjects) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(Equal("failed to evaluate path [spec.nonexistant]: jsonpath returned empty list: spec.nonexistant")) + Context("that matches the outputs", func() { + It("returns the outputs and the matched object", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).NotTo(HaveOccurred()) + Expect(outputs["an-output"]).To(Equal(apiextensionsv1.JSON{Raw: []byte(`"a thing"`)})) + Expect(outputSourceObject).To(Equal(stampedObjects[0])) + }) }) }) }) - Context("when there are multiple objects", func() { - BeforeEach(func() { - stampedObjects = []*unstructured.Unstructured{secondStampedObject, firstStampedObject} + Context("two stamped objects", func() { + Context("with no conditions", func() { + BeforeEach(func() { + firstObject := &unstructured.Unstructured{} + firstObjectYaml := utils.HereYamlF(` + apiVersion: thing/v1 + kind: Thing + metadata: + name: first-thing + namespace: somens + creationTimestamp: "2021-09-17T16:02:30Z" + status: + conditions: {} + `) + _, _, err := serializer.Decode([]byte(firstObjectYaml), nil, firstObject) + Expect(err).NotTo(HaveOccurred()) - apiTemplate.Spec.Outputs = map[string]string{ - "simplistic": "spec.simple", - "complexish": "spec.complex", - } - }) + secondObject := &unstructured.Unstructured{} + secondObjectYaml := utils.HereYamlF(` + apiVersion: thing/v1 + kind: Thing + metadata: + name: second-thing + namespace: somens + creationTimestamp: "2021-09-17T17:02:30Z" + status: + conditions: {} + `) + _, _, err = serializer.Decode([]byte(secondObjectYaml), nil, secondObject) + Expect(err).NotTo(HaveOccurred()) - Context("when none have succeeded", func() { - BeforeEach(func() { - Expect(utils.AlterFieldOfNestedStringMaps(firstStampedObject.Object, "status.conditions.[0]status", "False")).To(Succeed()) - Expect(utils.AlterFieldOfNestedStringMaps(secondStampedObject.Object, "status.conditions.[0]status", "False")).To(Succeed()) + // Out of order deliberately + stampedObjects = []*unstructured.Unstructured{secondObject, firstObject} }) - It("returns empty outputs", func() { - template := templates.NewRunTemplateModel(apiTemplate) - outputs, evaluatedStampedObject, err := template.GetOutput(stampedObjects) + + It("returns no output", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) Expect(err).NotTo(HaveOccurred()) Expect(outputs).To(BeEmpty()) - Expect(evaluatedStampedObject).To(BeNil()) + Expect(outputSourceObject).To(BeNil()) }) }) - Context("when only the least recently has succeeded", func() { + + Context("with [succeeded:false, succeeded:false] conditions", func() { BeforeEach(func() { - Expect(utils.AlterFieldOfNestedStringMaps(secondStampedObject.Object, "status.conditions.[0]status", "False")).To(Succeed()) - }) - It("returns the output of the earlier submitted and successful object", func() { - template := templates.NewRunTemplateModel(apiTemplate) - outputs, evaluatedStampedObject, err := template.GetOutput(stampedObjects) + firstObject := &unstructured.Unstructured{} + firstObjectYaml := utils.HereYamlF(` + apiVersion: thing/v1 + kind: Thing + metadata: + name: first-thing + namespace: somens + creationTimestamp: "2021-09-17T16:02:30Z" + status: + conditions: + - type: Succeeded + status: "False" + simple-result: first result + `) + _, _, err := serializer.Decode([]byte(firstObjectYaml), nil, firstObject) + Expect(err).NotTo(HaveOccurred()) + + secondObject := &unstructured.Unstructured{} + secondObjectYaml := utils.HereYamlF(` + apiVersion: thing/v1 + kind: Thing + metadata: + name: second-thing + namespace: somens + creationTimestamp: "2021-09-17T17:02:30Z" + status: + conditions: + - type: Succeeded + status: "False" + simple-result: second result + `) + _, _, err = serializer.Decode([]byte(secondObjectYaml), nil, secondObject) Expect(err).NotTo(HaveOccurred()) - Expect(outputs["simplistic"]).To(Equal(apiextensionsv1.JSON{Raw: []byte(`"is a string"`)})) - Expect(outputs["complexish"]).To(Equal(apiextensionsv1.JSON{Raw: []byte(`{"name":"complex object","type":"object"}`)})) - Expect(evaluatedStampedObject).To(Equal(firstStampedObject)) + + // Out of order deliberately + stampedObjects = []*unstructured.Unstructured{secondObject, firstObject} }) - }) - Context("when all have succeeded", func() { - It("returns the output of the most recently submitted and successful object", func() { - template := templates.NewRunTemplateModel(apiTemplate) - outputs, evaluatedStampedObject, err := template.GetOutput(stampedObjects) + + It("returns no output", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) Expect(err).NotTo(HaveOccurred()) - Expect(outputs["simplistic"]).To(Equal(apiextensionsv1.JSON{Raw: []byte(`"2nd-simple"`)})) - Expect(outputs["complexish"]).To(Equal(apiextensionsv1.JSON{Raw: []byte(`"2nd-complex"`)})) - Expect(evaluatedStampedObject).To(Equal(secondStampedObject)) + Expect(outputs).To(BeEmpty()) + Expect(outputSourceObject).To(BeNil()) }) }) - Context("when the field of one object don't match the declared output fields", func() { + + Context("with [succeeded:true, succeeded:false] conditions", func() { + var firstObject, secondObject *unstructured.Unstructured BeforeEach(func() { - apiTemplate.Spec.Outputs = map[string]string{ - "simplistic": "spec.only-exists-on-first-object", - } - }) + firstObject = &unstructured.Unstructured{} + firstObjectYaml := utils.HereYamlF(` + apiVersion: thing/v1 + kind: Thing + metadata: + name: first-thing + namespace: somens + creationTimestamp: "2021-09-17T16:02:30Z" + status: + conditions: + - type: Succeeded + status: "True" + simple-result: first result + `) + _, _, err := serializer.Decode([]byte(firstObjectYaml), nil, firstObject) + Expect(err).NotTo(HaveOccurred()) - It("returns the output of the most recently submitted, successful, non-error inducing object", func() { - template := templates.NewRunTemplateModel(apiTemplate) - outputs, evaluatedStampedObject, err := template.GetOutput(stampedObjects) + secondObject = &unstructured.Unstructured{} + secondObjectYaml := utils.HereYamlF(` + apiVersion: thing/v1 + kind: Thing + metadata: + name: second-thing + namespace: somens + creationTimestamp: "2021-09-17T17:02:30Z" + status: + conditions: + - type: Succeeded + status: "False" + simple-result: second result + `) + _, _, err = serializer.Decode([]byte(secondObjectYaml), nil, secondObject) Expect(err).NotTo(HaveOccurred()) - Expect(outputs["simplistic"]).To(Equal(apiextensionsv1.JSON{Raw: []byte(`"populated"`)})) - Expect(evaluatedStampedObject).To(Equal(firstStampedObject)) + + // Out of order deliberately + stampedObjects = []*unstructured.Unstructured{secondObject, firstObject} + }) + + Context("with no output specified in the template", func() { + BeforeEach(func() { + template = makeTemplate(map[string]string{}) + }) + + It("returns the empty outputs and the matched object", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).NotTo(HaveOccurred()) + Expect(outputs).To(BeEmpty()) + Expect(outputSourceObject).To(Equal(firstObject)) + }) }) + + Context("that do not match the outputs in the template", func() { + BeforeEach(func() { + template = makeTemplate(map[string]string{ + "an-output": "status.nonexistant", + }) + }) + + It("returns no output, the matching object and an error", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).To(MatchError("failed to evaluate path [status.nonexistant]: jsonpath returned empty list: status.nonexistant")) + Expect(outputs).To(BeEmpty()) + Expect(outputSourceObject).To(Equal(firstObject)) + }) + + }) + + Context("that matches the outputs in the template", func() { + BeforeEach(func() { + template = makeTemplate(map[string]string{ + "an-output": "status.simple-result", + }) + }) + + It("returns the earliest matched outputs and the earliest matched object", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).NotTo(HaveOccurred()) + Expect(outputs["an-output"]).To(Equal(apiextensionsv1.JSON{Raw: []byte(`"first result"`)})) + Expect(outputSourceObject).To(Equal(firstObject)) + }) + }) + }) - Context("when the fields of all objects don't match the declared output fields", func() { + + Context("with [succeeded:true, succeeded:true] conditions", func() { + var firstObject, secondObject *unstructured.Unstructured + BeforeEach(func() { - apiTemplate.Spec.Outputs = map[string]string{ - "simplistic": "spec.nonexistant", - } + firstObject = &unstructured.Unstructured{} + firstObjectYaml := utils.HereYamlF(` + apiVersion: thing/v1 + kind: Thing + metadata: + name: first-thing + namespace: somens + creationTimestamp: "2021-09-17T16:02:30Z" + status: + conditions: + - type: Succeeded + status: "True" + simple-result: first result + first-only: first only result + `) + _, _, err := serializer.Decode([]byte(firstObjectYaml), nil, firstObject) + Expect(err).NotTo(HaveOccurred()) + + secondObject = &unstructured.Unstructured{} + secondObjectYaml := utils.HereYamlF(` + apiVersion: thing/v1 + kind: Thing + metadata: + name: second-thing + namespace: somens + creationTimestamp: "2021-09-17T17:02:30Z" + status: + conditions: + - type: Succeeded + status: "True" + simple-result: second result + second-only: second only result + `) + _, _, err = serializer.Decode([]byte(secondObjectYaml), nil, secondObject) + Expect(err).NotTo(HaveOccurred()) + + // Out of order deliberately + stampedObjects = []*unstructured.Unstructured{secondObject, firstObject} + + }) + Context("with no output specified in the template", func() { + BeforeEach(func() { + template = makeTemplate(map[string]string{}) + }) + + It("returns an empty output and the latest matched object", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).NotTo(HaveOccurred()) + Expect(outputs).To(BeEmpty()) + Expect(outputSourceObject).To(Equal(secondObject)) + }) }) - It("returns a helpful error", func() { - template := templates.NewRunTemplateModel(apiTemplate) - _, _, err := template.GetOutput(stampedObjects) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(Equal("failed to evaluate path [spec.nonexistant]: jsonpath returned empty list: spec.nonexistant")) + + Context("neither match the outputs", func() { + BeforeEach(func() { + template = makeTemplate(map[string]string{ + "an-output": "status.nonexistant", + }) + }) + + It("returns an error and the latest object", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).To(MatchError("failed to evaluate path [status.nonexistant]: jsonpath returned empty list: status.nonexistant")) + Expect(outputs).To(BeEmpty()) + Expect(outputSourceObject).To(Equal(secondObject)) + }) }) - Context("and one does not have succeeded condition", func() { + Context("later does not match the outputs", func() { BeforeEach(func() { - stampedObjects = []*unstructured.Unstructured{unconditionedStampedObject, firstStampedObject} + template = makeTemplate(map[string]string{ + "an-output": "status.first-only", + }) }) - It("returns a helpful error", func() { - template := templates.NewRunTemplateModel(apiTemplate) - _, _, err := template.GetOutput(stampedObjects) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to evaluate path [spec.nonexistant]: jsonpath returned empty list: spec.nonexistant")) + It("returns an error and the latest object", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).To(MatchError("failed to evaluate path [status.first-only]: jsonpath returned empty list: status.first-only")) + Expect(outputs).To(BeEmpty()) + Expect(outputSourceObject).To(Equal(secondObject)) }) }) + + Context("earlier does not match the outputs", func() { + BeforeEach(func() { + template = makeTemplate(map[string]string{ + "an-output": "status.second-only", + }) + }) + + It("returns the latest", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).NotTo(HaveOccurred()) + Expect(outputs["an-output"]).To(Equal(apiextensionsv1.JSON{Raw: []byte(`"second only result"`)})) + Expect(outputSourceObject).To(Equal(secondObject)) + }) + }) + + Context("both match the outputs", func() { + BeforeEach(func() { + template = makeTemplate(map[string]string{ + "an-output": "status.simple-result", + }) + }) + + It("returns the latest matched output and the latest matched object", func() { + outputs, outputSourceObject, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).NotTo(HaveOccurred()) + Expect(outputs["an-output"]).To(Equal(apiextensionsv1.JSON{Raw: []byte(`"second result"`)})) + Expect(outputSourceObject).To(Equal(secondObject)) + }) + }) + }) + }) + + Describe("supports complex output objects", func() { + var stampedObject *unstructured.Unstructured + + BeforeEach(func() { + template = makeTemplate(map[string]string{ + "my-complex-output": "status.complex-result", + }) + + stampedObject = &unstructured.Unstructured{} + stampedObjectYaml := utils.HereYamlF(` + apiVersion: thing/v1 + kind: Thing + metadata: + name: named-thing + namespace: somens + creationTimestamp: "2021-09-17T16:02:30Z" + status: + conditions: + - type: Succeeded + status: "True" + complex-result: + - name: item1 + value: + field1: one + field2: two + - name: item2 + value: a string + `) + + _, _, err := serializer.Decode([]byte(stampedObjectYaml), nil, stampedObject) + Expect(err).NotTo(HaveOccurred()) + stampedObjects = []*unstructured.Unstructured{stampedObject} }) + + It("returns the output", func() { + outputs, _, err := template.GetLatestSuccessfulOutput(stampedObjects) + Expect(err).NotTo(HaveOccurred()) + + Expect(outputs["my-complex-output"]).To(Equal(apiextensionsv1.JSON{Raw: []byte(`[{"name":"item1","value":{"field1":"one","field2":"two"}},{"name":"item2","value":"a string"}]`)})) + }) + }) }) })