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
7 changes: 7 additions & 0 deletions cmd/wfctl/plugin_infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"

"github.com/GoCodeAlone/workflow/config"
"github.com/GoCodeAlone/workflow/iac/requirements"
)

// LoadPluginManifests loads all plugin.json files from the given plugins directory.
Expand Down Expand Up @@ -96,3 +97,9 @@ func DetectPluginInfraNeeds(cfg *config.WorkflowConfig, manifests map[string]*co

return needs
}

// DetectPluginRequirementsV2 exposes provider-neutral moduleInfraRequirementsV2
// declarations without changing the legacy DetectPluginInfraNeeds return type.
func DetectPluginRequirementsV2(cfg *config.WorkflowConfig, manifests map[string]*config.PluginManifestFile) ([]requirements.Requirement, error) {
return requirements.DiscoverManifestRequirements(cfg, manifests)
}
40 changes: 40 additions & 0 deletions cmd/wfctl/plugin_infra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"

"github.com/GoCodeAlone/workflow/config"
"github.com/GoCodeAlone/workflow/iac/requirements"
)

func TestLoadPluginManifests_EmptyDir(t *testing.T) {
Expand Down Expand Up @@ -215,3 +216,42 @@ func TestDetectPluginInfraNeeds_ServiceModules(t *testing.T) {
t.Errorf("type: got %q", needs[0].Type)
}
}

func TestDetectPluginRequirementsV2(t *testing.T) {
cfg := &config.WorkflowConfig{
Modules: []config.ModuleConfig{{Name: "telemetry", Type: "observability.telemetry"}},
Services: map[string]*config.ServiceConfig{
"api": {
Binary: "./cmd/api",
Expose: []config.ExposeConfig{{Port: 8080, Protocol: "http"}},
},
},
}
manifests := map[string]*config.PluginManifestFile{
"workflow-plugin-observability": {
Name: "workflow-plugin-observability",
ModuleInfraRequirementsV2: config.PluginInfraRequirementsV2{
"observability.telemetry": {
Requires: []config.ModuleInfraRequirementV2{{
Key: "observability.telemetry.default",
Kind: "observability",
TelemetrySignals: []string{"traces"},
ObservabilityBackends: []string{"otel"},
DeploymentModes: []string{"sidecar"},
}},
},
},
},
}

reqs, err := DetectPluginRequirementsV2(cfg, manifests)
if err != nil {
t.Fatalf("DetectPluginRequirementsV2: %v", err)
}
if len(reqs) != 1 {
t.Fatalf("requirements len = %d, want 1", len(reqs))
}
if reqs[0].Key != "observability.telemetry.default" || reqs[0].Kind != requirements.KindObservability {
t.Fatalf("requirement = %+v", reqs[0])
}
}
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func IsApplicationConfig(data []byte) bool {
type ModuleConfig struct {
Name string `json:"name" yaml:"name"`
Type string `json:"type" yaml:"type"`
Satisfies []string `json:"satisfies,omitempty" yaml:"satisfies,omitempty"`
Config map[string]any `json:"config,omitempty" yaml:"config,omitempty"`
DependsOn []string `json:"dependsOn,omitempty" yaml:"dependsOn,omitempty"`
Branches map[string]string `json:"branches,omitempty" yaml:"branches,omitempty"`
Expand Down
29 changes: 29 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,35 @@ modules:
}
}

func TestLoadFromFile_ModuleSatisfies(t *testing.T) {
content := `
modules:
- name: otel-collector
type: infra.container_service
satisfies:
- observability.telemetry.default
config:
image: otel/opentelemetry-collector-contrib:latest
`
dir := t.TempDir()
fp := filepath.Join(dir, "modules.yaml")
if err := os.WriteFile(fp, []byte(content), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}

cfg, err := LoadFromFile(fp)
if err != nil {
t.Fatalf("LoadFromFile failed: %v", err)
}
if len(cfg.Modules) != 1 {
t.Fatalf("modules len = %d, want 1", len(cfg.Modules))
}
got := cfg.Modules[0].Satisfies
if len(got) != 1 || got[0] != "observability.telemetry.default" {
t.Fatalf("Satisfies = %v, want [observability.telemetry.default]", got)
}
}

func TestExternalPluginDeclParsing(t *testing.T) {
yaml := `
modules: []
Expand Down
39 changes: 34 additions & 5 deletions config/plugin_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,21 @@ package config
// PluginInfraRequirements maps module types to their infrastructure needs.
type PluginInfraRequirements map[string]*ModuleInfraSpec

// PluginInfraRequirementsV2 maps module types to provider-neutral IaC
// requirements. The values intentionally use manifest-friendly strings; the
// iac/requirements package owns typed enum validation and protobuf conversion.
type PluginInfraRequirementsV2 map[string]*ModuleInfraSpecV2

// ModuleInfraSpec declares what a module type requires.
type ModuleInfraSpec struct {
Requires []InfraRequirement `json:"requires" yaml:"requires"`
}

// ModuleInfraSpecV2 declares typed requirement metadata for a module type.
type ModuleInfraSpecV2 struct {
Requires []ModuleInfraRequirementV2 `json:"requires" yaml:"requires"`
}

// InfraRequirement is a single infrastructure dependency.
type InfraRequirement struct {
Type string `json:"type" yaml:"type"`
Expand All @@ -20,13 +30,32 @@ type InfraRequirement struct {
Optional bool `json:"optional,omitempty" yaml:"optional,omitempty"`
}

// ModuleInfraRequirementV2 is the plugin.json authoring shape for derived-IaC
// requirements. It mirrors the portable fields in plugin/external/proto/iac.proto
// using strings so manifests stay easy to read and preserve unknown future
// provider details under Parameters.
type ModuleInfraRequirementV2 struct {
Key string `json:"key" yaml:"key"`
Kind string `json:"kind" yaml:"kind"`
Source string `json:"source,omitempty" yaml:"source,omitempty"`
ResourceTypeHint string `json:"resourceTypeHint,omitempty" yaml:"resourceTypeHint,omitempty"`
Environment string `json:"environment,omitempty" yaml:"environment,omitempty"`
Runtimes []string `json:"runtimes,omitempty" yaml:"runtimes,omitempty"`
TelemetrySignals []string `json:"telemetrySignals,omitempty" yaml:"telemetrySignals,omitempty"`
ObservabilityBackends []string `json:"observabilityBackends,omitempty" yaml:"observabilityBackends,omitempty"`
DeploymentModes []string `json:"deploymentModes,omitempty" yaml:"deploymentModes,omitempty"`
VendorFeatures []string `json:"vendorFeatures,omitempty" yaml:"vendorFeatures,omitempty"`
Parameters map[string]any `json:"parameters,omitempty" yaml:"parameters,omitempty"`
}

// PluginManifestFile represents the full plugin.json manifest.
type PluginManifestFile struct {
Name string `json:"name" yaml:"name"`
Version string `json:"version" yaml:"version"`
Description string `json:"description" yaml:"description"`
Capabilities PluginCapabilities `json:"capabilities" yaml:"capabilities"`
ModuleInfraRequirements PluginInfraRequirements `json:"moduleInfraRequirements,omitempty" yaml:"moduleInfraRequirements,omitempty"`
Name string `json:"name" yaml:"name"`
Version string `json:"version" yaml:"version"`
Description string `json:"description" yaml:"description"`
Capabilities PluginCapabilities `json:"capabilities" yaml:"capabilities"`
ModuleInfraRequirements PluginInfraRequirements `json:"moduleInfraRequirements,omitempty" yaml:"moduleInfraRequirements,omitempty"`
ModuleInfraRequirementsV2 PluginInfraRequirementsV2 `json:"moduleInfraRequirementsV2,omitempty" yaml:"moduleInfraRequirementsV2,omitempty"`
}

// PluginCapabilities describes what module, step, trigger types, build hooks,
Expand Down
63 changes: 63 additions & 0 deletions config/plugin_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,66 @@ func TestPluginManifestNoInfraRequirements(t *testing.T) {
t.Errorf("expected nil ModuleInfraRequirements, got %v", manifest.ModuleInfraRequirements)
}
}

func TestPluginManifestRequirementV2YAML(t *testing.T) {
raw := `
name: workflow-plugin-observability
version: "0.1.2"
description: Observability plugin
capabilities:
moduleTypes:
- observability.telemetry
stepTypes: []
triggerTypes: []
moduleInfraRequirementsV2:
observability.telemetry:
requires:
- key: observability.telemetry.default
kind: observability
source: observability.telemetry
resourceTypeHint: infra.container_service
environment: production
runtimes:
- kubernetes
- digitalocean_app_platform
telemetrySignals:
- traces
- metrics
- logs
observabilityBackends:
- otel
- datadog
deploymentModes:
- sidecar
- sibling_service
vendorFeatures:
- datadog.apm
parameters:
collector: otel
`

var manifest PluginManifestFile
if err := yaml.Unmarshal([]byte(raw), &manifest); err != nil {
t.Fatalf("unmarshal YAML: %v", err)
}
spec := manifest.ModuleInfraRequirementsV2["observability.telemetry"]
if spec == nil {
t.Fatal("expected observability.telemetry v2 infra requirements")
}
if len(spec.Requires) != 1 {
t.Fatalf("Requires len = %d, want 1", len(spec.Requires))
}
req := spec.Requires[0]
if req.Key != "observability.telemetry.default" {
t.Fatalf("Key = %q", req.Key)
}
if req.Kind != "observability" {
t.Fatalf("Kind = %q", req.Kind)
}
if len(req.TelemetrySignals) != 3 {
t.Fatalf("TelemetrySignals = %v", req.TelemetrySignals)
}
if req.Parameters["collector"] != "otel" {
t.Fatalf("Parameters = %v", req.Parameters)
}
}
43 changes: 43 additions & 0 deletions decisions/0043-iac-derived-requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 0043. Derive IaC Through Provider Requirements

**Status:** Accepted
**Date:** 2026-05-25
**Decision-makers:** Workflow maintainers, autonomous pipeline
**Related:** `docs/plans/2026-05-25-iac-derived-requirements-design.md`

## Context

Workflow needs to derive infrastructure for higher-level application and
observability declarations without hard-coding DigitalOcean, AWS, GCP, Azure,
Datadog, Grafana, Prometheus, or Loki behavior in core. Existing
`moduleInfraRequirements` is useful but static and manifest-only. Existing IaC
provider plugins already expose strict typed gRPC services, with optional
services advertised by registration. The user also requires explicit YAML keys
for user-provided overrides and strict proto compatibility where possible,
which means portable requirement concepts should use proto enums rather than a
stringly typed vocabulary.

## Decision

We will add a core requirement model and `wfctl infra derive`, but provider
plugins will own requirement-to-resource mapping through an optional strict-proto
IaC service. Portable requirement fields will be proto enums; JSON bytes and
namespaced string vendor features are allowed only for provider/product
extension data that cannot be modeled generically. Generated modules will
include `satisfies` keys, and manually written modules can use the same keys to
suppress derivation. We reject a provider-specific CLI plugin command because
YAML mutation and cross-provider plugin discovery belong in `wfctl` core. We
reject apply-time derivation because it hides generated infrastructure from
review and CI. We reject core-owned provider mapping because it would recreate
provider-specific assumptions in the framework.

## Consequences

Derivation becomes reviewable, idempotent, and reusable for observability, web
apps, message brokers, databases, caches, and storage. Provider plugins gain a
small but real new compatibility surface and must test their mappings. Workflow
core must maintain a YAML node editor, a stable requirement proto, and secret
safety checks for generated specs. The editor must preserve `modules[].satisfies`
before generated YAML can safely round-trip visually. Older provider plugins keep
working for explicit `infra.*` YAML but cannot derive resources until they
implement the optional mapper service.
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
### Adversarial Review Report

**Phase:** design
**Artifact:** `docs/plans/2026-05-25-iac-derived-requirements-design.md`
**Status:** FAIL

**Findings (Critical):**
- None.

**Findings (Important):**
- [Repo-precedent conflicts / strict proto] `Requirement Model` lines 124-138:
the design says `kind`, `runtime`, and `features` are strings, even though the
user explicitly asked to be strict-proto compatible where possible and the IaC
proto already uses enums for wire-stable concepts such as drift class. A
string vocabulary is easy to typo and hard for provider plugins to validate
compatibly. Recommendation: make category, signal, backend, deployment mode,
and runtime typed proto enums; reserve repeated string extensions only for
vendor-specific features with a documented non-strict reason.
- [Missing failure modes] `wfctl infra derive` lines 220-229: config loading
resolves imports, but YAML mutation edits one file. If a requirement comes
from an imported module, the design does not say whether generated modules go
to the root file, the source file, or a new derived file. This can corrupt
ownership boundaries in multi-file configs. Recommendation: define v1 as
root-file expansion only, with `source` diagnostics and an explicit future
`--target-file`; tests must cover imported configs.
- [User-intent drift / plugin interface] `Requirement Sources` lines 191-206:
dynamic plugin-side requirement providers are deferred to the future, but the
user's Go-interface analogy asks for plugins/modules/steps to satisfy an
interface without hard dependency. Static manifest requirements are not enough
for config-driven observability backends. Recommendation: include a v1
lightweight Go interface for in-process providers and a strict-proto
external-plugin service, even if the first implementation supports static
manifests too.
- [Security/privacy] `Provider Mapping Service` lines 174-180: provider
mappers return concrete `ResourceSpec` configs, but there is no rule
preventing secret material from being written into YAML. Observability and
Datadog configs frequently involve API keys. Recommendation: require mappers
to emit only secret references or `secrets.generate` requirements; `wfctl`
rejects generated specs containing known secret-looking plaintext values.
- [Repo-precedent conflicts / editor preservation] `Satisfaction Keys` lines
146-162: adding top-level `modules[].satisfies` to Go config is not enough.
`workflow-editor`'s `ModuleConfig` type does not include that field, and its
`js-yaml` round-trip reconstructs module objects. Editing generated YAML in
the editor can silently drop satisfaction keys. Recommendation: include
workflow-editor type/serialization preservation in the plan or explicitly
block editor round-trip support until it lands.

**Findings (Minor):**
- [Missing failure modes] `wfctl infra derive` lines 212-216: provider and
runtime are passed as flags, but existing configs already declare
`iac.provider` modules and per-resource `iac_provider`/`provider` refs.
Recommendation: define precedence: explicit flag, then env-specific
provider, then single configured provider, else ambiguity error.
- [YAGNI] `Requirement Model` lines 129-136: modeling every runtime and backend
in the core design risks expanding before the first implementation proves the
shape. Recommendation: enumerate only the required observability/runtime
constants for v1 and keep vendor extension fields for provider plugins.

**Bug-class scan transcript:**

| Class | Result | Note |
|---|---|---|
| Unstated assumptions | Finding | The design assumes root-file YAML writes are acceptable after imported config resolution. |
| Repo-precedent conflicts | Finding | The string feature vocabulary conflicts with `plugin/external/proto/iac.proto`'s strict typed-service precedent. |
| YAGNI violations | Finding | The design risks over-modeling future backends/runtimes before the first provider mapper exists. |
| Missing failure modes | Finding | Multi-file imports, provider-precedence ambiguity, and generated secret handling need explicit behavior. |
| Security / privacy | Finding | Provider-generated ResourceSpecs may accidentally write API keys or tokens into YAML. |
| Rollback story | Clean | The rollback section covers CLI, proto, YAML schema, and provider mapper rollback. |
| Simpler alternative not considered | Clean | Static manifest-only scaffolding and apply-time derivation were considered and rejected. |
| User-intent drift | Finding | Deferring the plugin/module interface weakens the user's stated composability requirement. |

**Options the author may not have considered:**

1. Root-only generated overlay file: instead of mutating the main config, always
write `workflow.derived.yaml` and add it to `imports:`. This improves review
isolation but requires import-order guarantees and may be surprising when a
user expected in-place expansion.
2. Manifest-v2 first, mapper service later: ship a smaller static-only scaffold
and defer provider RPCs. This is simpler, but it fails the runtime-specific
sidecar/daemonset/ECS/DO mapping requirement and would likely be reworked.

**Verdict reasoning:** The design direction is sound, but the strict-proto,
multi-file, secret-safety, and plugin-interface gaps are important enough to fix
before writing an implementation plan. No critical flaw requires abandoning the
approach.

Loading
Loading