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 <ayushshyamkumar888@gmail.com>

* feat: enhance CUE generation with deduplication and inner braces for array elements

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* feat: enhance WorkflowStep parameter handling in CUE generation

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* feat: add WithDirectFields method to BuiltinActionBuilder for direct field rendering

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* feat: streamline string formatting in CUE generation for improved readability

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* feat: enhance CUE generation with deduplication support and improve test assertions

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* feat: add support for ClosedUnion and condition rendering in CUE generation

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* ci: retrigger checks

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* feat: refactor value rendering in CUE generation to improve builder support

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* feat: add GuardedBlockAction and corresponding CUE generation support

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* feat: update writeOneOfParam and writeStructField to use marker instead of optional

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* feat: add ClosedUnionParam support and update related tests for optionality

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* feat: update parameter definitions to remove required constraints in example code

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* feat: enhance CUE generation with multiline value indentation and improved block handling

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* feat: add baseContainer definition to CUE generation for container patterns

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

* 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 <ayushshyamkumar888@gmail.com>

* ci: retrigger checks

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>

---------

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
This commit is contained in:
Ayush Kumar
2026-03-17 09:20:48 -07:00
committed by GitHub
parent c6ee19ee45
commit 401ad98a37
13 changed files with 1771 additions and 20 deletions
+4 -4
View File
@@ -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
`
+142 -9
View File
@@ -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 "_"
+301
View File
@@ -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:"))
})
})
})
+82
View File
@@ -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))
})
})
})
+348
View File
@@ -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))
}
+418
View File
@@ -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"`))
})
})
})
+72
View File
@@ -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.
+94
View File
@@ -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))
})
})
})
+1
View File
@@ -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))
+83 -3
View File
@@ -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:"))
})
})
})
+2
View File
@@ -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"
)
+105 -4
View File
@@ -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"
+119
View File
@@ -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))
})
})
})