Skip to content

Commit eedc88f

Browse files
authored
feat(wfctl): add template validate, contract test, and compat check commands
Adds three new wfctl commands for ensuring template validity and contract stability across releases: - wfctl template validate: validates project templates against engine's known module/step types - wfctl contract test: generates API contracts and detects breaking changes between versions - wfctl compat check: verifies config compatibility with current engine Includes static type registry (~50 module types, ~40 step types) extracted from all plugin packages. 55 new tests.
1 parent 42506a4 commit eedc88f

File tree

9 files changed

+3225
-0
lines changed

9 files changed

+3225
-0
lines changed

cmd/wfctl/compat.go

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"flag"
6+
"fmt"
7+
"os"
8+
"strings"
9+
10+
"github.com/GoCodeAlone/workflow/config"
11+
)
12+
13+
// CompatibilityInfo describes the compatibility requirements and test history of a config.
14+
type CompatibilityInfo struct {
15+
MinEngineVersion string `json:"minEngineVersion"`
16+
MaxEngineVersion string `json:"maxEngineVersion,omitempty"`
17+
RequiredSteps []string `json:"requiredSteps"`
18+
RequiredModules []string `json:"requiredModules"`
19+
TestedVersions []string `json:"testedVersions"`
20+
}
21+
22+
// compatCheckResult holds the result of a compatibility check.
23+
type compatCheckResult struct {
24+
EngineVersion string `json:"engineVersion"`
25+
RequiredModules []compatItem `json:"requiredModules"`
26+
RequiredSteps []compatItem `json:"requiredSteps"`
27+
Compatible bool `json:"compatible"`
28+
Issues []string `json:"issues,omitempty"`
29+
}
30+
31+
// compatItem represents a single required type and whether it's available.
32+
type compatItem struct {
33+
Type string `json:"type"`
34+
Available bool `json:"available"`
35+
}
36+
37+
// runCompat dispatches compat subcommands.
38+
func runCompat(args []string) error {
39+
if len(args) < 1 {
40+
return compatUsage()
41+
}
42+
switch args[0] {
43+
case "check":
44+
return runCompatCheck(args[1:])
45+
default:
46+
return compatUsage()
47+
}
48+
}
49+
50+
func compatUsage() error {
51+
fmt.Fprintf(os.Stderr, `Usage: wfctl compat <subcommand> [options]
52+
53+
Subcommands:
54+
check Check config compatibility with the current engine version
55+
56+
Run 'wfctl compat check -h' for details.
57+
`)
58+
return fmt.Errorf("compat subcommand is required")
59+
}
60+
61+
// runCompatCheck checks a config file for compatibility with the current engine version.
62+
func runCompatCheck(args []string) error {
63+
fs2 := flag.NewFlagSet("compat check", flag.ContinueOnError)
64+
format := fs2.String("format", "text", "Output format: text or json")
65+
fs2.Usage = func() {
66+
fmt.Fprintf(fs2.Output(), `Usage: wfctl compat check [options] <config.yaml>
67+
68+
Check whether a workflow config is compatible with the current engine version.
69+
Reports which module and step types are available in the engine.
70+
71+
Options:
72+
`)
73+
fs2.PrintDefaults()
74+
}
75+
if err := fs2.Parse(args); err != nil {
76+
return err
77+
}
78+
if fs2.NArg() < 1 {
79+
fs2.Usage()
80+
return fmt.Errorf("config.yaml path is required")
81+
}
82+
83+
configPath := fs2.Arg(0)
84+
cfg, err := config.LoadFromFile(configPath)
85+
if err != nil {
86+
return fmt.Errorf("failed to load config: %w", err)
87+
}
88+
89+
result := checkCompatibility(cfg)
90+
91+
switch strings.ToLower(*format) {
92+
case "json":
93+
enc := json.NewEncoder(os.Stdout)
94+
enc.SetIndent("", " ")
95+
return enc.Encode(result)
96+
default:
97+
printCompatResult(result)
98+
}
99+
100+
if !result.Compatible {
101+
return fmt.Errorf("compatibility check failed: %d issue(s)", len(result.Issues))
102+
}
103+
return nil
104+
}
105+
106+
// checkCompatibility checks a config against the current engine's known types.
107+
func checkCompatibility(cfg *config.WorkflowConfig) *compatCheckResult {
108+
knownModules := KnownModuleTypes()
109+
knownSteps := KnownStepTypes()
110+
111+
result := &compatCheckResult{
112+
EngineVersion: version,
113+
Compatible: true,
114+
}
115+
116+
// Check module types
117+
for _, mod := range cfg.Modules {
118+
item := compatItem{
119+
Type: mod.Type,
120+
}
121+
if _, ok := knownModules[mod.Type]; ok {
122+
item.Available = true
123+
} else {
124+
item.Available = false
125+
result.Compatible = false
126+
result.Issues = append(result.Issues, fmt.Sprintf("module type %q is not available in this engine version", mod.Type))
127+
}
128+
result.RequiredModules = append(result.RequiredModules, item)
129+
}
130+
131+
// Deduplicate modules
132+
result.RequiredModules = deduplicateCompatItems(result.RequiredModules)
133+
134+
// Check step types from pipelines
135+
stepSet := make(map[string]bool)
136+
for _, pipelineRaw := range cfg.Pipelines {
137+
pipelineMap, ok := pipelineRaw.(map[string]any)
138+
if !ok {
139+
continue
140+
}
141+
if stepsRaw, ok := pipelineMap["steps"].([]any); ok {
142+
for _, stepRaw := range stepsRaw {
143+
if stepMap, ok := stepRaw.(map[string]any); ok {
144+
if stepType, ok := stepMap["type"].(string); ok && stepType != "" {
145+
stepSet[stepType] = true
146+
}
147+
}
148+
}
149+
}
150+
}
151+
152+
for stepType := range stepSet {
153+
item := compatItem{
154+
Type: stepType,
155+
}
156+
if _, ok := knownSteps[stepType]; ok {
157+
item.Available = true
158+
} else {
159+
item.Available = false
160+
result.Compatible = false
161+
result.Issues = append(result.Issues, fmt.Sprintf("step type %q is not available in this engine version", stepType))
162+
}
163+
result.RequiredSteps = append(result.RequiredSteps, item)
164+
}
165+
166+
// Sort for determinism
167+
sortCompatItems(result.RequiredModules)
168+
sortCompatItems(result.RequiredSteps)
169+
170+
return result
171+
}
172+
173+
// deduplicateCompatItems removes duplicate items, keeping the first occurrence.
174+
func deduplicateCompatItems(items []compatItem) []compatItem {
175+
seen := make(map[string]bool)
176+
var out []compatItem
177+
for _, item := range items {
178+
if !seen[item.Type] {
179+
seen[item.Type] = true
180+
out = append(out, item)
181+
}
182+
}
183+
return out
184+
}
185+
186+
// sortCompatItems sorts compat items by type name.
187+
func sortCompatItems(items []compatItem) {
188+
for i := 1; i < len(items); i++ {
189+
for j := i; j > 0 && items[j].Type < items[j-1].Type; j-- {
190+
items[j], items[j-1] = items[j-1], items[j]
191+
}
192+
}
193+
}
194+
195+
// printCompatResult prints a human-readable compatibility check result.
196+
func printCompatResult(r *compatCheckResult) {
197+
fmt.Printf("Engine version: %s\n", r.EngineVersion)
198+
199+
if len(r.RequiredModules) > 0 {
200+
fmt.Printf("\nRequired modules:\n")
201+
for _, item := range r.RequiredModules {
202+
if item.Available {
203+
fmt.Printf(" %s +\n", item.Type)
204+
} else {
205+
fmt.Printf(" %s (NOT AVAILABLE)\n", item.Type)
206+
}
207+
}
208+
}
209+
210+
if len(r.RequiredSteps) > 0 {
211+
fmt.Printf("\nRequired steps:\n")
212+
for _, item := range r.RequiredSteps {
213+
if item.Available {
214+
fmt.Printf(" %s +\n", item.Type)
215+
} else {
216+
fmt.Printf(" %s (NOT AVAILABLE)\n", item.Type)
217+
}
218+
}
219+
}
220+
221+
if r.Compatible {
222+
fmt.Println("\nCompatibility: PASS")
223+
} else {
224+
fmt.Printf("\nCompatibility: FAIL (%d issue(s))\n", len(r.Issues))
225+
for _, issue := range r.Issues {
226+
fmt.Printf(" - %s\n", issue)
227+
}
228+
}
229+
}

0 commit comments

Comments
 (0)