diff --git a/cmd/add_container.go b/cmd/add_container.go index fb8d27a8..b931ea8f 100644 --- a/cmd/add_container.go +++ b/cmd/add_container.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" pipelineutils "github.com/syntasso/kratix-cli/cmd/pipeline_utils" + promiseutils "github.com/syntasso/kratix-cli/cmd/promise_utils" "github.com/syntasso/kratix/api/v1alpha1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ctrl "sigs.k8s.io/controller-runtime" @@ -211,7 +212,7 @@ func generateWorkflow(c *pipelineutils.PipelineCmdArgs, containerName, image, pr } else { updatePipeline(c.Lifecycle, c.Action, pipelinesUnstructured, &promise) - fileBytes, err = yaml.Marshal(promise) + fileBytes, err = promiseutils.MarshalPromiseWithoutStatus(promise) if err != nil { return err } diff --git a/cmd/build_promise.go b/cmd/build_promise.go index 8a8adfa7..2540feed 100644 --- a/cmd/build_promise.go +++ b/cmd/build_promise.go @@ -82,7 +82,7 @@ func BuildPromise(cmd *cobra.Command, args []string) error { promise.Spec.Dependencies = dependencies } - promiseBytes, err := yaml.Marshal(promise) + promiseBytes, err := promiseutils.MarshalPromiseWithoutStatus(*promise) if err != nil { return err } diff --git a/cmd/init_operator_promise.go b/cmd/init_operator_promise.go index 9dfa08dc..970e4b12 100644 --- a/cmd/init_operator_promise.go +++ b/cmd/init_operator_promise.go @@ -10,6 +10,7 @@ import ( "text/template" "github.com/spf13/cobra" + promiseutils "github.com/syntasso/kratix-cli/cmd/promise_utils" "github.com/syntasso/kratix/api/v1alpha1" corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -213,6 +214,14 @@ func writePromiseFiles(outputDir string, filesToWrite map[string]any) error { if err := writePromiseFiles(subdir, v); err != nil { return err } + case v1alpha1.Promise: + fileContentBytes, err := promiseutils.MarshalPromiseWithoutStatus(v) + if err != nil { + return err + } + if err = os.WriteFile(filepath.Join(outputDir, key), fileContentBytes, filePerm); err != nil { + return err + } default: fileContentBytes, err := yamlsig.Marshal(v) if err != nil { diff --git a/cmd/promise_utils/promise.go b/cmd/promise_utils/promise.go index fbb46440..a5c68d5f 100644 --- a/cmd/promise_utils/promise.go +++ b/cmd/promise_utils/promise.go @@ -16,6 +16,27 @@ import ( "sigs.k8s.io/yaml" ) +// MarshalPromiseWithoutStatus marshals a Promise to YAML, omitting the +// server-managed status block. PromiseStatus's fields don't have +// `omitempty`, so a direct yaml.Marshal always emits a `status:` block, even +// for a freshly scaffolded Promise that has never been applied to a cluster. +// That block can also drift from whatever the currently installed Kratix CRD +// expects, causing strict-decoding errors on apply. +func MarshalPromiseWithoutStatus(promise v1alpha1.Promise) ([]byte, error) { + jsonBytes, err := json.Marshal(promise) + if err != nil { + return nil, err + } + + var obj map[string]interface{} + if err := json.Unmarshal(jsonBytes, &obj); err != nil { + return nil, err + } + delete(obj, "status") + + return yaml.Marshal(obj) +} + func LoadPromiseWithWorkflows(dir string) (*v1alpha1.Promise, error) { var promise v1alpha1.Promise diff --git a/cmd/promise_utils/promise_test.go b/cmd/promise_utils/promise_test.go new file mode 100644 index 00000000..a681c6b3 --- /dev/null +++ b/cmd/promise_utils/promise_test.go @@ -0,0 +1,77 @@ +package promiseutils_test + +import ( + promiseutils "github.com/syntasso/kratix-cli/cmd/promise_utils" + "github.com/syntasso/kratix/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MarshalPromiseWithoutStatus", func() { + var promise v1alpha1.Promise + + BeforeEach(func() { + promise = v1alpha1.Promise{ + TypeMeta: metav1.TypeMeta{ + Kind: "Promise", + APIVersion: "platform.kratix.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-promise", + Labels: map[string]string{ + "kratix.io/promise-version": "v0.0.1", + }, + }, + } + }) + + When("the Promise has a zero-value status", func() { + It("omits the status block entirely, rather than emitting zero-value fields", func() { + out, err := promiseutils.MarshalPromiseWithoutStatus(promise) + Expect(err).NotTo(HaveOccurred()) + + var obj map[string]interface{} + Expect(yaml.Unmarshal(out, &obj)).To(Succeed()) + Expect(obj).NotTo(HaveKey("status")) + }) + }) + + When("the Promise has a populated status", func() { + BeforeEach(func() { + promise.Status = v1alpha1.PromiseStatus{ + Status: "Available", + Workflows: 5, + WorkflowsSucceeded: 3, + WorkflowsFailed: 2, + } + }) + + It("still omits the status block, since status is server-managed", func() { + out, err := promiseutils.MarshalPromiseWithoutStatus(promise) + Expect(err).NotTo(HaveOccurred()) + + var obj map[string]interface{} + Expect(yaml.Unmarshal(out, &obj)).To(Succeed()) + Expect(obj).NotTo(HaveKey("status")) + }) + }) + + It("preserves the rest of the Promise", func() { + promise.Spec.DestinationSelectors = []v1alpha1.PromiseScheduling{ + {MatchLabels: map[string]string{"environment": "prod"}}, + } + + out, err := promiseutils.MarshalPromiseWithoutStatus(promise) + Expect(err).NotTo(HaveOccurred()) + + var roundTripped v1alpha1.Promise + Expect(yaml.Unmarshal(out, &roundTripped)).To(Succeed()) + + Expect(roundTripped.Name).To(Equal("my-promise")) + Expect(roundTripped.Labels).To(HaveKeyWithValue("kratix.io/promise-version", "v0.0.1")) + Expect(roundTripped.Spec.DestinationSelectors).To(Equal(promise.Spec.DestinationSelectors)) + }) +}) diff --git a/cmd/promise_utils/promise_utils_suite_test.go b/cmd/promise_utils/promise_utils_suite_test.go new file mode 100644 index 00000000..a4414d68 --- /dev/null +++ b/cmd/promise_utils/promise_utils_suite_test.go @@ -0,0 +1,13 @@ +package promiseutils_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPromiseUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "PromiseUtils Suite") +} diff --git a/cmd/update_api.go b/cmd/update_api.go index 267d46cc..9a9c60a4 100644 --- a/cmd/update_api.go +++ b/cmd/update_api.go @@ -101,13 +101,14 @@ func UpdateAPI(cmd *cobra.Command, args []string) error { } apiContents := &runtime.RawExtension{Raw: jsonBytes} - var data interface{} = apiContents + + var bytes []byte if !splitFile { promise.Spec.API = apiContents - data = promise + bytes, err = promiseutils.MarshalPromiseWithoutStatus(promise) + } else { + bytes, err = yaml.Marshal(apiContents) } - - bytes, err := yaml.Marshal(data) if err != nil { return err } diff --git a/cmd/update_dependencies.go b/cmd/update_dependencies.go index e304a7dc..6336eb88 100644 --- a/cmd/update_dependencies.go +++ b/cmd/update_dependencies.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" pipelineutils "github.com/syntasso/kratix-cli/cmd/pipeline_utils" + promiseutils "github.com/syntasso/kratix-cli/cmd/promise_utils" "github.com/syntasso/kratix/api/v1alpha1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/yaml" @@ -175,7 +176,7 @@ func updatePromiseDependencies(dependencies []v1alpha1.Dependency) error { return err } promise.Spec.Dependencies = dependencies - bytes, err := yamlsig.Marshal(promise) + bytes, err := promiseutils.MarshalPromiseWithoutStatus(promise) if err != nil { return err } diff --git a/cmd/update_destination_selector.go b/cmd/update_destination_selector.go index eb9740dd..6fd307bc 100644 --- a/cmd/update_destination_selector.go +++ b/cmd/update_destination_selector.go @@ -3,10 +3,10 @@ package cmd import ( "fmt" "github.com/spf13/cobra" + promiseutils "github.com/syntasso/kratix-cli/cmd/promise_utils" "github.com/syntasso/kratix/api/v1alpha1" "os" "path/filepath" - "sigs.k8s.io/yaml" "strings" ) @@ -51,7 +51,7 @@ func UpdateSelector(cmd *cobra.Command, args []string) error { } var promiseBytes []byte - if promiseBytes, err = yaml.Marshal(promise); err != nil { + if promiseBytes, err = promiseutils.MarshalPromiseWithoutStatus(promise); err != nil { return err } if err = os.WriteFile(filepath.Join(dir, "promise.yaml"), promiseBytes, filePerm); err != nil { diff --git a/test/add_test.go b/test/add_test.go index 0a28a9e2..d64c9723 100644 --- a/test/add_test.go +++ b/test/add_test.go @@ -149,6 +149,8 @@ var _ = Describe("add", func() { Expect(pipelineWorkflowPathExists(dir, "promise", "configure", "pipeline1", "a-good-container", "resources/")).To(BeTrue()) Expect(pipelineWorkflowPathExists(dir, "promise", "delete", "pipeline0", "project-cleanup", "Dockerfile")).To(BeTrue()) Expect(pipelineWorkflowPathExists(dir, "promise", "delete", "pipeline0", "project-cleanup", "resources/")).To(BeTrue()) + + expectNoStatusBlock(dir) }) When("multiple containers are added to the same pipeline", func() { diff --git a/test/assets/crossplane/expected-output-with-compositions/promise.yaml b/test/assets/crossplane/expected-output-with-compositions/promise.yaml index da99af1e..b69bac7b 100644 --- a/test/assets/crossplane/expected-output-with-compositions/promise.yaml +++ b/test/assets/crossplane/expected-output-with-compositions/promise.yaml @@ -253,7 +253,3 @@ spec: value: ObjectStorage image: ghcr.io/syntasso/kratix-cli/from-api-to-crossplane-claim:v0.2.2 name: from-api-to-crossplane-claim -status: - workflows: 0 - workflowsFailed: 0 - workflowsSucceeded: 0 diff --git a/test/assets/crossplane/expected-output-with-empty-openAPIV3Schema/promise.yaml b/test/assets/crossplane/expected-output-with-empty-openAPIV3Schema/promise.yaml index 4e6b6b0f..1853d8dd 100644 --- a/test/assets/crossplane/expected-output-with-empty-openAPIV3Schema/promise.yaml +++ b/test/assets/crossplane/expected-output-with-empty-openAPIV3Schema/promise.yaml @@ -80,7 +80,3 @@ spec: value: ObjectStorage image: ghcr.io/syntasso/kratix-cli/from-api-to-crossplane-claim:v0.2.2 name: from-api-to-crossplane-claim -status: - workflows: 0 - workflowsFailed: 0 - workflowsSucceeded: 0 diff --git a/test/assets/crossplane/expected-output-with-functions/promise.yaml b/test/assets/crossplane/expected-output-with-functions/promise.yaml index 524fda6c..ca81f8a1 100644 --- a/test/assets/crossplane/expected-output-with-functions/promise.yaml +++ b/test/assets/crossplane/expected-output-with-functions/promise.yaml @@ -183,7 +183,3 @@ spec: value: ObjectStorage image: ghcr.io/syntasso/kratix-cli/from-api-to-crossplane-claim:v0.2.2 name: from-api-to-crossplane-claim -status: - workflows: 0 - workflowsFailed: 0 - workflowsSucceeded: 0 diff --git a/test/assets/crossplane/expected-output-with-no-spec-properties/promise.yaml b/test/assets/crossplane/expected-output-with-no-spec-properties/promise.yaml index 02a93d7e..47bbc561 100644 --- a/test/assets/crossplane/expected-output-with-no-spec-properties/promise.yaml +++ b/test/assets/crossplane/expected-output-with-no-spec-properties/promise.yaml @@ -101,7 +101,3 @@ spec: value: ObjectStorage image: ghcr.io/syntasso/kratix-cli/from-api-to-crossplane-claim:v0.2.2 name: from-api-to-crossplane-claim -status: - workflows: 0 - workflowsFailed: 0 - workflowsSucceeded: 0 diff --git a/test/assets/crossplane/expected-output-with-skip-dependencies/promise.yaml b/test/assets/crossplane/expected-output-with-skip-dependencies/promise.yaml index 5748bc0e..a8ecd6bd 100644 --- a/test/assets/crossplane/expected-output-with-skip-dependencies/promise.yaml +++ b/test/assets/crossplane/expected-output-with-skip-dependencies/promise.yaml @@ -103,7 +103,3 @@ spec: value: ObjectStorage image: ghcr.io/syntasso/kratix-cli/from-api-to-crossplane-claim:v0.2.2 name: from-api-to-crossplane-claim -status: - workflows: 0 - workflowsFailed: 0 - workflowsSucceeded: 0 diff --git a/test/assets/crossplane/expected-output/promise.yaml b/test/assets/crossplane/expected-output/promise.yaml index cf8fd727..f45d4bd9 100644 --- a/test/assets/crossplane/expected-output/promise.yaml +++ b/test/assets/crossplane/expected-output/promise.yaml @@ -177,7 +177,3 @@ spec: value: ObjectStorage image: ghcr.io/syntasso/kratix-cli/from-api-to-crossplane-claim:v0.2.2 name: from-api-to-crossplane-claim -status: - workflows: 0 - workflowsFailed: 0 - workflowsSucceeded: 0 diff --git a/test/assets/e2e-cnpg/expected-promise.yaml b/test/assets/e2e-cnpg/expected-promise.yaml index dc9a0374..809ea58c 100644 --- a/test/assets/e2e-cnpg/expected-promise.yaml +++ b/test/assets/e2e-cnpg/expected-promise.yaml @@ -21780,7 +21780,3 @@ spec: value: Cluster image: ghcr.io/syntasso/kratix-cli/from-api-to-operator:v0.2.2 name: from-api-to-operator -status: - workflows: 0 - workflowsFailed: 0 - workflowsSucceeded: 0 diff --git a/test/assets/pulumi/expected-output/promise.yaml b/test/assets/pulumi/expected-output/promise.yaml index 6497b75f..7f209da1 100644 --- a/test/assets/pulumi/expected-output/promise.yaml +++ b/test/assets/pulumi/expected-output/promise.yaml @@ -86,7 +86,3 @@ spec: - /pulumi-stack-generator image: ghcr.io/syntasso/kratix-cli/pulumi-generator:v0.1.3 name: pulumi-stack-generator -status: - workflows: 0 - workflowsFailed: 0 - workflowsSucceeded: 0 diff --git a/test/assets/terraform/expected-output-vpc/promise.yaml b/test/assets/terraform/expected-output-vpc/promise.yaml index adc0e36e..077a137b 100644 --- a/test/assets/terraform/expected-output-vpc/promise.yaml +++ b/test/assets/terraform/expected-output-vpc/promise.yaml @@ -89,7 +89,3 @@ spec: value: git::https://github.com/GoogleCloudPlatform/cloud-foundation-fabric.git//modules/api-gateway?ref=v49.1.0 image: ghcr.io/syntasso/kratix-cli/terraform-generate:v0.6.0 name: terraform-generate -status: - workflows: 0 - workflowsFailed: 0 - workflowsSucceeded: 0 diff --git a/test/assets/terraform/expected-output/promise.yaml b/test/assets/terraform/expected-output/promise.yaml index a4759460..c5eacfc5 100644 --- a/test/assets/terraform/expected-output/promise.yaml +++ b/test/assets/terraform/expected-output/promise.yaml @@ -231,7 +231,3 @@ spec: value: git::https://github.com/syntasso/terraform-google-cloud-run?ref=v0.16.4 image: ghcr.io/syntasso/kratix-cli/terraform-generate:v0.6.0 name: terraform-generate -status: - workflows: 0 - workflowsFailed: 0 - workflowsSucceeded: 0 diff --git a/test/init_test.go b/test/init_test.go index a2d4be1c..2d8604be 100644 --- a/test/init_test.go +++ b/test/init_test.go @@ -152,6 +152,11 @@ func matchPromise(dir, name, group, version, kind, singular, plural string) { _, promiseCRD, err := promise.GetAPI() ExpectWithOffset(1, err).NotTo(HaveOccurred()) matchCRD(promiseCRD, group, version, kind, singular, plural) + + // status is server-managed and must never be written to a scaffolded/updated promise.yaml + var obj map[string]interface{} + ExpectWithOffset(1, yaml.Unmarshal(promiseYAML, &obj)).To(Succeed()) + ExpectWithOffset(1, obj).NotTo(HaveKey("status")) } func matchCRD(promiseCRD *apiextensionsv1.CustomResourceDefinition, group, version, kind, singular, plural string) { diff --git a/test/update_test.go b/test/update_test.go index b3ffd52f..64a53c2d 100644 --- a/test/update_test.go +++ b/test/update_test.go @@ -492,6 +492,8 @@ var _ = Describe("update", func() { kinds = append(kinds, d.Object["kind"].(string)) } Expect(kinds).To(ConsistOf("Namespace", "Namespace", "Deployment")) + + expectNoStatusBlock(workingDir) }) When("promise.yaml does not exist", func() { @@ -680,6 +682,16 @@ func getDependencies(dir string, split bool) v1alpha1.Dependencies { return deps } +// status is server-managed and must never be written to a scaffolded/updated promise.yaml +func expectNoStatusBlock(dir string) { + promiseYAML, err := os.ReadFile(filepath.Join(dir, "promise.yaml")) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + var obj map[string]interface{} + ExpectWithOffset(1, yamlsig.Unmarshal(promiseYAML, &obj)).To(Succeed()) + ExpectWithOffset(1, obj).NotTo(HaveKey("status")) +} + func matchGvkInAPIFile(dir, group, version, kind, singular, plural string) { apiYAML, err := os.ReadFile(filepath.Join(dir, "api.yaml")) ExpectWithOffset(1, err).NotTo(HaveOccurred())