Skip to content

feat(wfctl): deployment state tracking, config diff, and resource correlation#166

Merged
intel352 merged 1 commit intomainfrom
feat/deploy-state-and-diff
Feb 25, 2026
Merged

feat(wfctl): deployment state tracking, config diff, and resource correlation#166
intel352 merged 1 commit intomainfrom
feat/deploy-state-and-diff

Conversation

@intel352
Copy link
Contributor

Summary

  • Adds deployment.state.json tracking that records deployed modules and pipelines with stateful flags, resource IDs, config snapshots, and migration history after each deploy
  • Adds wfctl diff <old.yaml> <new.yaml> command that compares two workflow configs, shows added/removed/changed modules and pipelines, flags breaking changes on stateful resources, and supports both text and JSON output
  • Adds resource correlation logic that maps module types (storage.sqlite, messaging.broker, etc.) to infrastructure resource kinds (database, broker, cache, volume, stateless) and detects data-loss risks when stateful config keys change

New Files

  • cmd/wfctl/deploy_state.goDeploymentState struct, SaveState/LoadState, BuildStateFromConfig
  • cmd/wfctl/resource_correlation.goClassifyModule, IsStateful, GenerateResourceID, DetectBreakingChanges
  • cmd/wfctl/diff.gorunDiff command with text/JSON output and -check-breaking flag

Test plan

  • go build ./cmd/wfctl/ passes with no errors
  • go test ./cmd/wfctl/... passes — all tests green including 56 new test cases
  • wfctl diff -h shows usage
  • wfctl diff old.yaml new.yaml produces a human-readable diff
  • wfctl diff -format json old.yaml new.yaml produces structured JSON
  • wfctl diff -check-breaking old.yaml new.yaml exits non-zero when stateful module config changes
  • wfctl diff -state deployment.state.json old.yaml new.yaml correlates resource IDs from state

🤖 Generated with Claude Code

… correlation

- cmd/wfctl/deploy_state.go: DeploymentState struct, SaveState/LoadState, BuildStateFromConfig;
  writes deployment.state.json tracking deployed modules/pipelines with stateful flags and resource IDs
- cmd/wfctl/resource_correlation.go: ClassifyModule, IsStateful, GenerateResourceID, DetectBreakingChanges;
  maps workflow module types to infrastructure resource kinds and identifies data-loss risks
- cmd/wfctl/diff.go: wfctl diff command compares two config files, shows added/removed/changed modules
  and pipelines, flags breaking changes on stateful resources, supports text and JSON output
- cmd/wfctl/main.go: register diff command and update usage string
- Comprehensive tests for all three new files (56 new test cases)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 25, 2026 07:06
@intel352 intel352 merged commit 42506a4 into main Feb 25, 2026
14 of 15 checks passed
@intel352 intel352 deleted the feat/deploy-state-and-diff branch February 25, 2026 07:06
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds new wfctl CLI capabilities to help operators understand config changes and track deployed resources over time, including resource-kind correlation and breaking-change detection.

Changes:

  • Introduces deployment state manifest types/helpers (deployment.state.json) plus resource classification and breaking-change detection utilities.
  • Adds wfctl diff <old.yaml> <new.yaml> with text/JSON output, optional state correlation, and a -check-breaking mode.
  • Extends wfctl command registry/usage to include the new diff command.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
cmd/wfctl/main.go Registers the new diff subcommand and updates CLI help text.
cmd/wfctl/diff.go Implements config/pipeline diffing, rendering (text/JSON), and breaking-change checks.
cmd/wfctl/diff_test.go Adds unit/integration coverage for config diffs, pipeline diffs, JSON, and runDiff behaviors.
cmd/wfctl/deploy_state.go Defines deployment-state schema plus save/load/build helpers for state correlation.
cmd/wfctl/deploy_state_test.go Tests state save/load round-trip and state construction from configs.
cmd/wfctl/resource_correlation.go Implements module→resource-kind mapping, statefulness, resource IDs, and breaking-change detection.
cmd/wfctl/resource_correlation_test.go Tests classification/statefulness/resource IDs and breaking-change detection behavior.

d.Type = oldMod.Type
d.Stateful = IsStateful(oldMod.Type)
if d.Stateful {
d.Detail = "REMOVED — WARNING: stateful resource may still hold data"
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-check-breaking is documented as warning on “removed stateful modules”, but stateful removals currently only set Detail and do not populate BreakingChanges/result.BreakingChanges. As a result, wfctl diff -check-breaking will exit 0 even if a stateful module is removed. Please treat stateful removals as breaking changes (e.g., add a BreakingChange entry when d.Stateful is true in the removed case).

Suggested change
d.Detail = "REMOVED — WARNING: stateful resource may still hold data"
d.Detail = "REMOVED — WARNING: stateful resource may still hold data"
d.BreakingChanges = append(d.BreakingChanges, BreakingChange{
Field: "module",
OldValue: oldMod.Type,
NewValue: nil,
})

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +68
default:
printDiffText(result)
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The -format flag help says “text or json”, but unknown values currently fall through to the text renderer. This makes typos silently succeed with unexpected output. Consider validating *format and returning an error for unsupported formats (while still supporting text explicitly).

Suggested change
default:
printDiffText(result)
case "text":
printDiffText(result)
default:
return fmt.Errorf("unsupported format %q (valid formats: text, json)", *format)

Copilot uses AI. Check for mistakes.
Comment on lines +398 to +412
// isConfigChanged returns true if the two config maps differ in any key or value.
func isConfigChanged(oldCfg, newCfg map[string]any) bool {
if len(oldCfg) != len(newCfg) {
return true
}
for k, ov := range oldCfg {
nv, ok := newCfg[k]
if !ok {
return true
}
if fmt.Sprintf("%v", ov) != fmt.Sprintf("%v", nv) {
return true
}
}
return false
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isConfigChanged compares values via fmt.Sprintf("%v", ...). For nested maps, Go’s map iteration order is randomized, so two semantically identical maps can stringify differently and be reported as changed (or vice versa). Prefer a deterministic deep comparison (e.g., reflect.DeepEqual, or JSON-marshal both sides with stable key ordering) to avoid flaky/incorrect diffs.

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +100
if err == nil {
configHash = "sha256:" + h
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildStateFromConfig silently ignores hashing errors when configFile is set (it just leaves ConfigHash empty). For a deployment-state manifest, a missing/incorrect hash is likely actionable and should probably fail the build/state generation (or at least return/log a clear error) so operators don’t think the state corresponds to a config when it doesn’t.

Suggested change
if err == nil {
configHash = "sha256:" + h
}
if err != nil {
return nil, fmt.Errorf("failed to compute hash for config file %q: %w", configFile, err)
}
configHash = "sha256:" + h

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +131
// Deep-copy the config map so mutations to the original don't bleed in.
var cfgCopy map[string]any
if len(mod.Config) > 0 {
cfgCopy = make(map[string]any, len(mod.Config))
for k, v := range mod.Config {
cfgCopy[k] = v
}
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says “Deep-copy the config map”, but the implementation only shallow-copies the top-level map; nested maps/slices remain shared and can still be mutated from the original config. Either update the comment to match reality or perform a true deep copy (e.g., via JSON round-trip) to ensure the snapshot is isolated.

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +46
// DeploymentState is the full state manifest written after a successful deploy.
// It is serialised to deployment.state.json alongside the workflow config.
type DeploymentState struct {
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description and the comment here claim the deployment state is written “after a successful deploy” / “alongside the workflow config”, but the current deploy command implementation doesn’t call BuildStateFromConfig/SaveState anywhere (and these functions are otherwise unused). Either wire state generation into wfctl deploy ... or adjust the PR description/docs to match what’s actually delivered.

Copilot uses AI. Check for mistakes.
}

// moduleTypeLabel returns a short human-readable label for a module type,
// stripping well-known prefixes to keep output concise.
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moduleTypeLabel’s comment says it “stripp[es] well-known prefixes”, but the implementation only TrimSpaces. Either implement the intended prefix-stripping or update the comment to avoid misleading callers about output format.

Suggested change
// stripping well-known prefixes to keep output concise.
// trimming surrounding whitespace to keep output concise.

Copilot uses AI. Check for mistakes.
// diffConfigs produces a DiffResult comparing two workflow configs.
// state is optional; when provided, resource IDs are correlated from it.
func diffConfigs(oldCfg, newCfg *config.WorkflowConfig, state *DeploymentState) DiffResult {
result := DiffResult{}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

diffConfigs leaves DiffResult.OldConfig and DiffResult.NewConfig empty, so JSON output from wfctl diff -format json won’t indicate which files were compared. Consider setting these in runDiff (e.g., from oldPath/newPath) before encoding/printing the result.

Suggested change
result := DiffResult{}
result := DiffResult{
OldConfig: "old",
NewConfig: "new",
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants