Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,36 @@ value: '{{ step "parse-request" "path_params" "id" }}'
value: '{{ index .steps "parse-request" "path_params" "id" }}'
```

`wfctl template validate --config workflow.yaml` lints template expressions and warns on undefined step references, forward references, and suggests the `step` function for hyphenated names.
#### Missing Key Behaviour

By default, template expressions that reference a missing map key (e.g. a typo in a step field name) resolve to the zero value silently — but the engine now logs a **WARN** message to make the problem visible:

```
WARN template resolved missing key to zero value pipeline=my-pipeline error="..."
```

To turn the warning into a hard error, set `strict_templates: true` on the pipeline:

```yaml
pipelines:
my-pipeline:
strict_templates: true # any missing key access fails the pipeline step
steps:
- name: process
type: step.set
config:
values:
tenant: "{{ .steps.auth.affilate_id }}" # typo: affilate_id instead of affiliate_id → step fails immediately
```

| Mode | `strict_templates` | Missing key result |
|------|-------------------|--------------------|
| Default | `false` | Zero value (`<no value>`) + WARN log |
| Strict | `true` | Step returns an error |

Strict mode applies to **both** direct dot-access (`{{ .steps.auth.field }}`) and the `step`/`trigger` helper functions (`{{ step "auth" "field" }}`). A missing key via either syntax will fail the step when `strict_templates: true` is set.

`wfctl template validate --config workflow.yaml` lints template expressions and warns on undefined step references and forward references. Use `strict_templates: true` in the pipeline config to catch field-level typos at runtime.
### Infrastructure
| Type | Description | Plugin |
|------|-------------|--------|
Expand Down
20 changes: 10 additions & 10 deletions cmd/wfctl/infra_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,11 @@ func runInfraStateImport(args []string) error {
// --- tfstate export ---

type tfState struct {
Version int `json:"version"`
TerraformVersion string `json:"terraform_version"`
Serial int `json:"serial"`
Lineage string `json:"lineage"`
Outputs map[string]any `json:"outputs"`
Version int `json:"version"`
TerraformVersion string `json:"terraform_version"`
Serial int `json:"serial"`
Lineage string `json:"lineage"`
Outputs map[string]any `json:"outputs"`
Resources []tfStateResource `json:"resources"`
}

Expand Down Expand Up @@ -304,11 +304,11 @@ func importFromPulumi(srcFile, stateDir string) error {
var checkpoint struct {
Latest struct {
Resources []struct {
URN string `json:"urn"`
Type string `json:"type"`
ID string `json:"id"`
Inputs map[string]any `json:"inputs"`
Outputs map[string]any `json:"outputs"`
URN string `json:"urn"`
Type string `json:"type"`
ID string `json:"id"`
Inputs map[string]any `json:"inputs"`
Outputs map[string]any `json:"outputs"`
} `json:"resources"`
} `json:"latest"`
}
Expand Down
48 changes: 24 additions & 24 deletions cmd/wfctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,30 +53,30 @@ func isHelpRequested(err error) bool {
// the runtime functions that are registered in the CLICommandRegistry service
// and invoked by step.cli_invoke from within each command's pipeline.
var commands = map[string]func([]string) error{
"init": runInit,
"validate": runValidate,
"inspect": runInspect,
"run": runRun,
"plugin": runPlugin,
"pipeline": runPipeline,
"schema": runSchema,
"snippets": runSnippets,
"manifest": runManifest,
"migrate": runMigrate,
"build-ui": runBuildUI,
"ui": runUI,
"publish": runPublish,
"deploy": runDeploy,
"api": runAPI,
"diff": runDiff,
"template": runTemplate,
"contract": runContract,
"compat": runCompat,
"generate": runGenerate,
"git": runGit,
"registry": runRegistry,
"update": runUpdate,
"mcp": runMCP,
"init": runInit,
"validate": runValidate,
"inspect": runInspect,
"run": runRun,
"plugin": runPlugin,
"pipeline": runPipeline,
"schema": runSchema,
"snippets": runSnippets,
"manifest": runManifest,
"migrate": runMigrate,
"build-ui": runBuildUI,
"ui": runUI,
"publish": runPublish,
"deploy": runDeploy,
"api": runAPI,
"diff": runDiff,
"template": runTemplate,
"contract": runContract,
"compat": runCompat,
"generate": runGenerate,
"git": runGit,
"registry": runRegistry,
"update": runUpdate,
"mcp": runMCP,
"modernize": runModernize,
"infra": runInfra,
"docs": runDocs,
Expand Down
8 changes: 4 additions & 4 deletions cmd/wfctl/plugin_install_new_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ func buildPluginTarGz(t *testing.T, pluginName string, binaryContent []byte, pjC
t.Helper()
topDir := pluginName + "-" + runtime.GOOS + "-" + runtime.GOARCH
entries := map[string][]byte{
topDir + "/" + pluginName: binaryContent,
topDir + "/plugin.json": pjContent,
topDir + "/" + pluginName: binaryContent,
topDir + "/plugin.json": pjContent,
}
return buildTarGz(t, entries, 0755)
}
Expand Down Expand Up @@ -166,8 +166,8 @@ func TestInstallFromURL_NameNormalization(t *testing.T) {

pjContent := minimalPluginJSON(fullName, "0.1.0")
entries := map[string][]byte{
"top/" + fullName: []byte("#!/bin/sh\n"),
"top/plugin.json": pjContent,
"top/" + fullName: []byte("#!/bin/sh\n"),
"top/plugin.json": pjContent,
}
tarball := buildTarGz(t, entries, 0755)

Expand Down
12 changes: 6 additions & 6 deletions cmd/wfctl/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,9 @@ type testMockConfig struct {
}

type testCase struct {
Description string `yaml:"description"`
Trigger testTriggerDef `yaml:"trigger"`
StopAfter string `yaml:"stop_after"`
Description string `yaml:"description"`
Trigger testTriggerDef `yaml:"trigger"`
StopAfter string `yaml:"stop_after"`
Mocks *testMockConfig `yaml:"mocks"`
Assertions []testAssertion `yaml:"assertions"`
}
Expand All @@ -262,9 +262,9 @@ type testTriggerDef struct {
}

type testAssertion struct {
Step string `yaml:"step"`
Output map[string]any `yaml:"output"`
Executed *bool `yaml:"executed"`
Step string `yaml:"step"`
Output map[string]any `yaml:"output"`
Executed *bool `yaml:"executed"`
Response *testResponseAssert `yaml:"response"`
}

Expand Down
5 changes: 5 additions & 0 deletions config/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ type PipelineConfig struct {
OnError string `json:"on_error,omitempty" yaml:"on_error,omitempty"`
Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty"`
Compensation []PipelineStepConfig `json:"compensation,omitempty" yaml:"compensation,omitempty"`
// StrictTemplates causes the pipeline to return an error when any template
// expression references a missing map key, instead of silently using the zero
// value. Useful for catching typos in step field references at runtime.
// Default is false (missing keys produce a warning log and resolve to zero).
StrictTemplates bool `json:"strict_templates,omitempty" yaml:"strict_templates,omitempty"`
}

// PipelineTriggerConfig defines what starts a pipeline.
Expand Down
13 changes: 6 additions & 7 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ type StdEngine struct {
// configHash is the SHA-256 hash of the last config built via BuildFromConfig.
// Format: "sha256:<hex>". Empty until BuildFromConfig is called.
configHash string

}

// App returns the underlying modular.Application.
Expand Down Expand Up @@ -142,7 +141,6 @@ func (e *StdEngine) SetPluginInstaller(installer *plugin.PluginInstaller) {
e.pluginInstaller = installer
}


// NewStdEngine creates a new workflow engine
func NewStdEngine(app modular.Application, logger modular.Logger) *StdEngine {
e := &StdEngine{
Expand Down Expand Up @@ -854,11 +852,12 @@ func (e *StdEngine) configurePipelines(pipelineCfg map[string]any) error {
}

pipeline := &module.Pipeline{
Name: pipelineName,
Steps: steps,
OnError: onError,
Timeout: timeout,
Compensation: compSteps,
Name: pipelineName,
Steps: steps,
OnError: onError,
Timeout: timeout,
Compensation: compSteps,
StrictTemplates: pipeCfg.StrictTemplates,
}

// Propagate the engine's logger to the pipeline so that execution logs
Expand Down
4 changes: 2 additions & 2 deletions interfaces/iac_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func (m *mockProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.
return nil, nil
}
func (m *mockProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { return nil, nil }
func (m *mockProvider) Close() error { return nil }
func (m *mockProvider) Close() error { return nil }

// mockDriver implements ResourceDriver
type mockDriver struct{}
Expand Down Expand Up @@ -84,7 +84,7 @@ func (s *mockState) GetResource(_ context.Context, _ string) (*interfaces.Resour
func (s *mockState) ListResources(_ context.Context) ([]interfaces.ResourceState, error) {
return nil, nil
}
func (s *mockState) DeleteResource(_ context.Context, _ string) error { return nil }
func (s *mockState) DeleteResource(_ context.Context, _ string) error { return nil }
func (s *mockState) SavePlan(_ context.Context, _ interfaces.IaCPlan) error { return nil }
func (s *mockState) GetPlan(_ context.Context, _ string) (*interfaces.IaCPlan, error) {
return nil, nil
Expand Down
11 changes: 11 additions & 0 deletions interfaces/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ type PipelineContext struct {

// Metadata holds execution metadata (pipeline name, trace ID, etc.)
Metadata map[string]any

// StrictTemplates causes template execution to return an error instead of
// the zero value when a template expression references a missing map key.
// When false (the default), missing keys resolve to the zero value with a
// warning logged via Logger. Enable via pipeline config: strict_templates: true.
StrictTemplates bool

// Logger is used to emit warnings when a template expression resolves a
// missing key to the zero value (non-strict mode). When nil, slog.Default()
// is used.
Logger *slog.Logger
}

// NewPipelineContext creates a PipelineContext initialized with trigger data.
Expand Down
6 changes: 3 additions & 3 deletions module/iac_state_azure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import (

// mockAzureClient is an in-memory implementation of AzureBlobClient for testing.
type mockAzureClient struct {
mu sync.Mutex
blobs map[string][]byte // name -> body
leases map[string]string // name -> leaseID (empty = not leased)
mu sync.Mutex
blobs map[string][]byte // name -> body
leases map[string]string // name -> leaseID (empty = not leased)
}

func newMockAzureClient() *mockAzureClient {
Expand Down
6 changes: 3 additions & 3 deletions module/iac_state_gcs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import (
// mockGCSClient is an in-memory implementation of GCSObjectClient for testing.
type mockGCSClient struct {
mu sync.Mutex
objects map[string][]byte // key -> body
generation map[string]int64 // key -> current generation
errOnPut map[string]error // key -> error to return on conditional Put
objects map[string][]byte // key -> body
generation map[string]int64 // key -> current generation
errOnPut map[string]error // key -> error to return on conditional Put
}

func newMockGCSClient() *mockGCSClient {
Expand Down
8 changes: 4 additions & 4 deletions module/infra_module_deploy_bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func (d *deployCapableDriver) Update(_ context.Context, image string) error {
d.updateCalled = true
return nil
}
func (d *deployCapableDriver) HealthCheck(_ context.Context, _ string) error { return d.healthErr }
func (d *deployCapableDriver) HealthCheck(_ context.Context, _ string) error { return d.healthErr }
func (d *deployCapableDriver) CurrentImage(_ context.Context) (string, error) { return d.image, nil }
func (d *deployCapableDriver) ReplicaCount(_ context.Context) (int, error) { return d.replicas, nil }

Expand Down Expand Up @@ -93,9 +93,9 @@ func (d *deployCapableDriver) DestroyCanary(_ context.Context) error {

type deployProviderMock struct {
*infraMockProvider
deployDriver *deployCapableDriver
bgDriver *deployCapableDriver
canaryDriver *deployCapableDriver
deployDriver *deployCapableDriver
bgDriver *deployCapableDriver
canaryDriver *deployCapableDriver
}

func (p *deployProviderMock) ProvideDeployDriver(_ string) DeployDriver {
Expand Down
42 changes: 21 additions & 21 deletions module/infra_module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,16 @@ func newInfraMockApp() *infraMockApp {
return &infraMockApp{services: make(map[string]any)}
}

func (a *infraMockApp) RegisterConfigSection(string, modular.ConfigProvider) {}
func (a *infraMockApp) RegisterConfigSection(string, modular.ConfigProvider) {}
func (a *infraMockApp) GetConfigSection(string) (modular.ConfigProvider, error) { return nil, nil }
func (a *infraMockApp) ConfigSections() map[string]modular.ConfigProvider {
return nil
}
func (a *infraMockApp) Logger() modular.Logger { return &noopLogger{} }
func (a *infraMockApp) SetLogger(modular.Logger) {}
func (a *infraMockApp) ConfigProvider() modular.ConfigProvider { return nil }
func (a *infraMockApp) SvcRegistry() modular.ServiceRegistry { return a.services }
func (a *infraMockApp) RegisterModule(modular.Module) {}
func (a *infraMockApp) Logger() modular.Logger { return &noopLogger{} }
func (a *infraMockApp) SetLogger(modular.Logger) {}
func (a *infraMockApp) ConfigProvider() modular.ConfigProvider { return nil }
func (a *infraMockApp) SvcRegistry() modular.ServiceRegistry { return a.services }
func (a *infraMockApp) RegisterModule(modular.Module) {}
func (a *infraMockApp) RegisterService(name string, svc any) error {
a.services[name] = svc
return nil
Expand All @@ -46,23 +46,23 @@ func (a *infraMockApp) GetService(name string, target any) error {
}
return nil
}
func (a *infraMockApp) Init() error { return nil }
func (a *infraMockApp) Start() error { return nil }
func (a *infraMockApp) Stop() error { return nil }
func (a *infraMockApp) Run() error { return nil }
func (a *infraMockApp) IsVerboseConfig() bool { return false }
func (a *infraMockApp) SetVerboseConfig(bool) {}
func (a *infraMockApp) Context() context.Context { return context.Background() }
func (a *infraMockApp) GetServicesByModule(string) []string { return nil }
func (a *infraMockApp) Init() error { return nil }
func (a *infraMockApp) Start() error { return nil }
func (a *infraMockApp) Stop() error { return nil }
func (a *infraMockApp) Run() error { return nil }
func (a *infraMockApp) IsVerboseConfig() bool { return false }
func (a *infraMockApp) SetVerboseConfig(bool) {}
func (a *infraMockApp) Context() context.Context { return context.Background() }
func (a *infraMockApp) GetServicesByModule(string) []string { return nil }
func (a *infraMockApp) GetServiceEntry(string) (*modular.ServiceRegistryEntry, bool) {
return nil, false
}
func (a *infraMockApp) GetServicesByInterface(reflect.Type) []*modular.ServiceRegistryEntry {
return nil
}
func (a *infraMockApp) GetModule(string) modular.Module { return nil }
func (a *infraMockApp) GetAllModules() map[string]modular.Module { return nil }
func (a *infraMockApp) StartTime() time.Time { return time.Time{} }
func (a *infraMockApp) GetModule(string) modular.Module { return nil }
func (a *infraMockApp) GetAllModules() map[string]modular.Module { return nil }
func (a *infraMockApp) StartTime() time.Time { return time.Time{} }
func (a *infraMockApp) OnConfigLoaded(func(modular.Application) error) {}

// infraMockProvider implements interfaces.IaCProvider for tests.
Expand Down Expand Up @@ -408,11 +408,11 @@ func TestInfraModule_ProvidesServices(t *testing.T) {

func TestInfraModule_ResourceConfig_StripsStandardKeys(t *testing.T) {
m := NewInfraModule("db", "infra.database", map[string]any{
"provider": "aws",
"size": "m",
"provider": "aws",
"size": "m",
"resources": map[string]any{"cpu": "2"},
"engine": "postgres",
"version": "16",
"engine": "postgres",
"version": "16",
})
cfg := m.ResourceConfig()
if _, ok := cfg["provider"]; ok {
Expand Down
Loading
Loading