From 3f7ad2ba954e845becc2ef05ff7d2d15a7a134cf Mon Sep 17 00:00:00 2001 From: Ayush Kumar <65535504+roguepikachu@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:59:18 +0530 Subject: [PATCH] Feat: defkit comp def discrepancies (#7048) * Feat: add MapVariant operation and support for OneOf parameters with default variant Signed-off-by: Ayush Kumar * Feat: enhance string parameter output to include optional prefix in CUE generation Signed-off-by: Ayush Kumar * Feat: add ConditionalOrFieldRef for fallback handling and support inline array values Signed-off-by: Ayush Kumar * Feat: update CUE generation to support inline arrays with conditional wrapping Signed-off-by: Ayush Kumar * Feat: update CUE generation to support inline arrays with conditional wrapping Signed-off-by: Ayush Kumar * Feat: add support for compound optional fields and enhance array builder with guarded filtering Signed-off-by: Ayush Kumar * Feat: add comprehensive tests for ArrayBuilder functionality Signed-off-by: Ayush Kumar * Feat: implement field grouping in StatusBuilder for consolidated CUE output Signed-off-by: Ayush Kumar * Feat: enhance CUE decomposition to support condValues and improve filtering logic Signed-off-by: Ayush Kumar * Feat: add metadata labels to ComponentDefinition and update CUE generation Signed-off-by: Ayush Kumar * Feat: clean up comments and formatting in cuegen and param files Signed-off-by: Ayush Kumar * Feat: enhance CUE generation, trait definitions, and PatchContainer logic CUE Generation: - Simplify condition decomposition logic and rename GetDirective method - Add condition decomposition and lifting logic to improve generated CUE output - Refactor cueTypeForParamType to use a standalone cueTypeStr function for reusability Collections: - Enhance MapVariant operation to merge variant mappings and preserve non-matching items Trait Definitions: - Enhance TraitDefinition and PatchContainerConfig with new attributes (MultiContainerCheckField, MultiContainerErrMsg) - Update emission logic in TraitCUEGenerator for multi-container support PatchContainer: - Update error messages to use camelCase for consistency with KubeVela conventions Signed-off-by: Ayush Kumar * Feat: enhance test descriptions for clarity and accuracy in array_builder, collections, and status tests Signed-off-by: Ayush Kumar * Feat: enhance handling of optional fields in collections and improve test descriptions for clarity Signed-off-by: Ayush Kumar * Feat: improve ConditionalOrFieldRef tests for clarity and accuracy in handling primary and fallback fields Signed-off-by: Ayush Kumar * Feat: enhance test descriptions for clarity and accuracy in array_builder and expr tests Signed-off-by: Ayush Kumar * Feat: improve formatting of test data in collections tests for better readability Signed-off-by: Ayush Kumar --------- Signed-off-by: Ayush Kumar --- pkg/definition/defkit/array_builder.go | 22 + pkg/definition/defkit/array_builder_test.go | 744 ++++++++++++++++++ pkg/definition/defkit/collections.go | 115 ++- pkg/definition/defkit/collections_test.go | 262 +++++- pkg/definition/defkit/component.go | 11 + pkg/definition/defkit/cuegen.go | 561 +++++++++++-- pkg/definition/defkit/cuegen_test.go | 636 +++++++++++++++ pkg/definition/defkit/expr.go | 28 + pkg/definition/defkit/expr_test.go | 79 +- pkg/definition/defkit/helper.go | 18 + pkg/definition/defkit/param.go | 71 +- pkg/definition/defkit/param_test.go | 254 ++++-- pkg/definition/defkit/patch_container.go | 4 + pkg/definition/defkit/patch_container_test.go | 196 +++++ pkg/definition/defkit/resource.go | 27 + pkg/definition/defkit/resource_test.go | 39 + pkg/definition/defkit/status.go | 129 ++- pkg/definition/defkit/status_test.go | 231 +++++- pkg/definition/defkit/trait.go | 133 ++-- pkg/definition/defkit/trait_test.go | 247 +++++- 20 files changed, 3541 insertions(+), 266 deletions(-) create mode 100644 pkg/definition/defkit/array_builder_test.go diff --git a/pkg/definition/defkit/array_builder.go b/pkg/definition/defkit/array_builder.go index 5c455c853..6dbe1ca6c 100644 --- a/pkg/definition/defkit/array_builder.go +++ b/pkg/definition/defkit/array_builder.go @@ -32,6 +32,7 @@ type arrayEntry struct { cond Condition // for conditional entries source Value // for forEach entries (iteration source) guard Condition // for forEach entries (optional guard: if source != _|_) + filter Predicate // for forEach entries (optional filter: if v.field == value) itemBuilder *ItemBuilder // for forEachWith entries (complex per-item logic) } @@ -129,6 +130,27 @@ func (a *ArrayBuilder) ForEachWithVar(varName string, source Value, fn func(item return a } +// ForEachWithGuardedFiltered adds a guarded and filtered complex iterated item to the array. +// The guard condition wraps the for loop, and the filter predicate filters iteration items. +// Generates: if guard for v in source if filter { ... } +func (a *ArrayBuilder) ForEachWithGuardedFiltered(guard Condition, filter Predicate, source Value, fn func(item *ItemBuilder)) *ArrayBuilder { + return a.ForEachWithGuardedFilteredVar("v", guard, filter, source, fn) +} + +// ForEachWithGuardedFilteredVar is like ForEachWithGuardedFiltered but allows specifying the iteration variable name. +func (a *ArrayBuilder) ForEachWithGuardedFilteredVar(varName string, guard Condition, filter Predicate, source Value, fn func(item *ItemBuilder)) *ArrayBuilder { + ib := &ItemBuilder{varName: varName, ops: make([]itemOp, 0)} + fn(ib) + a.entries = append(a.entries, arrayEntry{ + kind: entryForEachWith, + source: source, + guard: guard, + filter: filter, + itemBuilder: ib, + }) + return a +} + // --- ItemBuilder --- // itemOp is a single operation recorded by the ItemBuilder. diff --git a/pkg/definition/defkit/array_builder_test.go b/pkg/definition/defkit/array_builder_test.go new file mode 100644 index 000000000..9a1f54856 --- /dev/null +++ b/pkg/definition/defkit/array_builder_test.go @@ -0,0 +1,744 @@ +/* +Copyright 2025 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package defkit_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/oam-dev/kubevela/pkg/definition/defkit" +) + +var _ = Describe("ArrayBuilder", func() { + + Describe("NewArray", func() { + It("should create an empty array builder", func() { + ab := defkit.NewArray() + Expect(ab).NotTo(BeNil()) + Expect(ab.Entries()).To(BeEmpty()) + }) + + It("should implement the Value interface and retain builder behavior", func() { + ab := defkit.NewArray() + var v defkit.Value = ab // compile-time interface check + Expect(v).NotTo(BeNil()) + // Verify the builder still works through the interface + ab.Item(defkit.NewArrayElement().Set("name", defkit.Lit("test"))) + Expect(ab.Entries()).To(HaveLen(1)) + }) + }) + + Describe("Item", func() { + It("should add a static entry", func() { + elem := defkit.NewArrayElement().Set("name", defkit.Lit("cpu")) + ab := defkit.NewArray().Item(elem) + Expect(ab.Entries()).To(HaveLen(1)) + }) + + It("should chain multiple static items", func() { + elem1 := defkit.NewArrayElement().Set("name", defkit.Lit("cpu")) + elem2 := defkit.NewArrayElement().Set("name", defkit.Lit("memory")) + ab := defkit.NewArray().Item(elem1).Item(elem2) + Expect(ab.Entries()).To(HaveLen(2)) + }) + }) + + Describe("ItemIf", func() { + It("should add a conditional entry", func() { + mem := defkit.String("memory") + elem := defkit.NewArrayElement().Set("name", defkit.Lit("memory")) + ab := defkit.NewArray().ItemIf(mem.IsSet(), elem) + Expect(ab.Entries()).To(HaveLen(1)) + }) + }) + + Describe("ForEach", func() { + It("should add a forEach entry", func() { + ports := defkit.List("ports") + elem := defkit.NewArrayElement().Set("port", defkit.Reference("m.port")) + ab := defkit.NewArray().ForEach(ports, elem) + Expect(ab.Entries()).To(HaveLen(1)) + }) + }) + + Describe("ForEachGuarded", func() { + It("should add a guarded forEach entry", func() { + ports := defkit.List("ports") + elem := defkit.NewArrayElement().Set("port", defkit.Reference("m.port")) + ab := defkit.NewArray().ForEachGuarded(ports.IsSet(), ports, elem) + Expect(ab.Entries()).To(HaveLen(1)) + }) + }) + + Describe("ForEachWith", func() { + It("should add a forEachWith entry with ItemBuilder", func() { + ports := defkit.List("ports") + ab := defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + item.Set("port", v.Field("port")) + }) + entries := ab.Entries() + Expect(entries).To(HaveLen(1)) + }) + + It("should use 'v' as default variable name", func() { + ports := defkit.List("ports") + ab := defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + Expect(item.VarName()).To(Equal("v")) + }) + Expect(ab.Entries()).To(HaveLen(1)) + }) + }) + + Describe("ForEachWithVar", func() { + It("should use custom variable name", func() { + ports := defkit.List("ports") + ab := defkit.NewArray().ForEachWithVar("p", ports, func(item *defkit.ItemBuilder) { + Expect(item.VarName()).To(Equal("p")) + v := item.Var() + item.Set("port", v.Field("port")) + }) + Expect(ab.Entries()).To(HaveLen(1)) + }) + }) + + Describe("ForEachWithGuardedFiltered", func() { + It("should add entry with guard and filter", func() { + ports := defkit.List("ports") + ab := defkit.NewArray().ForEachWithGuardedFiltered( + ports.IsSet(), + defkit.FieldEquals("expose", true), + ports, + func(item *defkit.ItemBuilder) { + v := item.Var() + item.Set("port", v.Field("port")) + }, + ) + Expect(ab.Entries()).To(HaveLen(1)) + }) + + It("should use 'v' as default variable name", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWithGuardedFiltered( + ports.IsSet(), + defkit.FieldEquals("expose", true), + ports, + func(item *defkit.ItemBuilder) { + Expect(item.VarName()).To(Equal("v")) + }, + ) + }) + }) + + Describe("ForEachWithGuardedFilteredVar", func() { + It("should use custom variable name with guard and filter", func() { + ports := defkit.List("ports") + ab := defkit.NewArray().ForEachWithGuardedFilteredVar( + "p", + ports.IsSet(), + defkit.FieldEquals("expose", true), + ports, + func(item *defkit.ItemBuilder) { + Expect(item.VarName()).To(Equal("p")) + v := item.Var() + item.Set("port", v.Field("port")) + }, + ) + Expect(ab.Entries()).To(HaveLen(1)) + }) + }) + + Describe("mixed entries", func() { + It("should support mixing static, conditional, forEach, and forEachWith entries", func() { + mem := defkit.String("memory") + ports := defkit.List("ports") + metrics := defkit.List("metrics") + + staticElem := defkit.NewArrayElement().Set("name", defkit.Lit("cpu")) + condElem := defkit.NewArrayElement().Set("name", defkit.Lit("memory")) + forEachElem := defkit.NewArrayElement().Set("name", defkit.Reference("m.name")) + + ab := defkit.NewArray(). + Item(staticElem). + ItemIf(mem.IsSet(), condElem). + ForEach(metrics, forEachElem). + ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + item.Set("port", v.Field("port")) + }) + + Expect(ab.Entries()).To(HaveLen(4)) + }) + }) + + Describe("fluent chaining", func() { + It("should return the same builder for all methods", func() { + ab := defkit.NewArray() + elem := defkit.NewArrayElement().Set("name", defkit.Lit("test")) + ports := defkit.List("ports") + + result := ab.Item(elem) + Expect(result).To(BeIdenticalTo(ab)) + + result = ab.ItemIf(ports.IsSet(), elem) + Expect(result).To(BeIdenticalTo(ab)) + + result = ab.ForEach(ports, elem) + Expect(result).To(BeIdenticalTo(ab)) + + result = ab.ForEachGuarded(ports.IsSet(), ports, elem) + Expect(result).To(BeIdenticalTo(ab)) + + result = ab.ForEachWith(ports, func(item *defkit.ItemBuilder) {}) + Expect(result).To(BeIdenticalTo(ab)) + + result = ab.ForEachWithGuardedFiltered( + ports.IsSet(), defkit.FieldEquals("expose", true), ports, + func(item *defkit.ItemBuilder) {}, + ) + Expect(result).To(BeIdenticalTo(ab)) + }) + }) +}) + +var _ = Describe("ItemBuilder", func() { + + Describe("Var", func() { + It("should return an IterVarBuilder with the correct variable name", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + Expect(v).NotTo(BeNil()) + + // v.Field returns an IterFieldRef which implements Value + fieldRef := v.Field("port") + Expect(fieldRef).NotTo(BeNil()) + Expect(fieldRef.VarName()).To(Equal("v")) + Expect(fieldRef.FieldName()).To(Equal("port")) + + // v.Ref returns an IterVarRef for the whole variable + varRef := v.Ref() + Expect(varRef).NotTo(BeNil()) + Expect(varRef.VarName()).To(Equal("v")) + }) + }) + }) + + Describe("Set", func() { + It("should record a setOp", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + item.Set("port", v.Field("port")) + item.Set("name", v.Field("name")) + Expect(item.Ops()).To(HaveLen(2)) + }) + }) + }) + + Describe("If", func() { + It("should record an ifBlockOp with nested operations", func() { + ports := defkit.List("ports") + exposeType := defkit.String("exposeType") + defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + item.If(defkit.Eq(exposeType, defkit.Lit("NodePort")), func() { + item.Set("nodePort", v.Field("nodePort")) + }) + Expect(item.Ops()).To(HaveLen(1)) + }) + }) + }) + + Describe("IfSet", func() { + It("should record a conditional for field existence", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + item.IfSet("name", func() { + item.Set("name", v.Field("name")) + }) + Expect(item.Ops()).To(HaveLen(1)) + }) + }) + }) + + Describe("IfNotSet", func() { + It("should record a conditional for field non-existence", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + _ = item.Var() + item.IfNotSet("name", func() { + item.Set("name", defkit.Lit("default")) + }) + Expect(item.Ops()).To(HaveLen(1)) + }) + }) + }) + + Describe("IfSet and IfNotSet together", func() { + It("should form an if/else pattern", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + item.IfSet("containerPort", func() { + item.Set("containerPort", v.Field("containerPort")) + }) + item.IfNotSet("containerPort", func() { + item.Set("containerPort", v.Field("port")) + }) + Expect(item.Ops()).To(HaveLen(2)) + }) + }) + }) + + Describe("Let", func() { + It("should record a letOp and return a reference with correct name", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + ref := item.Let("_name", + defkit.Plus(defkit.Lit("port-"), defkit.StrconvFormatInt(v.Field("port"), 10))) + letRef, ok := ref.(*defkit.IterLetRef) + Expect(ok).To(BeTrue(), "expected *IterLetRef") + Expect(letRef.RefName()).To(Equal("_name")) + Expect(item.Ops()).To(HaveLen(1)) + }) + }) + + It("should allow using the let reference in subsequent operations", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + nameRef := item.Let("_name", + defkit.Plus(defkit.Lit("port-"), defkit.StrconvFormatInt(v.Field("port"), 10))) + item.SetDefault("name", nameRef, "string") + Expect(item.Ops()).To(HaveLen(2)) + }) + }) + }) + + Describe("SetDefault", func() { + It("should record a setDefaultOp", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + item.SetDefault("name", defkit.Lit("default"), "string") + Expect(item.Ops()).To(HaveLen(1)) + }) + }) + }) + + Describe("FieldExists", func() { + It("should return a Condition for field existence with correct var and field name", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + cond := item.FieldExists("name") + Expect(cond).NotTo(BeNil()) + iterCond, ok := cond.(*defkit.IterFieldExistsCondition) + Expect(ok).To(BeTrue(), "expected *IterFieldExistsCondition") + Expect(iterCond.VarName()).To(Equal("v")) + Expect(iterCond.FieldName()).To(Equal("name")) + Expect(iterCond.IsNegated()).To(BeFalse()) + }) + }) + }) + + Describe("FieldNotExists", func() { + It("should return a negated Condition for field non-existence", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + cond := item.FieldNotExists("name") + Expect(cond).NotTo(BeNil()) + iterCond, ok := cond.(*defkit.IterFieldExistsCondition) + Expect(ok).To(BeTrue(), "expected *IterFieldExistsCondition") + Expect(iterCond.VarName()).To(Equal("v")) + Expect(iterCond.FieldName()).To(Equal("name")) + Expect(iterCond.IsNegated()).To(BeTrue()) + }) + }) + }) + + Describe("complex ItemBuilder pattern", func() { + It("should support nested conditionals with let bindings and defaults", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + + // Unconditional field + item.Set("port", v.Field("port")) + + // If/else for containerPort + item.IfSet("containerPort", func() { + item.Set("containerPort", v.Field("containerPort")) + }) + item.IfNotSet("containerPort", func() { + item.Set("containerPort", v.Field("port")) + }) + + // Nested: name with let binding, default, and protocol suffix + item.IfSet("name", func() { + item.Set("name", v.Field("name")) + }) + item.IfNotSet("name", func() { + nameRef := item.Let("_name", + defkit.Plus(defkit.Lit("port-"), defkit.StrconvFormatInt(v.Field("port"), 10))) + item.SetDefault("name", nameRef, "string") + item.If(defkit.Ne(v.Field("protocol"), defkit.Lit("TCP")), func() { + item.Set("name", defkit.Plus(nameRef, defkit.Lit("-"), defkit.StringsToLower(v.Field("protocol")))) + }) + }) + + // set=1, ifSet(containerPort)=1, ifNotSet(containerPort)=1, ifSet(name)=1, ifNotSet(name)=1 + Expect(item.Ops()).To(HaveLen(5)) + }) + }) + }) +}) + +var _ = Describe("IterVarBuilder", func() { + It("should return field references with correct variable and field names", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + portRef := v.Field("port") + Expect(portRef.VarName()).To(Equal("v")) + Expect(portRef.FieldName()).To(Equal("port")) + + nameRef := v.Field("name") + Expect(nameRef.VarName()).To(Equal("v")) + Expect(nameRef.FieldName()).To(Equal("name")) + }) + }) + + It("should return a whole-variable reference with correct variable name", func() { + items := defkit.StringList("items") + defkit.NewArray().ForEachWith(items, func(item *defkit.ItemBuilder) { + v := item.Var() + ref := v.Ref() + Expect(ref.VarName()).To(Equal("v")) + }) + }) + + It("should use custom variable name for field references", func() { + ports := defkit.List("ports") + defkit.NewArray().ForEachWithVar("p", ports, func(item *defkit.ItemBuilder) { + v := item.Var() + fieldRef := v.Field("port") + Expect(fieldRef.VarName()).To(Equal("p")) + Expect(fieldRef.FieldName()).To(Equal("port")) + + ref := v.Ref() + Expect(ref.VarName()).To(Equal("p")) + }) + }) +}) + +var _ = Describe("ArrayConcat", func() { + It("should create an array concatenation value", func() { + left := defkit.NewArray() + right := defkit.List("extra") + concat := defkit.ArrayConcat(left, right) + Expect(concat).NotTo(BeNil()) + Expect(concat.Left()).To(BeIdenticalTo(left)) + Expect(concat.Right()).To(BeIdenticalTo(right)) + }) + + It("should implement the Value interface and preserve operands", func() { + left := defkit.NewArray() + right := defkit.List("extra") + var v defkit.Value = defkit.ArrayConcat(left, right) // compile-time interface check + Expect(v).NotTo(BeNil()) + concat := v.(*defkit.ArrayConcatValue) + Expect(concat.Left()).To(BeIdenticalTo(left)) + Expect(concat.Right()).To(BeIdenticalTo(right)) + }) +}) + +var _ = Describe("ArrayBuilder CUE Generation", func() { + var gen *defkit.CUEGenerator + + BeforeEach(func() { + gen = defkit.NewCUEGenerator() + }) + + It("should generate CUE for ForEachWith with simple field assignments", func() { + ports := defkit.List("ports").WithFields( + defkit.Int("port").Required(), + defkit.String("name"), + ) + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + Params(ports). + Template(func(tpl *defkit.Template) { + containerPorts := defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + item.Set("containerPort", v.Field("port")) + item.Set("name", v.Field("name")) + }) + tpl.Output( + defkit.NewResource("apps/v1", "Deployment"). + SetIf(ports.IsSet(), "spec.template.spec.containers[0].ports", containerPorts), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + Expect(cue).To(ContainSubstring("for v in parameter.ports")) + Expect(cue).To(ContainSubstring("containerPort: v.port")) + Expect(cue).To(ContainSubstring("name: v.name")) + }) + + It("should generate CUE for ForEachWith with IfSet/IfNotSet conditionals", func() { + ports := defkit.List("ports").WithFields( + defkit.Int("port").Required(), + defkit.Int("containerPort"), + ) + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + Params(ports). + Template(func(tpl *defkit.Template) { + containerPorts := defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + item.IfSet("containerPort", func() { + item.Set("containerPort", v.Field("containerPort")) + }) + item.IfNotSet("containerPort", func() { + item.Set("containerPort", v.Field("port")) + }) + }) + tpl.Output( + defkit.NewResource("apps/v1", "Deployment"). + SetIf(ports.IsSet(), "spec.template.spec.containers[0].ports", containerPorts), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + Expect(cue).To(ContainSubstring("if v.containerPort != _|_")) + Expect(cue).To(ContainSubstring("containerPort: v.containerPort")) + Expect(cue).To(ContainSubstring("if v.containerPort == _|_")) + Expect(cue).To(ContainSubstring("containerPort: v.port")) + }) + + It("should generate CUE for ForEachWith with let bindings and defaults", func() { + ports := defkit.List("ports").WithFields( + defkit.Int("port").Required(), + ) + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + Params(ports). + Template(func(tpl *defkit.Template) { + containerPorts := defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + nameRef := item.Let("_name", + defkit.Plus(defkit.Lit("port-"), defkit.StrconvFormatInt(v.Field("port"), 10))) + item.SetDefault("name", nameRef, "string") + }) + tpl.Output( + defkit.NewResource("apps/v1", "Deployment"). + SetIf(ports.IsSet(), "spec.template.spec.containers[0].ports", containerPorts), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + Expect(cue).To(ContainSubstring(`_name: "port-" + strconv.FormatInt(v.port, 10)`)) + Expect(cue).To(ContainSubstring("name: *_name | string")) + }) + + It("should generate CUE for ForEachWithVar with custom variable name", func() { + ports := defkit.List("ports").WithFields( + defkit.Int("port").Required(), + ) + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + Params(ports). + Template(func(tpl *defkit.Template) { + containerPorts := defkit.NewArray().ForEachWithVar("p", ports, func(item *defkit.ItemBuilder) { + v := item.Var() + item.Set("containerPort", v.Field("port")) + }) + tpl.Output( + defkit.NewResource("apps/v1", "Deployment"). + SetIf(ports.IsSet(), "spec.template.spec.containers[0].ports", containerPorts), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + Expect(cue).To(ContainSubstring("for p in parameter.ports")) + Expect(cue).To(ContainSubstring("containerPort: p.port")) + }) + + It("should generate CUE for ForEachWithGuardedFiltered with guard and filter", func() { + ports := defkit.List("ports").WithFields( + defkit.Int("port").Required(), + defkit.Bool("expose").Default(false), + ) + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + Params(ports). + Template(func(tpl *defkit.Template) { + exposePorts := defkit.NewArray().ForEachWithGuardedFiltered( + ports.IsSet(), + defkit.FieldEquals("expose", true), + ports, + func(item *defkit.ItemBuilder) { + v := item.Var() + item.Set("port", v.Field("port")) + }, + ) + tpl.Output( + defkit.NewResource("apps/v1", "Deployment"). + Set("spec.ports", exposePorts), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + // Guard condition + Expect(cue).To(ContainSubstring(`parameter["ports"] != _|_`)) + // Filter predicate + Expect(cue).To(ContainSubstring("v.expose == true")) + // Field assignment + Expect(cue).To(ContainSubstring("port: v.port")) + }) + + It("should generate CUE for ForEachWith with nested If conditions", func() { + ports := defkit.List("ports").WithFields( + defkit.Int("port").Required(), + defkit.String("protocol"), + ) + exposeType := defkit.String("exposeType") + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + Params(ports, exposeType). + Template(func(tpl *defkit.Template) { + result := defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + item.Set("port", v.Field("port")) + item.If(defkit.Ne(v.Field("protocol"), defkit.Lit("TCP")), func() { + item.Set("protocol", v.Field("protocol")) + }) + }) + tpl.Output( + defkit.NewResource("apps/v1", "Deployment"). + SetIf(ports.IsSet(), "spec.ports", result), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + Expect(cue).To(ContainSubstring("port: v.port")) + Expect(cue).To(ContainSubstring(`v.protocol != "TCP"`)) + Expect(cue).To(ContainSubstring("protocol: v.protocol")) + }) + + It("should auto-detect strconv import from ForEachWith ItemBuilder ops", func() { + ports := defkit.List("ports").WithFields( + defkit.Int("port").Required(), + ) + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + Params(ports). + Template(func(tpl *defkit.Template) { + result := defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + item.Let("_name", + defkit.Plus(defkit.Lit("port-"), defkit.StrconvFormatInt(v.Field("port"), 10))) + }) + tpl.Output( + defkit.NewResource("apps/v1", "Deployment"). + Set("spec.ports", result), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + Expect(cue).To(ContainSubstring(`"strconv"`)) + }) + + It("should auto-detect strings import from ForEachWith ItemBuilder ops", func() { + ports := defkit.List("ports").WithFields( + defkit.Int("port").Required(), + defkit.String("protocol"), + ) + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + WithImports(). + Params(ports). + Template(func(tpl *defkit.Template) { + result := defkit.NewArray().ForEachWith(ports, func(item *defkit.ItemBuilder) { + v := item.Var() + item.Set("name", defkit.StringsToLower(v.Field("protocol"))) + }) + tpl.Output( + defkit.NewResource("apps/v1", "Deployment"). + Set("spec.ports", result), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + Expect(cue).To(ContainSubstring(`"strings"`)) + }) + + It("should generate CUE for helper backed by FromArray with ArrayBuilder", func() { + ports := defkit.List("ports").WithFields( + defkit.Int("port").Required(), + defkit.Bool("expose").Default(false), + ) + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + Params(ports). + Template(func(tpl *defkit.Template) { + exposePortsArray := defkit.NewArray().ForEachWithGuardedFiltered( + ports.IsSet(), + defkit.FieldEquals("expose", true), + ports, + func(item *defkit.ItemBuilder) { + v := item.Var() + item.Set("port", v.Field("port")) + }, + ) + exposePorts := tpl.Helper("exposePorts"). + FromArray(exposePortsArray). + AfterOutput(). + Build() + + tpl.Output( + defkit.NewResource("apps/v1", "Deployment"). + Set("metadata.name", defkit.VelaCtx().Name()), + ) + tpl.OutputsIf(exposePorts.NotEmpty(), "svc", + defkit.NewResource("v1", "Service"). + Set("spec.ports", exposePorts), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + // Helper should be named exposePorts + Expect(cue).To(ContainSubstring("exposePorts:")) + // Guard + filter + iteration + Expect(cue).To(ContainSubstring(`parameter["ports"] != _|_`)) + Expect(cue).To(ContainSubstring("v.expose == true")) + Expect(cue).To(ContainSubstring("port: v.port")) + // Outputs should reference exposePorts + Expect(cue).To(ContainSubstring("len(exposePorts) != 0")) + }) +}) diff --git a/pkg/definition/defkit/collections.go b/pkg/definition/defkit/collections.go index 95d1fd47c..f02bd9724 100644 --- a/pkg/definition/defkit/collections.go +++ b/pkg/definition/defkit/collections.go @@ -90,6 +90,19 @@ func (c *CollectionOp) Map(mappings FieldMap) *CollectionOp { return c } +// MapVariant adds a conditional field mapping that only applies when the iteration +// variable's discriminator field equals the given variant name. +// Generates: if v.discriminator == "variantName" { ...mappings... } +// Usage: .MapVariant("type", "pvc", FieldMap{"persistentVolumeClaim.claimName": FieldRef("claimName")}) +func (c *CollectionOp) MapVariant(discriminator, variantName string, mappings FieldMap) *CollectionOp { + c.ops = append(c.ops, &mapVariantOp{ + discriminator: discriminator, + variantName: variantName, + mappings: mappings, + }) + return c +} + // Pick selects only the specified fields from each item. // Usage: .Pick("name", "mountPath") func (c *CollectionOp) Pick(fields ...string) *CollectionOp { @@ -236,10 +249,36 @@ func (f FieldRef) resolve(item map[string]any) any { } // Or provides a fallback if the field is nil or empty. +// Generates CUE like: *v.field | fallback func (f FieldRef) Or(fallback FieldValue) *OrFieldRef { return &OrFieldRef{primary: f, fallback: fallback} } +// OrConditional provides a fallback using if/else blocks instead of default syntax. +// Generates CUE like: +// +// if v.field != _|_ { name: v.field } +// if v.field == _|_ { name: fallbackExpr } +func (f FieldRef) OrConditional(fallback FieldValue) *ConditionalOrFieldRef { + return &ConditionalOrFieldRef{primary: f, fallback: fallback} +} + +// ConditionalOrFieldRef represents a field reference with a conditional fallback. +// Instead of generating CUE default syntax (*v.field | fallback), it generates +// two if/else blocks for the field. +type ConditionalOrFieldRef struct { + primary FieldRef + fallback FieldValue +} + +func (c *ConditionalOrFieldRef) resolve(item map[string]any) any { + val := item[string(c.primary)] + if val == nil || val == "" { + return c.fallback.resolve(item) + } + return val +} + // OrFieldRef represents a field reference with a fallback value. type OrFieldRef struct { primary FieldRef @@ -368,7 +407,18 @@ func (m *mapOp) apply(items []any) []any { if itemMap, ok := item.(map[string]any); ok { newItem := make(map[string]any) for newKey, fieldVal := range m.mappings { - newItem[newKey] = fieldVal.resolve(itemMap) + resolved := fieldVal.resolve(itemMap) + // Skip nil values from optional field types — they should be + // omitted from the output entirely, not included as nil. + if resolved == nil { + if _, isOpt := fieldVal.(*OptionalField); isOpt { + continue + } + if _, isCompOpt := fieldVal.(*CompoundOptionalField); isCompOpt { + continue + } + } + newItem[newKey] = resolved } result = append(result, newItem) } @@ -376,6 +426,47 @@ func (m *mapOp) apply(items []any) []any { return result } +// mapVariantOp represents a conditional map operation based on a discriminator field value. +// When the iteration variable's discriminator field equals the variant name, +// the variant's field mappings are included. +// Generates: if v.discriminator == "variantName" { ...mappings... } +type mapVariantOp struct { + discriminator string + variantName string + mappings FieldMap +} + +func (m *mapVariantOp) apply(items []any) []any { + // Runtime apply: merge variant mappings into matching items, pass others through. + // This mirrors CUE semantics where each variant condition is evaluated for every + // item in the loop body — non-matching items are kept so later MapVariant ops + // can process them. + result := make([]any, 0, len(items)) + for _, item := range items { + itemMap, ok := item.(map[string]any) + if !ok { + result = append(result, item) + continue + } + disc, exists := itemMap[m.discriminator] + if !exists || fmt.Sprintf("%v", disc) != m.variantName { + // Non-matching: pass through unchanged + result = append(result, item) + continue + } + // Matching: copy existing fields and merge variant mappings + newItem := make(map[string]any, len(itemMap)+len(m.mappings)) + for k, v := range itemMap { + newItem[k] = v + } + for newKey, fieldVal := range m.mappings { + newItem[newKey] = fieldVal.resolve(itemMap) + } + result = append(result, newItem) + } + return result +} + type pickOp struct { fields []string } @@ -698,6 +789,28 @@ func OptionalFieldRef(field string) *OptionalField { return &OptionalField{field: field} } +// CompoundOptionalField includes a field value only if the field exists AND an additional condition is met. +// Generates CUE: if v.field != _|_ if additionalCond { fieldName: v.field } +type CompoundOptionalField struct { + field string + additionalCond Condition +} + +func (o *CompoundOptionalField) resolve(item map[string]any) any { + val, exists := item[o.field] + if !exists || val == nil { + return nil + } + return val +} + +// OptionalFieldWithCond creates a field reference that includes the field only when +// both the field exists and the additional condition is satisfied. +// Generates CUE: if v.field != _|_ if cond { fieldName: v.field } +func OptionalFieldWithCond(field string, cond Condition) *CompoundOptionalField { + return &CompoundOptionalField{field: field, additionalCond: cond} +} + // NestedFieldMap creates a nested object from field mappings. // This is an alias for Nested, providing a clearer name for struct array helpers. // Usage: defkit.NestedFieldMap(defkit.FieldMap{"claimName": defkit.FieldRef("claimName")}) diff --git a/pkg/definition/defkit/collections_test.go b/pkg/definition/defkit/collections_test.go index bdf3e3611..c44131d14 100644 --- a/pkg/definition/defkit/collections_test.go +++ b/pkg/definition/defkit/collections_test.go @@ -81,6 +81,19 @@ var _ = Describe("Collections", func() { Expect(col.Operations()).To(HaveLen(1)) }) + It("should chain MapVariant operation", func() { + volumes := defkit.List("volumes") + col := defkit.Each(volumes). + Map(defkit.FieldMap{"name": defkit.FieldRef("name")}). + MapVariant("type", "pvc", defkit.FieldMap{ + "persistentVolumeClaim.claimName": defkit.FieldRef("claimName"), + }). + MapVariant("type", "emptyDir", defkit.FieldMap{ + "emptyDir.medium": defkit.FieldRef("medium"), + }) + Expect(col.Operations()).To(HaveLen(3)) + }) + It("should chain Rename operation", func() { ports := defkit.List("ports") col := defkit.Each(ports).Rename("port", "containerPort") @@ -101,42 +114,85 @@ var _ = Describe("Collections", func() { }) Context("FieldRef", func() { - It("should resolve field from item", func() { + It("should store field name and resolve correctly from item", func() { ref := defkit.FieldRef("port") - Expect(ref).NotTo(BeNil()) + Expect(string(ref)).To(Equal("port")) + + // Verify it resolves correctly through a collection + ports := defkit.List("ports") + col := defkit.Each(ports).Map(defkit.FieldMap{"p": ref}) + items := []any{map[string]any{"port": 8080}} + results := col.Collect(items) + Expect(results).To(HaveLen(1)) + Expect(results[0]["p"]).To(Equal(8080)) }) - It("should support Or fallback", func() { - ref := defkit.FieldRef("name").Or(defkit.Format("port-%v", defkit.FieldRef("port"))) - Expect(ref).NotTo(BeNil()) + It("should support Or fallback and use fallback when primary is nil", func() { + ports := defkit.List("ports") + col := defkit.Each(ports).Map(defkit.FieldMap{ + "display": defkit.FieldRef("name").Or(defkit.Format("port-%v", defkit.FieldRef("port"))), + }) + // Primary field present — should use primary + items := []any{map[string]any{"port": 80, "name": "http"}} + results := col.Collect(items) + Expect(results[0]["display"]).To(Equal("http")) + + // Primary field missing — should use fallback + items = []any{map[string]any{"port": 443}} + results = col.Collect(items) + Expect(results[0]["display"]).To(Equal("port-443")) }) }) Context("FieldEquals", func() { - It("should create equality predicate", func() { + It("should filter items matching the field equality predicate", func() { pred := defkit.FieldEquals("expose", true) - Expect(pred).NotTo(BeNil()) + ports := defkit.List("ports") + col := defkit.Each(ports).Filter(pred) + items := []any{ + map[string]any{"port": 80, "expose": true}, + map[string]any{"port": 443, "expose": false}, + } + results := col.Collect(items) + Expect(results).To(HaveLen(1)) + Expect(results[0]["port"]).To(Equal(80)) }) }) Context("FieldExists", func() { - It("should create existence predicate", func() { + It("should filter items where field is present", func() { pred := defkit.FieldExists("items") - Expect(pred).NotTo(BeNil()) + ports := defkit.List("data") + col := defkit.Each(ports).Filter(pred) + items := []any{ + map[string]any{"name": "a", "items": []string{"x"}}, + map[string]any{"name": "b"}, + } + results := col.Collect(items) + Expect(results).To(HaveLen(1)) + Expect(results[0]["name"]).To(Equal("a")) }) }) Context("Format", func() { - It("should create format field value", func() { + It("should create format field value with required imports", func() { f := defkit.Format("port-%v", defkit.FieldRef("port")) Expect(f).NotTo(BeNil()) + Expect(f.RequiredImports()).To(ContainElement("strconv")) }) }) Context("LitField", func() { - It("should create literal field value", func() { + It("should resolve literal field value from item", func() { lit := defkit.LitField("TCP") Expect(lit).NotTo(BeNil()) + // LitField should resolve to the literal value regardless of item content + ports := defkit.List("ports") + col := defkit.Each(ports).Map(defkit.FieldMap{"protocol": lit}) + items := []any{map[string]any{"port": 80}} + results := col.Collect(items) + Expect(results).To(HaveLen(1)) + Expect(results[0]["protocol"]).To(Equal("TCP")) }) }) @@ -181,32 +237,98 @@ var _ = Describe("Collections", func() { }) Context("Nested", func() { - It("should create nested field mapping", func() { + It("should resolve nested field mapping from item", func() { nested := defkit.Nested(defkit.FieldMap{ "claimName": defkit.FieldRef("claimName"), }) Expect(nested).NotTo(BeNil()) + // Verify nested resolves correctly through a collection + vols := defkit.List("volumes") + col := defkit.Each(vols).Map(defkit.FieldMap{ + "pvc": nested, + }) + items := []any{map[string]any{"claimName": "my-pvc"}} + results := col.Collect(items) + Expect(results).To(HaveLen(1)) + Expect(results[0]["pvc"]).To(Equal(map[string]any{"claimName": "my-pvc"})) }) }) Context("Optional and OptionalFieldRef", func() { - It("should create optional field reference", func() { - opt := defkit.Optional("items") - Expect(opt).NotTo(BeNil()) + It("should include optional field when present and omit when absent", func() { + vols := defkit.List("volumes") + col := defkit.Each(vols).Map(defkit.FieldMap{ + "name": defkit.FieldRef("name"), + "items": defkit.Optional("items"), + }) + items := []any{ + map[string]any{"name": "vol1", "items": []string{"a"}}, + map[string]any{"name": "vol2"}, + } + results := col.Collect(items) + Expect(results).To(HaveLen(2)) + Expect(results[0]).To(HaveKey("items")) + Expect(results[1]).NotTo(HaveKey("items")) }) It("should create optional field reference via OptionalFieldRef alias", func() { - opt := defkit.OptionalFieldRef("subPath") - Expect(opt).NotTo(BeNil()) + vols := defkit.List("volumes") + col := defkit.Each(vols).Map(defkit.FieldMap{ + "name": defkit.FieldRef("name"), + "subPath": defkit.OptionalFieldRef("subPath"), + }) + items := []any{ + map[string]any{"name": "vol1", "subPath": "/data"}, + map[string]any{"name": "vol2"}, + } + results := col.Collect(items) + Expect(results).To(HaveLen(2)) + Expect(results[0]["subPath"]).To(Equal("/data")) + Expect(results[1]).NotTo(HaveKey("subPath")) + }) + }) + + Context("CompoundOptionalField", func() { + It("should create compound optional field with OptionalFieldWithCond", func() { + cond := defkit.Eq(defkit.String("exposeType"), defkit.Lit("NodePort")) + compOpt := defkit.OptionalFieldWithCond("nodePort", cond) + Expect(compOpt).NotTo(BeNil()) + // Verify it can be used in a FieldMap and the CUE generation picks it up + ports := defkit.List("ports").WithFields( + defkit.Int("port").Required(), + defkit.Int("nodePort"), + ) + col := defkit.Each(ports).Map(defkit.FieldMap{ + "port": defkit.FieldRef("port"), + "nodePort": compOpt, + }) + Expect(col.Operations()).To(HaveLen(1)) + }) + + It("should be usable as a FieldValue in FieldMap alongside regular fields", func() { + cond := defkit.Eq(defkit.String("exposeType"), defkit.Lit("NodePort")) + fm := defkit.FieldMap{ + "port": defkit.FieldRef("port"), + "nodePort": defkit.OptionalFieldWithCond("nodePort", cond), + } + Expect(fm).To(HaveLen(2)) + Expect(fm).To(HaveKey("port")) + Expect(fm).To(HaveKey("nodePort")) }) }) Context("NestedFieldMap", func() { - It("should create nested field mapping (alias for Nested)", func() { + It("should resolve nested field mapping identically to Nested", func() { nested := defkit.NestedFieldMap(defkit.FieldMap{ "claimName": defkit.FieldRef("claimName"), }) Expect(nested).NotTo(BeNil()) + vols := defkit.List("volumes") + col := defkit.Each(vols).Map(defkit.FieldMap{"pvc": nested}) + items := []any{map[string]any{"claimName": "my-pvc"}} + results := col.Collect(items) + Expect(results).To(HaveLen(1)) + Expect(results[0]["pvc"]).To(Equal(map[string]any{"claimName": "my-pvc"})) }) }) @@ -817,6 +939,65 @@ var _ = Describe("Collections", func() { }) }) + Context("MapVariant chained operation behavior", func() { + It("should apply chained variant mappings to matching items and pass others through", func() { + volumes := defkit.List("volumes") + col := defkit.Each(volumes). + MapVariant("type", "pvc", defkit.FieldMap{ + "pvcClaim": defkit.FieldRef("claimName"), + }). + MapVariant("type", "configMap", defkit.FieldMap{ + "cmRef": defkit.FieldRef("cmName"), + }). + MapVariant("type", "emptyDir", defkit.FieldMap{ + "medium": defkit.FieldRef("medium"), + }) + + items := []any{ + map[string]any{"name": "data", "type": "pvc", "claimName": "data-pvc"}, + map[string]any{"name": "config", "type": "configMap", "cmName": "app-config"}, + map[string]any{"name": "cache", "type": "emptyDir", "medium": "Memory"}, + } + + results := col.Collect(items) + Expect(results).To(HaveLen(3)) + + // PVC item should have variant field merged, original fields preserved + Expect(results[0]["name"]).To(Equal("data")) + Expect(results[0]["pvcClaim"]).To(Equal("data-pvc")) + Expect(results[0]).NotTo(HaveKey("cmRef")) + + // ConfigMap item should have variant field merged + Expect(results[1]["name"]).To(Equal("config")) + Expect(results[1]["cmRef"]).To(Equal("app-config")) + Expect(results[1]).NotTo(HaveKey("pvcClaim")) + + // EmptyDir item should have variant field merged + Expect(results[2]["name"]).To(Equal("cache")) + Expect(results[2]["medium"]).To(Equal("Memory")) + }) + + It("should preserve items with no matching variant", func() { + volumes := defkit.List("volumes") + col := defkit.Each(volumes). + MapVariant("type", "pvc", defkit.FieldMap{ + "pvcClaim": defkit.FieldRef("claimName"), + }) + + items := []any{ + map[string]any{"name": "data", "type": "pvc", "claimName": "data-pvc"}, + map[string]any{"name": "config", "type": "configMap", "cmName": "app-config"}, + } + + results := col.Collect(items) + Expect(results).To(HaveLen(2)) + Expect(results[0]["pvcClaim"]).To(Equal("data-pvc")) + // Non-matching item passes through unchanged + Expect(results[1]["name"]).To(Equal("config")) + Expect(results[1]["type"]).To(Equal("configMap")) + }) + }) + Context("Flatten operation behavior", func() { It("should flatten nested arrays", func() { volumes := defkit.List("volumes") @@ -877,6 +1058,51 @@ var _ = Describe("Collections", func() { }) }) + Context("OrConditional (ConditionalOrFieldRef)", func() { + It("should create ConditionalOrFieldRef that resolves primary field", func() { + ref := defkit.FieldRef("name").OrConditional(defkit.Format("port-%v", defkit.FieldRef("port"))) + col := defkit.Each(defkit.List("items")).Map(defkit.FieldMap{"result": ref}) + results := col.Collect([]any{map[string]any{"name": "web", "port": 80}}) + Expect(results).To(HaveLen(1)) + Expect(results[0]["result"]).To(Equal("web")) + }) + + It("should resolve primary vs fallback across multiple items with full structure", func() { + ports := defkit.List("ports") + col := defkit.Each(ports).Map(defkit.FieldMap{ + "displayName": defkit.FieldRef("name").OrConditional(defkit.Format("port-%v", defkit.FieldRef("port"))), + "portNumber": defkit.FieldRef("port"), + "protocol": defkit.FieldRef("proto").OrConditional(defkit.LitField("TCP")), + }) + + items := []any{ + map[string]any{"port": 80, "name": "http", "proto": "HTTP"}, // all primary fields present + map[string]any{"port": 443}, // name and proto nil → fallback + map[string]any{"port": 8080, "name": "", "proto": ""}, // name and proto empty → fallback + map[string]any{"port": 9090, "name": "grpc"}, // name present, proto nil → mixed + } + + results := col.Collect(items) + Expect(results).To(HaveLen(4)) + + Expect(results[0]["displayName"]).To(Equal("http")) + Expect(results[0]["portNumber"]).To(Equal(80)) + Expect(results[0]["protocol"]).To(Equal("HTTP")) + + Expect(results[1]["displayName"]).To(Equal("port-443")) + Expect(results[1]["portNumber"]).To(Equal(443)) + Expect(results[1]["protocol"]).To(Equal("TCP")) + + Expect(results[2]["displayName"]).To(Equal("port-8080")) + Expect(results[2]["portNumber"]).To(Equal(8080)) + Expect(results[2]["protocol"]).To(Equal("TCP")) + + Expect(results[3]["displayName"]).To(Equal("grpc")) + Expect(results[3]["portNumber"]).To(Equal(9090)) + Expect(results[3]["protocol"]).To(Equal("TCP")) + }) + }) + Context("FormatField RequiredImports", func() { It("should require strconv for numeric formatting", func() { f := defkit.Format("port-%v", defkit.FieldRef("port")) diff --git a/pkg/definition/defkit/component.go b/pkg/definition/defkit/component.go index 5e592513e..a6a16fb48 100644 --- a/pkg/definition/defkit/component.go +++ b/pkg/definition/defkit/component.go @@ -29,6 +29,7 @@ import ( type ComponentDefinition struct { baseDefinition // embedded common fields (name, description, params, template, etc.) workload WorkloadType + labels map[string]string // metadata labels for the component definition } // WorkloadType represents the workload type for a component. @@ -140,6 +141,16 @@ func (c *ComponentDefinition) Helper(name string, param Param) *ComponentDefinit return c } +// Labels sets metadata labels on the component definition. +// Usage: component.Labels(map[string]string{"ui-hidden": "true"}) +func (c *ComponentDefinition) Labels(labels map[string]string) *ComponentDefinition { + c.labels = labels + return c +} + +// GetLabels returns the component's metadata labels. +func (c *ComponentDefinition) GetLabels() map[string]string { return c.labels } + // RawCUE sets raw CUE for complex component definitions that don't fit the builder pattern. // When set, this bypasses all other template settings and outputs the raw CUE directly. func (c *ComponentDefinition) RawCUE(cue string) *ComponentDefinition { diff --git a/pkg/definition/defkit/cuegen.go b/pkg/definition/defkit/cuegen.go index 4fd4b3dd2..acb991430 100644 --- a/pkg/definition/defkit/cuegen.go +++ b/pkg/definition/defkit/cuegen.go @@ -18,9 +18,13 @@ package defkit import ( "fmt" + "sort" "strings" ) +// cueOpenStruct is the CUE literal for an open struct type. +const cueOpenStruct = "{...}" + // cueLabel quotes a CUE field label if it contains characters that are not // valid in a CUE identifier (letters, digits, underscore, $). func cueLabel(name string) string { @@ -172,6 +176,16 @@ func (g *CUEGenerator) collectImportsFromValue(v interface{}) { g.collectImportsFromFieldValue(fv) } } + case *ArrayBuilder: + for _, entry := range val.Entries() { + if entry.itemBuilder != nil { + g.collectImportsFromItemOps(entry.itemBuilder.Ops()) + } + } + case *PlusExpr: + for _, part := range val.Parts() { + g.collectImportsFromValue(part) + } } } @@ -192,6 +206,8 @@ func (g *CUEGenerator) collectImportsFromFieldValue(fv FieldValue) { switch val := fv.(type) { case *OrFieldRef: g.collectImportsFromFieldValue(val.fallback) + case *ConditionalOrFieldRef: + g.collectImportsFromFieldValue(val.fallback) case *NestedField: for _, nested := range val.mapping { g.collectImportsFromFieldValue(nested) @@ -199,6 +215,22 @@ func (g *CUEGenerator) collectImportsFromFieldValue(fv FieldValue) { } } +// collectImportsFromItemOps recursively scans ItemBuilder operations for import requirements. +func (g *CUEGenerator) collectImportsFromItemOps(ops []itemOp) { + for _, op := range ops { + switch o := op.(type) { + case setOp: + g.collectImportsFromValue(o.value) + case letOp: + g.collectImportsFromValue(o.value) + case setDefaultOp: + g.collectImportsFromValue(o.defValue) + case ifBlockOp: + g.collectImportsFromItemOps(o.body) + } + } +} + // collectImportsFromResource checks all operations in a resource for import requirements. func (g *CUEGenerator) collectImportsFromResource(res *Resource) { if res == nil { @@ -260,7 +292,15 @@ func (g *CUEGenerator) GenerateFullDefinition(c *ComponentDefinition) string { sb.WriteString(fmt.Sprintf("%s: {\n", cueLabel(c.GetName()))) sb.WriteString(fmt.Sprintf("%stype: \"component\"\n", g.indent)) sb.WriteString(fmt.Sprintf("%sannotations: {}\n", g.indent)) - sb.WriteString(fmt.Sprintf("%slabels: {}\n", g.indent)) + if len(c.GetLabels()) > 0 { + sb.WriteString(fmt.Sprintf("%slabels: {\n", g.indent)) + for k, v := range c.GetLabels() { + sb.WriteString(fmt.Sprintf("%s\t%q: %q\n", g.indent, k, v)) + } + sb.WriteString(fmt.Sprintf("%s}\n", g.indent)) + } else { + sb.WriteString(fmt.Sprintf("%slabels: {}\n", g.indent)) + } sb.WriteString(fmt.Sprintf("%sdescription: %q\n", g.indent, c.GetDescription())) // Write attributes @@ -674,6 +714,8 @@ func (g *CUEGenerator) writeHelper(sb *strings.Builder, helper *HelperVar, depth g.writeMultiSourceHelper(sb, col, depth) case *CollectionOp: g.writeCollectionOpHelper(sb, col, depth, guard) + case *ArrayBuilder: + sb.WriteString(g.arrayBuilderToCUE(col, depth)) default: sb.WriteString("[]") } @@ -787,8 +829,29 @@ func (g *CUEGenerator) writeCollectionOpHelper(sb *strings.Builder, col *Collect } for fieldName, fieldVal := range mOp.mappings { - valStr := g.fieldValueToCUE(fieldVal) - sb.WriteString(fmt.Sprintf("%s%s: %s\n", fieldIndent, fieldName, valStr)) + if condRef, isConditional := fieldVal.(*ConditionalOrFieldRef); isConditional { + // Emit if/else pattern for conditional field reference + primaryField := string(condRef.primary) + fallbackStr := g.fieldValueToCUE(condRef.fallback) + sb.WriteString(fmt.Sprintf("%sif v.%s != _|_ {\n", fieldIndent, primaryField)) + sb.WriteString(fmt.Sprintf("%s\t%s: v.%s\n", fieldIndent, fieldName, primaryField)) + sb.WriteString(fmt.Sprintf("%s}\n", fieldIndent)) + sb.WriteString(fmt.Sprintf("%sif v.%s == _|_ {\n", fieldIndent, primaryField)) + sb.WriteString(fmt.Sprintf("%s\t%s: %s\n", fieldIndent, fieldName, fallbackStr)) + sb.WriteString(fmt.Sprintf("%s}\n", fieldIndent)) + } else if optField, isOptional := fieldVal.(*OptionalField); isOptional { + sb.WriteString(fmt.Sprintf("%sif v.%s != _|_ {\n", fieldIndent, optField.field)) + sb.WriteString(fmt.Sprintf("%s\t%s: v.%s\n", fieldIndent, fieldName, optField.field)) + sb.WriteString(fmt.Sprintf("%s}\n", fieldIndent)) + } else if compOpt, isCompound := fieldVal.(*CompoundOptionalField); isCompound { + condStr := g.conditionToCUE(compOpt.additionalCond) + sb.WriteString(fmt.Sprintf("%sif v.%s != _|_ if %s {\n", fieldIndent, compOpt.field, condStr)) + sb.WriteString(fmt.Sprintf("%s\t%s: v.%s\n", fieldIndent, fieldName, compOpt.field)) + sb.WriteString(fmt.Sprintf("%s}\n", fieldIndent)) + } else { + valStr := g.fieldValueToCUE(fieldVal) + sb.WriteString(fmt.Sprintf("%s%s: %s\n", fieldIndent, fieldName, valStr)) + } } sb.WriteString(fmt.Sprintf("%s},\n%s]", innerIndent, strings.Repeat(g.indent, depth))) @@ -837,6 +900,11 @@ func (g *CUEGenerator) writeFieldMapAsHelper(sb *strings.Builder, mapping FieldM sb.WriteString(fmt.Sprintf("%sif v.%s != _|_ {\n", indent, fieldVal.(*OptionalField).field)) sb.WriteString(fmt.Sprintf("%s\t%s: v.%s\n", indent, fieldName, fieldVal.(*OptionalField).field)) sb.WriteString(fmt.Sprintf("%s}\n", indent)) + } else if compOpt, isCompound := fieldVal.(*CompoundOptionalField); isCompound { + condStr := g.conditionToCUE(compOpt.additionalCond) + sb.WriteString(fmt.Sprintf("%sif v.%s != _|_ if %s {\n", indent, compOpt.field, condStr)) + sb.WriteString(fmt.Sprintf("%s\t%s: v.%s\n", indent, fieldName, compOpt.field)) + sb.WriteString(fmt.Sprintf("%s}\n", indent)) } else { sb.WriteString(fmt.Sprintf("%s%s: %s\n", indent, fieldName, valStr)) } @@ -919,6 +987,15 @@ func (g *CUEGenerator) writeResourceOutput(sb *strings.Builder, name string, res } } +// condValueEntry represents an additional conditional value at a field node. +// When the same path is written with multiple different conditions (e.g., +// volumeMounts set under both "volumeMounts is set" and "volumes is set"), +// the first write uses value/cond and additional writes append here. +type condValueEntry struct { + value Value + cond Condition +} + // fieldNode represents a node in the field tree being built. type fieldNode struct { value Value // Direct value (if leaf) @@ -927,11 +1004,13 @@ type fieldNode struct { childOrder []string // Track insertion order isArray bool arrayIndex int - spreads []spreadEntry // Spread operations at this node level - forEach *ForEachOp // ForEach operation (for trait patches) - patchKey *PatchKeyOp // PatchKey operation (for array patches with merge key) - spreadAll *SpreadAllOp // SpreadAll operation (for array constraint patches) - patchStrategy string // e.g. "retainKeys" → generates // +patchStrategy=retainKeys + spreads []spreadEntry // Spread operations at this node level + forEach *ForEachOp // ForEach operation (for trait patches) + patchKey *PatchKeyOp // PatchKey operation (for array patches with merge key) + spreadAll *SpreadAllOp // SpreadAll operation (for array constraint patches) + patchStrategy string // e.g. "retainKeys" → generates // +patchStrategy=retainKeys + directives []string // e.g. ["patchKey=ip"] → generates // +patchKey=ip + condValues []condValueEntry // additional conditional values at same path } // spreadEntry represents a conditional spread operation. @@ -970,6 +1049,8 @@ func (g *CUEGenerator) buildFieldTree(ops []ResourceOp) *fieldNode { g.insertSpreadAllIntoTree(root, o, nil) case *PatchStrategyAnnotationOp: g.insertAnnotationIntoTree(root, o.Path(), o.Strategy()) + case *DirectiveOp: + g.insertDirectiveIntoTree(root, o.Path(), o.GetDirective()) case *IfBlock: // For if blocks, process inner ops with the block's condition for _, innerOp := range o.Ops() { @@ -995,6 +1076,8 @@ func (g *CUEGenerator) buildFieldTree(ops []ResourceOp) *fieldNode { g.insertSpreadAllIntoTree(root, inner, o.Cond()) case *PatchStrategyAnnotationOp: g.insertAnnotationIntoTree(root, inner.Path(), inner.Strategy()) + case *DirectiveOp: + g.insertDirectiveIntoTree(root, inner.Path(), inner.GetDirective()) } } } @@ -1003,6 +1086,20 @@ func (g *CUEGenerator) buildFieldTree(ops []ResourceOp) *fieldNode { return root } +// insertDirectiveIntoTree navigates to a node by path and adds a directive annotation. +func (g *CUEGenerator) insertDirectiveIntoTree(root *fieldNode, path string, directive string) { + parts := splitPath(path) + current := root + for _, part := range parts { + if _, exists := current.children[part]; !exists { + current.children[part] = newFieldNode() + current.childOrder = append(current.childOrder, part) + } + current = current.children[part] + } + current.directives = append(current.directives, directive) +} + // insertAnnotationIntoTree navigates to a node by path and sets its patchStrategy annotation. func (g *CUEGenerator) insertAnnotationIntoTree(root *fieldNode, path string, strategy string) { parts := splitPath(path) @@ -1058,8 +1155,25 @@ func (g *CUEGenerator) insertIntoTree(root *fieldNode, path string, value Value, // If this is the last part, set the value if i == len(parts)-1 { - current.value = value - current.cond = cond + if current.value != nil && cond != nil { + // This path already has a value. If the existing value also + // has a condition, append this as an additional conditional + // value instead of overwriting. + if current.cond != nil { + current.condValues = append(current.condValues, condValueEntry{ + value: value, + cond: cond, + }) + } else { + // Existing value is unconditional; the new conditional + // value takes precedence (shouldn't normally happen). + current.value = value + current.cond = cond + } + } else { + current.value = value + current.cond = cond + } } } } @@ -1267,10 +1381,15 @@ func (g *CUEGenerator) writeFieldTree(sb *strings.Builder, node *fieldNode, dept for _, name := range node.childOrder { child := node.children[name] - if child.cond != nil { + switch { + case len(child.condValues) > 0: + // Multi-conditional nodes render their own if blocks internally, + // so they must be treated as unconditional at the parent level. + unconditional = append(unconditional, name) + case child.cond != nil: condStr := g.conditionToCUE(child.cond) conditional[condStr] = append(conditional[condStr], name) - } else { + default: unconditional = append(unconditional, name) } } @@ -1325,6 +1444,12 @@ func (g *CUEGenerator) liftChildConditions(node *fieldNode) { canLift = false break } + // Don't lift if any grandchild has multiple conditional values, + // since those nodes manage their own condition rendering. + if len(grand.condValues) > 0 { + canLift = false + break + } condStr := g.conditionToCUE(grand.cond) if sharedCondStr == "" { sharedCondStr = condStr @@ -1346,6 +1471,78 @@ func (g *CUEGenerator) liftChildConditions(node *fieldNode) { } } +// tryDecomposeOrLift attempts to decompose a struct node into per-condition +// blocks or lift a shared child condition to the parent. Returns true if the +// node was handled, false if normal rendering should proceed. +func (g *CUEGenerator) tryDecomposeOrLift(sb *strings.Builder, name string, node *fieldNode, indent string, depth int) bool { + if node.value != nil || len(node.children) == 0 || len(node.spreads) > 0 || node.forEach != nil || node.patchKey != nil { + return false + } + + // Decompose a struct node into per-condition blocks when every child + // subtree shares the same uniform set of leaf conditions. + condGroups := g.canDecomposeByCondition(node) + if condGroups != nil { + condStrs := make([]string, 0, len(condGroups)) + for cs := range condGroups { + condStrs = append(condStrs, cs) + } + sort.Strings(condStrs) + + for _, condStr := range condStrs { + sb.WriteString(fmt.Sprintf("%sif %s {\n", indent, condStr)) + sb.WriteString(fmt.Sprintf("%s\t%s: {\n", indent, name)) + for _, childName := range node.childOrder { + child := node.children[childName] + filteredChild := g.filterNodeByCondition(child, condStr) + if filteredChild != nil { + g.writeFieldNode(sb, childName, filteredChild, depth+2) + } + } + sb.WriteString(fmt.Sprintf("%s\t}\n", indent)) + sb.WriteString(fmt.Sprintf("%s}\n", indent)) + } + return true + } + + // If all children share the same condition, lift it to avoid empty parent structs. + condStr := "" + canLift := true + for _, childName := range node.childOrder { + child := node.children[childName] + if child.cond == nil { + canLift = false + break + } + childCondStr := g.conditionToCUE(child.cond) + if condStr == "" { + condStr = childCondStr + } else if condStr != childCondStr { + canLift = false + break + } + } + if canLift && condStr != "" { + clone := &fieldNode{ + children: make(map[string]*fieldNode, len(node.children)), + childOrder: append([]string(nil), node.childOrder...), + } + for childName, child := range node.children { + childCopy := *child + childCopy.cond = nil + clone.children[childName] = &childCopy + } + sb.WriteString(fmt.Sprintf("%sif %s {\n", indent, condStr)) + sb.WriteString(fmt.Sprintf("%s\t%s: {\n", indent, name)) + g.writeFieldTree(sb, clone, depth+2) + sb.WriteString(fmt.Sprintf("%s\t}\n", indent)) + sb.WriteString(fmt.Sprintf("%s}\n", indent)) + return true + } + + return false +} + // writeFieldNode writes a single field node as CUE. func (g *CUEGenerator) writeFieldNode(sb *strings.Builder, name string, node *fieldNode, depth int) { indent := strings.Repeat(g.indent, depth) @@ -1376,47 +1573,40 @@ func (g *CUEGenerator) writeFieldNode(sb *strings.Builder, name string, node *fi return } - // If all children share the same condition and there are no unconditional parts, - // lift the condition to avoid emitting empty parent structs. - if node.value == nil && len(node.children) > 0 && len(node.spreads) == 0 && node.forEach == nil && node.patchKey == nil { - condStr := "" - canLift := true - for _, childName := range node.childOrder { - child := node.children[childName] - if child.cond == nil { - canLift = false - break - } - childCondStr := g.conditionToCUE(child.cond) - if condStr == "" { - condStr = childCondStr - } else if condStr != childCondStr { - canLift = false - break - } - } - if canLift && condStr != "" { - // Clone node with cleared child conditions for rendering. - clone := &fieldNode{ - children: make(map[string]*fieldNode, len(node.children)), - childOrder: append([]string(nil), node.childOrder...), - } - for childName, child := range node.children { - childCopy := *child - childCopy.cond = nil - clone.children[childName] = &childCopy - } - sb.WriteString(fmt.Sprintf("%sif %s {\n", indent, condStr)) - sb.WriteString(fmt.Sprintf("%s\t%s: {\n", indent, name)) - g.writeFieldTree(sb, clone, depth+2) - sb.WriteString(fmt.Sprintf("%s\t}\n", indent)) - sb.WriteString(fmt.Sprintf("%s}\n", indent)) - return - } + // Try to decompose or lift conditions for cleaner CUE output. + if g.tryDecomposeOrLift(sb, name, node, indent, depth) { + return + } + + // Emit directive annotations before the field + for _, directive := range node.directives { + sb.WriteString(fmt.Sprintf("%s// +%s\n", indent, directive)) } // Regular field if node.value != nil && len(node.children) == 0 { + if len(node.condValues) > 0 { + // Multiple conditional values at the same path — render each + // inside its own if block. + if node.cond != nil { + condStr := g.conditionToCUE(node.cond) + valStr := g.valueToCUE(node.value) + sb.WriteString(fmt.Sprintf("%sif %s {\n", indent, condStr)) + sb.WriteString(fmt.Sprintf("%s\t%s: %s\n", indent, name, valStr)) + sb.WriteString(fmt.Sprintf("%s}\n", indent)) + } else { + valStr := g.valueToCUE(node.value) + sb.WriteString(fmt.Sprintf("%s%s: %s\n", indent, name, valStr)) + } + for _, cv := range node.condValues { + condStr := g.conditionToCUE(cv.cond) + valStr := g.valueToCUE(cv.value) + sb.WriteString(fmt.Sprintf("%sif %s {\n", indent, condStr)) + sb.WriteString(fmt.Sprintf("%s\t%s: %s\n", indent, name, valStr)) + sb.WriteString(fmt.Sprintf("%s}\n", indent)) + } + return + } // Leaf node with value valStr := g.valueToCUE(node.value) sb.WriteString(fmt.Sprintf("%s%s: %s\n", indent, name, valStr)) @@ -1428,6 +1618,134 @@ func (g *CUEGenerator) writeFieldNode(sb *strings.Builder, name string, node *fi } } +// canDecomposeByCondition checks if a struct node's children can be split into +// separate conditional blocks. This is possible when every child subtree has +// the same uniform set of leaf conditions (e.g., every leaf is guarded by either +// condition A or condition B). Returns a map of conditionString -> childNames, +// or nil if decomposition is not possible. +func (g *CUEGenerator) canDecomposeByCondition(node *fieldNode) map[string][]string { + if node.value != nil || len(node.spreads) > 0 || node.forEach != nil || node.patchKey != nil { + return nil + } + if len(node.children) == 0 { + return nil + } + + // Collect the set of leaf conditions from each child subtree + childCondSets := make(map[string]map[string]bool) + for _, childName := range node.childOrder { + child := node.children[childName] + condSet := make(map[string]bool) + g.collectLeafConditions(child, condSet) + if len(condSet) == 0 { + return nil + } + // If any leaf is unconditional (empty string), can't decompose + if condSet[""] { + return nil + } + childCondSets[childName] = condSet + } + + // All children must have the same condition set + var referenceSet map[string]bool + for _, condSet := range childCondSets { + if referenceSet == nil { + referenceSet = condSet + } else { + if len(condSet) != len(referenceSet) { + return nil + } + for k := range referenceSet { + if !condSet[k] { + return nil + } + } + } + } + + // Need at least 2 different conditions to warrant decomposition + if len(referenceSet) < 2 { + return nil + } + + result := make(map[string][]string) + for condStr := range referenceSet { + result[condStr] = append(result[condStr], node.childOrder...) + } + return result +} + +// collectLeafConditions traverses a subtree and collects the CUE string +// representations of all leaf conditions. An unconditional leaf adds "". +func (g *CUEGenerator) collectLeafConditions(node *fieldNode, condSet map[string]bool) { + if node.cond != nil && node.value != nil { + condSet[g.conditionToCUE(node.cond)] = true + // Also collect conditions from condValues (additional conditional + // values at the same path). + for _, cv := range node.condValues { + condSet[g.conditionToCUE(cv.cond)] = true + } + return + } + if node.cond != nil && len(node.children) > 0 { + // Intermediate node with a condition (already lifted) + condSet[g.conditionToCUE(node.cond)] = true + return + } + if node.value != nil && node.cond == nil { + condSet[""] = true + return + } + for _, childName := range node.childOrder { + child := node.children[childName] + g.collectLeafConditions(child, condSet) + } +} + +// filterNodeByCondition returns a copy of the subtree containing only leaves +// that match the given condition string. Intermediate nodes have their +// conditions cleared since the caller wraps the result in the condition block. +func (g *CUEGenerator) filterNodeByCondition(node *fieldNode, condStr string) *fieldNode { + if node.cond != nil { + if g.conditionToCUE(node.cond) == condStr { + nodeCopy := *node + nodeCopy.cond = nil + nodeCopy.condValues = nil // Strip condValues; other conditions handled by their own block + return &nodeCopy + } + // Check if the target condition is in condValues instead of the primary + for _, cv := range node.condValues { + if g.conditionToCUE(cv.cond) == condStr { + nodeCopy := *node + nodeCopy.value = cv.value + nodeCopy.cond = nil + nodeCopy.condValues = nil + return &nodeCopy + } + } + return nil + } + + filtered := &fieldNode{ + children: make(map[string]*fieldNode), + childOrder: make([]string, 0), + value: node.value, + } + for _, childName := range node.childOrder { + child := node.children[childName] + filteredChild := g.filterNodeByCondition(child, condStr) + if filteredChild != nil { + filtered.children[childName] = filteredChild + filtered.childOrder = append(filtered.childOrder, childName) + } + } + if len(filtered.children) == 0 && filtered.value == nil { + return nil + } + return filtered +} + // valueToCUE converts a Value to CUE syntax. func (g *CUEGenerator) valueToCUE(v Value) string { switch val := v.(type) { @@ -1442,7 +1760,7 @@ func (g *CUEGenerator) valueToCUE(v Value) string { case *HelperVar: // Return reference to the helper by name return val.Name() - case *StringParam, *IntParam, *BoolParam, *FloatParam, *ArrayParam, *MapParam, *StringKeyMapParam, *EnumParam: + case *StringParam, *IntParam, *BoolParam, *FloatParam, *ArrayParam, *MapParam, *StringKeyMapParam, *EnumParam, *OneOfParam: return "parameter." + v.(Param).Name() case *DynamicMapParam: // Dynamic map parameters reference just "parameter" @@ -1454,6 +1772,8 @@ func (g *CUEGenerator) valueToCUE(v Value) string { return g.collectionOpToCUE(val) case *MultiSource: return g.multiSourceToCUE(val) + case *InlineArrayValue: + return g.inlineArrayToCUE(val) case *ConcatExprValue: return g.concatExprToCUE(val) case *CUEFunc: @@ -1679,7 +1999,15 @@ func (g *CUEGenerator) arrayBuilderToCUE(ab *ArrayBuilder, depth int) string { case entryForEachWith: sourceStr := g.valueToCUE(entry.source) - sb.WriteString(fmt.Sprintf("%sfor %s in %s {\n", innerIndent, entry.itemBuilder.VarName(), sourceStr)) + guardPrefix := "" + if entry.guard != nil { + guardPrefix = "if " + g.conditionToCUE(entry.guard) + " " + } + filterSuffix := "" + if entry.filter != nil { + filterSuffix = " if " + g.predicateToCUE(entry.filter) + } + sb.WriteString(fmt.Sprintf("%s%sfor %s in %s%s {\n", innerIndent, guardPrefix, entry.itemBuilder.VarName(), sourceStr, filterSuffix)) g.writeItemBuilderOps(&sb, entry.itemBuilder.Ops(), depth+2) sb.WriteString(fmt.Sprintf("%s},\n", innerIndent)) } @@ -1736,12 +2064,15 @@ func (g *CUEGenerator) collectionOpToCUE(col *CollectionOp) string { } } - // Check if there's a Map operation + // Check if there's a Map or MapVariant operation hasMap := false + hasVariant := false for _, op := range ops { if _, ok := op.(*mapOp); ok { hasMap = true - break + } + if _, ok := op.(*mapVariantOp); ok { + hasVariant = true } } @@ -1764,7 +2095,7 @@ func (g *CUEGenerator) collectionOpToCUE(col *CollectionOp) string { sb.WriteString(filterCondition) } - if hasMap { + if hasMap || hasVariant { // Map operations: render mapped fields in a struct sb.WriteString(" {\n") sb.WriteString("\t\t\t\t{\n") @@ -1775,6 +2106,21 @@ func (g *CUEGenerator) collectionOpToCUE(col *CollectionOp) string { sb.WriteString(fmt.Sprintf("\t\t\t\t\tif v.%s != _|_ {\n", optField.field)) sb.WriteString(fmt.Sprintf("\t\t\t\t\t\t%s: v.%s\n", fieldName, optField.field)) sb.WriteString("\t\t\t\t\t}\n") + } else if compOpt, isCompound := fieldVal.(*CompoundOptionalField); isCompound { + condStr := g.conditionToCUE(compOpt.additionalCond) + sb.WriteString(fmt.Sprintf("\t\t\t\t\tif v.%s != _|_ if %s {\n", compOpt.field, condStr)) + sb.WriteString(fmt.Sprintf("\t\t\t\t\t\t%s: v.%s\n", fieldName, compOpt.field)) + sb.WriteString("\t\t\t\t\t}\n") + } else if condRef, isConditional := fieldVal.(*ConditionalOrFieldRef); isConditional { + // Emit if/else pattern for conditional field reference + primaryField := string(condRef.primary) + fallbackStr := g.fieldValueToCUE(condRef.fallback) + sb.WriteString(fmt.Sprintf("\t\t\t\t\tif v.%s != _|_ {\n", primaryField)) + sb.WriteString(fmt.Sprintf("\t\t\t\t\t\t%s: v.%s\n", fieldName, primaryField)) + sb.WriteString("\t\t\t\t\t}\n") + sb.WriteString(fmt.Sprintf("\t\t\t\t\tif v.%s == _|_ {\n", primaryField)) + sb.WriteString(fmt.Sprintf("\t\t\t\t\t\t%s: %s\n", fieldName, fallbackStr)) + sb.WriteString("\t\t\t\t\t}\n") } else { valStr := g.fieldValueToCUE(fieldVal) sb.WriteString(fmt.Sprintf("\t\t\t\t\t%s: %s\n", fieldName, valStr)) @@ -1782,6 +2128,23 @@ func (g *CUEGenerator) collectionOpToCUE(col *CollectionOp) string { } } } + // MapVariant operations: render conditional field blocks + for _, op := range ops { + if mvOp, ok := op.(*mapVariantOp); ok { + sb.WriteString(fmt.Sprintf("\t\t\t\t\tif v.%s == %q {\n", mvOp.discriminator, mvOp.variantName)) + for fieldName, fieldVal := range mvOp.mappings { + if optField, isOptional := fieldVal.(*OptionalField); isOptional { + sb.WriteString(fmt.Sprintf("\t\t\t\t\t\tif v.%s != _|_ {\n", optField.field)) + sb.WriteString(fmt.Sprintf("\t\t\t\t\t\t\t%s: v.%s\n", fieldName, optField.field)) + sb.WriteString("\t\t\t\t\t\t}\n") + } else { + valStr := g.fieldValueToCUE(fieldVal) + sb.WriteString(fmt.Sprintf("\t\t\t\t\t\t%s: %s\n", fieldName, valStr)) + } + } + sb.WriteString("\t\t\t\t\t}\n") + } + } sb.WriteString("\t\t\t\t}\n") sb.WriteString("\t\t\t}]") } else { @@ -2052,6 +2415,19 @@ func (g *CUEGenerator) concatExprToCUE(ce *ConcatExprValue) string { return strings.Join(parts, " + ") } +// inlineArrayToCUE converts an InlineArrayValue to CUE syntax. +// Generates: [{field1: value1, field2: value2}] +func (g *CUEGenerator) inlineArrayToCUE(arr *InlineArrayValue) string { + var sb strings.Builder + sb.WriteString("[{\n") + for fieldName, fieldVal := range arr.Fields() { + valStr := g.valueToCUE(fieldVal) + sb.WriteString(fmt.Sprintf("\t\t\t\t\t\t\t%s: %s\n", fieldName, valStr)) + } + sb.WriteString("\t\t\t\t\t\t}]") + return sb.String() +} + // conditionToCUE converts a Condition to CUE syntax. func (g *CUEGenerator) conditionToCUE(cond Condition) string { switch c := cond.(type) { @@ -2262,11 +2638,21 @@ func (g *CUEGenerator) writeStatus(sb *strings.Builder, c *ComponentDefinition, func (g *CUEGenerator) writeParam(sb *strings.Builder, param Param, depth int) { indent := strings.Repeat(g.indent, depth) + // Write // +ignore directive if set (before +usage) + if ip, ok := param.(interface{ IsIgnore() bool }); ok && ip.IsIgnore() { + sb.WriteString(fmt.Sprintf("%s// +ignore\n", indent)) + } + // Write description as comment if present if desc := param.GetDescription(); desc != "" { sb.WriteString(fmt.Sprintf("%s// +usage=%s\n", indent, desc)) } + // Write // +short=X directive if set (after +usage) + if sp, ok := param.(interface{ GetShort() string }); ok && sp.GetShort() != "" { + sb.WriteString(fmt.Sprintf("%s// +short=%s\n", indent, sp.GetShort())) + } + name := param.Name() optional := "?" forceOptional := false @@ -2299,6 +2685,8 @@ func (g *CUEGenerator) writeParam(sb *strings.Builder, param Param, depth int) { g.writeStructParam(sb, p, indent, name, optional, depth) case *EnumParam: g.writeEnumParam(sb, p, indent, name, optional) + case *OneOfParam: + g.writeOneOfParam(sb, p, indent, name, optional, depth) default: // Generic fallback sb.WriteString(fmt.Sprintf("%s%s%s: _\n", indent, name, optional)) @@ -2356,9 +2744,9 @@ func (g *CUEGenerator) writeStringParam(sb *strings.Builder, p *StringParam, ind if p.HasDefault() { if len(constraints) > 0 { - sb.WriteString(fmt.Sprintf("%s%s: *%q | string & %s\n", indent, name, p.GetDefault(), strings.Join(constraints, " & "))) + sb.WriteString(fmt.Sprintf("%s%s%s: *%q | string & %s\n", indent, name, optional, p.GetDefault(), strings.Join(constraints, " & "))) } else { - sb.WriteString(fmt.Sprintf("%s%s: *%q | string\n", indent, name, p.GetDefault())) + sb.WriteString(fmt.Sprintf("%s%s%s: *%q | string\n", indent, name, optional, p.GetDefault())) } } else { if len(constraints) > 0 { @@ -2521,11 +2909,11 @@ func (g *CUEGenerator) writeMapParam(sb *strings.Builder, p *MapParam, indent, n if cueType != "" { sb.WriteString(fmt.Sprintf("%s%s%s: [string]: %s\n", indent, name, optional, cueType)) } else { - sb.WriteString(fmt.Sprintf("%s%s%s: {...}\n", indent, name, optional)) + sb.WriteString(fmt.Sprintf("%s%s%s: %s\n", indent, name, optional, cueOpenStruct)) } } else { // Generic object - sb.WriteString(fmt.Sprintf("%s%s%s: {...}\n", indent, name, optional)) + sb.WriteString(fmt.Sprintf("%s%s%s: %s\n", indent, name, optional, cueOpenStruct)) } } @@ -2635,8 +3023,50 @@ func (g *CUEGenerator) writeEnumParam(sb *strings.Builder, p *EnumParam, indent, } } -// cueTypeForParamType converts a ParamType to its CUE type string. -func (g *CUEGenerator) cueTypeForParamType(pt ParamType) string { +// writeOneOfParam writes a discriminated union parameter. +// The param name is used as the discriminator field name (e.g., OneOf("type")). +// Generates: +// +// type: *"default" | "variant1" | "variant2" +// if type == "variant1" { field1: string } +// if type == "variant2" { field2: int } +func (g *CUEGenerator) writeOneOfParam(sb *strings.Builder, p *OneOfParam, indent, name, optional string, depth int) { + variants := p.GetVariants() + + // Build discriminator field: type: *"default" | "variant1" | "variant2" + var enumParts []string + if p.HasDefault() { + defaultStr := fmt.Sprintf("%v", p.GetDefault()) + enumParts = append(enumParts, fmt.Sprintf("*%s", formatCUEValue(p.GetDefault()))) + for _, v := range variants { + if v.Name() != defaultStr { + enumParts = append(enumParts, fmt.Sprintf("%q", v.Name())) + } + } + } else { + for _, v := range variants { + enumParts = append(enumParts, fmt.Sprintf("%q", v.Name())) + } + } + + sb.WriteString(fmt.Sprintf("%s%s%s: %s\n", indent, name, optional, strings.Join(enumParts, " | "))) + + // Write conditional blocks for each variant + for _, variant := range variants { + fields := variant.GetFields() + if len(fields) == 0 { + continue + } + sb.WriteString(fmt.Sprintf("%sif %s == %q {\n", indent, name, variant.Name())) + for _, field := range fields { + g.writeStructField(sb, field, depth+1) + } + sb.WriteString(fmt.Sprintf("%s}\n", indent)) + } +} + +// cueTypeStr converts a ParamType to its CUE type string. +func cueTypeStr(pt ParamType) string { switch pt { case ParamTypeString: return string(ParamTypeString) @@ -2648,15 +3078,18 @@ func (g *CUEGenerator) cueTypeForParamType(pt ParamType) string { return "float" case ParamTypeArray: return "[...]" - case ParamTypeMap: - return "{...}" - case ParamTypeStruct: - return "{...}" + case ParamTypeMap, ParamTypeStruct, ParamTypeOneOf: + return cueOpenStruct default: return "_" } } +// cueTypeForParamType converts a ParamType to its CUE type string. +func (g *CUEGenerator) cueTypeForParamType(pt ParamType) string { + return cueTypeStr(pt) +} + // formatCUEValue formats a Go value as a CUE literal. func formatCUEValue(v any) string { switch val := v.(type) { diff --git a/pkg/definition/defkit/cuegen_test.go b/pkg/definition/defkit/cuegen_test.go index 822ae764e..184d9c07d 100644 --- a/pkg/definition/defkit/cuegen_test.go +++ b/pkg/definition/defkit/cuegen_test.go @@ -17,6 +17,8 @@ limitations under the License. package defkit_test import ( + "strings" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -137,6 +139,48 @@ var _ = Describe("CUEGenerator", func() { // ForceOptional with default: has ? (field can be absent, defaults when present) Expect(cue).To(ContainSubstring(`optionalDefault?: *"Honor" | "Ignore"`)) }) + + It("should generate // +ignore directive for ignored parameters", func() { + comp := defkit.NewComponent("test"). + Params( + defkit.String("visible").Description("A visible field"), + defkit.Enum("hidden").Values("A", "B").Default("A").Ignore().Description("An ignored field"), + ) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).NotTo(ContainSubstring("// +ignore\n\t// +usage=A visible field")) + Expect(cue).To(ContainSubstring("// +ignore\n\t// +usage=An ignored field")) + }) + + It("should generate // +short directive for params with short flags", func() { + comp := defkit.NewComponent("test"). + Params( + defkit.String("image").Required().Description("Container image").Short("i"), + ) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("// +usage=Container image")) + Expect(cue).To(ContainSubstring("// +short=i")) + }) + + It("should generate both // +ignore and // +short directives in correct order", func() { + comp := defkit.NewComponent("test"). + Params( + defkit.Int("port").Ignore().Description("Deprecated field, please use ports instead").Short("p"), + ) + + cue := gen.GenerateParameterSchema(comp) + + // Order should be: // +ignore, // +usage=..., // +short=p + ignoreIdx := strings.Index(cue, "// +ignore") + usageIdx := strings.Index(cue, "// +usage=Deprecated field") + shortIdx := strings.Index(cue, "// +short=p") + Expect(ignoreIdx).To(BeNumerically(">", 0)) + Expect(usageIdx).To(BeNumerically(">", ignoreIdx)) + Expect(shortIdx).To(BeNumerically(">", usageIdx)) + }) }) Describe("GenerateParameterSchema with complex types", func() { @@ -220,6 +264,412 @@ var _ = Describe("CUEGenerator", func() { }) }) + Describe("GenerateParameterSchema with OneOf parameters", func() { + It("should generate discriminator field and conditional variant blocks", func() { + comp := defkit.NewComponent("test"). + Params( + defkit.OneOf("type"). + Default("emptyDir"). + Description("Volume type"). + Variants( + defkit.Variant("pvc").Fields( + defkit.Field("claimName", defkit.ParamTypeString).Required(), + ), + defkit.Variant("emptyDir").Fields( + defkit.Field("medium", defkit.ParamTypeString).Default("").Enum("", "Memory"), + ), + ), + ) + + cue := gen.GenerateParameterSchema(comp) + + // Discriminator field with default + Expect(cue).To(ContainSubstring(`*"emptyDir"`)) + Expect(cue).To(ContainSubstring(`"pvc"`)) + // Conditional blocks + Expect(cue).To(ContainSubstring(`if type == "pvc"`)) + Expect(cue).To(ContainSubstring("claimName: string")) + Expect(cue).To(ContainSubstring(`if type == "emptyDir"`)) + Expect(cue).To(ContainSubstring(`medium:`)) + }) + + It("should generate OneOf inside array with shared fields", func() { + comp := defkit.NewComponent("test"). + Params( + defkit.List("volumes").WithFields( + defkit.String("name").Required(), + defkit.OneOf("type").Default("emptyDir").Variants( + defkit.Variant("pvc").Fields( + defkit.Field("claimName", defkit.ParamTypeString).Required(), + ), + defkit.Variant("emptyDir"), + ), + ), + ) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("volumes?:")) + Expect(cue).To(ContainSubstring("[...{")) + Expect(cue).To(ContainSubstring("name: string")) + Expect(cue).To(ContainSubstring(`type: *"emptyDir" | "pvc"`)) + Expect(cue).To(ContainSubstring(`if type == "pvc"`)) + Expect(cue).To(ContainSubstring("claimName: string")) + }) + + It("should omit conditional block for empty variants", func() { + comp := defkit.NewComponent("test"). + Params( + defkit.OneOf("kind"). + Variants( + defkit.Variant("simple"), // no fields + defkit.Variant("complex").Fields( + defkit.Field("config", defkit.ParamTypeString).Required(), + ), + ), + ) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).NotTo(ContainSubstring(`if kind == "simple"`)) + Expect(cue).To(ContainSubstring(`if kind == "complex"`)) + Expect(cue).To(ContainSubstring("config: string")) + }) + }) + + Describe("GenerateFullDefinition with MapVariant", func() { + It("should generate conditional field blocks in comprehension", func() { + volumes := defkit.List("volumes") + comp := defkit.NewComponent("test"). + Workload("batch/v1", "Job"). + Params(volumes). + Template(func(tpl *defkit.Template) { + tpl.Output( + defkit.NewResource("batch/v1", "Job"). + Set("spec.volumes", + defkit.Each(volumes). + Map(defkit.FieldMap{"name": defkit.FieldRef("name")}). + MapVariant("type", "pvc", defkit.FieldMap{ + "persistentVolumeClaim.claimName": defkit.FieldRef("claimName"), + }). + MapVariant("type", "emptyDir", defkit.FieldMap{ + "emptyDir.medium": defkit.FieldRef("medium"), + }), + ), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + Expect(cue).To(ContainSubstring("for v in parameter.volumes")) + Expect(cue).To(ContainSubstring("name: v.name")) + Expect(cue).To(ContainSubstring(`if v.type == "pvc"`)) + Expect(cue).To(ContainSubstring("persistentVolumeClaim.claimName: v.claimName")) + Expect(cue).To(ContainSubstring(`if v.type == "emptyDir"`)) + Expect(cue).To(ContainSubstring("emptyDir.medium: v.medium")) + }) + + It("should generate optional fields inside variant blocks", func() { + volumes := defkit.List("volumes") + comp := defkit.NewComponent("test"). + Workload("batch/v1", "Job"). + Params(volumes). + Template(func(tpl *defkit.Template) { + tpl.Output( + defkit.NewResource("batch/v1", "Job"). + Set("spec.volumes", + defkit.Each(volumes). + Map(defkit.FieldMap{"name": defkit.FieldRef("name")}). + MapVariant("type", "configMap", defkit.FieldMap{ + "configMap.name": defkit.FieldRef("cmName"), + "configMap.items": defkit.OptionalFieldRef("items"), + }), + ), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + Expect(cue).To(ContainSubstring(`if v.type == "configMap"`)) + Expect(cue).To(ContainSubstring("configMap.name: v.cmName")) + Expect(cue).To(ContainSubstring("if v.items != _|_")) + Expect(cue).To(ContainSubstring("configMap.items: v.items")) + }) + }) + + Describe("Multi-conditional leaf values (condValues)", func() { + It("should render both conditional values when same path is set with different conditions", func() { + gen := defkit.NewCUEGenerator() + + volMounts := defkit.String("volumeMounts") + volumes := defkit.String("volumes") + + comp := defkit.NewComponent("test-multi-cond"). + Description("Test multi-conditional values"). + AutodetectWorkload(). + Params(volMounts, volumes). + Template(func(tpl *defkit.Template) { + res := defkit.NewResource("batch/v1", "Job") + res. + If(volMounts.IsSet()). + Set("spec.containers[0].volumeMounts", defkit.Lit("mountsArray")). + EndIf(). + If(volumes.IsSet()). + Set("spec.containers[0].volumeMounts", defkit.Lit("volumesArray")). + EndIf() + tpl.Output(res) + }) + + cue := gen.GenerateFullDefinition(comp) + // Both conditions should appear for the same field + Expect(cue).To(ContainSubstring(`if parameter["volumeMounts"] != _|_`)) + Expect(cue).To(ContainSubstring(`if parameter["volumes"] != _|_`)) + // The field should appear twice, each in its own if block + Expect(strings.Count(cue, "volumeMounts:")).To(BeNumerically(">=", 2)) + }) + + It("should not activate condValues when path is set unconditionally then conditionally", func() { + gen := defkit.NewCUEGenerator() + + optional := defkit.String("opt") + + comp := defkit.NewComponent("test-uncond-then-cond"). + Description("Test unconditional then conditional"). + AutodetectWorkload(). + Params(optional). + Template(func(tpl *defkit.Template) { + res := defkit.NewResource("v1", "Pod") + res. + Set("spec.field", defkit.Lit("default")). + SetIf(optional.IsSet(), "spec.field", optional) + tpl.Output(res) + }) + + cue := gen.GenerateFullDefinition(comp) + // Conditional should win (overwrites unconditional) + Expect(cue).To(ContainSubstring("field: parameter.opt")) + }) + }) + + Describe("Decomposition with condValues (condValues + canDecomposeByCondition)", func() { + It("should decompose struct when leaf nodes have condValues from SetIf with different And conditions", func() { + // This is the webservice CPU/memory limit branching pattern: + // Two SetIf calls with different conditions target the same leaf path, + // creating condValues. The parent struct must still decompose correctly. + gen := defkit.NewCUEGenerator() + + cpu := defkit.String("cpu") + limit := defkit.Object("limit") + + comp := defkit.NewComponent("test-condval-decompose"). + Description("Test condValues decomposition"). + AutodetectWorkload(). + Params(cpu, limit). + Template(func(tpl *defkit.Template) { + res := defkit.NewResource("v1", "Pod") + // When cpu is set and limit.cpu exists: use limit.cpu for limits, cpu for requests + res.SetIf(defkit.And(cpu.IsSet(), defkit.PathExists("parameter.limit.cpu")), + "spec.resources.requests.cpu", cpu) + res.SetIf(defkit.And(cpu.IsSet(), defkit.PathExists("parameter.limit.cpu")), + "spec.resources.limits.cpu", defkit.Reference("parameter.limit.cpu")) + // When cpu is set but limit.cpu does NOT exist: use cpu for both + res.SetIf(defkit.And(cpu.IsSet(), defkit.Not(defkit.PathExists("parameter.limit.cpu"))), + "spec.resources.limits.cpu", cpu) + res.SetIf(defkit.And(cpu.IsSet(), defkit.Not(defkit.PathExists("parameter.limit.cpu"))), + "spec.resources.requests.cpu", cpu) + tpl.Output(res) + }) + + cue := gen.GenerateFullDefinition(comp) + + // Both compound conditions should appear as separate blocks + Expect(cue).To(ContainSubstring(`parameter["cpu"] != _|_`)) + Expect(cue).To(ContainSubstring("parameter.limit.cpu")) + + // resources should be decomposed into per-condition blocks + // Each block should contain both limits and requests + Expect(strings.Count(cue, "resources:")).To(BeNumerically(">=", 2)) + Expect(strings.Count(cue, "limits:")).To(BeNumerically(">=", 2)) + Expect(strings.Count(cue, "requests:")).To(BeNumerically(">=", 2)) + + // The block where limit.cpu exists should use parameter.limit.cpu for limits + Expect(cue).To(ContainSubstring("cpu: parameter.limit.cpu")) + // The block where limit.cpu does NOT exist should use parameter.cpu for limits + Expect(strings.Count(cue, "cpu: parameter.cpu")).To(BeNumerically(">=", 2)) + }) + + It("should decompose when two sibling leaves at the same path have different condValues", func() { + // Pattern: limits.cpu set under condition A (primary) and condition B (condValue), + // requests.cpu set under condition A (primary) and condition B (condValue). + // collectLeafConditions must see both A and B from condValues. + gen := defkit.NewCUEGenerator() + + flagA := defkit.Bool("flagA") + flagB := defkit.Bool("flagB") + + comp := defkit.NewComponent("test-condval-siblings"). + Description("Test condValues sibling decomposition"). + AutodetectWorkload(). + Params(flagA, flagB). + Template(func(tpl *defkit.Template) { + res := defkit.NewResource("v1", "Pod") + res.SetIf(flagA.IsSet(), "spec.nested.child1", defkit.Lit("A1")) + res.SetIf(flagB.IsSet(), "spec.nested.child1", defkit.Lit("B1")) + res.SetIf(flagA.IsSet(), "spec.nested.child2", defkit.Lit("A2")) + res.SetIf(flagB.IsSet(), "spec.nested.child2", defkit.Lit("B2")) + tpl.Output(res) + }) + + cue := gen.GenerateFullDefinition(comp) + + // Both conditions should appear + Expect(cue).To(ContainSubstring(`parameter["flagA"]`)) + Expect(cue).To(ContainSubstring(`parameter["flagB"]`)) + // nested should be decomposed into per-condition blocks + Expect(strings.Count(cue, "nested:")).To(BeNumerically(">=", 2)) + + // In the flagA block: child1 = "A1", child2 = "A2" + flagAIdx := strings.Index(cue, `parameter["flagA"]`) + Expect(flagAIdx).To(BeNumerically(">", 0)) + endA := flagAIdx + 200 + if endA > len(cue) { + endA = len(cue) + } + afterFlagA := cue[flagAIdx:endA] + Expect(afterFlagA).To(ContainSubstring(`child1: "A1"`)) + Expect(afterFlagA).To(ContainSubstring(`child2: "A2"`)) + + // In the flagB block: child1 = "B1", child2 = "B2" + flagBIdx := strings.Index(cue, `parameter["flagB"]`) + Expect(flagBIdx).To(BeNumerically(">", 0)) + endB := flagBIdx + 200 + if endB > len(cue) { + endB = len(cue) + } + afterFlagB := cue[flagBIdx:endB] + Expect(afterFlagB).To(ContainSubstring(`child1: "B1"`)) + Expect(afterFlagB).To(ContainSubstring(`child2: "B2"`)) + }) + + It("should filter condValues correctly: primary condition returns primary value, condValue condition returns condValue value", func() { + // Verifies filterNodeByCondition returns the correct value + // when the target condition matches a condValue rather than the primary. + gen := defkit.NewCUEGenerator() + + modeA := defkit.String("modeA") + modeB := defkit.String("modeB") + + comp := defkit.NewComponent("test-filter-condval"). + Description("Test filterNodeByCondition with condValues"). + AutodetectWorkload(). + Params(modeA, modeB). + Template(func(tpl *defkit.Template) { + res := defkit.NewResource("v1", "Pod") + // Same leaf path, two different conditions, two different values + res.SetIf(modeA.IsSet(), "spec.wrapper.target", defkit.Lit("value-from-A")) + res.SetIf(modeB.IsSet(), "spec.wrapper.target", defkit.Lit("value-from-B")) + // Second leaf to make decomposition viable + res.SetIf(modeA.IsSet(), "spec.wrapper.other", defkit.Lit("other-A")) + res.SetIf(modeB.IsSet(), "spec.wrapper.other", defkit.Lit("other-B")) + tpl.Output(res) + }) + + cue := gen.GenerateFullDefinition(comp) + + // Find the modeA block and verify it has value-from-A + modeAIdx := strings.Index(cue, `parameter["modeA"]`) + Expect(modeAIdx).To(BeNumerically(">", 0)) + endA := modeAIdx + 250 + if endA > len(cue) { + endA = len(cue) + } + afterA := cue[modeAIdx:endA] + Expect(afterA).To(ContainSubstring(`target: "value-from-A"`)) + Expect(afterA).To(ContainSubstring(`other: "other-A"`)) + + // Find the modeB block and verify it has value-from-B + modeBIdx := strings.Index(cue, `parameter["modeB"]`) + Expect(modeBIdx).To(BeNumerically(">", 0)) + endB := modeBIdx + 250 + if endB > len(cue) { + endB = len(cue) + } + afterB := cue[modeBIdx:endB] + Expect(afterB).To(ContainSubstring(`target: "value-from-B"`)) + Expect(afterB).To(ContainSubstring(`other: "other-B"`)) + }) + }) + + Describe("Intermediate node decomposition (canDecomposeByCondition)", func() { + It("should decompose struct into per-condition blocks when all leaves share the same condition set", func() { + gen := defkit.NewCUEGenerator() + + cpu := defkit.String("cpu") + memory := defkit.String("memory") + + comp := defkit.NewComponent("test-decompose"). + Description("Test condition decomposition"). + AutodetectWorkload(). + Params(cpu, memory). + Template(func(tpl *defkit.Template) { + res := defkit.NewResource("v1", "Pod") + res. + If(cpu.IsSet()). + Set("spec.resources.limits.cpu", cpu). + Set("spec.resources.requests.cpu", cpu). + EndIf(). + If(memory.IsSet()). + Set("spec.resources.limits.memory", memory). + Set("spec.resources.requests.memory", memory). + EndIf() + tpl.Output(res) + }) + + cue := gen.GenerateFullDefinition(comp) + // resources should NOT appear unconditionally + // It should appear inside if blocks + Expect(cue).To(ContainSubstring(`if parameter["cpu"] != _|_ {`)) + Expect(cue).To(ContainSubstring(`if parameter["memory"] != _|_ {`)) + + // Each condition block should contain resources with limits and requests + // Find the cpu block + cpuIdx := strings.Index(cue, `if parameter["cpu"] != _|_ {`) + Expect(cpuIdx).To(BeNumerically(">", 0)) + // After the cpu condition, resources should appear + afterCpu := cue[cpuIdx:] + Expect(afterCpu).To(ContainSubstring("resources:")) + Expect(afterCpu).To(ContainSubstring("limits:")) + Expect(afterCpu).To(ContainSubstring("requests:")) + }) + + It("should not decompose when children have unconditional values", func() { + gen := defkit.NewCUEGenerator() + + cpu := defkit.String("cpu") + + comp := defkit.NewComponent("test-no-decompose"). + Description("Test no decomposition"). + AutodetectWorkload(). + Params(cpu). + Template(func(tpl *defkit.Template) { + res := defkit.NewResource("v1", "Pod") + res. + If(cpu.IsSet()). + Set("spec.resources.limits.cpu", cpu). + EndIf(). + Set("spec.resources.limits.memory", defkit.Lit("128Mi")) + tpl.Output(res) + }) + + cue := gen.GenerateFullDefinition(comp) + // resources should appear as a regular struct (not decomposed) + // because it has a mix of conditional and unconditional children + Expect(cue).To(ContainSubstring("resources:")) + Expect(cue).To(ContainSubstring("limits:")) + Expect(cue).To(ContainSubstring(`memory: "128Mi"`)) + }) + }) + Describe("GenerateFullDefinition", func() { It("should generate complete CUE definition", func() { comp := defkit.NewComponent("webservice"). @@ -399,4 +849,190 @@ var _ = Describe("CUEGenerator", func() { Expect(cue).To(ContainSubstring("strings")) }) }) + + Describe("GenerateFullDefinition with ConditionalOrFieldRef", func() { + It("should generate if/else pattern for conditional field reference", func() { + gen := defkit.NewCUEGenerator() + + ports := defkit.List("ports").WithFields( + defkit.Int("port").Required(), + defkit.String("name"), + ) + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + Params(ports). + Template(func(tpl *defkit.Template) { + containerPorts := defkit.Each(ports).Map(defkit.FieldMap{ + "containerPort": defkit.FieldRef("port"), + "name": defkit.FieldRef("name").OrConditional(defkit.Format("port-%v", defkit.FieldRef("port"))), + }) + tpl.Output( + defkit.NewResource("apps/v1", "Deployment"). + Set("spec.template.spec.containers[0].ports", containerPorts), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + // Should generate if/else blocks, NOT default syntax + Expect(cue).To(ContainSubstring("if v.name != _|_")) + Expect(cue).To(ContainSubstring("name: v.name")) + Expect(cue).To(ContainSubstring("if v.name == _|_")) + Expect(cue).To(ContainSubstring("strconv.FormatInt(v.port, 10)")) + }) + }) + + Describe("GenerateFullDefinition with Directive", func() { + It("should render // +patchKey directive before field value", func() { + gen := defkit.NewCUEGenerator() + + hostAliases := defkit.Object("hostAliases") + comp := defkit.NewComponent("test"). + Workload("apps/v1", "DaemonSet"). + Params(hostAliases). + Template(func(tpl *defkit.Template) { + tpl.Output( + defkit.NewResource("apps/v1", "DaemonSet"). + SetIf(hostAliases.IsSet(), "spec.template.spec.hostAliases", hostAliases). + Directive("spec.template.spec.hostAliases", "patchKey=ip"), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + // Directive should appear before the field value + Expect(cue).To(ContainSubstring("// +patchKey=ip")) + Expect(cue).To(ContainSubstring("hostAliases: parameter.hostAliases")) + + // Verify ordering: directive before field + patchIdx := strings.Index(cue, "// +patchKey=ip") + hostIdx := strings.Index(cue, "hostAliases: parameter.hostAliases") + Expect(patchIdx).To(BeNumerically("<", hostIdx)) + }) + }) + + Describe("GenerateFullDefinition with CompoundOptionalField", func() { + It("should generate compound conditional for OptionalFieldWithCond in collection Map", func() { + gen := defkit.NewCUEGenerator() + + exposeType := defkit.Enum("exposeType"). + Values("ClusterIP", "NodePort", "LoadBalancer"). + Default("ClusterIP") + ports := defkit.List("ports").WithFields( + defkit.Int("port").Required(), + defkit.String("name"), + defkit.Int("nodePort"), + defkit.Bool("expose").Default(false), + ) + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + Params(exposeType, ports). + Template(func(tpl *defkit.Template) { + exposePorts := defkit.Each(ports). + Filter(defkit.FieldEquals("expose", true)). + Map(defkit.FieldMap{ + "port": defkit.FieldRef("port"), + "nodePort": defkit.OptionalFieldWithCond("nodePort", defkit.Eq(exposeType, defkit.Lit("NodePort"))), + }) + tpl.Output( + defkit.NewResource("apps/v1", "Deployment"). + Set("spec.ports", exposePorts), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + // Should generate compound conditional: if v.nodePort != _|_ if { nodePort: v.nodePort } + Expect(cue).To(ContainSubstring("v.nodePort != _|_")) + Expect(cue).To(ContainSubstring(`parameter.exposeType == "NodePort"`)) + Expect(cue).To(ContainSubstring("nodePort: v.nodePort")) + }) + + It("should generate simple optional conditional alongside compound conditional", func() { + gen := defkit.NewCUEGenerator() + + exposeType := defkit.Enum("exposeType"). + Values("ClusterIP", "NodePort", "LoadBalancer"). + Default("ClusterIP") + ports := defkit.List("ports").WithFields( + defkit.Int("port").Required(), + defkit.Int("nodePort"), + defkit.String("protocol"), + defkit.Bool("expose").Default(false), + ) + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + Params(exposeType, ports). + Template(func(tpl *defkit.Template) { + exposePorts := defkit.Each(ports). + Filter(defkit.FieldEquals("expose", true)). + Map(defkit.FieldMap{ + "port": defkit.FieldRef("port"), + "nodePort": defkit.OptionalFieldWithCond("nodePort", defkit.Eq(exposeType, defkit.Lit("NodePort"))), + "protocol": defkit.OptionalFieldRef("protocol"), + }) + tpl.Output( + defkit.NewResource("apps/v1", "Deployment"). + Set("spec.ports", exposePorts), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + // Compound conditional for nodePort + Expect(cue).To(ContainSubstring("v.nodePort != _|_")) + Expect(cue).To(ContainSubstring(`parameter.exposeType == "NodePort"`)) + Expect(cue).To(ContainSubstring("nodePort: v.nodePort")) + + // Simple optional conditional for protocol + Expect(cue).To(ContainSubstring("v.protocol != _|_")) + Expect(cue).To(ContainSubstring("protocol: v.protocol")) + }) + }) + + Describe("GenerateFullDefinition with InlineArray", func() { + It("should render inline array value as [{field: value}]", func() { + gen := defkit.NewCUEGenerator() + + port := defkit.Int("port") + comp := defkit.NewComponent("test"). + Workload("apps/v1", "DaemonSet"). + Params(port). + Template(func(tpl *defkit.Template) { + tpl.Output( + defkit.NewResource("apps/v1", "DaemonSet"). + Set("spec.template.spec.containers[0].ports", defkit.InlineArray(map[string]defkit.Value{ + "containerPort": port, + })), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + Expect(cue).To(ContainSubstring("containerPort: parameter.port")) + }) + + It("should render inline array with multiple fields", func() { + gen := defkit.NewCUEGenerator() + + port := defkit.Int("port") + comp := defkit.NewComponent("test"). + Workload("apps/v1", "DaemonSet"). + Params(port). + Template(func(tpl *defkit.Template) { + tpl.Output( + defkit.NewResource("apps/v1", "DaemonSet"). + Set("spec.template.spec.containers[0].ports", defkit.InlineArray(map[string]defkit.Value{ + "containerPort": port, + "protocol": defkit.Lit("TCP"), + })), + ) + }) + + cue := gen.GenerateFullDefinition(comp) + + Expect(cue).To(ContainSubstring("containerPort: parameter.port")) + Expect(cue).To(ContainSubstring(`protocol: "TCP"`)) + }) + }) }) diff --git a/pkg/definition/defkit/expr.go b/pkg/definition/defkit/expr.go index d485141e6..cfe6aa059 100644 --- a/pkg/definition/defkit/expr.go +++ b/pkg/definition/defkit/expr.go @@ -355,6 +355,14 @@ type CUEFunc struct { func (c *CUEFunc) expr() {} func (c *CUEFunc) value() {} +// RequiredImports returns the CUE imports required by this function call. +func (c *CUEFunc) RequiredImports() []string { + if c.pkg != "" { + return []string{c.pkg} + } + return nil +} + // Package returns the CUE package name. func (c *CUEFunc) Package() string { return c.pkg } @@ -798,3 +806,23 @@ func (c *IterFieldExistsCondition) FieldName() string { return c.field } // IsNegated returns true if this is a "not exists" check. func (c *IterFieldExistsCondition) IsNegated() bool { return c.negate } + +// InlineArrayValue represents an inline array literal containing struct elements. +// This generates CUE like: [{field1: value1, field2: value2}] +// Used for deprecated parameter fallbacks that create a single-element array. +type InlineArrayValue struct { + fields map[string]Value +} + +func (a *InlineArrayValue) expr() {} +func (a *InlineArrayValue) value() {} + +// Fields returns the field mappings. +func (a *InlineArrayValue) Fields() map[string]Value { return a.fields } + +// InlineArray creates an inline array value with a single struct element. +// Example: InlineArray(map[string]Value{"containerPort": port}) +// Generates: [{containerPort: parameter.port}] +func InlineArray(fields map[string]Value) *InlineArrayValue { + return &InlineArrayValue{fields: fields} +} diff --git a/pkg/definition/defkit/expr_test.go b/pkg/definition/defkit/expr_test.go index 0fa4a0014..afe76ee5d 100644 --- a/pkg/definition/defkit/expr_test.go +++ b/pkg/definition/defkit/expr_test.go @@ -194,59 +194,63 @@ var _ = Describe("Expressions", func() { }) Context("CueFunc expressions", func() { - It("should create StrconvFormatInt function", func() { + It("should create StrconvFormatInt function with correct args", func() { num := defkit.Int("port") fn := defkit.StrconvFormatInt(num, 10) - Expect(fn).NotTo(BeNil()) Expect(fn.Package()).To(Equal("strconv")) Expect(fn.Function()).To(Equal("FormatInt")) Expect(fn.Args()).To(HaveLen(2)) + Expect(fn.Args()[0]).To(Equal(num)) + Expect(fn.Args()[1]).To(Equal(defkit.Lit(10))) }) - It("should create StringsToLower function", func() { + It("should create StringsToLower function with correct arg", func() { str := defkit.String("name") fn := defkit.StringsToLower(str) - Expect(fn).NotTo(BeNil()) Expect(fn.Package()).To(Equal("strings")) Expect(fn.Function()).To(Equal("ToLower")) Expect(fn.Args()).To(HaveLen(1)) + Expect(fn.Args()[0]).To(Equal(str)) }) - It("should create StringsToUpper function", func() { + It("should create StringsToUpper function with correct arg", func() { str := defkit.String("name") fn := defkit.StringsToUpper(str) - Expect(fn).NotTo(BeNil()) Expect(fn.Package()).To(Equal("strings")) Expect(fn.Function()).To(Equal("ToUpper")) Expect(fn.Args()).To(HaveLen(1)) + Expect(fn.Args()[0]).To(Equal(str)) }) - It("should create StringsHasPrefix function", func() { + It("should create StringsHasPrefix function with correct args", func() { str := defkit.String("path") fn := defkit.StringsHasPrefix(str, "/api") - Expect(fn).NotTo(BeNil()) Expect(fn.Package()).To(Equal("strings")) Expect(fn.Function()).To(Equal("HasPrefix")) Expect(fn.Args()).To(HaveLen(2)) + Expect(fn.Args()[0]).To(Equal(str)) + Expect(fn.Args()[1]).To(Equal(defkit.Lit("/api"))) }) - It("should create StringsHasSuffix function", func() { + It("should create StringsHasSuffix function with correct args", func() { str := defkit.String("file") fn := defkit.StringsHasSuffix(str, ".yaml") - Expect(fn).NotTo(BeNil()) Expect(fn.Package()).To(Equal("strings")) Expect(fn.Function()).To(Equal("HasSuffix")) Expect(fn.Args()).To(HaveLen(2)) + Expect(fn.Args()[0]).To(Equal(str)) + Expect(fn.Args()[1]).To(Equal(defkit.Lit(".yaml"))) }) - It("should create ListConcat function", func() { + It("should create ListConcat function with correct args", func() { list1 := defkit.List("list1") list2 := defkit.List("list2") fn := defkit.ListConcat(list1, list2) - Expect(fn).NotTo(BeNil()) Expect(fn.Package()).To(Equal("list")) Expect(fn.Function()).To(Equal("Concat")) Expect(fn.Args()).To(HaveLen(2)) + Expect(fn.Args()[0]).To(Equal(list1)) + Expect(fn.Args()[1]).To(Equal(list2)) }) }) @@ -259,12 +263,14 @@ var _ = Describe("Expressions", func() { }) It("should set fields on array element", func() { + nameLit := defkit.Lit("test") + portLit := defkit.Lit(8080) elem := defkit.NewArrayElement(). - Set("name", defkit.Lit("test")). - Set("port", defkit.Lit(8080)) + Set("name", nameLit). + Set("port", portLit) Expect(elem.Fields()).To(HaveLen(2)) - Expect(elem.Fields()["name"]).NotTo(BeNil()) - Expect(elem.Fields()["port"]).NotTo(BeNil()) + Expect(elem.Fields()["name"]).To(Equal(nameLit)) + Expect(elem.Fields()["port"]).To(Equal(portLit)) }) It("should set conditional fields on array element", func() { @@ -286,6 +292,11 @@ var _ = Describe("Expressions", func() { It("should create ForEachMap expression", func() { forEach := defkit.ForEachMap() Expect(forEach).NotTo(BeNil()) + Expect(forEach.Source()).To(Equal("parameter")) + Expect(forEach.KeyVar()).To(Equal("k")) + Expect(forEach.ValVar()).To(Equal("v")) + Expect(forEach.KeyExpr()).To(BeEmpty()) + Expect(forEach.ValExpr()).To(BeEmpty()) }) It("should set source and variable names", func() { @@ -316,11 +327,47 @@ var _ = Describe("Expressions", func() { It("should create parameter reference", func() { ref := defkit.ParamRef("image") Expect(ref).NotTo(BeNil()) + Expect(ref.Path()).To(Equal("parameter.image")) }) It("should reference nested parameter", func() { ref := defkit.ParamRef("config.port") Expect(ref).NotTo(BeNil()) + Expect(ref.Path()).To(Equal("parameter.config.port")) + }) + }) + + Context("InlineArrayValue", func() { + It("should create inline array with fields and store correct values", func() { + port := defkit.Int("port") + arr := defkit.InlineArray(map[string]defkit.Value{ + "containerPort": port, + }) + Expect(arr.Fields()).To(HaveLen(1)) + Expect(arr.Fields()).To(HaveKey("containerPort")) + Expect(arr.Fields()["containerPort"]).To(Equal(port)) + }) + + It("should create inline array with multiple fields and store correct values", func() { + port := defkit.Int("port") + protocol := defkit.Lit("TCP") + arr := defkit.InlineArray(map[string]defkit.Value{ + "containerPort": port, + "protocol": protocol, + }) + Expect(arr.Fields()).To(HaveLen(2)) + Expect(arr.Fields()["containerPort"]).To(Equal(port)) + Expect(arr.Fields()["protocol"]).To(Equal(protocol)) + }) + + It("should implement Value interface", func() { + port := defkit.Int("port") + arr := defkit.InlineArray(map[string]defkit.Value{ + "containerPort": port, + }) + var v defkit.Value = arr // compile-time check + Expect(v).NotTo(BeNil()) + Expect(arr.Fields()["containerPort"]).To(Equal(port)) }) }) }) diff --git a/pkg/definition/defkit/helper.go b/pkg/definition/defkit/helper.go index 7982afa4e..83899b6ea 100644 --- a/pkg/definition/defkit/helper.go +++ b/pkg/definition/defkit/helper.go @@ -252,6 +252,14 @@ func (hb *HelperBuilder) FromFields(source Value, fields ...string) *HelperBuild return hb } +// FromArray uses a pre-built ArrayBuilder as the helper source. +// This enables complex iteration patterns (ForEachWithGuardedFiltered) +// that can't be expressed through the standard From/Filter/Map pipeline. +func (hb *HelperBuilder) FromArray(ab *ArrayBuilder) *HelperBuilder { + hb.source = &arrayBuilderSource{builder: ab} + return hb +} + // FromHelper references another helper as the source. // This enables helper chaining for patterns like deduplication. // @@ -273,6 +281,13 @@ type helperRefSource struct { func (h *helperRefSource) isHelperSource() {} +// arrayBuilderSource wraps an ArrayBuilder as a helper source. +type arrayBuilderSource struct { + builder *ArrayBuilder +} + +func (a *arrayBuilderSource) isHelperSource() {} + // Each applies a transformation function to each element. // The function receives a Value representing the current item and returns // a transformed Value. @@ -474,6 +489,9 @@ func (hb *HelperBuilder) buildCollection() Value { hb.applyOpsToCollection(col) return col + case *arrayBuilderSource: + return src.builder + default: // Default: empty collection return Each(Lit([]any{})) diff --git a/pkg/definition/defkit/param.go b/pkg/definition/defkit/param.go index 9fad940d3..0ad2e2404 100644 --- a/pkg/definition/defkit/param.go +++ b/pkg/definition/defkit/param.go @@ -23,7 +23,9 @@ type baseParam struct { required bool defaultValue any description string - forceOptional bool // when true, field stays optional even with a default value + forceOptional bool // when true, field stays optional even with a default value + short string // short flag alias (e.g. "i" → // +short=i) + ignore bool // when true, emits // +ignore directive } func (p *baseParam) expr() {} @@ -37,6 +39,8 @@ func (p *baseParam) HasDefault() bool { return p.defaultValue != nil } func (p *baseParam) GetDefault() any { return p.defaultValue } func (p *baseParam) GetDescription() string { return p.description } func (p *baseParam) IsForceOptional() bool { return p.forceOptional } +func (p *baseParam) GetShort() string { return p.short } +func (p *baseParam) IsIgnore() bool { return p.ignore } // IsSet returns a condition that checks if the parameter has a value. // This is used with SetIf for conditional field assignment. @@ -142,6 +146,20 @@ func (p *StringParam) ForceOptional() *StringParam { return p } +// Short sets a short flag alias for the parameter. +// This generates a // +short=X directive in the CUE output. +func (p *StringParam) Short(s string) *StringParam { + p.short = s + return p +} + +// Ignore marks the parameter as ignored by the UI. +// This generates a // +ignore directive in the CUE output. +func (p *StringParam) Ignore() *StringParam { + p.ignore = true + return p +} + // Default sets a default value for the parameter. func (p *StringParam) Default(value string) *StringParam { p.defaultValue = value @@ -320,6 +338,20 @@ func (p *IntParam) Description(desc string) *IntParam { return p } +// Short sets a short flag alias for the parameter. +// This generates a // +short=X directive in the CUE output. +func (p *IntParam) Short(s string) *IntParam { + p.short = s + return p +} + +// Ignore marks the parameter as ignored by the UI. +// This generates a // +ignore directive in the CUE output. +func (p *IntParam) Ignore() *IntParam { + p.ignore = true + return p +} + // Min sets the minimum value constraint for the parameter. // This generates CUE like: int & >=n func (p *IntParam) Min(n int) *IntParam { @@ -417,6 +449,20 @@ func (p *BoolParam) Description(desc string) *BoolParam { return p } +// Short sets a short flag alias for the parameter. +// This generates a // +short=X directive in the CUE output. +func (p *BoolParam) Short(s string) *BoolParam { + p.short = s + return p +} + +// Ignore marks the parameter as ignored by the UI. +// This generates a // +ignore directive in the CUE output. +func (p *BoolParam) Ignore() *BoolParam { + p.ignore = true + return p +} + // IsTrue returns a condition that checks if the bool parameter is truthy. // In CUE, this generates `if parameter.name` instead of `if parameter.name == true`. func (p *BoolParam) IsTrue() Condition { @@ -1033,6 +1079,20 @@ func (p *EnumParam) Description(desc string) *EnumParam { return p } +// Short sets a short flag alias for the parameter. +// This generates a // +short=X directive in the CUE output. +func (p *EnumParam) Short(s string) *EnumParam { + p.short = s + return p +} + +// Ignore marks the parameter as ignored by the UI. +// This generates a // +ignore directive in the CUE output. +func (p *EnumParam) Ignore() *EnumParam { + p.ignore = true + return p +} + // GetValues returns the allowed enum values. func (p *EnumParam) GetValues() []string { return p.values @@ -1095,6 +1155,12 @@ func (p *OneOfParam) Variants(variants ...*OneOfVariant) *OneOfParam { return p } +// Default sets the default variant name for the discriminator. +func (p *OneOfParam) Default(value string) *OneOfParam { + p.defaultValue = value + return p +} + // Required marks the parameter as required. func (p *OneOfParam) Required() *OneOfParam { p.required = true @@ -1197,6 +1263,9 @@ func (p *StringKeyMapParam) Description(desc string) *StringKeyMapParam { return p } +// GetType returns the parameter type. +func (p *StringKeyMapParam) GetType() ParamType { return p.paramType } + // DynamicMapParam represents a parameter where the parameter itself is a dynamic map. // In CUE: parameter: [string]: T (where T is the value type) // This is used for traits like labels where all user values become map keys. diff --git a/pkg/definition/defkit/param_test.go b/pkg/definition/defkit/param_test.go index 4ed706e8f..7d8827015 100644 --- a/pkg/definition/defkit/param_test.go +++ b/pkg/definition/defkit/param_test.go @@ -362,42 +362,51 @@ var _ = Describe("Parameters", func() { It("should create Eq condition from IntParam", func() { replicas := defkit.Int("replicas") cond := replicas.Eq(3) - Expect(cond).NotTo(BeNil()) - // Check it implements Condition interface - _, ok := cond.(defkit.Condition) //lint:ignore S1040 intentional interface check for documentation - Expect(ok).To(BeTrue()) + pcc, ok := cond.(*defkit.ParamCompareCondition) + Expect(ok).To(BeTrue(), "expected *ParamCompareCondition") + Expect(pcc.ParamName()).To(Equal("replicas")) + Expect(pcc.Op()).To(Equal("==")) + Expect(pcc.CompareValue()).To(Equal(3)) }) It("should create comparison conditions from StringParam", func() { status := defkit.String("status") - // Eq - eqCond := status.Eq("running") - Expect(eqCond).NotTo(BeNil()) + eqCond, ok := status.Eq("running").(*defkit.ParamCompareCondition) + Expect(ok).To(BeTrue()) + Expect(eqCond.ParamName()).To(Equal("status")) + Expect(eqCond.Op()).To(Equal("==")) + Expect(eqCond.CompareValue()).To(Equal("running")) - // Ne - neCond := status.Ne("error") - Expect(neCond).NotTo(BeNil()) + neCond, ok := status.Ne("error").(*defkit.ParamCompareCondition) + Expect(ok).To(BeTrue()) + Expect(neCond.ParamName()).To(Equal("status")) + Expect(neCond.Op()).To(Equal("!=")) + Expect(neCond.CompareValue()).To(Equal("error")) }) It("should create numeric comparison conditions", func() { replicas := defkit.Int("replicas") - // Gt - gtCond := replicas.Gt(1) - Expect(gtCond).NotTo(BeNil()) + gtCond, ok := replicas.Gt(1).(*defkit.ParamCompareCondition) + Expect(ok).To(BeTrue()) + Expect(gtCond.Op()).To(Equal(">")) + Expect(gtCond.CompareValue()).To(Equal(1)) - // Gte - gteCond := replicas.Gte(1) - Expect(gteCond).NotTo(BeNil()) + gteCond, ok := replicas.Gte(1).(*defkit.ParamCompareCondition) + Expect(ok).To(BeTrue()) + Expect(gteCond.Op()).To(Equal(">=")) + Expect(gteCond.CompareValue()).To(Equal(1)) - // Lt - ltCond := replicas.Lt(10) - Expect(ltCond).NotTo(BeNil()) + ltCond, ok := replicas.Lt(10).(*defkit.ParamCompareCondition) + Expect(ok).To(BeTrue()) + Expect(ltCond.Op()).To(Equal("<")) + Expect(ltCond.CompareValue()).To(Equal(10)) - // Lte - lteCond := replicas.Lte(10) - Expect(lteCond).NotTo(BeNil()) + lteCond, ok := replicas.Lte(10).(*defkit.ParamCompareCondition) + Expect(ok).To(BeTrue()) + Expect(lteCond.Op()).To(Equal("<=")) + Expect(lteCond.CompareValue()).To(Equal(10)) }) }) @@ -405,30 +414,35 @@ var _ = Describe("Parameters", func() { It("should create Add expression from IntParam", func() { replicas := defkit.Int("replicas") expr := replicas.Add(1) - Expect(expr).NotTo(BeNil()) - // Check it implements Value interface - _, ok := expr.(defkit.Value) //lint:ignore S1040 intentional interface check for documentation - Expect(ok).To(BeTrue()) + arith, ok := expr.(*defkit.ParamArithExpr) + Expect(ok).To(BeTrue(), "expected *ParamArithExpr") + Expect(arith.ParamName()).To(Equal("replicas")) + Expect(arith.Op()).To(Equal("+")) + Expect(arith.ArithValue()).To(Equal(1)) }) It("should create arithmetic expressions from IntParam", func() { replicas := defkit.Int("replicas") - // Add - addExpr := replicas.Add(1) - Expect(addExpr).NotTo(BeNil()) + addExpr, ok := replicas.Add(1).(*defkit.ParamArithExpr) + Expect(ok).To(BeTrue()) + Expect(addExpr.Op()).To(Equal("+")) + Expect(addExpr.ArithValue()).To(Equal(1)) - // Sub - subExpr := replicas.Sub(1) - Expect(subExpr).NotTo(BeNil()) + subExpr, ok := replicas.Sub(1).(*defkit.ParamArithExpr) + Expect(ok).To(BeTrue()) + Expect(subExpr.Op()).To(Equal("-")) + Expect(subExpr.ArithValue()).To(Equal(1)) - // Mul - mulExpr := replicas.Mul(2) - Expect(mulExpr).NotTo(BeNil()) + mulExpr, ok := replicas.Mul(2).(*defkit.ParamArithExpr) + Expect(ok).To(BeTrue()) + Expect(mulExpr.Op()).To(Equal("*")) + Expect(mulExpr.ArithValue()).To(Equal(2)) - // Div - divExpr := replicas.Div(2) - Expect(divExpr).NotTo(BeNil()) + divExpr, ok := replicas.Div(2).(*defkit.ParamArithExpr) + Expect(ok).To(BeTrue()) + Expect(divExpr.Op()).To(Equal("/")) + Expect(divExpr.ArithValue()).To(Equal(2)) }) }) @@ -436,19 +450,21 @@ var _ = Describe("Parameters", func() { It("should create Concat expression from StringParam", func() { name := defkit.String("name") expr := name.Concat("-suffix") - Expect(expr).NotTo(BeNil()) - // Check it implements Value interface - _, ok := expr.(defkit.Value) //lint:ignore S1040 intentional interface check for documentation - Expect(ok).To(BeTrue()) + concat, ok := expr.(*defkit.ParamConcatExpr) + Expect(ok).To(BeTrue(), "expected *ParamConcatExpr") + Expect(concat.ParamName()).To(Equal("name")) + Expect(concat.Suffix()).To(Equal("-suffix")) + Expect(concat.Prefix()).To(BeEmpty()) }) It("should create Prepend expression from StringParam", func() { name := defkit.String("name") expr := name.Prepend("prefix-") - Expect(expr).NotTo(BeNil()) - // Check it implements Value interface - _, ok := expr.(defkit.Value) //lint:ignore S1040 intentional interface check for documentation - Expect(ok).To(BeTrue()) + concat, ok := expr.(*defkit.ParamConcatExpr) + Expect(ok).To(BeTrue(), "expected *ParamConcatExpr") + Expect(concat.ParamName()).To(Equal("name")) + Expect(concat.Prefix()).To(Equal("prefix-")) + Expect(concat.Suffix()).To(BeEmpty()) }) }) @@ -476,14 +492,20 @@ var _ = Describe("Parameters", func() { config := defkit.Struct("config") fieldRef := config.Field("port") cond := fieldRef.IsSet() - Expect(cond).NotTo(BeNil()) + isSet, ok := cond.(*defkit.ParamPathIsSetCondition) + Expect(ok).To(BeTrue(), "expected *ParamPathIsSetCondition") + Expect(isSet.Path()).To(Equal("config.port")) }) It("should create Eq condition from field ref", func() { config := defkit.Struct("config") fieldRef := config.Field("enabled") cond := fieldRef.Eq(true) - Expect(cond).NotTo(BeNil()) + pcc, ok := cond.(*defkit.ParamCompareCondition) + Expect(ok).To(BeTrue(), "expected *ParamCompareCondition") + Expect(pcc.ParamName()).To(Equal("config.enabled")) + Expect(pcc.Op()).To(Equal("==")) + Expect(pcc.CompareValue()).To(Equal(true)) }) }) }) @@ -492,26 +514,33 @@ var _ = Describe("Parameters", func() { It("should create StringList parameter", func() { p := defkit.StringList("tags") Expect(p.Name()).To(Equal("tags")) + Expect(p.ElementType()).To(Equal(defkit.ParamTypeString)) + Expect(p.IsRequired()).To(BeFalse()) }) It("should create IntList parameter", func() { p := defkit.IntList("ports") Expect(p.Name()).To(Equal("ports")) + Expect(p.ElementType()).To(Equal(defkit.ParamTypeInt)) + Expect(p.IsRequired()).To(BeFalse()) }) It("should create StringKeyMap parameter", func() { p := defkit.StringKeyMap("labels") Expect(p.Name()).To(Equal("labels")) + Expect(p.GetType()).To(Equal(defkit.ParamTypeMap)) }) It("should create List parameter", func() { p := defkit.List("items") Expect(p.Name()).To(Equal("items")) + Expect(p.IsRequired()).To(BeFalse()) }) It("should create Object parameter", func() { p := defkit.Object("config") Expect(p.Name()).To(Equal("config")) + Expect(p.IsRequired()).To(BeFalse()) }) }) @@ -519,13 +548,19 @@ var _ = Describe("Parameters", func() { It("should create IsSet condition from param", func() { replicas := defkit.Int("replicas") cond := replicas.IsSet() - Expect(cond).NotTo(BeNil()) + isSet, ok := cond.(*defkit.IsSetCondition) + Expect(ok).To(BeTrue(), "expected *IsSetCondition") + Expect(isSet.ParamName()).To(Equal("replicas")) }) It("should create NotSet condition from param", func() { replicas := defkit.Int("replicas") cond := replicas.NotSet() - Expect(cond).NotTo(BeNil()) + notCond, ok := cond.(*defkit.NotCondition) + Expect(ok).To(BeTrue(), "expected *NotCondition") + inner, ok := notCond.Inner().(*defkit.IsSetCondition) + Expect(ok).To(BeTrue(), "expected inner *IsSetCondition") + Expect(inner.ParamName()).To(Equal("replicas")) }) }) @@ -533,13 +568,17 @@ var _ = Describe("Parameters", func() { It("should create IsTrue condition", func() { enabled := defkit.Bool("enabled") cond := enabled.IsTrue() - Expect(cond).NotTo(BeNil()) + truthy, ok := cond.(*defkit.TruthyCondition) + Expect(ok).To(BeTrue(), "expected *TruthyCondition") + Expect(truthy.ParamName()).To(Equal("enabled")) }) It("should create IsFalse condition", func() { enabled := defkit.Bool("enabled") cond := enabled.IsFalse() - Expect(cond).NotTo(BeNil()) + falsy, ok := cond.(*defkit.FalsyCondition) + Expect(ok).To(BeTrue(), "expected *FalsyCondition") + Expect(falsy.ParamName()).To(Equal("enabled")) }) }) @@ -556,7 +595,11 @@ var _ = Describe("Parameters", func() { It("should create length constraint conditions", func() { arr := defkit.Array("items") gteCond := arr.LenGte(1) - Expect(gteCond).NotTo(BeNil()) + lenCond, ok := gteCond.(*defkit.LenCondition) + Expect(ok).To(BeTrue(), "expected *LenCondition") + Expect(lenCond.ParamName()).To(Equal("items")) + Expect(lenCond.Op()).To(Equal(">=")) + Expect(lenCond.Length()).To(Equal(1)) }) It("should set WithFields for array items", func() { @@ -603,6 +646,30 @@ var _ = Describe("Parameters", func() { }) }) + Context("OneOfParam Default method", func() { + It("should set default variant name", func() { + p := defkit.OneOf("type").Default("emptyDir") + Expect(p.HasDefault()).To(BeTrue()) + Expect(p.GetDefault()).To(Equal("emptyDir")) + }) + + It("should support fluent chaining with Default", func() { + p := defkit.OneOf("type"). + Default("emptyDir"). + Description("Volume type"). + Variants( + defkit.Variant("pvc").Fields( + defkit.Field("claimName", defkit.ParamTypeString).Required(), + ), + defkit.Variant("emptyDir"), + ) + Expect(p.HasDefault()).To(BeTrue()) + Expect(p.GetDefault()).To(Equal("emptyDir")) + Expect(p.GetDescription()).To(Equal("Volume type")) + Expect(p.GetVariants()).To(HaveLen(2)) + }) + }) + Context("IntParam Optional method", func() { It("should set int as optional", func() { p := defkit.Int("replicas").Optional() @@ -628,13 +695,21 @@ var _ = Describe("Parameters", func() { It("should create LenLt condition", func() { arr := defkit.Array("items") cond := arr.LenLt(10) - Expect(cond).NotTo(BeNil()) + lenCond, ok := cond.(*defkit.LenCondition) + Expect(ok).To(BeTrue(), "expected *LenCondition") + Expect(lenCond.ParamName()).To(Equal("items")) + Expect(lenCond.Op()).To(Equal("<")) + Expect(lenCond.Length()).To(Equal(10)) }) It("should create LenLte condition", func() { arr := defkit.Array("items") cond := arr.LenLte(10) - Expect(cond).NotTo(BeNil()) + lenCond, ok := cond.(*defkit.LenCondition) + Expect(ok).To(BeTrue(), "expected *LenCondition") + Expect(lenCond.ParamName()).To(Equal("items")) + Expect(lenCond.Op()).To(Equal("<=")) + Expect(lenCond.Length()).To(Equal(10)) }) }) @@ -756,7 +831,9 @@ var _ = Describe("Parameters", func() { It("should create IsSet condition", func() { path := defkit.ParamPath("config.port") cond := path.IsSet() - Expect(cond).NotTo(BeNil()) + isSet, ok := cond.(*defkit.ParamPathIsSetCondition) + Expect(ok).To(BeTrue(), "expected *ParamPathIsSetCondition") + Expect(isSet.Path()).To(Equal("config.port")) }) }) @@ -789,7 +866,70 @@ var _ = Describe("Parameters", func() { It("should create Ne condition", func() { p := defkit.Int("count") cond := p.Ne(0) - Expect(cond).NotTo(BeNil()) + pcc, ok := cond.(*defkit.ParamCompareCondition) + Expect(ok).To(BeTrue(), "expected *ParamCompareCondition") + Expect(pcc.ParamName()).To(Equal("count")) + Expect(pcc.Op()).To(Equal("!=")) + Expect(pcc.CompareValue()).To(Equal(0)) + }) + }) + + Context("Short method", func() { + It("should set short flag on StringParam", func() { + p := defkit.String("image").Short("i") + Expect(p.GetShort()).To(Equal("i")) + }) + It("should set short flag on IntParam", func() { + p := defkit.Int("port").Short("p") + Expect(p.GetShort()).To(Equal("p")) + }) + It("should set short flag on BoolParam", func() { + p := defkit.Bool("debug").Short("d") + Expect(p.GetShort()).To(Equal("d")) + }) + It("should set short flag on EnumParam", func() { + p := defkit.Enum("protocol").Values("TCP", "UDP").Short("p") + Expect(p.GetShort()).To(Equal("p")) + }) + It("should return empty string when not set", func() { + p := defkit.String("image") + Expect(p.GetShort()).To(BeEmpty()) + }) + It("should support fluent chaining with other methods", func() { + p := defkit.String("image").Required().Description("Container image").Short("i") + Expect(p.Name()).To(Equal("image")) + Expect(p.IsRequired()).To(BeTrue()) + Expect(p.GetDescription()).To(Equal("Container image")) + Expect(p.GetShort()).To(Equal("i")) + }) + }) + + Context("Ignore method", func() { + It("should mark StringParam as ignored", func() { + p := defkit.String("port").Ignore() + Expect(p.IsIgnore()).To(BeTrue()) + }) + It("should mark IntParam as ignored", func() { + p := defkit.Int("port").Ignore() + Expect(p.IsIgnore()).To(BeTrue()) + }) + It("should mark BoolParam as ignored", func() { + p := defkit.Bool("debug").Ignore() + Expect(p.IsIgnore()).To(BeTrue()) + }) + It("should mark EnumParam as ignored", func() { + p := defkit.Enum("type").Values("A", "B").Ignore() + Expect(p.IsIgnore()).To(BeTrue()) + }) + It("should not be ignored by default", func() { + p := defkit.String("image") + Expect(p.IsIgnore()).To(BeFalse()) + }) + It("should support fluent chaining with Short and other methods", func() { + p := defkit.Int("port").Ignore().Description("Deprecated field").Short("p") + Expect(p.IsIgnore()).To(BeTrue()) + Expect(p.GetShort()).To(Equal("p")) + Expect(p.GetDescription()).To(Equal("Deprecated field")) }) }) }) diff --git a/pkg/definition/defkit/patch_container.go b/pkg/definition/defkit/patch_container.go index 053512666..cc0b8324e 100644 --- a/pkg/definition/defkit/patch_container.go +++ b/pkg/definition/defkit/patch_container.go @@ -223,10 +223,14 @@ type PatchContainerConfig struct { ContainersDescription string // +usage description for the containers param (auto-generated if empty) CustomParamsBlock string // custom CUE block for #PatchParams (for complex types) MultiContainerParam string // alternate name for multi-container param (default: "probes" for probes, "containers" for others) + MultiContainerCheckField string // field name for multi-container error check (default: "containerName") + MultiContainerErrMsg string // custom error message for multi-container mode (default: "container name must be set for %s") CustomPatchContainerBlock string // custom CUE block for PatchContainer body (for complex merge logic) CustomPatchBlock string // custom CUE block for the patch: spec: template: spec: { ... } body CustomParameterBlock string // custom CUE block for the parameter definition PatchStrategy string // if set, emitted as // +patchStrategy= before the patch block (e.g., "open") + ParamsTypeName string // custom name for the #PatchParams helper (default: "PatchParams") + NoDefaultDisjunction bool // if true, omit the * default marker on parameter disjunction } // --- Let Binding Support --- diff --git a/pkg/definition/defkit/patch_container_test.go b/pkg/definition/defkit/patch_container_test.go index b063856da..c095c1fea 100644 --- a/pkg/definition/defkit/patch_container_test.go +++ b/pkg/definition/defkit/patch_container_test.go @@ -628,6 +628,202 @@ var _ = Describe("PatchContainer", func() { }) }) + Context("MultiContainerCheckField and MultiContainerErrMsg", func() { + It("should use default check field 'containerName' and default error message with camelCase", func() { + trait := defkit.NewTrait("default-multi-err-test"). + Description("Test default multi-container error"). + AppliesTo("deployments.apps"). + Template(func(tpl *defkit.Template) { + tpl.UsePatchContainer(defkit.PatchContainerConfig{ + ContainerNameParam: "containerName", + DefaultToContextName: true, + AllowMultiple: true, + ContainersParam: "containers", + PatchFields: []defkit.PatchContainerField{ + {ParamName: "image", TargetField: "image"}, + }, + }) + }) + + cue := trait.ToCue() + + // Default check field is "containerName" + Expect(cue).To(ContainSubstring(`if c.containerName == ""`)) + Expect(cue).To(ContainSubstring(`if c.containerName != ""`)) + // Default error message uses "containerName" (camelCase, matching the field name) + Expect(cue).To(ContainSubstring(`err: "containerName must be set for containers"`)) + }) + + It("should use custom MultiContainerCheckField when set", func() { + trait := defkit.NewTrait("custom-check-field-test"). + Description("Test custom check field"). + AppliesTo("deployments.apps"). + Template(func(tpl *defkit.Template) { + tpl.UsePatchContainer(defkit.PatchContainerConfig{ + ContainerNameParam: "containerName", + DefaultToContextName: true, + AllowMultiple: true, + MultiContainerParam: "probes", + MultiContainerCheckField: "name", + PatchFields: []defkit.PatchContainerField{ + {ParamName: "image", TargetField: "image"}, + }, + }) + }) + + cue := trait.ToCue() + + // Custom check field "name" instead of default "containerName" + Expect(cue).To(ContainSubstring(`if c.name == ""`)) + Expect(cue).To(ContainSubstring(`if c.name != ""`)) + Expect(cue).NotTo(ContainSubstring(`c.containerName == ""`)) + }) + + It("should use custom MultiContainerErrMsg when set", func() { + trait := defkit.NewTrait("custom-err-msg-test"). + Description("Test custom error message"). + AppliesTo("deployments.apps"). + Template(func(tpl *defkit.Template) { + tpl.UsePatchContainer(defkit.PatchContainerConfig{ + ContainerNameParam: "containerName", + DefaultToContextName: true, + AllowMultiple: true, + MultiContainerParam: "probes", + MultiContainerCheckField: "name", + MultiContainerErrMsg: "containerName must be set when specifying startup probe for multiple containers", + PatchFields: []defkit.PatchContainerField{ + {ParamName: "image", TargetField: "image"}, + }, + }) + }) + + cue := trait.ToCue() + + // Custom error message overrides the default + Expect(cue).To(ContainSubstring(`err: "containerName must be set when specifying startup probe for multiple containers"`)) + // Default error message should NOT appear + Expect(cue).NotTo(ContainSubstring(`err: "containerName must be set for probes"`)) + }) + + It("should use default error with MultiContainerParam name", func() { + trait := defkit.NewTrait("multi-param-err-test"). + Description("Test error message includes multi param name"). + AppliesTo("deployments.apps"). + Template(func(tpl *defkit.Template) { + tpl.UsePatchContainer(defkit.PatchContainerConfig{ + ContainerNameParam: "containerName", + DefaultToContextName: true, + AllowMultiple: true, + MultiContainerParam: "probes", + PatchFields: []defkit.PatchContainerField{ + {ParamName: "image", TargetField: "image"}, + }, + }) + }) + + cue := trait.ToCue() + + // Default error message should include the multi-container param name + Expect(cue).To(ContainSubstring(`err: "containerName must be set for probes"`)) + }) + }) + + Context("writePatchParamMapping typed scalar fields", func() { + It("should map typed scalar fields unconditionally in _params block even with IsSet and no default", func() { + trait := defkit.NewTrait("typed-scalar-unconditional-test"). + Description("Test typed scalar fields pass through unconditionally"). + AppliesTo("deployments.apps"). + Template(func(tpl *defkit.Template) { + tpl.UsePatchContainer(defkit.PatchContainerConfig{ + ContainerNameParam: "containerName", + DefaultToContextName: true, + AllowMultiple: true, + ContainersParam: "containers", + Groups: []defkit.PatchContainerGroup{ + { + TargetField: "startupProbe", + Fields: defkit.PatchFields( + defkit.PatchField("terminationGracePeriodSeconds").Int().IsSet(), + defkit.PatchField("exec").IsSet(), + ), + }, + }, + }) + }) + + cue := trait.ToCue() + + // Int().IsSet() with no default: typed scalar should be unconditional in _params block + Expect(cue).To(ContainSubstring("terminationGracePeriodSeconds: parameter.terminationGracePeriodSeconds")) + // But untyped IsSet() with no default (e.g., exec) should remain conditional in _params block + Expect(cue).To(MatchRegexp(`if parameter\.exec != _\|_\s*\{[^}]*exec:\s*parameter\.exec`)) + + // Both should still be conditional in the PatchContainer body + Expect(cue).To(ContainSubstring("if _params.terminationGracePeriodSeconds != _|_")) + Expect(cue).To(ContainSubstring("if _params.exec != _|_")) + }) + + It("should keep untyped IsSet fields conditional in _params block", func() { + trait := defkit.NewTrait("untyped-conditional-test"). + Description("Test untyped fields remain conditional"). + AppliesTo("deployments.apps"). + Template(func(tpl *defkit.Template) { + tpl.UsePatchContainer(defkit.PatchContainerConfig{ + ContainerNameParam: "containerName", + DefaultToContextName: true, + AllowMultiple: true, + ContainersParam: "containers", + Groups: []defkit.PatchContainerGroup{ + { + TargetField: "startupProbe", + Fields: defkit.PatchFields( + defkit.PatchField("exec").IsSet(), + defkit.PatchField("httpGet").IsSet(), + ), + }, + }, + }) + }) + + cue := trait.ToCue() + + // Untyped IsSet() fields should still be conditional in _params block + Expect(cue).To(MatchRegexp(`if parameter\.exec != _\|_\s*\{[^}]*exec:\s*parameter\.exec`)) + Expect(cue).To(MatchRegexp(`if parameter\.httpGet != _\|_\s*\{[^}]*httpGet:\s*parameter\.httpGet`)) + }) + + It("should map typed scalar fields with default unconditionally regardless of IsSet", func() { + trait := defkit.NewTrait("typed-with-default-test"). + Description("Test typed fields with default are unconditional"). + AppliesTo("deployments.apps"). + Template(func(tpl *defkit.Template) { + tpl.UsePatchContainer(defkit.PatchContainerConfig{ + ContainerNameParam: "containerName", + DefaultToContextName: true, + AllowMultiple: true, + ContainersParam: "containers", + Groups: []defkit.PatchContainerGroup{ + { + TargetField: "startupProbe", + Fields: defkit.PatchFields( + defkit.PatchField("initialDelaySeconds").Int().IsSet().Default("0"), + defkit.PatchField("periodSeconds").Int().IsSet().Default("10"), + ), + }, + }, + }) + }) + + cue := trait.ToCue() + + // Typed fields with defaults are always unconditional in _params block + Expect(cue).To(ContainSubstring("initialDelaySeconds: parameter.initialDelaySeconds")) + Expect(cue).To(ContainSubstring("periodSeconds: parameter.periodSeconds")) + // Should NOT be wrapped in conditionals in _params block + Expect(cue).NotTo(MatchRegexp(`if parameter\.initialDelaySeconds[^{]*\{[^}]*initialDelaySeconds:\s*parameter\.initialDelaySeconds`)) + }) + }) + Context("Template Let Bindings", func() { It("should accumulate bindings in order for CUE generation", func() { tpl := defkit.NewTemplate() diff --git a/pkg/definition/defkit/resource.go b/pkg/definition/defkit/resource.go index 3c1f19c5c..85eb732ac 100644 --- a/pkg/definition/defkit/resource.go +++ b/pkg/definition/defkit/resource.go @@ -226,3 +226,30 @@ func (r *Resource) EndIf() *Resource { } return r } + +// Directive records a CUE directive annotation on a field path. +// The directive string should be like "patchKey=ip" and will be rendered as // +patchKey=ip. +func (r *Resource) Directive(path string, directive string) *Resource { + op := &DirectiveOp{path: path, directive: directive} + if r.currentIf != nil { + r.currentIf.ops = append(r.currentIf.ops, op) + } else { + r.ops = append(r.ops, op) + } + return r +} + +// DirectiveOp records a CUE directive annotation on a field path. +// The directive string (e.g. "patchKey=ip") is rendered as // +patchKey=ip. +type DirectiveOp struct { + path string + directive string +} + +func (d *DirectiveOp) resourceOp() {} + +// Path returns the field path. +func (d *DirectiveOp) Path() string { return d.path } + +// GetDirective returns the directive string. +func (d *DirectiveOp) GetDirective() string { return d.directive } diff --git a/pkg/definition/defkit/resource_test.go b/pkg/definition/defkit/resource_test.go index da041782b..81a4efa94 100644 --- a/pkg/definition/defkit/resource_test.go +++ b/pkg/definition/defkit/resource_test.go @@ -202,4 +202,43 @@ var _ = Describe("Resource", func() { Expect(isSpreadIf).To(BeTrue()) }) }) + + Context("Directive", func() { + It("should record a Directive operation", func() { + r := defkit.NewResource("apps/v1", "DaemonSet"). + Directive("spec.template.spec.hostAliases", "patchKey=ip") + Expect(r.Ops()).To(HaveLen(1)) + dirOp, ok := r.Ops()[0].(*defkit.DirectiveOp) + Expect(ok).To(BeTrue()) + Expect(dirOp.Path()).To(Equal("spec.template.spec.hostAliases")) + Expect(dirOp.GetDirective()).To(Equal("patchKey=ip")) + }) + + It("should record Directive within If block", func() { + hostAliases := defkit.Object("hostAliases") + r := defkit.NewResource("apps/v1", "DaemonSet"). + If(hostAliases.IsSet()). + Set("spec.template.spec.hostAliases", hostAliases). + Directive("spec.template.spec.hostAliases", "patchKey=ip"). + EndIf() + Expect(r.Ops()).To(HaveLen(1)) + ifBlock, ok := r.Ops()[0].(*defkit.IfBlock) + Expect(ok).To(BeTrue()) + Expect(ifBlock.Ops()).To(HaveLen(2)) + _, isDirOp := ifBlock.Ops()[1].(*defkit.DirectiveOp) + Expect(isDirOp).To(BeTrue()) + }) + + It("should combine with Set operations", func() { + hostAliases := defkit.Object("hostAliases") + r := defkit.NewResource("apps/v1", "DaemonSet"). + SetIf(hostAliases.IsSet(), "spec.template.spec.hostAliases", hostAliases). + Directive("spec.template.spec.hostAliases", "patchKey=ip") + Expect(r.Ops()).To(HaveLen(2)) + _, isSetIf := r.Ops()[0].(*defkit.SetIfOp) + _, isDirOp := r.Ops()[1].(*defkit.DirectiveOp) + Expect(isSetIf).To(BeTrue()) + Expect(isDirOp).To(BeTrue()) + }) + }) }) diff --git a/pkg/definition/defkit/status.go b/pkg/definition/defkit/status.go index 6ae7796bf..cb4a4d120 100644 --- a/pkg/definition/defkit/status.go +++ b/pkg/definition/defkit/status.go @@ -98,9 +98,7 @@ func (s *StatusBuilder) Build() string { var parts []string - for _, f := range s.fields { - parts = append(parts, s.buildField(f)) - } + parts = append(parts, s.buildGroupedFields()...) if s.message != "" { // Use simple quotes around message - don't use %q which escapes backslashes, @@ -111,6 +109,79 @@ func (s *StatusBuilder) Build() string { return strings.Join(parts, "\n") } +// buildGroupedFields groups fields by parent prefix and generates consolidated CUE blocks. +// Fields with the same parent (e.g., "status.active", "status.failed") are consolidated +// into a single block with column-aligned defaults. +func (s *StatusBuilder) buildGroupedFields() []string { + type fieldGroup struct { + parent string + fields []*StatusField + } + + groups := make([]fieldGroup, 0) + groupIndex := make(map[string]int) + var simpleFields []*StatusField + + for _, f := range s.fields { + parts := strings.Split(f.name, ".") + if len(parts) == 2 { + parent := parts[0] + if idx, ok := groupIndex[parent]; ok { + groups[idx].fields = append(groups[idx].fields, f) + } else { + groupIndex[parent] = len(groups) + groups = append(groups, fieldGroup{parent: parent, fields: []*StatusField{f}}) + } + } else { + simpleFields = append(simpleFields, f) + } + } + + var result []string + + for _, g := range groups { + result = append(result, s.buildConsolidatedGroup(g.parent, g.fields)) + } + + for _, f := range simpleFields { + result = append(result, s.buildField(f)) + } + + return result +} + +// buildConsolidatedGroup generates a single CUE block for fields sharing a parent prefix. +// Field values are column-aligned within the block. +func (s *StatusBuilder) buildConsolidatedGroup(parent string, fields []*StatusField) string { + var defaults []string + var conditionals []string + + // Calculate max child field name length for column alignment + maxLen := 0 + for _, f := range fields { + parts := strings.Split(f.name, ".") + childName := parts[1] + if len(childName) > maxLen { + maxLen = len(childName) + } + } + + for _, f := range fields { + parts := strings.Split(f.name, ".") + childName := parts[1] + padding := strings.Repeat(" ", maxLen-len(childName)) + + defaults = append(defaults, fmt.Sprintf("\t%s:%s %s", childName, padding, f.defaultExpr())) + conditionals = append(conditionals, fmt.Sprintf("\tif context.output.%s != _|_ {\n\t\t%s: context.output.%s\n\t}", f.sourcePath, childName, f.sourcePath)) + } + + if len(conditionals) == 0 { + return fmt.Sprintf("%s: {\n%s\n}", parent, strings.Join(defaults, "\n")) + } + + return fmt.Sprintf("%s: {\n%s\n} & {\n%s\n}", parent, strings.Join(defaults, "\n"), strings.Join(conditionals, "\n")) +} + // RawCUE sets raw CUE for complex status expressions that don't fit the builder pattern. func (s *StatusBuilder) RawCUE(cue string) *StatusBuilder { s.rawCUE = cue @@ -236,7 +307,18 @@ func (h *HealthBuilder) Build() string { parts = append(parts, h.buildGroupedFields()...) if len(h.conditions) > 0 { - healthExpr := strings.Join(h.conditions, " && ") + // When multiple conditions are joined with &&, auto-parenthesize each + // for correct precedence and readability. Skip already-parenthesized + // conditions (e.g. from StatusOr). Single conditions are left as-is. + conditions := h.conditions + if len(conditions) > 1 { + wrapped := make([]string, len(conditions)) + for i, c := range conditions { + wrapped[i] = parenthesizeCondition(c) + } + conditions = wrapped + } + healthExpr := strings.Join(conditions, " && ") if h.useDefault { parts = append(parts, fmt.Sprintf("_isHealth: %s", healthExpr), "isHealth: *_isHealth | bool") } else { @@ -251,6 +333,39 @@ func (h *HealthBuilder) Build() string { return strings.Join(parts, "\n") } +// parenthesizeCondition wraps a condition string in parentheses if it isn't already +// fully enclosed in a matching pair. This prevents double-wrapping conditions that +// are already parenthesized (e.g. from StatusOr). +func parenthesizeCondition(s string) string { + s = strings.TrimSpace(s) + if isFullyParenthesized(s) { + return s + } + return "(" + s + ")" +} + +// isFullyParenthesized checks whether the string is enclosed by a single matching +// pair of parentheses. For example "(a == b)" and "(a || b)" return true, but +// "(a) && (b)" returns false because the first closing paren appears before the end. +func isFullyParenthesized(s string) bool { + if len(s) < 2 || s[0] != '(' || s[len(s)-1] != ')' { + return false + } + depth := 0 + for i, ch := range s { + if ch == '(' { + depth++ + } else if ch == ')' { + depth-- + } + // If depth reaches 0 before the last character, the outer parens don't enclose everything + if depth == 0 && i < len(s)-1 { + return false + } + } + return depth == 0 +} + // buildGroupedFields groups fields by parent prefix and generates consolidated CUE blocks. // Fields with the same parent (e.g., "ready.replicas", "ready.updatedReplicas") are // consolidated into a single block with column-aligned defaults. @@ -402,9 +517,9 @@ func DeploymentHealth() *HealthBuilder { IntField("ready.replicas", "status.replicas", 0). IntField("ready.observedGeneration", "status.observedGeneration", 0). HealthyWhen( - "("+StatusEq("context.output.spec.replicas", "ready.readyReplicas")+")", - "("+StatusEq("context.output.spec.replicas", "ready.updatedReplicas")+")", - "("+StatusEq("context.output.spec.replicas", "ready.replicas")+")", + StatusEq("context.output.spec.replicas", "ready.readyReplicas"), + StatusEq("context.output.spec.replicas", "ready.updatedReplicas"), + StatusEq("context.output.spec.replicas", "ready.replicas"), StatusOr(StatusEq("ready.observedGeneration", "context.output.metadata.generation"), "ready.observedGeneration > context.output.metadata.generation"), ). WithDefault(). diff --git a/pkg/definition/defkit/status_test.go b/pkg/definition/defkit/status_test.go index 2dcfa147f..8360782e8 100644 --- a/pkg/definition/defkit/status_test.go +++ b/pkg/definition/defkit/status_test.go @@ -28,9 +28,11 @@ import ( var _ = Describe("Status", func() { Context("StatusBuilder", func() { - It("should create a status builder", func() { + It("should create a status builder that produces empty CUE without fields", func() { s := defkit.Status() Expect(s).NotTo(BeNil()) + cue := s.Build() + Expect(cue).To(BeEmpty()) }) It("should add IntField to status", func() { @@ -71,9 +73,11 @@ var _ = Describe("Status", func() { }) Context("HealthBuilder", func() { - It("should create a health builder", func() { + It("should create a health builder that produces empty CUE without conditions", func() { h := defkit.Health() Expect(h).NotTo(BeNil()) + cue := h.Build() + Expect(cue).To(BeEmpty()) }) It("should add IntField through HealthBuilder", func() { @@ -110,11 +114,11 @@ var _ = Describe("Status", func() { Expect(cue).To(ContainSubstring("isHealth: ready.replicas == desired.replicas")) }) - It("should add multiple health conditions", func() { + It("should add multiple health conditions with auto-parenthesization", func() { h := defkit.Health(). HealthyWhen("condition1", "condition2", "condition3") cue := h.Build() - Expect(cue).To(ContainSubstring("isHealth: condition1 && condition2 && condition3")) + Expect(cue).To(ContainSubstring("isHealth: (condition1) && (condition2) && (condition3)")) }) It("should support RawCUE override on health builder", func() { @@ -229,6 +233,49 @@ var _ = Describe("Status", func() { }) }) + Context("StatusBuilder field grouping", func() { + It("should consolidate multiple fields with same parent into one block", func() { + s := defkit.Status(). + IntField("status.active", "status.active", 0). + IntField("status.failed", "status.failed", 0). + IntField("status.succeeded", "status.succeeded", 0). + Message(`Active/Failed/Succeeded:\(status.active)/\(status.failed)/\(status.succeeded)`) + cue := s.Build() + Expect(strings.Count(cue, "status: {")).To(Equal(1)) + Expect(cue).To(ContainSubstring("active:")) + Expect(cue).To(ContainSubstring("failed:")) + Expect(cue).To(ContainSubstring("succeeded:")) + }) + + It("should keep different parent prefixes as separate blocks", func() { + s := defkit.Status(). + IntField("ready.replicas", "status.numberReady", 0). + IntField("desired.replicas", "status.desiredNumberScheduled", 0). + Message(`Ready:\(ready.replicas)/\(desired.replicas)`) + cue := s.Build() + Expect(strings.Count(cue, "ready: {")).To(Equal(1)) + Expect(strings.Count(cue, "desired: {")).To(Equal(1)) + }) + + It("should column-align fields within consolidated block", func() { + s := defkit.Status(). + IntField("status.active", "status.active", 0). + IntField("status.succeeded", "status.succeeded", 0) + cue := s.Build() + // "active" is shorter than "succeeded", so it should be padded + Expect(cue).To(ContainSubstring("active: *0 | int")) + Expect(cue).To(ContainSubstring("succeeded: *0 | int")) + }) + + It("should handle simple (non-nested) fields without grouping", func() { + s := defkit.Status(). + IntField("succeeded", "status.succeeded", 0) + cue := s.Build() + Expect(cue).To(ContainSubstring("succeeded: *0 | int")) + Expect(cue).To(ContainSubstring("context.output.status.succeeded")) + }) + }) + Context("HealthBuilder field grouping", func() { It("should consolidate multiple fields with same parent into one block", func() { h := defkit.Health(). @@ -286,104 +333,202 @@ var _ = Describe("Status", func() { }) }) + Context("HealthBuilder auto-parenthesization", func() { + It("should NOT parenthesize a single condition", func() { + h := defkit.Health(). + HealthyWhen("ready.replicas == desired.replicas") + cue := h.Build() + Expect(cue).To(ContainSubstring("isHealth: ready.replicas == desired.replicas")) + }) + + It("should auto-parenthesize multiple conditions", func() { + h := defkit.Health(). + HealthyWhen("a == b", "c == d") + cue := h.Build() + Expect(cue).To(ContainSubstring("isHealth: (a == b) && (c == d)")) + }) + + It("should NOT double-wrap already-parenthesized conditions", func() { + h := defkit.Health(). + HealthyWhen("a == b", defkit.StatusOr("x == y", "x > y")) + cue := h.Build() + // StatusOr returns "(x == y || x > y)" which is already fully parenthesized + Expect(cue).To(ContainSubstring("(a == b) && (x == y || x > y)")) + }) + + It("should NOT treat partially-parenthesized strings as fully wrapped", func() { + // "(a) && (b)" starts with ( and ends with ) but is not fully enclosed + h := defkit.Health(). + HealthyWhen("first", "(a) && (b)") + cue := h.Build() + Expect(cue).To(ContainSubstring("(first) && ((a) && (b))")) + }) + + It("should work with StatusEq without manual parens", func() { + h := defkit.Health(). + HealthyWhen( + defkit.StatusEq("spec.replicas", "ready.replicas"), + defkit.StatusEq("spec.replicas", "ready.updated"), + ) + cue := h.Build() + Expect(cue).To(ContainSubstring("(spec.replicas == ready.replicas) && (spec.replicas == ready.updated)")) + }) + + It("should work with WithDefault and StatusEq", func() { + h := defkit.Health(). + HealthyWhen( + defkit.StatusEq("a", "b"), + defkit.StatusEq("c", "d"), + ). + WithDefault() + cue := h.Build() + Expect(cue).To(ContainSubstring("_isHealth: (a == b) && (c == d)")) + Expect(cue).To(ContainSubstring("isHealth: *_isHealth | bool")) + }) + + It("should produce correct CUE for DaemonSetHealth with auto-parens", func() { + h := defkit.DaemonSetHealth() + cue := h.Build() + // Each equality condition should be parenthesized + Expect(cue).To(ContainSubstring("(desired.replicas == ready.replicas)")) + Expect(cue).To(ContainSubstring("(desired.replicas == updated.replicas)")) + Expect(cue).To(ContainSubstring("(desired.replicas == current.replicas)")) + // StatusOr is already wrapped, should not be double-wrapped + Expect(cue).To(ContainSubstring("(generation.observed == generation.metadata || generation.observed > generation.metadata)")) + }) + + It("should produce correct CUE for DeploymentHealth with auto-parens", func() { + h := defkit.DeploymentHealth() + cue := h.Build() + Expect(cue).To(ContainSubstring("(context.output.spec.replicas == ready.readyReplicas)")) + Expect(cue).To(ContainSubstring("(context.output.spec.replicas == ready.updatedReplicas)")) + Expect(cue).To(ContainSubstring("(context.output.spec.replicas == ready.replicas)")) + Expect(cue).To(ContainSubstring("_isHealth:")) + }) + }) + Context("HealthBuilder Expressions", func() { - It("should create Condition expression", func() { + It("should generate Condition expression that checks condition status", func() { h := defkit.Health() - cond := h.Condition("Ready") - Expect(cond).NotTo(BeNil()) + expr := h.Condition("Ready").IsTrue() + policy := h.Policy(expr) + Expect(policy).To(ContainSubstring("Ready")) + Expect(policy).To(ContainSubstring("isHealth:")) + Expect(policy).To(ContainSubstring(`"True"`)) }) - It("should create Field expression", func() { - h := defkit.Health() - field := h.Field("status.replicas") - Expect(field).NotTo(BeNil()) - }) - - It("should create FieldRef expression", func() { - h := defkit.Health() - ref := h.FieldRef("spec.replicas") - Expect(ref).NotTo(BeNil()) - }) - - It("should create Phase expression", func() { + It("should generate Phase expression that checks status.phase", func() { h := defkit.Health() expr := h.Phase("Running", "Succeeded") - Expect(expr).NotTo(BeNil()) + policy := h.Policy(expr) + Expect(policy).To(ContainSubstring("isHealth:")) + Expect(policy).To(ContainSubstring("Running")) + Expect(policy).To(ContainSubstring("Succeeded")) + Expect(policy).To(ContainSubstring("context.output.status.phase")) }) - It("should create PhaseField expression", func() { + It("should generate PhaseField expression with custom field path", func() { h := defkit.Health() expr := h.PhaseField("status.currentPhase", "Active", "Ready") - Expect(expr).NotTo(BeNil()) + policy := h.Policy(expr) + Expect(policy).To(ContainSubstring("isHealth:")) + Expect(policy).To(ContainSubstring("context.output.status.currentPhase")) + Expect(policy).To(ContainSubstring("Active")) + Expect(policy).To(ContainSubstring("Ready")) }) - It("should create Exists expression", func() { + It("should generate Exists expression that checks field != _|_", func() { h := defkit.Health() expr := h.Exists("status.loadBalancer.ingress") - Expect(expr).NotTo(BeNil()) + policy := h.Policy(expr) + Expect(policy).To(ContainSubstring("isHealth:")) + Expect(policy).To(ContainSubstring("status.loadBalancer.ingress")) + Expect(policy).To(ContainSubstring("!= _|_")) }) - It("should create NotExists expression", func() { + It("should generate NotExists expression that checks field == _|_", func() { h := defkit.Health() expr := h.NotExists("status.error") - Expect(expr).NotTo(BeNil()) + policy := h.Policy(expr) + Expect(policy).To(ContainSubstring("isHealth:")) + Expect(policy).To(ContainSubstring("status.error")) + Expect(policy).To(ContainSubstring("== _|_")) }) - It("should create And expression", func() { + It("should generate And expression combining multiple conditions", func() { h := defkit.Health() expr1 := h.Condition("Ready").IsTrue() expr2 := h.Condition("Synced").IsTrue() and := h.And(expr1, expr2) - Expect(and).NotTo(BeNil()) + policy := h.Policy(and) + Expect(policy).To(ContainSubstring("Ready")) + Expect(policy).To(ContainSubstring("Synced")) + Expect(policy).To(ContainSubstring("&&")) }) - It("should create Or expression", func() { + It("should generate Or expression combining multiple conditions", func() { h := defkit.Health() expr1 := h.Phase("Running") expr2 := h.Phase("Succeeded") or := h.Or(expr1, expr2) - Expect(or).NotTo(BeNil()) + policy := h.Policy(or) + Expect(policy).To(ContainSubstring("Running")) + Expect(policy).To(ContainSubstring("Succeeded")) + Expect(policy).To(ContainSubstring("||")) }) - It("should create Not expression", func() { + It("should generate Not expression negating a condition", func() { h := defkit.Health() expr := h.Condition("Stalled").IsTrue() not := h.Not(expr) - Expect(not).NotTo(BeNil()) + policy := h.Policy(not) + Expect(policy).To(ContainSubstring("Stalled")) + Expect(policy).To(ContainSubstring("!")) }) - It("should create Always expression", func() { + It("should generate Always expression as isHealth: true", func() { h := defkit.Health() expr := h.Always() - Expect(expr).NotTo(BeNil()) + policy := h.Policy(expr) + Expect(policy).To(ContainSubstring("isHealth: true")) }) - It("should create AllTrue expression", func() { + It("should generate AllTrue expression checking multiple conditions are True", func() { h := defkit.Health() expr := h.AllTrue("Ready", "Synced", "Available") - Expect(expr).NotTo(BeNil()) + policy := h.Policy(expr) + Expect(policy).To(ContainSubstring("Ready")) + Expect(policy).To(ContainSubstring("Synced")) + Expect(policy).To(ContainSubstring("Available")) + Expect(policy).To(ContainSubstring("&&")) }) - It("should create AnyTrue expression", func() { + It("should generate AnyTrue expression checking any condition is True", func() { h := defkit.Health() expr := h.AnyTrue("Ready", "Available") - Expect(expr).NotTo(BeNil()) + policy := h.Policy(expr) + Expect(policy).To(ContainSubstring("Ready")) + Expect(policy).To(ContainSubstring("Available")) + Expect(policy).To(ContainSubstring("||")) }) - It("should set health condition with HealthyWhenExpr", func() { + It("should set health condition with HealthyWhenExpr and generate correct CUE", func() { h := defkit.Health() expr := h.Condition("Ready").IsTrue() h.HealthyWhenExpr(expr) cue := h.Build() - Expect(cue).NotTo(BeEmpty()) + Expect(cue).To(ContainSubstring("isHealth:")) + Expect(cue).To(ContainSubstring("Ready")) + Expect(cue).To(ContainSubstring(`"True"`)) }) - It("should generate policy from expression", func() { + It("should generate policy with correct isHealth expression from Condition", func() { h := defkit.Health() expr := h.Condition("Ready").IsTrue() policy := h.Policy(expr) - Expect(policy).NotTo(BeEmpty()) Expect(policy).To(ContainSubstring("isHealth:")) + Expect(policy).To(ContainSubstring("Ready")) + Expect(policy).To(ContainSubstring(`"True"`)) }) }) }) diff --git a/pkg/definition/defkit/trait.go b/pkg/definition/defkit/trait.go index a617b9a7a..a3957ae42 100644 --- a/pkg/definition/defkit/trait.go +++ b/pkg/definition/defkit/trait.go @@ -42,10 +42,12 @@ type TraitDefinition struct { baseDefinition // embedded common fields (name, description, params, template, etc.) appliesToWorkloads []string // e.g., ["deployments.apps", "statefulsets.apps"] conflictsWith []string // traits that conflict with this one + conflictsWithSet bool // true when ConflictsWith() was explicitly called podDisruptive bool // whether applying this trait causes pod restart stage string // "PreDispatch" or "PostDispatch" (default: "") templateBlock string // raw CUE for template: block only (uses fluent API for header) labels map[string]string // metadata labels for the trait definition + workloadRefPath *string // workloadRefPath attribute (nil = omit, pointer to distinguish from empty) } // NewTrait creates a new TraitDefinition builder. @@ -75,6 +77,7 @@ func (t *TraitDefinition) AppliesTo(workloads ...string) *TraitDefinition { // ConflictsWith specifies traits that cannot be used together with this trait. func (t *TraitDefinition) ConflictsWith(traits ...string) *TraitDefinition { + t.conflictsWithSet = true t.conflictsWith = append(t.conflictsWith, traits...) return t } @@ -85,6 +88,12 @@ func (t *TraitDefinition) PodDisruptive(disruptive bool) *TraitDefinition { return t } +// WorkloadRefPath sets the workloadRefPath attribute for the trait. +func (t *TraitDefinition) WorkloadRefPath(path string) *TraitDefinition { + t.workloadRefPath = &path + return t +} + // Stage sets the trait application stage. // Use "PreDispatch" for traits that must run before dispatch, // "PostDispatch" for traits that run after (e.g., creating Services). @@ -412,13 +421,17 @@ func (g *TraitCUEGenerator) GenerateFullDefinition(t *TraitDefinition) string { sb.WriteString(fmt.Sprintf("%stype: \"trait\"\n", g.indent)) sb.WriteString(fmt.Sprintf("%sannotations: {}\n", g.indent)) - // Write labels block only if there are labels (omit empty labels to match original CUE format) - if len(t.GetLabels()) > 0 { - sb.WriteString(fmt.Sprintf("%slabels: {\n", g.indent)) - for k, v := range t.GetLabels() { - sb.WriteString(fmt.Sprintf("%s\t%q: %q\n", g.indent, k, v)) + // Write labels block when Labels() was explicitly called (nil check distinguishes unset from empty) + if t.labels != nil { + if len(t.labels) > 0 { + sb.WriteString(fmt.Sprintf("%slabels: {\n", g.indent)) + for k, v := range t.labels { + sb.WriteString(fmt.Sprintf("%s\t%q: %q\n", g.indent, k, v)) + } + sb.WriteString(fmt.Sprintf("%s}\n", g.indent)) + } else { + sb.WriteString(fmt.Sprintf("%slabels: {}\n", g.indent)) } - sb.WriteString(fmt.Sprintf("%s}\n", g.indent)) } sb.WriteString(fmt.Sprintf("%sdescription: %q\n", g.indent, t.GetDescription())) @@ -439,10 +452,8 @@ func (g *TraitCUEGenerator) GenerateFullDefinition(t *TraitDefinition) string { func (g *TraitCUEGenerator) writeAttributes(sb *strings.Builder, t *TraitDefinition, depth int) { indent := strings.Repeat(g.indent, depth) - // podDisruptive (only emit when true, false is the default) - if t.IsPodDisruptive() { - sb.WriteString(fmt.Sprintf("%spodDisruptive: %v\n", indent, t.IsPodDisruptive())) - } + // podDisruptive (always emit — vela CUE always includes this attribute) + sb.WriteString(fmt.Sprintf("%spodDisruptive: %v\n", indent, t.IsPodDisruptive())) // stage (if set) if t.GetStage() != "" { @@ -458,13 +469,17 @@ func (g *TraitCUEGenerator) writeAttributes(sb *strings.Builder, t *TraitDefinit sb.WriteString(fmt.Sprintf("%sappliesToWorkloads: [%s]\n", indent, strings.Join(workloads, ", "))) } - // conflictsWith (if set) - if len(t.GetConflictsWith()) > 0 { - conflicts := make([]string, len(t.GetConflictsWith())) - for i, c := range t.GetConflictsWith() { - conflicts[i] = fmt.Sprintf("%q", c) + // conflictsWith (emit when explicitly set, even if empty) + if t.conflictsWithSet { + if len(t.GetConflictsWith()) > 0 { + conflicts := make([]string, len(t.GetConflictsWith())) + for i, c := range t.GetConflictsWith() { + conflicts[i] = fmt.Sprintf("%q", c) + } + sb.WriteString(fmt.Sprintf("%sconflictsWith: [%s]\n", indent, strings.Join(conflicts, ", "))) + } else { + sb.WriteString(fmt.Sprintf("%sconflictsWith: []\n", indent)) } - sb.WriteString(fmt.Sprintf("%sconflictsWith: [%s]\n", indent, strings.Join(conflicts, ", "))) } // status (customStatus and healthPolicy) @@ -490,6 +505,11 @@ func (g *TraitCUEGenerator) writeAttributes(sb *strings.Builder, t *TraitDefinit sb.WriteString(fmt.Sprintf("%s}\n", indent)) } + + // workloadRefPath (if explicitly set) + if t.workloadRefPath != nil { + sb.WriteString(fmt.Sprintf("%sworkloadRefPath: %q\n", indent, *t.workloadRefPath)) + } } // GenerateTemplate generates the template block for a trait. @@ -1098,7 +1118,7 @@ func (g *TraitCUEGenerator) generateParameterBlock(t *TraitDefinition, depth int if dynMap.GetValueTypeUnion() != "" { typeStr = dynMap.GetValueTypeUnion() } else { - typeStr = cueTypeForParamType(dynMap.GetValueType()) + typeStr = cueTypeStr(dynMap.GetValueType()) } sb.WriteString(fmt.Sprintf("%sparameter: [string]: %s\n", indent, typeStr)) return sb.String() @@ -1123,28 +1143,6 @@ func (g *TraitCUEGenerator) generateParameterBlock(t *TraitDefinition, depth int return sb.String() } -// cueTypeForParamType converts a ParamType to its CUE type string (standalone function). -func cueTypeForParamType(pt ParamType) string { - switch pt { - case ParamTypeString: - return string(ParamTypeString) - case ParamTypeInt: - return "int" - case ParamTypeBool: - return "bool" - case ParamTypeFloat: - return "float" - case ParamTypeArray: - return "[...]" - case ParamTypeMap: - return "{...}" - case ParamTypeStruct: - return "{...}" - default: - return "_" - } -} - // writePatchContainerPattern generates the complete PatchContainer pattern in CUE. // This generates the #PatchParams helper, PatchContainer definition, patch block, // parameter schema, and errs aggregation. @@ -1153,10 +1151,16 @@ func (g *TraitCUEGenerator) writePatchContainerPattern(sb *strings.Builder, conf innerIndent := strings.Repeat(g.indent, depth+1) deepIndent := strings.Repeat(g.indent, depth+2) - // Generate #PatchParams helper definition + // Determine the helper type name (default: "PatchParams") + paramsTypeName := config.ParamsTypeName + if paramsTypeName == "" { + paramsTypeName = "PatchParams" + } + + // Generate helper definition if config.CustomParamsBlock != "" { // Use custom params block (for complex schemas like startup-probe) - sb.WriteString(fmt.Sprintf("%s#PatchParams: {\n", indent)) + sb.WriteString(fmt.Sprintf("%s#%s: {\n", indent, paramsTypeName)) sb.WriteString(fmt.Sprintf("%s// +usage=Specify the name of the target container, if not set, use the component name\n", innerIndent)) sb.WriteString(fmt.Sprintf("%scontainerName: *\"\" | string\n", innerIndent)) // Write each line of custom params block with proper indentation @@ -1165,7 +1169,7 @@ func (g *TraitCUEGenerator) writePatchContainerPattern(sb *strings.Builder, conf } sb.WriteString(fmt.Sprintf("%s}\n", indent)) } else { - sb.WriteString(fmt.Sprintf("%s#PatchParams: {\n", indent)) + sb.WriteString(fmt.Sprintf("%s#%s: {\n", indent, paramsTypeName)) sb.WriteString(fmt.Sprintf("%s// +usage=Specify the name of the target container, if not set, use the component name\n", innerIndent)) sb.WriteString(fmt.Sprintf("%scontainerName: *\"\" | string\n", innerIndent)) @@ -1192,11 +1196,10 @@ func (g *TraitCUEGenerator) writePatchContainerPattern(sb *strings.Builder, conf sb.WriteString(fmt.Sprintf("%s}\n", indent)) } else { sb.WriteString(fmt.Sprintf("%sPatchContainer: {\n", indent)) - sb.WriteString(fmt.Sprintf("%s_params: #PatchParams\n", innerIndent)) + sb.WriteString(fmt.Sprintf("%s_params: #%s\n", innerIndent, paramsTypeName)) sb.WriteString(fmt.Sprintf("%sname: _params.containerName\n", innerIndent)) sb.WriteString(fmt.Sprintf("%s_baseContainers: context.output.spec.template.spec.containers\n", innerIndent)) sb.WriteString(fmt.Sprintf("%s_matchContainers_: [for _container_ in _baseContainers if _container_.name == name {_container_}]\n", innerIndent)) - sb.WriteString(fmt.Sprintf("%s_baseContainer: *_|_ | {...}\n", innerIndent)) // Container not found error sb.WriteString(fmt.Sprintf("%sif len(_matchContainers_) == 0 {\n", innerIndent)) @@ -1276,10 +1279,18 @@ func (g *TraitCUEGenerator) writePatchContainerPattern(sb *strings.Builder, conf sb.WriteString(fmt.Sprintf("%sif parameter.%s != _|_ {\n", innerIndent, multiParam)) sb.WriteString(fmt.Sprintf("%s\t// +patchKey=name\n", innerIndent)) sb.WriteString(fmt.Sprintf("%s\tcontainers: [for c in parameter.%s {\n", innerIndent, multiParam)) - sb.WriteString(fmt.Sprintf("%s\t\tif c.containerName == \"\" {\n", innerIndent)) - sb.WriteString(fmt.Sprintf("%s\t\t\terr: \"containerName must be set for %s\"\n", innerIndent, multiParam)) + checkField := "containerName" + if config.MultiContainerCheckField != "" { + checkField = config.MultiContainerCheckField + } + errMsg := fmt.Sprintf("containerName must be set for %s", multiParam) + if config.MultiContainerErrMsg != "" { + errMsg = config.MultiContainerErrMsg + } + sb.WriteString(fmt.Sprintf("%s\t\tif c.%s == \"\" {\n", innerIndent, checkField)) + sb.WriteString(fmt.Sprintf("%s\t\t\terr: \"%s\"\n", innerIndent, errMsg)) sb.WriteString(fmt.Sprintf("%s\t\t}\n", innerIndent)) - sb.WriteString(fmt.Sprintf("%s\t\tif c.containerName != \"\" {\n", innerIndent)) + sb.WriteString(fmt.Sprintf("%s\t\tif c.%s != \"\" {\n", innerIndent, checkField)) sb.WriteString(fmt.Sprintf("%s\t\t\tPatchContainer & {_params: c}\n", innerIndent)) sb.WriteString(fmt.Sprintf("%s\t\t}\n", innerIndent)) sb.WriteString(fmt.Sprintf("%s\t}]\n", innerIndent)) @@ -1336,16 +1347,20 @@ func (g *TraitCUEGenerator) writePatchContainerPattern(sb *strings.Builder, conf } } case config.AllowMultiple && multiParam != "": - sb.WriteString(fmt.Sprintf("%sparameter: *#PatchParams | close({\n", indent)) + defaultMarker := "*" + if config.NoDefaultDisjunction { + defaultMarker = "" + } + sb.WriteString(fmt.Sprintf("%sparameter: %s#%s | close({\n", indent, defaultMarker, paramsTypeName)) containersDesc := config.ContainersDescription if containersDesc == "" { containersDesc = "Specify the settings for multiple containers" } sb.WriteString(fmt.Sprintf("%s// +usage=%s\n", innerIndent, containersDesc)) - sb.WriteString(fmt.Sprintf("%s%s: [...#PatchParams]\n", innerIndent, multiParam)) + sb.WriteString(fmt.Sprintf("%s%s: [...#%s]\n", innerIndent, multiParam, paramsTypeName)) sb.WriteString(fmt.Sprintf("%s})\n", indent)) default: - sb.WriteString(fmt.Sprintf("%sparameter: #PatchParams\n", indent)) + sb.WriteString(fmt.Sprintf("%sparameter: #%s\n", indent, paramsTypeName)) } // Generate errs aggregation @@ -1457,7 +1472,7 @@ func (g *TraitCUEGenerator) writePatchContainerGroup(sb *strings.Builder, group // propagating _|_ when the parameter is unset. Fields with defaults or value-based // conditions (like '!= ""') always have a value and can be mapped unconditionally. func (g *TraitCUEGenerator) writePatchParamMapping(sb *strings.Builder, field PatchContainerField, indent string, prefix string) { - if field.Condition == "!= _|_" && field.ParamDefault == "" { + if field.Condition == "!= _|_" && field.ParamDefault == "" && field.ParamType == "" { sb.WriteString(fmt.Sprintf("%sif %s%s %s {\n", indent, prefix, field.ParamName, field.Condition)) sb.WriteString(fmt.Sprintf("%s\t%s: %s%s\n", indent, field.ParamName, prefix, field.ParamName)) sb.WriteString(fmt.Sprintf("%s}\n", indent)) @@ -1497,13 +1512,17 @@ func (g *TraitCUEGenerator) GenerateDefinitionWithRawTemplate(t *TraitDefinition sb.WriteString(fmt.Sprintf("%stype: \"trait\"\n", g.indent)) sb.WriteString(fmt.Sprintf("%sannotations: {}\n", g.indent)) - // Write labels block only if there are labels (omit empty labels to match original CUE format) - if len(t.GetLabels()) > 0 { - sb.WriteString(fmt.Sprintf("%slabels: {\n", g.indent)) - for k, v := range t.GetLabels() { - sb.WriteString(fmt.Sprintf("%s\t%q: %q\n", g.indent, k, v)) + // Write labels block when Labels() was explicitly called (nil check distinguishes unset from empty) + if t.labels != nil { + if len(t.labels) > 0 { + sb.WriteString(fmt.Sprintf("%slabels: {\n", g.indent)) + for k, v := range t.labels { + sb.WriteString(fmt.Sprintf("%s\t%q: %q\n", g.indent, k, v)) + } + sb.WriteString(fmt.Sprintf("%s}\n", g.indent)) + } else { + sb.WriteString(fmt.Sprintf("%slabels: {}\n", g.indent)) } - sb.WriteString(fmt.Sprintf("%s}\n", g.indent)) } sb.WriteString(fmt.Sprintf("%sdescription: %q\n", g.indent, t.GetDescription())) diff --git a/pkg/definition/defkit/trait_test.go b/pkg/definition/defkit/trait_test.go index 4bc185855..3500a00d9 100644 --- a/pkg/definition/defkit/trait_test.go +++ b/pkg/definition/defkit/trait_test.go @@ -187,8 +187,8 @@ parameter: #PatchParams Expect(cue).To(ContainSubstring(`scaler: {`)) Expect(cue).To(ContainSubstring(`type: "trait"`)) Expect(cue).To(ContainSubstring(`description: "Scale workloads"`)) - // podDisruptive: false is not emitted (it's the default) - Expect(cue).NotTo(ContainSubstring(`podDisruptive: false`)) + // podDisruptive is always emitted + Expect(cue).To(ContainSubstring(`podDisruptive: false`)) Expect(cue).To(ContainSubstring(`appliesToWorkloads: ["deployments.apps"]`)) }) @@ -1375,4 +1375,247 @@ template: { Expect(cue).To(ContainSubstring("host?: string")) }) }) + + Context("podDisruptive always emitted", func() { + It("should emit podDisruptive: true when set to true", func() { + trait := defkit.NewTrait("disruptive"). + Description("Disruptive trait"). + AppliesTo("deployments.apps"). + PodDisruptive(true) + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring("podDisruptive: true")) + }) + + It("should emit podDisruptive: false when set to false", func() { + trait := defkit.NewTrait("nondisruptive"). + Description("Non-disruptive trait"). + AppliesTo("deployments.apps"). + PodDisruptive(false) + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring("podDisruptive: false")) + }) + + It("should emit podDisruptive: false when not explicitly set", func() { + trait := defkit.NewTrait("default-disruptive"). + Description("Default trait"). + AppliesTo("deployments.apps") + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring("podDisruptive: false")) + }) + }) + + Context("ConflictsWith empty list emission", func() { + It("should emit conflictsWith with values when set", func() { + trait := defkit.NewTrait("conflicts-with-values"). + Description("Trait with conflicts"). + AppliesTo("deployments.apps"). + ConflictsWith("scaler", "hpa") + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring(`conflictsWith: ["scaler", "hpa"]`)) + }) + + It("should emit conflictsWith: [] when explicitly set with no values", func() { + trait := defkit.NewTrait("conflicts-empty"). + Description("Trait with empty conflicts"). + AppliesTo("deployments.apps"). + ConflictsWith() + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring("conflictsWith: []")) + }) + + It("should not emit conflictsWith when never called", func() { + trait := defkit.NewTrait("no-conflicts"). + Description("Trait without conflicts"). + AppliesTo("deployments.apps") + + cue := trait.ToCue() + Expect(cue).NotTo(ContainSubstring("conflictsWith")) + }) + }) + + Context("Labels nil vs empty emission", func() { + It("should emit labels with values when set", func() { + trait := defkit.NewTrait("labels-values"). + Description("Trait with labels"). + AppliesTo("deployments.apps"). + Labels(map[string]string{"ui-hidden": "true"}) + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring("labels:")) + Expect(cue).To(ContainSubstring(`"ui-hidden": "true"`)) + }) + + It("should emit labels: {} when explicitly set to empty map", func() { + trait := defkit.NewTrait("labels-empty"). + Description("Trait with empty labels"). + AppliesTo("deployments.apps"). + Labels(map[string]string{}) + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring("labels: {}")) + }) + + It("should not emit labels when never called", func() { + trait := defkit.NewTrait("no-labels"). + Description("Trait without labels"). + AppliesTo("deployments.apps") + + cue := trait.ToCue() + Expect(cue).NotTo(ContainSubstring("labels:")) + }) + }) + + Context("WorkloadRefPath attribute", func() { + It("should emit workloadRefPath when set to empty string", func() { + trait := defkit.NewTrait("wlref-empty"). + Description("Trait with empty workloadRefPath"). + AppliesTo("deployments.apps"). + WorkloadRefPath("") + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring(`workloadRefPath: ""`)) + }) + + It("should emit workloadRefPath when set to a path", func() { + trait := defkit.NewTrait("wlref-path"). + Description("Trait with workloadRefPath"). + AppliesTo("deployments.apps"). + WorkloadRefPath("spec.workloadRef") + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring(`workloadRefPath: "spec.workloadRef"`)) + }) + + It("should not emit workloadRefPath when never called", func() { + trait := defkit.NewTrait("no-wlref"). + Description("Trait without wlref path"). + AppliesTo("deployments.apps") + + cue := trait.ToCue() + Expect(cue).NotTo(ContainSubstring("workloadRefPath")) + }) + }) + + Context("PatchContainer ParamsTypeName", func() { + It("should use default PatchParams when ParamsTypeName is empty", func() { + trait := defkit.NewTrait("default-params-name"). + Description("Test default params type name"). + AppliesTo("deployments.apps"). + PodDisruptive(true). + Template(func(tpl *defkit.Template) { + tpl.UsePatchContainer(defkit.PatchContainerConfig{ + ContainerNameParam: "containerName", + DefaultToContextName: true, + PatchFields: defkit.PatchFields( + defkit.PatchField("image").Strategy("retainKeys"), + ), + }) + }) + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring("#PatchParams: {")) + Expect(cue).To(ContainSubstring("_params: #PatchParams")) + Expect(cue).NotTo(ContainSubstring("#StartupProbeParams")) + }) + + It("should use custom ParamsTypeName when set", func() { + trait := defkit.NewTrait("custom-params-name"). + Description("Test custom params type name"). + AppliesTo("deployments.apps"). + PodDisruptive(true). + Template(func(tpl *defkit.Template) { + tpl.UsePatchContainer(defkit.PatchContainerConfig{ + ContainerNameParam: "containerName", + DefaultToContextName: true, + AllowMultiple: true, + MultiContainerParam: "probes", + ParamsTypeName: "StartupProbeParams", + CustomParamsBlock: "initialDelaySeconds: *0 | int", + PatchFields: defkit.PatchFields( + defkit.PatchField("initialDelaySeconds").Int().IsSet().Default("0"), + ), + }) + }) + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring("#StartupProbeParams: {")) + Expect(cue).To(ContainSubstring("_params: #StartupProbeParams")) + Expect(cue).To(ContainSubstring("parameter: *#StartupProbeParams | close({")) + Expect(cue).To(ContainSubstring("probes: [...#StartupProbeParams]")) + Expect(cue).NotTo(ContainSubstring("#PatchParams")) + }) + }) + + Context("PatchContainer NoDefaultDisjunction", func() { + It("should include * default marker by default", func() { + trait := defkit.NewTrait("default-disjunction"). + Description("Test default disjunction"). + AppliesTo("deployments.apps"). + PodDisruptive(true). + Template(func(tpl *defkit.Template) { + tpl.UsePatchContainer(defkit.PatchContainerConfig{ + ContainerNameParam: "containerName", + DefaultToContextName: true, + AllowMultiple: true, + ContainersParam: "containers", + PatchFields: defkit.PatchFields( + defkit.PatchField("image").Strategy("retainKeys"), + ), + }) + }) + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring("parameter: *#PatchParams | close({")) + }) + + It("should omit * default marker when NoDefaultDisjunction is true", func() { + trait := defkit.NewTrait("no-default-disjunction"). + Description("Test no default disjunction"). + AppliesTo("deployments.apps"). + PodDisruptive(true). + Template(func(tpl *defkit.Template) { + tpl.UsePatchContainer(defkit.PatchContainerConfig{ + ContainerNameParam: "containerName", + DefaultToContextName: true, + AllowMultiple: true, + ContainersParam: "containers", + NoDefaultDisjunction: true, + PatchFields: defkit.PatchFields( + defkit.PatchField("image").Strategy("retainKeys"), + ), + }) + }) + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring("parameter: #PatchParams | close({")) + Expect(cue).NotTo(ContainSubstring("parameter: *#PatchParams")) + }) + }) + + Context("PatchContainer no _baseContainer singular", func() { + It("should use _baseContainers (plural) not _baseContainer (singular)", func() { + trait := defkit.NewTrait("base-containers-test"). + Description("Test base containers plural"). + AppliesTo("deployments.apps"). + PodDisruptive(true). + Template(func(tpl *defkit.Template) { + tpl.UsePatchContainer(defkit.PatchContainerConfig{ + ContainerNameParam: "containerName", + DefaultToContextName: true, + PatchFields: defkit.PatchFields( + defkit.PatchField("image"), + ), + }) + }) + + cue := trait.ToCue() + Expect(cue).To(ContainSubstring("_baseContainers: context.output.spec.template.spec.containers")) + Expect(cue).NotTo(ContainSubstring("_baseContainer:")) + }) + }) })