From 401ad98a37243d085d01430d8730a42f866def2d Mon Sep 17 00:00:00 2001 From: Ayush Kumar <65535504+roguepikachu@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:50:48 +0530 Subject: [PATCH] Feat: add ClosedUnion and ClosedStruct support in defkit (#7069) * feat: add ClosedUnion and ClosedStruct support in defkit - Introduced ClosedUnionParam and ClosedStructOption to handle closed struct disjunctions in parameters. - Implemented methods for creating closed structs and adding fields. - Enhanced CUE generation to support closed unions, allowing for more complex parameter definitions. - Added tests for ClosedUnion and ClosedStruct to ensure correct functionality and integration. - Updated existing tests to cover new features and ensure backward compatibility. Signed-off-by: Ayush Kumar * feat: enhance CUE generation with deduplication and inner braces for array elements Signed-off-by: Ayush Kumar * feat: enhance WorkflowStep parameter handling in CUE generation Signed-off-by: Ayush Kumar * feat: add WithDirectFields method to BuiltinActionBuilder for direct field rendering Signed-off-by: Ayush Kumar * feat: streamline string formatting in CUE generation for improved readability Signed-off-by: Ayush Kumar * feat: enhance CUE generation with deduplication support and improve test assertions Signed-off-by: Ayush Kumar * feat: add support for ClosedUnion and condition rendering in CUE generation Signed-off-by: Ayush Kumar * ci: retrigger checks Signed-off-by: Ayush Kumar * feat: refactor value rendering in CUE generation to improve builder support Signed-off-by: Ayush Kumar * feat: add GuardedBlockAction and corresponding CUE generation support Signed-off-by: Ayush Kumar * feat: update writeOneOfParam and writeStructField to use marker instead of optional Signed-off-by: Ayush Kumar * feat: add ClosedUnionParam support and update related tests for optionality Signed-off-by: Ayush Kumar * feat: update parameter definitions to remove required constraints in example code Signed-off-by: Ayush Kumar * feat: enhance CUE generation with multiline value indentation and improved block handling Signed-off-by: Ayush Kumar * feat: add baseContainer definition to CUE generation for container patterns Signed-off-by: Ayush Kumar * fix: update test to expect _baseContainer singular field in PatchContainer output The _baseContainer: *_|_ | {...} field was intentionally added to CUE generation but the test was still asserting it should not exist. Signed-off-by: Ayush Kumar * ci: retrigger checks Signed-off-by: Ayush Kumar --------- Signed-off-by: Ayush Kumar --- pkg/addon/init.go | 8 +- pkg/definition/defkit/cuegen.go | 151 ++++++- pkg/definition/defkit/cuegen_test.go | 301 ++++++++++++++ pkg/definition/defkit/helper_test.go | 82 ++++ pkg/definition/defkit/op_builders.go | 348 ++++++++++++++++ pkg/definition/defkit/op_builders_test.go | 418 ++++++++++++++++++++ pkg/definition/defkit/param.go | 72 ++++ pkg/definition/defkit/param_test.go | 94 +++++ pkg/definition/defkit/trait.go | 1 + pkg/definition/defkit/trait_test.go | 86 +++- pkg/definition/defkit/types.go | 2 + pkg/definition/defkit/workflow_step.go | 109 ++++- pkg/definition/defkit/workflow_step_test.go | 119 ++++++ 13 files changed, 1771 insertions(+), 20 deletions(-) create mode 100644 pkg/definition/defkit/op_builders.go create mode 100644 pkg/definition/defkit/op_builders_test.go diff --git a/pkg/addon/init.go b/pkg/addon/init.go index a9d143ea3..1beac4763 100644 --- a/pkg/addon/init.go +++ b/pkg/addon/init.go @@ -802,7 +802,7 @@ require github.com/oam-dev/kubevela v1.10.0 // func myComponent() *defkit.ComponentDefinition { // return defkit.NewComponent("my-component"). // Description("My component description"). -// WithParameter("image", defkit.String().Required()). +// WithParameter("image", defkit.String()). // Template(func(tpl *defkit.Template) { // // Generate Kubernetes resources // }) @@ -823,7 +823,7 @@ package components // return defkit.NewTrait("my-trait"). // Description("My trait description"). // AppliesToWorkloads("deployments.apps"). -// WithParameter("replicas", defkit.Int().Required()). +// WithParameter("replicas", defkit.Int()). // PatchTemplate(func(tpl *defkit.PatchTemplate) { // // Patch the component output // }) @@ -843,7 +843,7 @@ package traits // func myPolicy() *defkit.PolicyDefinition { // return defkit.NewPolicy("my-policy"). // Description("My policy description"). -// WithParameter("clusters", defkit.StringArray().Required()) +// WithParameter("clusters", defkit.StringArray()) // } package policies ` @@ -860,7 +860,7 @@ package policies // func myStep() *defkit.WorkflowStepDefinition { // return defkit.NewWorkflowStep("my-step"). // Description("My workflow step description"). -// WithParameter("message", defkit.String().Required()) +// WithParameter("message", defkit.String()) // } package workflowsteps ` diff --git a/pkg/definition/defkit/cuegen.go b/pkg/definition/defkit/cuegen.go index 56cab506b..9e070103e 100644 --- a/pkg/definition/defkit/cuegen.go +++ b/pkg/definition/defkit/cuegen.go @@ -480,6 +480,25 @@ func (g *CUEGenerator) writeHelperDefFromParam(sb *strings.Builder, param Param, } else { sb.WriteString("[...]\n") } + case *ClosedUnionParam: + // For closed unions, write as: close({...}) | close({...}) + options := p.GetOptions() + if len(options) == 0 { + sb.WriteString("_\n") + } else { + indent := strings.Repeat(g.indent, depth) + for i, opt := range options { + if i > 0 { + sb.WriteString(" | ") + } + sb.WriteString("close({\n") + for _, field := range opt.GetFields() { + g.writeStructFieldForHelper(sb, field, depth+1) + } + sb.WriteString(fmt.Sprintf("%s})", indent)) + } + sb.WriteString("\n") + } case *IntParam: // For int types with optional constraints: int & >=1 & <=65535 var constraints []string @@ -925,7 +944,13 @@ func (g *CUEGenerator) writeCollectionOpHelper(sb *strings.Builder, col *Collect if dedup, ok := op.(*dedupeOp); ok { // For dedupe, use the elegant CUE pattern with _ignore marker // This pattern checks if any earlier item has the same key + var sourceName string if helperRef, ok := col.Source().(*HelperVar); ok { + sourceName = helperRef.Name() + } else if ref, ok := col.Source().(*Ref); ok { + sourceName = ref.Path() + } + if sourceName != "" { keyField := dedup.keyField sb.WriteString(fmt.Sprintf(`[ for val in [ @@ -938,7 +963,7 @@ func (g *CUEGenerator) writeCollectionOpHelper(sb *strings.Builder, col *Collect ] if val._ignore == _|_ { val }, - ]`, helperRef.Name(), helperRef.Name(), keyField, keyField)) + ]`, sourceName, sourceName, keyField, keyField)) return } } @@ -1807,8 +1832,25 @@ func (g *CUEGenerator) filterNodeByCondition(node *fieldNode, condStr string) *f return filtered } +// tryRenderBuilder checks if a Value implements CUERenderer or CUEConditionRenderer +// and renders it. Returns empty string if the value is not a builder. +func (g *CUEGenerator) tryRenderBuilder(v Value) string { + if ccr, ok := v.(CUEConditionRenderer); ok { + return ccr.RenderCUEWithCondition(g.valueToCUE, g.conditionToCUE) + } + if cr, ok := v.(CUERenderer); ok { + return cr.RenderCUE(g.valueToCUE) + } + return "" +} + // valueToCUE converts a Value to CUE syntax. func (g *CUEGenerator) valueToCUE(v Value) string { + // Check if the value can render itself (used by fluent builders). + if s := g.tryRenderBuilder(v); s != "" { + return s + } + switch val := v.(type) { case *Literal: return formatCUEValue(val.Val()) @@ -1982,14 +2024,14 @@ func (g *CUEGenerator) arrayElementToCUEWithDepth(elem *ArrayElement, depth int) sb.WriteString("{\n") for key, val := range elem.Fields() { - valStr := g.valueToCUE(val) + valStr := indentMultilineValue(g.valueToCUE(val), innerIndent) sb.WriteString(fmt.Sprintf("%s%s: %s\n", innerIndent, key, valStr)) } // Write conditional operations for _, op := range elem.Ops() { if setIf, ok := op.(*SetIfOp); ok { condStr := g.conditionToCUE(setIf.Cond()) - valStr := g.valueToCUE(setIf.Value()) + valStr := indentMultilineValue(g.valueToCUE(setIf.Value()), innerIndent+"\t") // Convert dot-separated path to CUE shorthand syntax: "a.b.c" -> "a: b: c" cuePath := strings.ReplaceAll(setIf.Path(), ".", ": ") sb.WriteString(fmt.Sprintf("%sif %s {\n", innerIndent, condStr)) @@ -2000,13 +2042,30 @@ func (g *CUEGenerator) arrayElementToCUEWithDepth(elem *ArrayElement, depth int) // Write patchKey-annotated fields (nested patchKey inside array elements) for _, pkf := range elem.PatchKeyFields() { sb.WriteString(fmt.Sprintf("%s// +patchKey=%s\n", innerIndent, pkf.key)) - valStr := g.valueToCUEAtDepth(pkf.value, depth+1) + valStr := indentMultilineValue(g.valueToCUEAtDepth(pkf.value, depth+1), innerIndent) sb.WriteString(fmt.Sprintf("%s%s: %s\n", innerIndent, pkf.field, valStr)) } sb.WriteString(fmt.Sprintf("%s}", indent)) return sb.String() } +// indentMultilineValue prepends indent to every line of s after the first. +// This is needed when embedding a multi-line valueToCUE result into an +// already-indented context: the first line sits on the same line as the +// key, but subsequent lines need the surrounding indentation added. +func indentMultilineValue(s, indent string) string { + if !strings.Contains(s, "\n") { + return s + } + lines := strings.Split(s, "\n") + for i := 1; i < len(lines); i++ { + if lines[i] != "" { + lines[i] = indent + lines[i] + } + } + return strings.Join(lines, "\n") +} + // arrayBuilderToCUE converts an ArrayBuilder to CUE syntax. // Generates: [{static}, if cond {{conditional}}, if guard for m in source {iterated}] func (g *CUEGenerator) arrayBuilderToCUE(ab *ArrayBuilder, depth int) string { @@ -2040,10 +2099,13 @@ func (g *CUEGenerator) arrayBuilderToCUE(ab *ArrayBuilder, depth int) string { } else { sb.WriteString(fmt.Sprintf("%sfor m in %s {\n", innerIndent, sourceStr)) } + // Wrap each element in inner braces for explicit struct boundary + extraIndent := deepIndent + "\t" + sb.WriteString(fmt.Sprintf("%s{\n", deepIndent)) // Write each field from the element template for key, val := range entry.element.Fields() { valStr := g.valueToCUE(val) - sb.WriteString(fmt.Sprintf("%s%s: %s\n", deepIndent, key, valStr)) + sb.WriteString(fmt.Sprintf("%s%s: %s\n", extraIndent, key, valStr)) } // Write conditional operations for _, op := range entry.element.Ops() { @@ -2051,11 +2113,12 @@ func (g *CUEGenerator) arrayBuilderToCUE(ab *ArrayBuilder, depth int) string { condStr := g.conditionToCUE(setIf.Cond()) valStr := g.valueToCUE(setIf.Value()) cuePath := strings.ReplaceAll(setIf.Path(), ".", ": ") - sb.WriteString(fmt.Sprintf("%sif %s {\n", deepIndent, condStr)) - sb.WriteString(fmt.Sprintf("%s\t%s: %s\n", deepIndent, cuePath, valStr)) - sb.WriteString(fmt.Sprintf("%s}\n", deepIndent)) + sb.WriteString(fmt.Sprintf("%sif %s {\n", extraIndent, condStr)) + sb.WriteString(fmt.Sprintf("%s\t%s: %s\n", extraIndent, cuePath, valStr)) + sb.WriteString(fmt.Sprintf("%s}\n", extraIndent)) } } + sb.WriteString(fmt.Sprintf("%s}\n", deepIndent)) sb.WriteString(fmt.Sprintf("%s},\n", innerIndent)) case entryForEachWith: @@ -2117,6 +2180,15 @@ func (g *CUEGenerator) collectionOpToCUE(col *CollectionOp) string { } } + // Check for deduplication + var dedupeKeyField string + for _, op := range ops { + if dedup, ok := op.(*dedupeOp); ok { + dedupeKeyField = dedup.keyField + break + } + } + // Build filter condition if present; AND-compose multiple filters filterCondition := "" for _, op := range ops { @@ -2147,6 +2219,30 @@ func (g *CUEGenerator) collectionOpToCUE(col *CollectionOp) string { } } + // Dedupe: render the nested-comprehension pattern. + // This is placed after all op detection so that guard/filter/map are not bypassed. + if dedupeKeyField != "" { + var sb strings.Builder + // Apply guard if present + if guard := col.GetGuard(); guard != nil { + guardStr := g.conditionToCUE(guard) + sb.WriteString(fmt.Sprintf("if %s ", guardStr)) + } + sb.WriteString(fmt.Sprintf(`[ + for val in [ + for i, vi in %s { + for j, vj in %s if j < i && vi.%s == vj.%s { + _ignore: true + } + vi + }, + ] if val._ignore == _|_ { + val + }, + ]`, sourceStr, sourceStr, dedupeKeyField, dedupeKeyField)) + return sb.String() + } + // Build the list comprehension var sb strings.Builder sb.WriteString("[") @@ -2791,6 +2887,8 @@ func (g *CUEGenerator) writeParam(sb *strings.Builder, param Param, depth int) { g.writeEnumParam(sb, p, indent, name, marker) case *OneOfParam: g.writeOneOfParam(sb, p, indent, name, marker, depth) + case *ClosedUnionParam: + g.writeClosedUnionParam(sb, p, indent, name, marker, depth) default: // Generic fallback sb.WriteString(fmt.Sprintf("%s%s%s: _\n", indent, name, marker)) @@ -3058,6 +3156,16 @@ func (g *CUEGenerator) writeStructField(sb *strings.Builder, f *StructField, dep fieldType := g.cueTypeForParamType(f.FieldType()) + // Check schemaRef first — references a helper definition like #HealthProbe + if schemaRef := f.GetSchemaRef(); schemaRef != "" { + if f.FieldType() == ParamTypeArray { + sb.WriteString(fmt.Sprintf("%s%s%s: [...#%s]\n", indent, name, marker, schemaRef)) + } else { + sb.WriteString(fmt.Sprintf("%s%s%s: #%s\n", indent, name, marker, schemaRef)) + } + return + } + nested := f.GetNested() switch { case nested != nil: @@ -3181,6 +3289,31 @@ func (g *CUEGenerator) writeOneOfParam(sb *strings.Builder, p *OneOfParam, inden } } +// writeClosedUnionParam writes a closed struct disjunction parameter. +// It generates CUE of the form: name: close({...}) | close({...}) +func (g *CUEGenerator) writeClosedUnionParam(sb *strings.Builder, p *ClosedUnionParam, indent, name, optional string, depth int) { + options := p.GetOptions() + if len(options) == 0 { + sb.WriteString(fmt.Sprintf("%s%s%s: _\n", indent, name, optional)) + return + } + + // Open the field assignment + sb.WriteString(fmt.Sprintf("%s%s%s: ", indent, name, optional)) + + for i, opt := range options { + if i > 0 { + sb.WriteString(" | ") + } + sb.WriteString("close({\n") + for _, field := range opt.GetFields() { + g.writeStructField(sb, field, depth+1) + } + sb.WriteString(fmt.Sprintf("%s})", indent)) + } + sb.WriteString("\n") +} + // cueTypeStr converts a ParamType to its CUE type string. func cueTypeStr(pt ParamType) string { switch pt { @@ -3194,7 +3327,7 @@ func cueTypeStr(pt ParamType) string { return "float" case ParamTypeArray: return "[...]" - case ParamTypeMap, ParamTypeStruct, ParamTypeOneOf: + case ParamTypeMap, ParamTypeStruct, ParamTypeOneOf, ParamTypeClosedUnion: return cueOpenStruct default: return "_" diff --git a/pkg/definition/defkit/cuegen_test.go b/pkg/definition/defkit/cuegen_test.go index e302783b1..60990b125 100644 --- a/pkg/definition/defkit/cuegen_test.go +++ b/pkg/definition/defkit/cuegen_test.go @@ -1201,4 +1201,305 @@ var _ = Describe("CUEGenerator", func() { Expect(cue).To(ContainSubstring("name?: string")) }) }) + + Describe("GenerateParameterSchema with ClosedUnion parameters", func() { + It("should generate close() disjunction with simple fields", func() { + comp := defkit.NewComponent("test"). + Params( + defkit.ClosedUnion("url"). + Description("Specify the url"). + Options( + defkit.ClosedStruct().WithFields( + defkit.Field("value", defkit.ParamTypeString), + ), + defkit.ClosedStruct().WithFields( + defkit.Field("ref", defkit.ParamTypeString), + ), + ), + ) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("// +usage=Specify the url")) + Expect(cue).To(ContainSubstring("url: close({")) + Expect(cue).To(ContainSubstring("value: string")) + Expect(cue).To(ContainSubstring("}) | close({")) + Expect(cue).To(ContainSubstring("ref: string")) + }) + + It("should generate close() disjunction with nested structs", func() { + comp := defkit.NewComponent("test"). + Params( + defkit.ClosedUnion("url"). + Options( + defkit.ClosedStruct().WithFields( + defkit.Field("value", defkit.ParamTypeString), + ), + defkit.ClosedStruct().WithFields( + defkit.Field("secretRef", defkit.ParamTypeStruct).Nested( + defkit.Struct("secretRef").WithFields( + defkit.Field("name", defkit.ParamTypeString).Description("name of the secret"), + defkit.Field("key", defkit.ParamTypeString).Description("key in the secret"), + ), + ), + ), + ), + ) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("url: close({")) + Expect(cue).To(ContainSubstring("value: string")) + Expect(cue).To(ContainSubstring("}) | close({")) + Expect(cue).To(ContainSubstring("secretRef: {")) + Expect(cue).To(ContainSubstring("// +usage=name of the secret")) + Expect(cue).To(ContainSubstring("name: string")) + Expect(cue).To(ContainSubstring("// +usage=key in the secret")) + Expect(cue).To(ContainSubstring("key: string")) + }) + + It("should generate optional closed union with ?", func() { + comp := defkit.NewComponent("test"). + Params( + defkit.ClosedUnion("source"). + Optional(). + Options( + defkit.ClosedStruct().WithFields( + defkit.Field("hcl", defkit.ParamTypeString), + ), + ), + ) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("source?:")) + }) + + It("should handle close() disjunction field ordering", func() { + comp := defkit.NewComponent("test"). + Params( + defkit.ClosedUnion("url").Options( + defkit.ClosedStruct().WithFields( + defkit.Field("value", defkit.ParamTypeString), + ), + defkit.ClosedStruct().WithFields( + defkit.Field("secretRef", defkit.ParamTypeStruct), + ), + ), + ) + + cue := gen.GenerateParameterSchema(comp) + + // Verify ordering: first option before second + valueIdx := strings.Index(cue, "value: string") + secretIdx := strings.Index(cue, "secretRef:") + Expect(valueIdx).To(BeNumerically(">=", 0), "expected 'value: string' to be present") + Expect(secretIdx).To(BeNumerically(">=", 0), "expected 'secretRef:' to be present") + Expect(valueIdx).To(BeNumerically("<", secretIdx)) + }) + + It("should handle empty options gracefully", func() { + comp := defkit.NewComponent("test"). + Params( + defkit.ClosedUnion("empty"), + ) + + cue := gen.GenerateParameterSchema(comp) + + // Empty options should produce fallback + Expect(cue).To(ContainSubstring("empty: _")) + }) + + It("should generate ClosedUnion in helper definition", func() { + comp := defkit.NewComponent("test"). + Params( + defkit.String("name"), + ). + Helper("URLConfig", defkit.ClosedUnion("urlConfig"). + Options( + defkit.ClosedStruct().WithFields( + defkit.Field("value", defkit.ParamTypeString), + ), + defkit.ClosedStruct().WithFields( + defkit.Field("secretRef", defkit.ParamTypeString), + ), + ), + ) + + cue := comp.ToCue() + + Expect(cue).To(ContainSubstring("#URLConfig:")) + Expect(cue).To(ContainSubstring("close({")) + Expect(cue).To(ContainSubstring("value: string")) + Expect(cue).To(ContainSubstring("}) | close({")) + Expect(cue).To(ContainSubstring("secretRef: string")) + }) + }) + + Describe("ForEachGuarded inner braces", func() { + It("should wrap each element in inner braces", func() { + hasStorage := defkit.PathExists("parameter.storage") + ws := defkit.NewWorkflowStep("test"). + Params( + defkit.Array("storage").WithFields( + defkit.String("name").Required(), + defkit.String("path").Required(), + ), + ). + Template(func(tpl *defkit.WorkflowStepTemplate) { + arr := defkit.NewArray(). + ForEachGuarded( + hasStorage, + defkit.ParamRef("storage"), + defkit.NewArrayElement(). + Set("name", defkit.Reference("m.name")). + Set("path", defkit.Reference("m.path")), + ) + tpl.Set("items", arr) + }) + + cue := ws.ToCue() + + // Should have inner braces around each element + Expect(cue).To(ContainSubstring("for m in")) + Expect(cue).To(MatchRegexp(`for m in .+ \{\n[^\n]*\{`)) + }) + + It("should wrap elements with conditional fields in inner braces", func() { + hasStorage := defkit.PathExists("parameter.storage") + ws := defkit.NewWorkflowStep("test"). + Params( + defkit.Array("storage").WithFields( + defkit.String("name").Required(), + defkit.String("subPath"), + ), + ). + Template(func(tpl *defkit.WorkflowStepTemplate) { + arr := defkit.NewArray(). + ForEachGuarded( + hasStorage, + defkit.ParamRef("storage"), + defkit.NewArrayElement(). + Set("name", defkit.Reference("m.name")). + SetIf(defkit.PathExists("m.subPath"), "subPath", defkit.Reference("m.subPath")), + ) + tpl.Set("mounts", arr) + }) + + cue := ws.ToCue() + + // Inner braces should contain both the field and the conditional + Expect(cue).To(ContainSubstring("name: m.name")) + Expect(cue).To(ContainSubstring("if m.subPath != _|_")) + }) + }) + + Describe("Dedupe from Reference source", func() { + It("should generate dedup pattern when source is a Reference", func() { + ws := defkit.NewWorkflowStep("test"). + Params(defkit.String("name").Required()). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.Set("deduped", defkit.From(defkit.Reference("volumesList")).Dedupe("name")) + }) + + cue := ws.ToCue() + + // Should generate the full dedup pattern, not simple [for v in ...] + Expect(cue).To(ContainSubstring("for val in [")) + Expect(cue).To(ContainSubstring("for i, vi in volumesList")) + Expect(cue).To(ContainSubstring("for j, vj in volumesList if j < i && vi.name == vj.name")) + Expect(cue).To(ContainSubstring("_ignore: true")) + Expect(cue).To(ContainSubstring("if val._ignore == _|_")) + Expect(cue).NotTo(ContainSubstring("[for v in volumesList { v }]")) + }) + + It("should generate dedup pattern with different key field", func() { + ws := defkit.NewWorkflowStep("test"). + Params(defkit.String("name").Required()). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.Set("unique", defkit.From(defkit.Reference("items")).Dedupe("id")) + }) + + cue := ws.ToCue() + + Expect(cue).To(ContainSubstring("vi.id == vj.id")) + Expect(cue).To(ContainSubstring("for i, vi in items")) + }) + }) + + Describe("TemplateBody with no params", func() { + It("should skip parameter block when TemplateBody is set and no params exist", func() { + ws := defkit.NewWorkflowStep("test"). + Description("test step"). + TemplateBody("nop: {}") + + cue := ws.ToCue() + + Expect(cue).To(ContainSubstring("nop: {}")) + Expect(cue).NotTo(ContainSubstring("parameter:")) + }) + + It("should still emit parameter block when TemplateBody is set but params exist", func() { + ws := defkit.NewWorkflowStep("test"). + Description("test step"). + Params(defkit.String("name")). + TemplateBody("nop: {}") + + cue := ws.ToCue() + + Expect(cue).To(ContainSubstring("nop: {}")) + Expect(cue).To(ContainSubstring("parameter:")) + Expect(cue).To(ContainSubstring("name: string")) + }) + + It("should emit parameter block when no TemplateBody and no params", func() { + ws := defkit.NewWorkflowStep("test"). + Description("test step") + + cue := ws.ToCue() + + // Default behavior: empty parameter block is emitted + Expect(cue).To(ContainSubstring("parameter:")) + }) + }) + + Describe("Builtin WithDirectFields", func() { + It("should render fields directly without $params wrapper", func() { + ws := defkit.NewWorkflowStep("test"). + Params(defkit.String("env").Required()). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.Builtin("app", "op.#ShareCloudResource"). + WithDirectFields(). + WithParams(map[string]defkit.Value{ + "env": defkit.Reference("parameter.env"), + "namespace": defkit.Reference("context.namespace"), + }). + Build() + }) + + cue := ws.ToCue() + + Expect(cue).To(ContainSubstring("app: op.#ShareCloudResource & {")) + Expect(cue).To(ContainSubstring("env: parameter.env")) + Expect(cue).To(ContainSubstring("namespace: context.namespace")) + Expect(cue).NotTo(ContainSubstring("$params:")) + }) + + It("should still use $params when WithDirectFields is not called", func() { + ws := defkit.NewWorkflowStep("test"). + Params(defkit.String("name").Required()). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.Builtin("deploy", "kube.#Apply"). + WithParams(map[string]defkit.Value{ + "value": defkit.Reference("object"), + }). + Build() + }) + + cue := ws.ToCue() + + Expect(cue).To(ContainSubstring("deploy: kube.#Apply & {")) + Expect(cue).To(ContainSubstring("$params:")) + }) + }) }) diff --git a/pkg/definition/defkit/helper_test.go b/pkg/definition/defkit/helper_test.go index 6b2d6da69..f504bec85 100644 --- a/pkg/definition/defkit/helper_test.go +++ b/pkg/definition/defkit/helper_test.go @@ -713,4 +713,86 @@ var _ = Describe("Helper", func() { }) }) }) + + Describe("HelperBuilder Filter and FilterCond on MultiSource", func() { + var tpl *defkit.Template + + BeforeEach(func() { + tpl = defkit.NewTemplate() + }) + + It("should propagate Filter to MultiSource", func() { + volumeMounts := defkit.Object("volumeMounts") + helper := tpl.Helper("filteredMounts"). + FromFields(volumeMounts, "pvc", "configMap"). + Filter(defkit.FieldEquals("readOnly", false)). + Build() + + ms, ok := helper.Collection().(*defkit.MultiSource) + Expect(ok).To(BeTrue()) + Expect(ms.Operations()).To(HaveLen(1)) + }) + + It("should propagate FilterCond to MultiSource", func() { + volumeMounts := defkit.Object("volumeMounts") + cond := defkit.Eq(defkit.Item().Get("enabled"), defkit.Lit(true)) + helper := tpl.Helper("filteredMounts"). + FromFields(volumeMounts, "pvc", "configMap"). + FilterCond(cond). + Build() + + ms, ok := helper.Collection().(*defkit.MultiSource) + Expect(ok).To(BeTrue()) + Expect(ms.Operations()).To(HaveLen(1)) + }) + + It("should propagate FilterCond to CollectionOp via From", func() { + ports := defkit.List("ports") + cond := defkit.Eq(defkit.Item().Get("enabled"), defkit.Lit(true)) + helper := tpl.Helper("filteredPorts"). + From(ports). + FilterCond(cond). + Build() + + col, ok := helper.Collection().(*defkit.CollectionOp) + Expect(ok).To(BeTrue()) + Expect(col.Operations()).To(HaveLen(1)) + }) + }) + + Describe("CollectionOp FilterCond", func() { + It("should add FilterCond operation to collection", func() { + ports := defkit.List("ports") + cond := defkit.Eq(defkit.Item().Get("enabled"), defkit.Lit(true)) + col := defkit.Each(ports).FilterCond(cond) + Expect(col.Operations()).To(HaveLen(1)) + }) + + It("should compose with other operations", func() { + ports := defkit.List("ports") + cond := defkit.Eq(defkit.Item().Get("enabled"), defkit.Lit(true)) + col := defkit.Each(ports). + Filter(defkit.FieldEquals("expose", true)). + FilterCond(cond). + Pick("name", "port") + Expect(col.Operations()).To(HaveLen(3)) + }) + }) + + Describe("MultiSource Filter and FilterCond", func() { + It("should add Filter operation to multi-source", func() { + volumeMounts := defkit.Object("volumeMounts") + ms := defkit.FromFields(volumeMounts, "pvc", "configMap") + ms.Filter(defkit.FieldEquals("readOnly", false)) + Expect(ms.Operations()).To(HaveLen(1)) + }) + + It("should add FilterCond operation to multi-source", func() { + volumeMounts := defkit.Object("volumeMounts") + ms := defkit.FromFields(volumeMounts, "pvc", "configMap") + cond := defkit.Eq(defkit.Item().Get("enabled"), defkit.Lit(true)) + ms.FilterCond(cond) + Expect(ms.Operations()).To(HaveLen(1)) + }) + }) }) diff --git a/pkg/definition/defkit/op_builders.go b/pkg/definition/defkit/op_builders.go new file mode 100644 index 000000000..c51decadb --- /dev/null +++ b/pkg/definition/defkit/op_builders.go @@ -0,0 +1,348 @@ +/* +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 + +import ( + "fmt" + "strings" +) + +// CUERenderer is implemented by builder types that can render themselves to CUE. +// The renderValue function is provided by the CUE generator to render nested Value types. +type CUERenderer interface { + RenderCUE(renderValue func(Value) string) string +} + +// CUEConditionRenderer extends CUERenderer with condition rendering support. +// Builders that need to render Condition values should implement this interface. +// The CUE generator will prefer this over CUERenderer when available. +type CUEConditionRenderer interface { + RenderCUEWithCondition(renderValue func(Value) string, renderCondition func(Condition) string) string +} + +// --------------------------------------------------------------------------- +// KubeRead builder +// --------------------------------------------------------------------------- + +// KubeReadBuilder builds a kube.#Read operation fluently. +type KubeReadBuilder struct { + apiVersion string + kind string + name Value + namespace Value + cluster Value + nsCond Condition // for NamespaceIf +} + +func (b *KubeReadBuilder) expr() {} +func (b *KubeReadBuilder) value() {} +func (b *KubeReadBuilder) condition() {} + +// KubeRead creates a new kube.#Read builder for the given apiVersion and kind. +func KubeRead(apiVersion, kind string) *KubeReadBuilder { + return &KubeReadBuilder{ + apiVersion: apiVersion, + kind: kind, + } +} + +// Name sets the metadata.name value. +func (b *KubeReadBuilder) Name(v Value) *KubeReadBuilder { + b.name = v + return b +} + +// Namespace sets the metadata.namespace value. +func (b *KubeReadBuilder) Namespace(v Value) *KubeReadBuilder { + b.namespace = v + return b +} + +// NamespaceIf sets the metadata.namespace conditionally. +func (b *KubeReadBuilder) NamespaceIf(cond Condition, v Value) *KubeReadBuilder { + b.nsCond = cond + b.namespace = v + return b +} + +// Cluster sets the optional cluster parameter for multi-cluster reads. +func (b *KubeReadBuilder) Cluster(v Value) *KubeReadBuilder { + b.cluster = v + return b +} + +// RenderCUE renders the builder to a CUE string. +func (b *KubeReadBuilder) RenderCUE(rv func(Value) string) string { + return b.RenderCUEWithCondition(rv, nil) +} + +// RenderCUEWithCondition renders the builder to a CUE string with condition support. +func (b *KubeReadBuilder) RenderCUEWithCondition(rv func(Value) string, rc func(Condition) string) string { + var sb strings.Builder + sb.WriteString("kube.#Read & {\n") + + // cluster param sits alongside value in $params + if b.cluster != nil { + sb.WriteString("\t$params: {\n") + sb.WriteString(fmt.Sprintf("\t\tcluster: %s\n", rv(b.cluster))) + sb.WriteString("\t\tvalue: {\n") + b.writeValueBody(&sb, rv, rc, "\t\t\t") + sb.WriteString("\t\t}\n") + sb.WriteString("\t}\n") + } else { + sb.WriteString("\t$params: value: {\n") + b.writeValueBody(&sb, rv, rc, "\t\t") + sb.WriteString("\t}\n") + } + + sb.WriteString("}") + return sb.String() +} + +func (b *KubeReadBuilder) writeValueBody(sb *strings.Builder, rv func(Value) string, rc func(Condition) string, indent string) { + sb.WriteString(fmt.Sprintf("%sapiVersion: %q\n", indent, b.apiVersion)) + sb.WriteString(fmt.Sprintf("%skind: %q\n", indent, b.kind)) + sb.WriteString(fmt.Sprintf("%smetadata: {\n", indent)) + if b.name != nil { + sb.WriteString(fmt.Sprintf("%s\tname: %s\n", indent, rv(b.name))) + } + if b.namespace != nil { + if b.nsCond != nil && rc != nil { + condStr := rc(b.nsCond) + sb.WriteString(fmt.Sprintf("%s\tif %s {\n", indent, condStr)) + sb.WriteString(fmt.Sprintf("%s\t\tnamespace: %s\n", indent, rv(b.namespace))) + sb.WriteString(fmt.Sprintf("%s\t}\n", indent)) + } else { + sb.WriteString(fmt.Sprintf("%s\tnamespace: %s\n", indent, rv(b.namespace))) + } + } + sb.WriteString(fmt.Sprintf("%s}\n", indent)) +} + +// --------------------------------------------------------------------------- +// KubeApply builder +// --------------------------------------------------------------------------- + +// KubeApplyBuilder builds a kube.#Apply operation fluently. +type KubeApplyBuilder struct { + objectValue Value + cluster Value +} + +func (b *KubeApplyBuilder) expr() {} +func (b *KubeApplyBuilder) value() {} +func (b *KubeApplyBuilder) condition() {} + +// KubeApply creates a new kube.#Apply builder with the given object value. +func KubeApply(objectValue Value) *KubeApplyBuilder { + return &KubeApplyBuilder{objectValue: objectValue} +} + +// Cluster sets the optional cluster parameter. +func (b *KubeApplyBuilder) Cluster(v Value) *KubeApplyBuilder { + b.cluster = v + return b +} + +// RenderCUE renders the builder to a CUE string. +func (b *KubeApplyBuilder) RenderCUE(rv func(Value) string) string { + var sb strings.Builder + sb.WriteString("kube.#Apply & {\n") + sb.WriteString("\t$params: {\n") + sb.WriteString(fmt.Sprintf("\t\tvalue: %s\n", rv(b.objectValue))) + if b.cluster != nil { + sb.WriteString(fmt.Sprintf("\t\tcluster: %s\n", rv(b.cluster))) + } + sb.WriteString("\t}\n") + sb.WriteString("}") + return sb.String() +} + +// --------------------------------------------------------------------------- +// HTTPPost builder +// --------------------------------------------------------------------------- + +// HTTPPostBuilder builds an http.#HTTPDo POST operation fluently. +type HTTPPostBuilder struct { + url Value + body Value + headers map[string]string +} + +func (b *HTTPPostBuilder) expr() {} +func (b *HTTPPostBuilder) value() {} +func (b *HTTPPostBuilder) condition() {} + +// HTTPPost creates a new http.#HTTPDo builder for a POST request. +func HTTPPost(url Value) *HTTPPostBuilder { + return &HTTPPostBuilder{ + url: url, + headers: make(map[string]string), + } +} + +// Body sets the request body. +func (b *HTTPPostBuilder) Body(v Value) *HTTPPostBuilder { + b.body = v + return b +} + +// Header adds a request header. +func (b *HTTPPostBuilder) Header(key, value string) *HTTPPostBuilder { + b.headers[key] = value + return b +} + +// RenderCUE renders the builder to a CUE string. +func (b *HTTPPostBuilder) RenderCUE(rv func(Value) string) string { + var sb strings.Builder + sb.WriteString("http.#HTTPDo & {\n") + sb.WriteString("\t$params: {\n") + sb.WriteString("\t\tmethod: \"POST\"\n") + sb.WriteString(fmt.Sprintf("\t\turl: %s\n", rv(b.url))) + sb.WriteString("\t\trequest: {\n") + if b.body != nil { + sb.WriteString(fmt.Sprintf("\t\t\tbody: %s\n", rv(b.body))) + } + for k, v := range b.headers { + sb.WriteString(fmt.Sprintf("\t\t\theader: %q: %q\n", k, v)) + } + sb.WriteString("\t\t}\n") + sb.WriteString("\t}\n") + sb.WriteString("}") + return sb.String() +} + +// --------------------------------------------------------------------------- +// ConvertString builder +// --------------------------------------------------------------------------- + +// ConvertStringBuilder builds a util.#ConvertString operation fluently. +type ConvertStringBuilder struct { + input Value +} + +func (b *ConvertStringBuilder) expr() {} +func (b *ConvertStringBuilder) value() {} +func (b *ConvertStringBuilder) condition() {} + +// ConvertString creates a new util.#ConvertString builder. +func ConvertString(input Value) *ConvertStringBuilder { + return &ConvertStringBuilder{input: input} +} + +// RenderCUE renders the builder to a CUE string. +func (b *ConvertStringBuilder) RenderCUE(rv func(Value) string) string { + return fmt.Sprintf("util.#ConvertString & {\n\t$params: bt: %s\n}", rv(b.input)) +} + +// --------------------------------------------------------------------------- +// WaitUntil builder +// --------------------------------------------------------------------------- + +// WaitUntilBuilder builds a builtin.#ConditionalWait operation fluently. +type WaitUntilBuilder struct { + continueExpr Value + guards []Value + messageCond Condition + messageVal Value +} + +func (b *WaitUntilBuilder) expr() {} +func (b *WaitUntilBuilder) value() {} +func (b *WaitUntilBuilder) condition() {} + +// WaitUntil creates a new builtin.#ConditionalWait builder. +func WaitUntil(continueExpr Value) *WaitUntilBuilder { + return &WaitUntilBuilder{continueExpr: continueExpr} +} + +// Guard adds guard conditions that must be true (not _|_) before the continue +// check is evaluated. Multiple guards are nested as chained if statements. +func (b *WaitUntilBuilder) Guard(guards ...Value) *WaitUntilBuilder { + b.guards = append(b.guards, guards...) + return b +} + +// MessageIf conditionally sets the message parameter. +func (b *WaitUntilBuilder) MessageIf(cond Condition, val Value) *WaitUntilBuilder { + b.messageCond = cond + b.messageVal = val + return b +} + +// RenderCUE renders the builder to a CUE string. +func (b *WaitUntilBuilder) RenderCUE(rv func(Value) string) string { + return b.RenderCUEWithCondition(rv, nil) +} + +// RenderCUEWithCondition renders the builder to a CUE string with condition support. +func (b *WaitUntilBuilder) RenderCUEWithCondition(rv func(Value) string, rc func(Condition) string) string { + var sb strings.Builder + sb.WriteString("builtin.#ConditionalWait & {\n") + + if len(b.guards) > 0 { + // Build chained guard: if g1 if g2 { ... } + var guardParts []string + for _, g := range b.guards { + guardParts = append(guardParts, fmt.Sprintf("if %s != _|_", rv(g))) + } + sb.WriteString(fmt.Sprintf("\t%s {\n", strings.Join(guardParts, " "))) + sb.WriteString(fmt.Sprintf("\t\t$params: continue: %s\n", rv(b.continueExpr))) + sb.WriteString("\t}\n") + } else { + sb.WriteString(fmt.Sprintf("\t$params: continue: %s\n", rv(b.continueExpr))) + } + + if b.messageCond != nil && b.messageVal != nil { + if rc != nil { + condStr := rc(b.messageCond) + sb.WriteString(fmt.Sprintf("\tif %s {\n", condStr)) + } else { + // Fallback when condition renderer is not available + sb.WriteString(fmt.Sprintf("\tif %s != _|_ {\n", rv(b.messageVal))) + } + sb.WriteString(fmt.Sprintf("\t\t$params: message: %s\n", rv(b.messageVal))) + sb.WriteString("\t}\n") + } + + sb.WriteString("}") + return sb.String() +} + +// --------------------------------------------------------------------------- +// Fail builder +// --------------------------------------------------------------------------- + +// FailBuilder builds a builtin.#Fail operation fluently. +type FailBuilder struct { + message Value +} + +func (b *FailBuilder) expr() {} +func (b *FailBuilder) value() {} +func (b *FailBuilder) condition() {} + +// Fail creates a new builtin.#Fail builder with the given message. +func Fail(message Value) *FailBuilder { + return &FailBuilder{message: message} +} + +// RenderCUE renders the builder to a CUE string. +func (b *FailBuilder) RenderCUE(rv func(Value) string) string { + return fmt.Sprintf("builtin.#Fail & {\n\t$params: message: %s\n}", rv(b.message)) +} diff --git a/pkg/definition/defkit/op_builders_test.go b/pkg/definition/defkit/op_builders_test.go new file mode 100644 index 000000000..c20b3c107 --- /dev/null +++ b/pkg/definition/defkit/op_builders_test.go @@ -0,0 +1,418 @@ +/* +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" +) + +// simpleRenderValue is a test helper that renders Values the same way cuegen does for Ref types. +func simpleRenderValue(v defkit.Value) string { + if cr, ok := v.(defkit.CUERenderer); ok { + return cr.RenderCUE(simpleRenderValue) + } + // For *Ref, use the Path() method via the interface trick + type pather interface{ Path() string } + if p, ok := v.(pather); ok { + return p.Path() + } + return "_" +} + +var _ = Describe("Operation Builders", func() { + + Describe("KubeRead", func() { + It("should render a basic kube.#Read with name and namespace", func() { + b := defkit.KubeRead("v1", "Secret"). + Name(defkit.Reference("parameter.name")). + Namespace(defkit.Reference("context.namespace")) + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring("kube.#Read & {")) + Expect(cue).To(ContainSubstring(`apiVersion: "v1"`)) + Expect(cue).To(ContainSubstring(`kind: "Secret"`)) + Expect(cue).To(ContainSubstring("name: parameter.name")) + Expect(cue).To(ContainSubstring("namespace: context.namespace")) + Expect(cue).To(ContainSubstring("$params: value: {")) + }) + + It("should render kube.#Read with cluster parameter", func() { + b := defkit.KubeRead("v1", "ConfigMap"). + Name(defkit.Reference("parameter.name")). + Namespace(defkit.Reference("context.namespace")). + Cluster(defkit.Reference("parameter.cluster")) + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring("kube.#Read & {")) + Expect(cue).To(ContainSubstring("cluster: parameter.cluster")) + Expect(cue).To(ContainSubstring(`apiVersion: "v1"`)) + Expect(cue).To(ContainSubstring(`kind: "ConfigMap"`)) + // When cluster is set, $params uses expanded form + Expect(cue).NotTo(ContainSubstring("$params: value: {")) + Expect(cue).To(ContainSubstring("$params: {")) + }) + + It("should render without namespace when not set", func() { + b := defkit.KubeRead("apps/v1", "Deployment"). + Name(defkit.Reference("parameter.name")) + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring("name: parameter.name")) + Expect(cue).NotTo(ContainSubstring("namespace:")) + }) + + It("should render with different apiVersion and kind", func() { + b := defkit.KubeRead("core.oam.dev/v1beta1", "Application"). + Name(defkit.Reference("context.name")). + Namespace(defkit.Reference("context.namespace")) + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring(`apiVersion: "core.oam.dev/v1beta1"`)) + Expect(cue).To(ContainSubstring(`kind: "Application"`)) + }) + + It("should render NamespaceIf with condition through CUE generator", func() { + ws := defkit.NewWorkflowStep("test"). + Params(defkit.String("name").Required()). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.Set("read", defkit.KubeRead("v1", "Secret"). + Name(defkit.Reference("parameter.name")). + NamespaceIf( + defkit.PathExists("parameter.namespace"), + defkit.Reference("parameter.namespace"), + ), + ) + }) + + cue := ws.ToCue() + + Expect(cue).To(ContainSubstring("if parameter.namespace != _|_ {")) + Expect(cue).To(ContainSubstring("namespace: parameter.namespace")) + }) + + It("should render NamespaceIf without condition renderer as unconditional", func() { + b := defkit.KubeRead("v1", "Secret"). + Name(defkit.Reference("parameter.name")). + NamespaceIf( + defkit.PathExists("parameter.namespace"), + defkit.Reference("parameter.namespace"), + ) + + // RenderCUE without condition renderer falls back to unconditional + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring("namespace: parameter.namespace")) + }) + + It("should implement Value interface", func() { + var v defkit.Value = defkit.KubeRead("v1", "Pod"). + Name(defkit.Reference("name")) + Expect(v).NotTo(BeNil()) + }) + }) + + Describe("KubeApply", func() { + It("should render a basic kube.#Apply", func() { + b := defkit.KubeApply(defkit.Reference("deployment")) + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring("kube.#Apply & {")) + Expect(cue).To(ContainSubstring("value: deployment")) + Expect(cue).To(ContainSubstring("$params: {")) + }) + + It("should render kube.#Apply with cluster", func() { + b := defkit.KubeApply(defkit.Reference("configMap")). + Cluster(defkit.Reference("parameter.cluster")) + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring("value: configMap")) + Expect(cue).To(ContainSubstring("cluster: parameter.cluster")) + }) + + It("should render kube.#Apply without cluster", func() { + b := defkit.KubeApply(defkit.Reference("jobValue")) + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).NotTo(ContainSubstring("cluster:")) + }) + + It("should implement Value interface", func() { + var v defkit.Value = defkit.KubeApply(defkit.Reference("obj")) + Expect(v).NotTo(BeNil()) + }) + }) + + Describe("HTTPPost", func() { + It("should render a basic POST request", func() { + b := defkit.HTTPPost(defkit.Reference("parameter.url")). + Body(defkit.Reference("data.value")). + Header("Content-Type", "application/json") + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring("http.#HTTPDo & {")) + Expect(cue).To(ContainSubstring(`method: "POST"`)) + Expect(cue).To(ContainSubstring("url: parameter.url")) + Expect(cue).To(ContainSubstring("body: data.value")) + Expect(cue).To(ContainSubstring(`header: "Content-Type": "application/json"`)) + }) + + It("should render without body when not set", func() { + b := defkit.HTTPPost(defkit.Reference("parameter.url")). + Header("Content-Type", "application/json") + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).NotTo(ContainSubstring("body:")) + Expect(cue).To(ContainSubstring("request: {")) + }) + + It("should render with computed URL", func() { + b := defkit.HTTPPost(defkit.Reference("stringValue.$returns.str")). + Body(defkit.Reference("data.value")). + Header("Content-Type", "application/json") + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring("url: stringValue.$returns.str")) + }) + + It("should implement Value interface", func() { + var v defkit.Value = defkit.HTTPPost(defkit.Reference("url")) + Expect(v).NotTo(BeNil()) + }) + }) + + Describe("ConvertString", func() { + It("should render util.#ConvertString", func() { + b := defkit.ConvertString( + defkit.Reference("base64.Decode(null, read.$returns.value.data[key])"), + ) + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring("util.#ConvertString & {")) + Expect(cue).To(ContainSubstring("$params: bt: base64.Decode(null, read.$returns.value.data[key])")) + }) + + It("should implement Value interface", func() { + var v defkit.Value = defkit.ConvertString(defkit.Reference("input")) + Expect(v).NotTo(BeNil()) + }) + }) + + Describe("WaitUntil", func() { + It("should render simple conditional wait", func() { + b := defkit.WaitUntil( + defkit.Reference("output.$returns.value.status.readyReplicas == parameter.replicas"), + ) + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring("builtin.#ConditionalWait & {")) + Expect(cue).To(ContainSubstring("$params: continue: output.$returns.value.status.readyReplicas == parameter.replicas")) + Expect(cue).NotTo(ContainSubstring("if ")) + }) + + It("should render with single guard", func() { + b := defkit.WaitUntil( + defkit.Reference(`apply.$returns.value.status.apply.state == "Available"`), + ).Guard( + defkit.Reference("apply.$returns.value.status"), + ) + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring("if apply.$returns.value.status != _|_")) + Expect(cue).To(ContainSubstring(`$params: continue: apply.$returns.value.status.apply.state == "Available"`)) + }) + + It("should render with chained guards", func() { + b := defkit.WaitUntil( + defkit.Reference(`apply.$returns.value.status.apply.state == "Available"`), + ).Guard( + defkit.Reference("apply.$returns.value.status"), + defkit.Reference("apply.$returns.value.status.apply"), + ) + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring("if apply.$returns.value.status != _|_ if apply.$returns.value.status.apply != _|_")) + }) + + It("should render MessageIf with condition through CUE generator", func() { + ws := defkit.NewWorkflowStep("test"). + Params(defkit.String("name").Required()). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.Set("wait", defkit.WaitUntil( + defkit.Reference("job.$returns.value.status.succeeded > 0"), + ).MessageIf( + defkit.PathExists("job.$returns.value.status.conditions"), + defkit.Reference("job.$returns.value.status.conditions[0].message"), + )) + }) + + cue := ws.ToCue() + + Expect(cue).To(ContainSubstring("if job.$returns.value.status.conditions != _|_ {")) + Expect(cue).To(ContainSubstring("$params: message: job.$returns.value.status.conditions[0].message")) + }) + + It("should implement Value interface", func() { + var v defkit.Value = defkit.WaitUntil(defkit.Reference("true")) + Expect(v).NotTo(BeNil()) + }) + }) + + Describe("Fail", func() { + It("should render builtin.#Fail with message", func() { + b := defkit.Fail(defkit.Reference("check.$returns.message")) + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring("builtin.#Fail & {")) + Expect(cue).To(ContainSubstring("$params: message: check.$returns.message")) + }) + + It("should render with literal message", func() { + b := defkit.Fail(defkit.Reference(`"failed to execute command"`)) + + cue := b.RenderCUE(simpleRenderValue) + + Expect(cue).To(ContainSubstring(`$params: message: "failed to execute command"`)) + }) + + It("should implement Value interface", func() { + var v defkit.Value = defkit.Fail(defkit.Reference("msg")) + Expect(v).NotTo(BeNil()) + }) + }) + + Describe("CUERenderer integration via valueToCUE", func() { + It("should render KubeRead through CUE generator", func() { + ws := defkit.NewWorkflowStep("test"). + Params(defkit.String("name").Required()). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.Set("output", defkit.KubeRead("v1", "Pod"). + Name(defkit.Reference("parameter.name")). + Namespace(defkit.Reference("context.namespace")), + ) + }) + + cue := ws.ToCue() + + Expect(cue).To(ContainSubstring("kube.#Read & {")) + Expect(cue).To(ContainSubstring(`apiVersion: "v1"`)) + Expect(cue).To(ContainSubstring(`kind: "Pod"`)) + Expect(cue).To(ContainSubstring("name: parameter.name")) + Expect(cue).To(ContainSubstring("namespace: context.namespace")) + }) + + It("should render HTTPPost through CUE generator", func() { + ws := defkit.NewWorkflowStep("test"). + Params(defkit.String("url").Required()). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.Set("req", defkit.HTTPPost(defkit.Reference("parameter.url")). + Body(defkit.Reference("data.value")). + Header("Content-Type", "application/json"), + ) + }) + + cue := ws.ToCue() + + Expect(cue).To(ContainSubstring("http.#HTTPDo & {")) + Expect(cue).To(ContainSubstring(`method: "POST"`)) + Expect(cue).To(ContainSubstring("url: parameter.url")) + }) + + It("should render ConvertString through CUE generator", func() { + ws := defkit.NewWorkflowStep("test"). + Params(defkit.String("name").Required()). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.Set("convert", defkit.ConvertString( + defkit.Reference("base64.Decode(null, data)"), + )) + }) + + cue := ws.ToCue() + + Expect(cue).To(ContainSubstring("util.#ConvertString & {")) + Expect(cue).To(ContainSubstring("$params: bt: base64.Decode(null, data)")) + }) + + It("should render WaitUntil with Guard through CUE generator", func() { + ws := defkit.NewWorkflowStep("test"). + Params(defkit.String("name").Required()). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.Set("wait", defkit.WaitUntil( + defkit.Reference("job.$returns.value.status.succeeded > 0"), + ).Guard( + defkit.Reference("job.$returns.value.status"), + defkit.Reference("job.$returns.value.status.succeeded"), + )) + }) + + cue := ws.ToCue() + + Expect(cue).To(ContainSubstring("builtin.#ConditionalWait & {")) + Expect(cue).To(ContainSubstring("if job.$returns.value.status != _|_ if job.$returns.value.status.succeeded != _|_")) + Expect(cue).To(ContainSubstring("$params: continue: job.$returns.value.status.succeeded > 0")) + }) + + It("should render WaitUntil without Guard through CUE generator", func() { + ws := defkit.NewWorkflowStep("test"). + Params(defkit.String("name").Required()). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.Set("wait", defkit.WaitUntil( + defkit.Reference("output.$returns.value.status.ready == true"), + )) + }) + + cue := ws.ToCue() + + Expect(cue).To(ContainSubstring("builtin.#ConditionalWait & {")) + Expect(cue).To(ContainSubstring("$params: continue: output.$returns.value.status.ready == true")) + Expect(cue).NotTo(ContainSubstring("if ")) + }) + + It("should render Fail through CUE generator", func() { + ws := defkit.NewWorkflowStep("test"). + Params(defkit.String("name").Required()). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.Set("error", defkit.Fail( + defkit.Reference(`"operation failed"`), + )) + }) + + cue := ws.ToCue() + + Expect(cue).To(ContainSubstring("builtin.#Fail & {")) + Expect(cue).To(ContainSubstring(`$params: message: "operation failed"`)) + }) + }) +}) diff --git a/pkg/definition/defkit/param.go b/pkg/definition/defkit/param.go index 9377fd920..0e4d6d6d3 100644 --- a/pkg/definition/defkit/param.go +++ b/pkg/definition/defkit/param.go @@ -1245,6 +1245,78 @@ func (p *OneOfParam) GetVariant(name string) *OneOfVariant { return nil } +// ClosedStructOption represents one option in a closed struct disjunction. +// It holds a set of struct fields that will be wrapped in close({...}). +type ClosedStructOption struct { + fields []*StructField +} + +// ClosedStruct creates a new closed struct option for use in ClosedUnion. +func ClosedStruct() *ClosedStructOption { + return &ClosedStructOption{ + fields: make([]*StructField, 0), + } +} + +// WithFields adds fields to the closed struct option. +func (c *ClosedStructOption) WithFields(fields ...*StructField) *ClosedStructOption { + c.fields = append(c.fields, fields...) + return c +} + +// GetFields returns the fields of this closed struct option. +func (c *ClosedStructOption) GetFields() []*StructField { + return c.fields +} + +// ClosedUnionParam represents a closed struct disjunction parameter. +// It generates CUE of the form: close({...}) | close({...}) +type ClosedUnionParam struct { + baseParam + options []*ClosedStructOption +} + +// ClosedUnion creates a new closed struct disjunction parameter. +func ClosedUnion(name string) *ClosedUnionParam { + return &ClosedUnionParam{ + baseParam: baseParam{ + name: name, + paramType: ParamTypeClosedUnion, + }, + options: make([]*ClosedStructOption, 0), + } +} + +// Options sets the closed struct alternatives for the disjunction. +func (p *ClosedUnionParam) Options(options ...*ClosedStructOption) *ClosedUnionParam { + p.options = append(p.options, options...) + return p +} + +// Required marks the parameter as required. +func (p *ClosedUnionParam) Required() *ClosedUnionParam { + p.required = true + return p +} + +// Optional marks the parameter as optional. +func (p *ClosedUnionParam) Optional() *ClosedUnionParam { + p.optional = true + p.required = false + return p +} + +// Description sets the parameter description. +func (p *ClosedUnionParam) Description(desc string) *ClosedUnionParam { + p.description = desc + return p +} + +// GetOptions returns the closed struct options. +func (p *ClosedUnionParam) GetOptions() []*ClosedStructOption { + return p.options +} + // Convenience functions for common parameter patterns // StringList creates a string array parameter. diff --git a/pkg/definition/defkit/param_test.go b/pkg/definition/defkit/param_test.go index d6efee181..1e7e36071 100644 --- a/pkg/definition/defkit/param_test.go +++ b/pkg/definition/defkit/param_test.go @@ -1001,4 +1001,98 @@ var _ = Describe("Parameters", func() { Expect(p.GetDescription()).To(Equal("Deprecated field")) }) }) + + Context("ClosedUnionParam", func() { + It("should create a closed union parameter with name", func() { + p := defkit.ClosedUnion("url") + Expect(p.Name()).To(Equal("url")) + Expect(p.IsRequired()).To(BeFalse()) + Expect(p.IsOptional()).To(BeFalse()) + Expect(p.HasDefault()).To(BeFalse()) + Expect(p.GetOptions()).To(HaveLen(0)) + }) + + It("should support required modifier", func() { + p := defkit.ClosedUnion("url").Required() + Expect(p.IsRequired()).To(BeTrue()) + Expect(p.IsOptional()).To(BeFalse()) + }) + + It("should support optional modifier", func() { + p := defkit.ClosedUnion("url").Required().Optional() + Expect(p.IsRequired()).To(BeFalse()) + }) + + It("should support description", func() { + p := defkit.ClosedUnion("url").Description("the url") + Expect(p.GetDescription()).To(Equal("the url")) + }) + + It("should support options with fields", func() { + p := defkit.ClosedUnion("url").Options( + defkit.ClosedStruct().WithFields( + defkit.Field("value", defkit.ParamTypeString).Required(), + ), + defkit.ClosedStruct().WithFields( + defkit.Field("secretRef", defkit.ParamTypeStruct).Required(), + ), + ) + Expect(p.GetOptions()).To(HaveLen(2)) + Expect(p.GetOptions()[0].GetFields()).To(HaveLen(1)) + Expect(p.GetOptions()[0].GetFields()[0].Name()).To(Equal("value")) + Expect(p.GetOptions()[1].GetFields()).To(HaveLen(1)) + Expect(p.GetOptions()[1].GetFields()[0].Name()).To(Equal("secretRef")) + }) + + It("should support fluent chaining", func() { + p := defkit.ClosedUnion("source"). + Required(). + Description("the source"). + Options( + defkit.ClosedStruct().WithFields( + defkit.Field("hcl", defkit.ParamTypeString).Required(), + ), + defkit.ClosedStruct().WithFields( + defkit.Field("remote", defkit.ParamTypeString).Required(), + defkit.Field("path", defkit.ParamTypeString), + ), + ) + Expect(p.Name()).To(Equal("source")) + Expect(p.IsRequired()).To(BeTrue()) + Expect(p.GetDescription()).To(Equal("the source")) + Expect(p.GetOptions()).To(HaveLen(2)) + Expect(p.GetOptions()[1].GetFields()).To(HaveLen(2)) + }) + }) + + Context("ClosedStructOption", func() { + It("should create an empty closed struct", func() { + cs := defkit.ClosedStruct() + Expect(cs.GetFields()).To(HaveLen(0)) + }) + + It("should add fields with WithFields", func() { + cs := defkit.ClosedStruct().WithFields( + defkit.Field("name", defkit.ParamTypeString).Required(), + defkit.Field("key", defkit.ParamTypeString).Required(), + ) + Expect(cs.GetFields()).To(HaveLen(2)) + Expect(cs.GetFields()[0].Name()).To(Equal("name")) + Expect(cs.GetFields()[1].Name()).To(Equal("key")) + }) + + It("should support nested struct fields", func() { + cs := defkit.ClosedStruct().WithFields( + defkit.Field("secretRef", defkit.ParamTypeStruct).Nested( + defkit.Struct("secretRef").WithFields( + defkit.Field("name", defkit.ParamTypeString).Required(), + defkit.Field("key", defkit.ParamTypeString).Required(), + ), + ), + ) + Expect(cs.GetFields()).To(HaveLen(1)) + Expect(cs.GetFields()[0].GetNested()).NotTo(BeNil()) + Expect(cs.GetFields()[0].GetNested().GetFields()).To(HaveLen(2)) + }) + }) }) diff --git a/pkg/definition/defkit/trait.go b/pkg/definition/defkit/trait.go index d1f44d869..1b7fdf21c 100644 --- a/pkg/definition/defkit/trait.go +++ b/pkg/definition/defkit/trait.go @@ -1299,6 +1299,7 @@ func (g *TraitCUEGenerator) writePatchContainerPattern(sb *strings.Builder, conf 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)) diff --git a/pkg/definition/defkit/trait_test.go b/pkg/definition/defkit/trait_test.go index b5c10568d..116c3f0e3 100644 --- a/pkg/definition/defkit/trait_test.go +++ b/pkg/definition/defkit/trait_test.go @@ -1597,8 +1597,8 @@ template: { }) }) - Context("PatchContainer no _baseContainer singular", func() { - It("should use _baseContainers (plural) not _baseContainer (singular)", func() { + Context("PatchContainer base container definitions", func() { + It("should generate _baseContainers (plural) and _baseContainer (singular) fields", func() { trait := defkit.NewTrait("base-containers-test"). Description("Test base containers plural"). AppliesTo("deployments.apps"). @@ -1615,7 +1615,7 @@ template: { cue := trait.ToCue() Expect(cue).To(ContainSubstring("_baseContainers: context.output.spec.template.spec.containers")) - Expect(cue).NotTo(ContainSubstring("_baseContainer:")) + Expect(cue).To(ContainSubstring("_baseContainer: *_|_ | {...}")) }) }) @@ -1723,5 +1723,85 @@ template: { Expect(trait.IsControlPlaneOnly()).To(BeTrue()) Expect(trait.IsRevisionEnabled()).To(BeTrue()) }) + + It("should NOT emit controlPlaneOnly in ToYAML when not set", func() { + yamlBytes, err := defkit.NewTrait("t").ToYAML() + Expect(err).NotTo(HaveOccurred()) + Expect(string(yamlBytes)).NotTo(ContainSubstring("controlPlaneOnly")) + }) + + It("should NOT emit revisionEnabled in ToYAML when not set", func() { + yamlBytes, err := defkit.NewTrait("t").ToYAML() + Expect(err).NotTo(HaveOccurred()) + Expect(string(yamlBytes)).NotTo(ContainSubstring("revisionEnabled")) + }) + + It("should emit manageWorkload: true in CUE attributes when set", func() { + cue := defkit.NewTrait("t").ManageWorkload().ToCue() + Expect(cue).To(ContainSubstring("manageWorkload: true")) + }) + + It("should NOT emit manageWorkload in CUE attributes when not set", func() { + cue := defkit.NewTrait("t").ToCue() + Expect(cue).NotTo(ContainSubstring("manageWorkload")) + }) + + It("should emit controlPlaneOnly: true in CUE attributes when set", func() { + cue := defkit.NewTrait("t").ControlPlaneOnly().ToCue() + Expect(cue).To(ContainSubstring("controlPlaneOnly: true")) + }) + + It("should NOT emit controlPlaneOnly in CUE attributes when not set", func() { + cue := defkit.NewTrait("t").ToCue() + Expect(cue).NotTo(ContainSubstring("controlPlaneOnly")) + }) + + It("should emit revisionEnabled: true in CUE attributes when set", func() { + cue := defkit.NewTrait("t").RevisionEnabled().ToCue() + Expect(cue).To(ContainSubstring("revisionEnabled: true")) + }) + + It("should NOT emit revisionEnabled in CUE attributes when not set", func() { + cue := defkit.NewTrait("t").ToCue() + Expect(cue).NotTo(ContainSubstring("revisionEnabled")) + }) + }) + + Context("Status Block CUE Render", func() { + It("should render statusDetails in trait CUE", func() { + cue := defkit.NewTrait("t").StatusDetails("phase: context.output.status.phase").ToCue() + Expect(cue).To(ContainSubstring("status:")) + Expect(cue).To(ContainSubstring("statusDetails:")) + Expect(cue).To(ContainSubstring("phase: context.output.status.phase")) + }) + + It("should render customStatus in trait CUE", func() { + cue := defkit.NewTrait("t").CustomStatus("message: \"Running\"").ToCue() + Expect(cue).To(ContainSubstring("status:")) + Expect(cue).To(ContainSubstring("customStatus:")) + }) + + It("should render healthPolicy in trait CUE", func() { + cue := defkit.NewTrait("t").HealthPolicy("isHealth: true").ToCue() + Expect(cue).To(ContainSubstring("status:")) + Expect(cue).To(ContainSubstring("healthPolicy:")) + }) + + It("should omit status block when none set", func() { + cue := defkit.NewTrait("t").ToCue() + Expect(cue).NotTo(ContainSubstring("status:")) + }) + + It("should render all three status fields together", func() { + cue := defkit.NewTrait("t"). + CustomStatus("message: \"Running\""). + HealthPolicy("isHealth: true"). + StatusDetails("phase: context.output.status.phase"). + ToCue() + Expect(cue).To(ContainSubstring("status:")) + Expect(cue).To(ContainSubstring("customStatus:")) + Expect(cue).To(ContainSubstring("healthPolicy:")) + Expect(cue).To(ContainSubstring("statusDetails:")) + }) }) }) diff --git a/pkg/definition/defkit/types.go b/pkg/definition/defkit/types.go index bd6e8eade..02cd9e085 100644 --- a/pkg/definition/defkit/types.go +++ b/pkg/definition/defkit/types.go @@ -91,4 +91,6 @@ const ( ParamTypeEnum ParamType = "enum" // ParamTypeOneOf represents a discriminated union parameter ParamTypeOneOf ParamType = "oneof" + // ParamTypeClosedUnion represents a closed struct disjunction parameter + ParamTypeClosedUnion ParamType = "closedunion" ) diff --git a/pkg/definition/defkit/workflow_step.go b/pkg/definition/defkit/workflow_step.go index 1b62a7fba..65e323c77 100644 --- a/pkg/definition/defkit/workflow_step.go +++ b/pkg/definition/defkit/workflow_step.go @@ -58,6 +58,7 @@ type BuiltinAction struct { name string // e.g., "multicluster.#Deploy", "builtin.#Suspend" params map[string]Value // parameters to pass useFullParam bool // if true, generates $params: parameter instead of $params: { key: value } + directFields bool // if true, renders fields directly without $params wrapper (for op.# operations) } func (b *BuiltinAction) isWorkflowAction() {} @@ -78,6 +79,17 @@ type ConditionalAction struct { func (c *ConditionalAction) isWorkflowAction() {} +// GuardedBlockAction represents a field that always exists with a guard condition inside. +// Generates: name: { if cond { ...contents... } } +// Unlike ConditionalAction which generates: if cond { name: { ...contents... } } +type GuardedBlockAction struct { + cond Condition + name string + value Value +} + +func (g *GuardedBlockAction) isWorkflowAction() {} + // NewWorkflowStep creates a new WorkflowStepDefinition builder. func NewWorkflowStep(name string) *WorkflowStepDefinition { return &WorkflowStepDefinition{ @@ -365,6 +377,19 @@ func (wt *WorkflowStepTemplate) SetIf(cond Condition, name string, value Value) return wt } +// SetGuardedBlock assigns a value to a field with the guard condition placed inside +// the field block, so the field always exists (possibly empty). +// Generates: name: { if cond { ...contents... } } +// Unlike SetIf which generates: if cond { name: { ...contents... } } +func (wt *WorkflowStepTemplate) SetGuardedBlock(cond Condition, name string, value Value) *WorkflowStepTemplate { + wt.actions = append(wt.actions, &GuardedBlockAction{ + cond: cond, + name: name, + value: value, + }) + return wt +} + // Suspend adds a suspend action. // Example: tpl.Suspend("Waiting for approval") func (wt *WorkflowStepTemplate) Suspend(message string) *WorkflowStepTemplate { @@ -416,6 +441,14 @@ func (b *BuiltinActionBuilder) WithFullParameter() *BuiltinActionBuilder { return b } +// WithDirectFields renders fields directly on the struct without the $params wrapper. +// This is used for op.# operations (e.g., op.#ShareCloudResource, op.#DeployCloudResource) +// that take fields as direct struct members rather than inside $params. +func (b *BuiltinActionBuilder) WithDirectFields() *BuiltinActionBuilder { + b.action.directFields = true + return b +} + // Build finalizes the action and adds it to the template. func (b *BuiltinActionBuilder) Build() *WorkflowStepTemplate { b.template.actions = append(b.template.actions, b.action) @@ -566,8 +599,11 @@ func (g *WorkflowStepCUEGenerator) GenerateTemplate(w *WorkflowStepDefinition) s sb.WriteString("\n") } - // Generate parameter section - sb.WriteString(g.generateParameterBlock(w, 1)) + // Generate parameter section (skip if no params and raw body is set, + // e.g. step-group which uses TemplateBody with no parameter references) + if len(w.GetParams()) > 0 || !w.HasRawTemplateBody() { + sb.WriteString(g.generateParameterBlock(w, 1)) + } if w.GetCustomStatus() != "" || w.GetHealthPolicy() != "" || w.GetStatusDetails() != "" { indent := g.indent @@ -622,6 +658,8 @@ func (g *WorkflowStepCUEGenerator) writeActions(sb *strings.Builder, wt *Workflo g.writeValueAction(sb, value, "\t", indent, gen) } sb.WriteString(fmt.Sprintf("%s}\n", indent)) + case *GuardedBlockAction: + g.writeGuardedBlockAction(sb, a, indent, gen) } } } @@ -636,10 +674,16 @@ func (g *WorkflowStepCUEGenerator) writeBuiltinAction(sb *strings.Builder, a *Bu } sb.WriteString(fmt.Sprintf("%s%s%s: %s & {\n", indent, extraIndent, actionName, a.name)) - if a.useFullParam { + switch { + case a.useFullParam: // Pass the entire parameter object as $params (e.g., builtin.#Suspend) sb.WriteString(fmt.Sprintf("%s%s\t$params: parameter\n", indent, extraIndent)) - } else if len(a.params) > 0 { + case a.directFields && len(a.params) > 0: + // Render fields directly without $params wrapper (for op.# operations) + for paramName, paramVal := range a.params { + sb.WriteString(fmt.Sprintf("%s%s\t%s: %s\n", indent, extraIndent, paramName, gen.valueToCUE(paramVal))) + } + case len(a.params) > 0: sb.WriteString(fmt.Sprintf("%s%s\t$params: {\n", indent, extraIndent)) for paramName, paramVal := range a.params { sb.WriteString(fmt.Sprintf("%s%s\t\t%s: %s\n", indent, extraIndent, paramName, gen.valueToCUE(paramVal))) @@ -658,6 +702,63 @@ func (g *WorkflowStepCUEGenerator) writeValueAction(sb *strings.Builder, a *Valu sb.WriteString(fmt.Sprintf("%s%s%s: %s\n", indent, extraIndent, name, gen.valueToCUE(a.value))) } +// writeGuardedBlockAction writes a field that always exists with a guard condition inside. +// Output: name: { if cond { ...value contents... } } +func (g *WorkflowStepCUEGenerator) writeGuardedBlockAction(sb *strings.Builder, a *GuardedBlockAction, indent string, gen *CUEGenerator) { + name := a.name + if strings.ContainsAny(name, "-./") { + name = fmt.Sprintf("%q", name) + } + innerIndent := indent + g.indent + condStr := gen.conditionToCUE(a.cond) + valStr := gen.valueToCUE(a.value) + + sb.WriteString(fmt.Sprintf("%s%s: {\n", indent, name)) + sb.WriteString(fmt.Sprintf("%sif %s {\n", innerIndent, condStr)) + + // If the value is a block (starts with { and ends with }), strip the outer braces + // and re-indent the inner content, preserving relative indentation. + trimmed := strings.TrimSpace(valStr) + if strings.HasPrefix(trimmed, "{") && strings.HasSuffix(trimmed, "}") { + // Strip outer braces + inner := trimmed[1 : len(trimmed)-1] + lines := strings.Split(inner, "\n") + + // Find minimum indentation of non-empty lines to dedent by + minIndent := -1 + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + indent := len(line) - len(strings.TrimLeft(line, " \t")) + if minIndent < 0 || indent < minIndent { + minIndent = indent + } + } + if minIndent < 0 { + minIndent = 0 + } + + // Re-emit lines with relative indentation preserved + targetIndent := innerIndent + "\t" + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + // Strip common leading indent, then prepend target indent + if len(line) >= minIndent { + line = line[minIndent:] + } + sb.WriteString(fmt.Sprintf("%s%s\n", targetIndent, line)) + } + } else { + sb.WriteString(fmt.Sprintf("%s\t%s\n", innerIndent, trimmed)) + } + + sb.WriteString(fmt.Sprintf("%s}\n", innerIndent)) + sb.WriteString(fmt.Sprintf("%s}\n", indent)) +} + // extractActionName extracts a simple action name from a builtin reference. func extractActionName(builtinRef string) string { // "multicluster.#Deploy" -> "deploy" diff --git a/pkg/definition/defkit/workflow_step_test.go b/pkg/definition/defkit/workflow_step_test.go index 2f1ddc75d..ddf804504 100644 --- a/pkg/definition/defkit/workflow_step_test.go +++ b/pkg/definition/defkit/workflow_step_test.go @@ -506,4 +506,123 @@ template: { Expect(yaml).To(ContainSubstring("Actual Description")) }) }) + + Context("SetGuardedBlock", func() { + It("should preserve nested indentation for multi-line values", func() { + step := defkit.NewWorkflowStep("notifier"). + Description("test"). + WithImports("vela/http", "vela/kube"). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.SetGuardedBlock( + defkit.PathExists("parameter.channel"), + "action", + defkit.NewArrayElement(). + SetIf(defkit.PathExists("parameter.channel.url"), "post1", + defkit.HTTPPost(defkit.Reference("parameter.channel.url")). + Body(defkit.Reference("json.Marshal(parameter.channel.msg)")). + Header("Content-Type", "application/json"), + ). + SetIf(defkit.PathExists("parameter.channel.secret"), "read", + defkit.KubeRead("v1", "Secret"). + Name(defkit.Reference("parameter.channel.secret.name")). + Namespace(defkit.Reference("context.namespace")), + ), + ) + }) + + cue := step.ToCue() + + // The HTTPDo block should be indented deeper than the if block + Expect(cue).To(ContainSubstring("action: {")) + Expect(cue).To(ContainSubstring("if parameter.channel != _|_ {")) + + // Verify nested indentation: $params must be indented inside http.#HTTPDo + lines := strings.Split(cue, "\n") + var httpDoLine, paramsLine, methodLine int + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "post1: http.#HTTPDo") { + httpDoLine = i + } + if strings.HasPrefix(trimmed, "$params: {") && httpDoLine > 0 && paramsLine == 0 { + paramsLine = i + } + if strings.HasPrefix(trimmed, "method: \"POST\"") && paramsLine > 0 && methodLine == 0 { + methodLine = i + } + } + Expect(httpDoLine).To(BeNumerically(">", 0), "should find post1: http.#HTTPDo line") + Expect(paramsLine).To(BeNumerically(">", httpDoLine), "should find $params after http.#HTTPDo") + Expect(methodLine).To(BeNumerically(">", paramsLine), "should find method after $params") + + // Verify each level is indented one more tab than its parent + httpDoIndent := len(lines[httpDoLine]) - len(strings.TrimLeft(lines[httpDoLine], "\t")) + paramsIndent := len(lines[paramsLine]) - len(strings.TrimLeft(lines[paramsLine], "\t")) + methodIndent := len(lines[methodLine]) - len(strings.TrimLeft(lines[methodLine], "\t")) + Expect(paramsIndent).To(Equal(httpDoIndent + 1)) + Expect(methodIndent).To(Equal(paramsIndent + 1)) + }) + + It("should handle simple non-block values", func() { + step := defkit.NewWorkflowStep("simple"). + Description("test"). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.SetGuardedBlock( + defkit.PathExists("parameter.x"), + "result", + defkit.Reference("parameter.x"), + ) + }) + + cue := step.ToCue() + Expect(cue).To(ContainSubstring("result: {")) + Expect(cue).To(ContainSubstring("if parameter.x != _|_ {")) + Expect(cue).To(ContainSubstring("parameter.x")) + }) + + It("should produce correct kube.#Read indentation inside guarded block", func() { + step := defkit.NewWorkflowStep("reader"). + Description("test"). + WithImports("vela/kube"). + Template(func(tpl *defkit.WorkflowStepTemplate) { + tpl.SetGuardedBlock( + defkit.PathExists("parameter.target"), + "fetch", + defkit.NewArrayElement(). + Set("read", defkit.KubeRead("v1", "Secret"). + Name(defkit.Reference("parameter.target.name")). + Namespace(defkit.Reference("context.namespace")), + ), + ) + }) + + cue := step.ToCue() + lines := strings.Split(cue, "\n") + + // Find the kube.#Read line and metadata line + var readLine, metadataLine, nameLine int + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "read: kube.#Read") { + readLine = i + } + if strings.HasPrefix(trimmed, "metadata: {") && readLine > 0 && metadataLine == 0 { + metadataLine = i + } + if strings.HasPrefix(trimmed, "name:") && metadataLine > 0 && nameLine == 0 { + nameLine = i + } + } + Expect(readLine).To(BeNumerically(">", 0)) + Expect(metadataLine).To(BeNumerically(">", readLine)) + Expect(nameLine).To(BeNumerically(">", metadataLine)) + + // Verify progressive indentation + readIndent := len(lines[readLine]) - len(strings.TrimLeft(lines[readLine], "\t")) + metaIndent := len(lines[metadataLine]) - len(strings.TrimLeft(lines[metadataLine], "\t")) + nameIndent := len(lines[nameLine]) - len(strings.TrimLeft(lines[nameLine], "\t")) + Expect(metaIndent).To(BeNumerically(">", readIndent)) + Expect(nameIndent).To(BeNumerically(">", metaIndent)) + }) + }) })