diff --git a/cmd/wfctl/iac_typed_adapter.go b/cmd/wfctl/iac_typed_adapter.go index 828b3685..677b5965 100644 --- a/cmd/wfctl/iac_typed_adapter.go +++ b/cmd/wfctl/iac_typed_adapter.go @@ -59,6 +59,7 @@ const ( iacServiceLogCapture = "workflow.plugin.external.iac.IaCProviderLogCapture" iacServiceFinalizer = "workflow.plugin.external.iac.IaCProviderFinalizer" iacServiceResourceDriver = "workflow.plugin.external.iac.ResourceDriver" + iacServiceRequirementMapper = "workflow.plugin.external.iac.IaCProviderRequirementMapper" ) // typedIaCAdapter implements interfaces.IaCProvider on top of the typed @@ -85,6 +86,7 @@ type typedIaCAdapter struct { logCapture pb.IaCProviderLogCaptureClient finalizer pb.IaCProviderFinalizerClient resourceDriv pb.ResourceDriverClient + reqMapper pb.IaCProviderRequirementMapperClient // cachedCaps memoizes the plugin's CapabilitiesResponse. Access via // fetchCapabilities — never read this field directly. @@ -131,6 +133,9 @@ func newTypedIaCAdapter(conn *grpc.ClientConn, registered map[string]bool) *type if registered[iacServiceResourceDriver] { a.resourceDriv = pb.NewResourceDriverClient(conn) } + if registered[iacServiceRequirementMapper] { + a.reqMapper = pb.NewIaCProviderRequirementMapperClient(conn) + } return a } diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 2d9de8f5..1a43559e 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -60,6 +60,8 @@ func runInfra(args []string) error { return infraUsage() } switch args[0] { + case "derive": + return runInfraDerive(args[1:]) case "plan": return runInfraPlan(args[1:]) case "apply": @@ -117,6 +119,7 @@ func infraUsage() error { Manage infrastructure defined in a workflow config. Actions: + derive Expand provider-derived IaC modules into workflow YAML plan Show planned infrastructure changes apply Apply infrastructure changes status Show current infrastructure status @@ -148,6 +151,8 @@ Options: --show-sensitive/-S Show sensitive values in plaintext (plan/apply only) --tag Tag to match resources (cleanup only; required) --dry-run Preview only (cleanup; default true) + --write Update config file in place (derive only) + --non-interactive Fail instead of prompting for ambiguous choices (derive only) --fix Opt into deletion (cleanup; overrides --dry-run) `) return fmt.Errorf("missing or unknown action") diff --git a/cmd/wfctl/infra_derive.go b/cmd/wfctl/infra_derive.go new file mode 100644 index 00000000..6c7cbed2 --- /dev/null +++ b/cmd/wfctl/infra_derive.go @@ -0,0 +1,306 @@ +package main + +import ( + "bytes" + "context" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/config/yamledit" + "github.com/GoCodeAlone/workflow/iac/derive" + "github.com/GoCodeAlone/workflow/iac/requirements" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "gopkg.in/yaml.v3" +) + +var infraDeriveMapperFactory = defaultInfraDeriveMapperFactory + +func runInfraDerive(args []string) error { + fs := flag.NewFlagSet("infra derive", flag.ContinueOnError) + fs.SetOutput(os.Stdout) + fs.Usage = func() { + fmt.Fprint(fs.Output(), `Usage: wfctl infra derive --config workflow.yaml [options] + +Derive provider-specific IaC modules from Workflow requirements. + +Options: + --config Config file + --provider IaC provider mapper to use + --runtime Target runtime + --env Environment name + --dry-run Print expanded YAML without mutating the config file + --write Update the config file in place + --non-interactive Fail instead of prompting for ambiguous choices + --format yaml Output format +`) + } + var configFile string + fs.StringVar(&configFile, "config", "", "Config file") + fs.StringVar(&configFile, "c", "", "Config file (short for --config)") + var provider string + fs.StringVar(&provider, "provider", "", "IaC provider mapper to use") + var runtimeFlag string + fs.StringVar(&runtimeFlag, "runtime", "", "Target runtime (kubernetes, ecs, cloud_run, azure_container_apps, digitalocean_app_platform)") + var envName string + fs.StringVar(&envName, "env", "", "Environment name") + var write bool + fs.BoolVar(&write, "write", false, "Update the config file in place") + var dryRun bool + fs.BoolVar(&dryRun, "dry-run", false, "Print expanded YAML without mutating the config file") + var nonInteractive bool + fs.BoolVar(&nonInteractive, "non-interactive", false, "Fail instead of prompting when derivation choices are ambiguous") + var format string + fs.StringVar(&format, "format", "yaml", "Output format (yaml)") + if err := fs.Parse(args); err != nil { + return err + } + if write && dryRun { + return fmt.Errorf("--write and --dry-run are mutually exclusive") + } + if format != "yaml" { + return fmt.Errorf("unsupported derive output format %q", format) + } + cfgFile, err := resolveInfraConfig(fs, configFile) + if err != nil { + return err + } + runtime, err := parseDeriveRuntime(runtimeFlag) + if err != nil { + return err + } + cfg, err := config.LoadFromFile(cfgFile) + if err != nil { + return fmt.Errorf("load %s: %w", cfgFile, err) + } + resolvedProvider, err := resolveDeriveProviderInteractive(cfg, provider, envName, nonInteractive) + if err != nil { + return err + } + providerCfg := providerConfigForType(cfg, resolvedProvider, envName) + mapper, closeMapper, err := infraDeriveMapperFactory(context.Background(), resolvedProvider, providerCfg) + if err != nil { + return err + } + if closeMapper != nil { + defer closeMapper() + } + result, err := derive.Derive(context.Background(), cfg, nil, mapper, derive.Options{ + Provider: resolvedProvider, + Runtime: runtime, + Environment: envName, + NonInteractive: nonInteractive, + }) + if err != nil { + return err + } + data, err := os.ReadFile(cfgFile) + if err != nil { + return fmt.Errorf("read %s: %w", cfgFile, err) + } + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("parse %s: %w", cfgFile, err) + } + changed, err := yamledit.AppendGeneratedModules(&doc, yamleditModules(result.Modules)) + if err != nil { + return err + } + if !changed { + fmt.Println("No derived IaC changes") + return nil + } + out, err := encodeYAMLDoc(&doc) + if err != nil { + return err + } + if !write { + _, err = os.Stdout.Write(out) + return err + } + if err := writeFileAtomic(cfgFile, out, 0o600); err != nil { + return err + } + fmt.Printf("Updated %s with %d derived module(s)\n", cfgFile, len(result.Modules)) + return nil +} + +func defaultInfraDeriveMapperFactory(ctx context.Context, provider string, providerCfg map[string]any) (derive.ProviderMapper, func(), error) { + if provider == "" { + return nil, nil, fmt.Errorf("derive requires --provider or exactly one iac.provider module") + } + loaded, closer, err := resolveIaCProvider(ctx, provider, providerCfg) + if err != nil { + return nil, nil, fmt.Errorf("load provider %q: %w", provider, err) + } + adapter, ok := loaded.(*typedIaCAdapter) + if !ok { + if closer != nil { + _ = closer.Close() + } + return nil, nil, fmt.Errorf("provider %q does not expose typed IaC services", provider) + } + client := adapter.RequirementMapper() + if client == nil { + if closer != nil { + _ = closer.Close() + } + return nil, nil, fmt.Errorf("provider %q does not support IaC requirement mapping", provider) + } + var closeFn func() + if closer != nil { + closeFn = func() { _ = closer.Close() } + } + return derive.ExternalProviderMapper{Client: client}, closeFn, nil +} + +func resolveDeriveProviderInteractive(cfg *config.WorkflowConfig, provider, envName string, nonInteractive bool) (string, error) { + if provider != "" { + return provider, nil + } + choices := derive.ProviderChoices(cfg, envName) + switch len(choices) { + case 0: + return "", nil + case 1: + return choices[0], nil + } + if nonInteractive || !stdinIsTerminal() { + return "", fmt.Errorf("multiple iac providers available %v; pass --provider", choices) + } + fmt.Fprintln(os.Stderr, "Select IaC provider for derived modules:") + for i, choice := range choices { + fmt.Fprintf(os.Stderr, " %d) %s\n", i+1, choice) + } + fmt.Fprint(os.Stderr, "Provider: ") + var selected int + if _, err := fmt.Fscan(os.Stdin, &selected); err != nil { + return "", fmt.Errorf("read provider selection: %w", err) + } + if selected < 1 || selected > len(choices) { + return "", fmt.Errorf("invalid provider selection %d", selected) + } + return choices[selected-1], nil +} + +func stdinIsTerminal() bool { + info, err := os.Stdin.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + +func (a *typedIaCAdapter) RequirementMapper() pb.IaCProviderRequirementMapperClient { + if a == nil { + return nil + } + return a.reqMapper +} + +func parseDeriveRuntime(raw string) (requirements.Runtime, error) { + switch raw { + case "": + return "", nil + case "kubernetes": + return requirements.RuntimeKubernetes, nil + case "ecs": + return requirements.RuntimeECS, nil + case "cloud_run", "cloud-run": + return requirements.RuntimeCloudRun, nil + case "azure_container_apps", "azure-container-apps": + return requirements.RuntimeAzureContainerApps, nil + case "digitalocean_app_platform", "do-app-platform", "digitalocean-app-platform": + return requirements.RuntimeDigitalOceanAppPlatform, nil + default: + return "", fmt.Errorf("unsupported runtime %q", raw) + } +} + +func providerConfigForType(cfg *config.WorkflowConfig, provider, envName string) map[string]any { + if cfg == nil || provider == "" { + return nil + } + for i := range cfg.Modules { + mod := &cfg.Modules[i] + if mod.Type != "iac.provider" { + continue + } + modCfg := mod.Config + if envName != "" { + if resolved, ok := mod.ResolveForEnv(envName); ok { + modCfg = resolved.Config + } + } + expanded := config.ExpandEnvInMap(modCfg) + if got, _ := expanded["provider"].(string); got == provider { + return expanded + } + } + return nil +} + +func yamleditModules(modules []derive.GeneratedModule) []yamledit.GeneratedModule { + out := make([]yamledit.GeneratedModule, 0, len(modules)) + for i := range modules { + out = append(out, yamledit.GeneratedModule{ + Name: modules[i].Name, + Type: modules[i].Type, + Satisfies: append([]string(nil), modules[i].Satisfies...), + Config: cloneAnyMap(modules[i].Config), + DependsOn: append([]string(nil), modules[i].DependsOn...), + }) + } + return out +} + +func cloneAnyMap(in map[string]any) map[string]any { + if in == nil { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func encodeYAMLDoc(doc *yaml.Node) ([]byte, error) { + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(doc); err != nil { + return nil, fmt.Errorf("encode YAML: %w", err) + } + if err := enc.Close(); err != nil { + return nil, fmt.Errorf("close YAML encoder: %w", err) + } + return buf.Bytes(), nil +} + +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write temp file: %w", err) + } + if err := tmp.Chmod(perm); err != nil { + tmp.Close() + return fmt.Errorf("chmod temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("replace %s: %w", path, err) + } + return nil +} diff --git a/cmd/wfctl/infra_derive_test.go b/cmd/wfctl/infra_derive_test.go new file mode 100644 index 00000000..f1a8e1f0 --- /dev/null +++ b/cmd/wfctl/infra_derive_test.go @@ -0,0 +1,185 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/iac/derive" +) + +func TestInfraDeriveHelpIncludesFlags(t *testing.T) { + out, err := captureStdout(t, func() error { + return runInfraDerive([]string{"--help"}) + }) + if err == nil { + t.Fatalf("help returned nil error, want flag.ErrHelp") + } + for _, want := range []string{"--write", "--dry-run", "--provider", "--runtime", "--env", "--non-interactive"} { + if !strings.Contains(out, want) { + t.Fatalf("help missing %s:\n%s", want, out) + } + } +} + +func TestInfraDeriveDryRunWithFakeMapperDoesNotMutateFile(t *testing.T) { + restore := installInfraDeriveFakeMapper(t, []derive.GeneratedModule{{ + Name: "otel-collector", + Type: "infra.container_service", + Satisfies: []string{"observability.telemetry.default"}, + Config: map[string]any{"image": "otel/opentelemetry-collector-contrib:latest"}, + }}) + defer restore() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "workflow.yaml") + original := "name: demo\nmodules: []\n" + if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + out, err := captureStdout(t, func() error { + return runInfraDerive([]string{"--config", cfgPath, "--provider", "fake", "--dry-run", "--non-interactive"}) + }) + if err != nil { + t.Fatalf("infra derive dry-run: %v", err) + } + if !strings.Contains(out, "satisfies:") || !strings.Contains(out, "observability.telemetry.default") { + t.Fatalf("dry-run output missing derived module:\n%s", out) + } + after, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + if string(after) != original { + t.Fatalf("dry-run mutated file:\n%s", after) + } +} + +func TestInfraDeriveWriteIsIdempotent(t *testing.T) { + restore := installInfraDeriveFakeMapper(t, []derive.GeneratedModule{{ + Name: "otel-collector", + Type: "infra.container_service", + Satisfies: []string{"observability.telemetry.default"}, + }}) + defer restore() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "workflow.yaml") + if err := os.WriteFile(cfgPath, []byte("name: demo\nmodules: []\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := runInfraDerive([]string{"--config", cfgPath, "--provider", "fake", "--write", "--non-interactive"}); err != nil { + t.Fatalf("infra derive write: %v", err) + } + first, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(first), "otel-collector") { + t.Fatalf("write did not add module:\n%s", first) + } + out, err := captureStdout(t, func() error { + return runInfraDerive([]string{"--config", cfgPath, "--provider", "fake", "--write", "--non-interactive"}) + }) + if err != nil { + t.Fatalf("second infra derive write: %v", err) + } + second, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + if string(second) != string(first) { + t.Fatalf("second write changed file\nfirst:\n%s\nsecond:\n%s", first, second) + } + if !strings.Contains(out, "No derived IaC changes") { + t.Fatalf("second write output missing no-op message:\n%s", out) + } +} + +func TestInfraDeriveMutatesOnlyRootConfigWhenImportsContributeRequirements(t *testing.T) { + restore := installInfraDeriveFakeMapper(t, []derive.GeneratedModule{{ + Name: "web-runtime", + Type: "infra.container_service", + Satisfies: []string{"web.api.api"}, + }}) + defer restore() + + dir := t.TempDir() + importPath := filepath.Join(dir, "service.yaml") + importOriginal := "services:\n api:\n binary: ./api\n" + if err := os.WriteFile(importPath, []byte(importOriginal), 0o600); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(dir, "workflow.yaml") + if err := os.WriteFile(cfgPath, []byte("imports:\n - service.yaml\nmodules: []\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := runInfraDerive([]string{"--config", cfgPath, "--provider", "fake", "--write", "--non-interactive"}); err != nil { + t.Fatalf("infra derive write: %v", err) + } + importAfter, err := os.ReadFile(importPath) + if err != nil { + t.Fatal(err) + } + if string(importAfter) != importOriginal { + t.Fatalf("import file mutated:\n%s", importAfter) + } + rootAfter, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rootAfter), "web-runtime") { + t.Fatalf("root config missing derived module:\n%s", rootAfter) + } +} + +func TestInfraDeriveNonInteractiveAmbiguousProvider(t *testing.T) { + restore := installInfraDeriveFakeMapper(t, nil) + defer restore() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "workflow.yaml") + cfg := `modules: + - name: aws-provider + type: iac.provider + config: + provider: aws + - name: do-provider + type: iac.provider + config: + provider: digitalocean +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + err := runInfraDerive([]string{"--config", cfgPath, "--non-interactive"}) + if err == nil { + t.Fatalf("infra derive succeeded, want ambiguity error") + } + if !strings.Contains(err.Error(), "aws") || !strings.Contains(err.Error(), "digitalocean") { + t.Fatalf("ambiguity error missing choices: %v", err) + } +} + +func installInfraDeriveFakeMapper(t *testing.T, modules []derive.GeneratedModule) func() { + t.Helper() + prev := infraDeriveMapperFactory + infraDeriveMapperFactory = func(context.Context, string, map[string]any) (derive.ProviderMapper, func(), error) { + return deriveFakeMapper{modules: modules}, nil, nil + } + return func() { infraDeriveMapperFactory = prev } +} + +type deriveFakeMapper struct { + modules []derive.GeneratedModule +} + +func (m deriveFakeMapper) MapRequirements(context.Context, derive.MapRequest) (derive.MapResult, error) { + accepted := make([]string, 0) + for _, mod := range m.modules { + accepted = append(accepted, mod.Satisfies...) + } + return derive.MapResult{AcceptedKeys: accepted, Modules: m.modules}, nil +} diff --git a/config/yamledit/module_append.go b/config/yamledit/module_append.go new file mode 100644 index 00000000..8fdf82c9 --- /dev/null +++ b/config/yamledit/module_append.go @@ -0,0 +1,262 @@ +package yamledit + +import ( + "fmt" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type GeneratedModule struct { + Name string + Type string + Satisfies []string + Config map[string]any + DependsOn []string +} + +func AppendGeneratedModules(root *yaml.Node, modules []GeneratedModule) (bool, error) { + if len(modules) == 0 { + return false, nil + } + doc, err := documentMapping(root) + if err != nil { + return false, err + } + modulesNode := mappingValue(doc, "modules") + if modulesNode == nil { + modulesNode = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + doc.Content = append(doc.Content, scalar("modules"), modulesNode) + } + if modulesNode.Kind != yaml.SequenceNode { + return false, fmt.Errorf("modules must be a YAML sequence") + } + + existingNames, existingSatisfies := existingModuleIdentity(modulesNode) + toAppend := filterGeneratedModules(modules, existingNames, existingSatisfies) + if len(toAppend) == 0 { + return false, nil + } + + insertAt := len(modulesNode.Content) + if lastInfra := lastInfraModuleIndex(modulesNode); lastInfra >= 0 { + insertAt = lastInfra + 1 + } + newNodes := make([]*yaml.Node, 0, len(toAppend)) + for i := range toAppend { + node, err := moduleNode(toAppend[i]) + if err != nil { + return false, err + } + newNodes = append(newNodes, node) + } + modulesNode.Content = append( + modulesNode.Content[:insertAt], + append(newNodes, modulesNode.Content[insertAt:]...)..., + ) + return true, nil +} + +func documentMapping(root *yaml.Node) (*yaml.Node, error) { + if root == nil { + return nil, fmt.Errorf("YAML document is nil") + } + switch root.Kind { + case yaml.DocumentNode: + if len(root.Content) == 0 { + root.Content = append(root.Content, &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}) + } + if root.Content[0].Kind != yaml.MappingNode { + return nil, fmt.Errorf("YAML document root must be a mapping") + } + return root.Content[0], nil + case yaml.MappingNode: + return root, nil + default: + return nil, fmt.Errorf("YAML document root must be a mapping") + } +} + +func mappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +} + +func existingModuleIdentity(modules *yaml.Node) (map[string]struct{}, map[string]struct{}) { + names := make(map[string]struct{}) + satisfies := make(map[string]struct{}) + for _, mod := range modules.Content { + if mod.Kind != yaml.MappingNode { + continue + } + if name := scalarMappingValue(mod, "name"); name != "" { + names[name] = struct{}{} + } + satisfiesNode := mappingValue(mod, "satisfies") + if satisfiesNode == nil || satisfiesNode.Kind != yaml.SequenceNode { + continue + } + for _, item := range satisfiesNode.Content { + if item.Kind == yaml.ScalarNode && item.Value != "" { + satisfies[item.Value] = struct{}{} + } + } + } + return names, satisfies +} + +func filterGeneratedModules(modules []GeneratedModule, existingNames, existingSatisfies map[string]struct{}) []GeneratedModule { + out := make([]GeneratedModule, 0, len(modules)) + seenNames := make(map[string]struct{}) + seenSatisfies := make(map[string]struct{}) + for i := range modules { + mod := modules[i] + if _, ok := existingNames[mod.Name]; ok { + continue + } + if _, ok := seenNames[mod.Name]; ok { + continue + } + if anySatisfiesKnown(mod.Satisfies, existingSatisfies) || anySatisfiesKnown(mod.Satisfies, seenSatisfies) { + continue + } + seenNames[mod.Name] = struct{}{} + for _, key := range mod.Satisfies { + seenSatisfies[key] = struct{}{} + } + out = append(out, mod) + } + sort.SliceStable(out, func(i, j int) bool { + return out[i].Name < out[j].Name + }) + return out +} + +func anySatisfiesKnown(keys []string, known map[string]struct{}) bool { + for _, key := range keys { + if _, ok := known[key]; ok { + return true + } + } + return false +} + +func lastInfraModuleIndex(modules *yaml.Node) int { + last := -1 + for i, mod := range modules.Content { + if strings.HasPrefix(scalarMappingValue(mod, "type"), "infra.") { + last = i + } + } + return last +} + +func scalarMappingValue(node *yaml.Node, key string) string { + value := mappingValue(node, key) + if value == nil || value.Kind != yaml.ScalarNode { + return "" + } + return value.Value +} + +func moduleNode(mod GeneratedModule) (*yaml.Node, error) { + if mod.Name == "" { + return nil, fmt.Errorf("generated module name is required") + } + if mod.Type == "" { + return nil, fmt.Errorf("generated module %q type is required", mod.Name) + } + node := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + appendScalarField(node, "name", mod.Name) + appendScalarField(node, "type", mod.Type) + if len(mod.Satisfies) > 0 { + appendSequenceField(node, "satisfies", mod.Satisfies) + } + if len(mod.Config) > 0 { + configNode, err := valueNode(mod.Config) + if err != nil { + return nil, fmt.Errorf("generated module %q config: %w", mod.Name, err) + } + node.Content = append(node.Content, scalar("config"), configNode) + } + if len(mod.DependsOn) > 0 { + appendSequenceField(node, "dependsOn", mod.DependsOn) + } + return node, nil +} + +func appendScalarField(node *yaml.Node, key, value string) { + node.Content = append(node.Content, scalar(key), scalar(value)) +} + +func appendSequenceField(node *yaml.Node, key string, values []string) { + seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, value := range values { + seq.Content = append(seq.Content, scalar(value)) + } + node.Content = append(node.Content, scalar(key), seq) +} + +func valueNode(value any) (*yaml.Node, error) { + switch typed := value.(type) { + case map[string]any: + return mapNode(typed) + case map[string]string: + asAny := make(map[string]any, len(typed)) + for k, v := range typed { + asAny[k] = v + } + return mapNode(asAny) + case []any: + seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, item := range typed { + child, err := valueNode(item) + if err != nil { + return nil, err + } + seq.Content = append(seq.Content, child) + } + return seq, nil + case []string: + seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, item := range typed { + seq.Content = append(seq.Content, scalar(item)) + } + return seq, nil + default: + var node yaml.Node + if err := node.Encode(value); err != nil { + return nil, err + } + return &node, nil + } +} + +func mapNode(values map[string]any) (*yaml.Node, error) { + node := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + child, err := valueNode(values[key]) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + node.Content = append(node.Content, scalar(key), child) + } + return node, nil +} + +func scalar(value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} +} diff --git a/config/yamledit/module_append_test.go b/config/yamledit/module_append_test.go new file mode 100644 index 00000000..520d49f0 --- /dev/null +++ b/config/yamledit/module_append_test.go @@ -0,0 +1,146 @@ +package yamledit + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestAppendGeneratedModulesPreservesCommentsAndUnknownKeys(t *testing.T) { + doc := readYAMLDoc(t, "comments_unknown.input.yaml") + changed, err := AppendGeneratedModules(doc, []GeneratedModule{{ + Name: "otel-collector", + Type: "infra.container_service", + Satisfies: []string{"observability.telemetry.default"}, + Config: map[string]any{ + "image": "otel/opentelemetry-collector-contrib:latest", + }, + DependsOn: []string{"api"}, + }}) + if err != nil { + t.Fatalf("append generated modules: %v", err) + } + if !changed { + t.Fatalf("changed = false, want true") + } + assertYAMLEqual(t, encodeYAML(t, doc), readGolden(t, "comments_unknown.golden.yaml")) +} + +func TestAppendGeneratedModulesCreatesModulesWhenAbsent(t *testing.T) { + doc := readYAMLDoc(t, "no_modules.input.yaml") + changed, err := AppendGeneratedModules(doc, []GeneratedModule{{ + Name: "nats", + Type: "infra.message_broker", + Satisfies: []string{"messaging.nats.default"}, + Config: map[string]any{"plan": "basic"}, + }}) + if err != nil { + t.Fatalf("append generated modules: %v", err) + } + if !changed { + t.Fatalf("changed = false, want true") + } + assertYAMLEqual(t, encodeYAML(t, doc), readGolden(t, "no_modules.golden.yaml")) +} + +func TestAppendGeneratedModulesInsertsAfterLastInfraModule(t *testing.T) { + doc := readYAMLDoc(t, "insert_after_infra.input.yaml") + changed, err := AppendGeneratedModules(doc, []GeneratedModule{ + {Name: "zeta", Type: "infra.cache", Satisfies: []string{"cache.default"}}, + {Name: "alpha", Type: "infra.database", Satisfies: []string{"database.default"}}, + }) + if err != nil { + t.Fatalf("append generated modules: %v", err) + } + if !changed { + t.Fatalf("changed = false, want true") + } + assertYAMLEqual(t, encodeYAML(t, doc), readGolden(t, "insert_after_infra.golden.yaml")) +} + +func TestAppendGeneratedModulesPreservesAnchors(t *testing.T) { + doc := readYAMLDoc(t, "anchors.input.yaml") + _, err := AppendGeneratedModules(doc, []GeneratedModule{{ + Name: "otel-collector", + Type: "infra.container_service", + Satisfies: []string{"observability.telemetry.default"}, + }}) + if err != nil { + t.Fatalf("append generated modules: %v", err) + } + out := encodeYAML(t, doc) + if !strings.Contains(string(out), "&base") || !strings.Contains(string(out), "*base") { + t.Fatalf("anchors were not preserved in output:\n%s", out) + } +} + +func TestAppendGeneratedModulesIsIdempotentBySatisfiesKey(t *testing.T) { + doc := readYAMLDoc(t, "comments_unknown.input.yaml") + modules := []GeneratedModule{{ + Name: "otel-collector", + Type: "infra.container_service", + Satisfies: []string{"observability.telemetry.default"}, + Config: map[string]any{"image": "otel/opentelemetry-collector-contrib:latest"}, + }} + changed, err := AppendGeneratedModules(doc, modules) + if err != nil { + t.Fatalf("first append: %v", err) + } + if !changed { + t.Fatalf("first append changed = false, want true") + } + first := encodeYAML(t, doc) + changed, err = AppendGeneratedModules(doc, modules) + if err != nil { + t.Fatalf("second append: %v", err) + } + if changed { + t.Fatalf("second append changed = true, want false") + } + if second := encodeYAML(t, doc); !bytes.Equal(second, first) { + t.Fatalf("second append changed YAML\nfirst:\n%s\nsecond:\n%s", first, second) + } +} + +func readYAMLDoc(t *testing.T, name string) *yaml.Node { + t.Helper() + var doc yaml.Node + if err := yaml.Unmarshal(readGolden(t, name), &doc); err != nil { + t.Fatalf("unmarshal %s: %v", name, err) + } + return &doc +} + +func readGolden(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + return data +} + +func encodeYAML(t *testing.T, doc *yaml.Node) []byte { + t.Helper() + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(doc); err != nil { + t.Fatalf("encode YAML: %v", err) + } + if err := enc.Close(); err != nil { + t.Fatalf("close YAML encoder: %v", err) + } + return buf.Bytes() +} + +func assertYAMLEqual(t *testing.T, got, want []byte) { + t.Helper() + if string(got) != string(want) { + t.Fatalf("YAML mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} diff --git a/config/yamledit/testdata/anchors.input.yaml b/config/yamledit/testdata/anchors.input.yaml new file mode 100644 index 00000000..95f38390 --- /dev/null +++ b/config/yamledit/testdata/anchors.input.yaml @@ -0,0 +1,6 @@ +common: &base + port: 8080 +modules: + - name: api + type: service.http + config: *base diff --git a/config/yamledit/testdata/comments_unknown.golden.yaml b/config/yamledit/testdata/comments_unknown.golden.yaml new file mode 100644 index 00000000..d3e8e092 --- /dev/null +++ b/config/yamledit/testdata/comments_unknown.golden.yaml @@ -0,0 +1,19 @@ +# app comment +name: demo +x-owned-by: platform +modules: + # api module comment + - name: api + type: service.http + customField: keep-me + config: + port: 8080 + - name: otel-collector + type: infra.container_service + satisfies: + - observability.telemetry.default + config: + image: otel/opentelemetry-collector-contrib:latest + dependsOn: + - api +tail: keep diff --git a/config/yamledit/testdata/comments_unknown.input.yaml b/config/yamledit/testdata/comments_unknown.input.yaml new file mode 100644 index 00000000..48818e4e --- /dev/null +++ b/config/yamledit/testdata/comments_unknown.input.yaml @@ -0,0 +1,11 @@ +# app comment +name: demo +x-owned-by: platform +modules: + # api module comment + - name: api + type: service.http + customField: keep-me + config: + port: 8080 +tail: keep diff --git a/config/yamledit/testdata/insert_after_infra.golden.yaml b/config/yamledit/testdata/insert_after_infra.golden.yaml new file mode 100644 index 00000000..d90d535d --- /dev/null +++ b/config/yamledit/testdata/insert_after_infra.golden.yaml @@ -0,0 +1,15 @@ +modules: + - name: api + type: service.http + - name: registry + type: infra.container_registry + - name: alpha + type: infra.database + satisfies: + - database.default + - name: zeta + type: infra.cache + satisfies: + - cache.default + - name: worker + type: service.worker diff --git a/config/yamledit/testdata/insert_after_infra.input.yaml b/config/yamledit/testdata/insert_after_infra.input.yaml new file mode 100644 index 00000000..6457823e --- /dev/null +++ b/config/yamledit/testdata/insert_after_infra.input.yaml @@ -0,0 +1,7 @@ +modules: + - name: api + type: service.http + - name: registry + type: infra.container_registry + - name: worker + type: service.worker diff --git a/config/yamledit/testdata/no_modules.golden.yaml b/config/yamledit/testdata/no_modules.golden.yaml new file mode 100644 index 00000000..c759c2f8 --- /dev/null +++ b/config/yamledit/testdata/no_modules.golden.yaml @@ -0,0 +1,11 @@ +name: demo +workflows: + default: + steps: [] +modules: + - name: nats + type: infra.message_broker + satisfies: + - messaging.nats.default + config: + plan: basic diff --git a/config/yamledit/testdata/no_modules.input.yaml b/config/yamledit/testdata/no_modules.input.yaml new file mode 100644 index 00000000..5fbba3c1 --- /dev/null +++ b/config/yamledit/testdata/no_modules.input.yaml @@ -0,0 +1,4 @@ +name: demo +workflows: + default: + steps: [] diff --git a/docs/WFCTL.md b/docs/WFCTL.md index b0df8760..201625e4 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -89,6 +89,7 @@ graph TD audit --> audit-plugins["plugins"] infra --> infra-plan["plan"] + infra --> infra-derive["derive"] infra --> infra-apply["apply"] infra --> infra-destroy["destroy"] infra --> infra-status["status"] @@ -172,7 +173,7 @@ graph TD | **Validation & Inspection** | `validate`, `inspect`, `schema`, `compat check`, `template validate`, `editor-schemas`, `dsl-reference` | | **API & Contract** | `api extract`, `contract test`, `diff` | | **Deployment** | `deploy docker/kubernetes/helm/cloud`, `build-ui`, `generate github-actions` | -| **Infrastructure** | `infra plan/apply/destroy/status/drift/import/bootstrap/outputs/test`, `infra state list/export/import` | +| **Infrastructure** | `infra derive/plan/apply/destroy/status/drift/import/bootstrap/outputs/test`, `infra state list/export/import` | | **CI/CD** | `ci generate`, `generate github-actions` | | **Documentation** | `docs generate` | | **Plugin Management** | `plugin`, `plugin-registry`, `registry`, `publish` | @@ -1360,6 +1361,7 @@ wfctl infra [options] [config.yaml] | Action | Description | |--------|-------------| +| `derive` | Expand provider-derived IaC modules into Workflow YAML | | `plan` | Show planned infrastructure changes | | `apply` | Apply infrastructure changes | | `status` | Show current infrastructure status | @@ -1389,6 +1391,37 @@ wfctl infra [options] [config.yaml] | `--force-rotate` | `` | (`bootstrap` only) Comma-separated list of secret names to regenerate, replacing existing values. Repeatable. Use to recover from known-bad secrets (empty value, leaked, dead key). Refuses `provider_credential` types. | | `--plugin-dir` | _(env `WFCTL_PLUGIN_DIR` or `data/plugins`)_ | Override the plugin directory for plugin-loading commands (plan, apply, status, drift, destroy, import, bootstrap, refresh-outputs, cleanup, align, audit-keys, prune, rotate-and-prune). Useful for isolated CI smoke tests. | +#### `infra derive` + +`wfctl infra derive` calculates missing infrastructure requirements from the +Workflow config and asks the selected provider mapper to generate concrete +`infra.*` modules. It is explicit by design: `infra plan` and `infra apply` do +not derive modules at apply time. + +```bash +wfctl infra derive --config workflow.yaml --provider digitalocean --runtime do-app-platform --env production --dry-run --non-interactive +wfctl infra derive --config workflow.yaml --provider aws --runtime ecs --write --non-interactive +``` + +Generated modules include `satisfies` keys so future runs can see that the +requirement has been handled. A user-authored module can opt out of derivation +the same way: + +```yaml +modules: + - name: otel-collector + type: infra.container_service + satisfies: + - observability.telemetry.default + config: + image: otel/opentelemetry-collector-contrib:latest +``` + +`--dry-run` prints the expanded YAML and leaves the file unchanged. `--write` +updates only the root `--config` file, even when imports contributed the +requirements. Use `--non-interactive` in CI or agent workflows so ambiguous +provider/runtime choices fail with a deterministic error instead of prompting. + **State Subcommands:** ``` @@ -1405,6 +1438,7 @@ wfctl infra state [options] ```bash wfctl infra plan infra.yaml +wfctl infra derive --config workflow.yaml --provider digitalocean --runtime do-app-platform --dry-run --non-interactive wfctl infra apply --auto-approve infra.yaml wfctl infra status --config infra.yaml wfctl infra drift infra.yaml diff --git a/iac/derive/engine.go b/iac/derive/engine.go new file mode 100644 index 00000000..2d578c40 --- /dev/null +++ b/iac/derive/engine.go @@ -0,0 +1,277 @@ +package derive + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/requirements" +) + +type Options struct { + Provider string + Runtime requirements.Runtime + Environment string + NonInteractive bool +} + +type GeneratedModule struct { + Name string + Type string + Satisfies []string + Config map[string]any + DependsOn []string +} + +type Diagnostic struct { + Key string + Code string + Message string +} + +type Note struct { + Key string + Message string + Interactive bool +} + +type Result struct { + Provider string + Runtime requirements.Runtime + Requirements []requirements.Requirement + Modules []GeneratedModule + Rejected []Diagnostic + Notes []Note +} + +type MapRequest struct { + Provider string + Runtime requirements.Runtime + Environment string + Requirements []requirements.Requirement +} + +type MapResult struct { + AcceptedKeys []string + Modules []GeneratedModule + Rejected []Diagnostic + Notes []Note +} + +type ProviderMapper interface { + MapRequirements(context.Context, MapRequest) (MapResult, error) +} + +func Derive(ctx context.Context, cfg *config.WorkflowConfig, reqs []requirements.Requirement, mapper ProviderMapper, opts Options) (Result, error) { + if mapper == nil { + return Result{}, fmt.Errorf("iac requirement mapper is nil") + } + if len(reqs) == 0 { + discovered, err := requirements.Discover(ctx, requirements.Input{ + Config: cfg, + Environment: opts.Environment, + }) + if err != nil { + return Result{}, err + } + reqs = discovered + } + provider, err := ResolveProvider(cfg, opts) + if err != nil { + return Result{}, err + } + runtime, err := resolveRuntime(reqs, opts) + if err != nil { + return Result{}, err + } + mapped, err := mapper.MapRequirements(ctx, MapRequest{ + Provider: provider, + Runtime: runtime, + Environment: opts.Environment, + Requirements: cloneRequirements(reqs), + }) + if err != nil { + return Result{}, err + } + modules := cloneModules(mapped.Modules) + for i := range modules { + if len(modules[i].Satisfies) == 0 { + modules[i].Satisfies = append([]string(nil), mapped.AcceptedKeys...) + } + if err := rejectPlaintextSecrets(modules[i]); err != nil { + return Result{}, err + } + } + return Result{ + Provider: provider, + Runtime: runtime, + Requirements: cloneRequirements(reqs), + Modules: modules, + Rejected: append([]Diagnostic(nil), mapped.Rejected...), + Notes: append([]Note(nil), mapped.Notes...), + }, nil +} + +func ResolveProvider(cfg *config.WorkflowConfig, opts Options) (string, error) { + if opts.Provider != "" { + return opts.Provider, nil + } + choices := ProviderChoices(cfg, opts.Environment) + switch len(choices) { + case 0: + return "", nil + case 1: + return choices[0], nil + default: + return "", fmt.Errorf("multiple iac providers available %v; pass --provider", choices) + } +} + +func ProviderChoices(cfg *config.WorkflowConfig, envName string) []string { + if cfg == nil { + return nil + } + seen := make(map[string]struct{}) + for i := range cfg.Modules { + mod := &cfg.Modules[i] + if mod.Type != "iac.provider" { + continue + } + modCfg := mod.Config + if envName != "" { + if resolved, ok := mod.ResolveForEnv(envName); ok { + modCfg = resolved.Config + } + } + provider, _ := config.ExpandEnvInMap(modCfg)["provider"].(string) + if provider != "" { + seen[provider] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for provider := range seen { + out = append(out, provider) + } + sort.Strings(out) + return out +} + +func resolveRuntime(reqs []requirements.Requirement, opts Options) (requirements.Runtime, error) { + if opts.Runtime != "" { + return opts.Runtime, nil + } + seen := make(map[requirements.Runtime]struct{}) + for i := range reqs { + for _, runtime := range reqs[i].Runtimes { + if runtime != "" { + seen[runtime] = struct{}{} + } + } + } + switch len(seen) { + case 0: + return "", nil + case 1: + for runtime := range seen { + return runtime, nil + } + } + choices := make([]string, 0, len(seen)) + for runtime := range seen { + choices = append(choices, string(runtime)) + } + sort.Strings(choices) + return "", fmt.Errorf("multiple requirement runtimes available %v; pass --runtime", choices) +} + +func rejectPlaintextSecrets(module GeneratedModule) error { + return scanSecretLikeValues(module.Config, func(path string) error { + return fmt.Errorf("generated module %q contains plaintext secret-like config key %q", module.Name, path) + }) +} + +func scanSecretLikeValues(value any, reject func(string) error) error { + return scanSecretLikeValuesAt("", value, reject) +} + +func scanSecretLikeValuesAt(path string, value any, reject func(string) error) error { + switch typed := value.(type) { + case map[string]any: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + childPath := joinPath(path, key) + if err := scanSecretLikeValuesAt(childPath, typed[key], reject); err != nil { + return err + } + } + case []any: + for i := range typed { + if err := scanSecretLikeValuesAt(path, typed[i], reject); err != nil { + return err + } + } + case string: + if secretLikeKey(path) && !isPlaceholder(typed) && typed != "" { + return reject(path) + } + } + return nil +} + +func joinPath(parent, key string) string { + if parent == "" { + return key + } + return parent + "." + key +} + +func secretLikeKey(key string) bool { + key = strings.ToLower(key) + key = strings.ReplaceAll(key, "-", "_") + return strings.Contains(key, "secret") || + strings.Contains(key, "password") || + strings.Contains(key, "token") || + strings.Contains(key, "api_key") || + strings.Contains(key, "apikey") +} + +func isPlaceholder(value string) bool { + return strings.HasPrefix(value, "${") && strings.HasSuffix(value, "}") +} + +func cloneRequirements(in []requirements.Requirement) []requirements.Requirement { + out := make([]requirements.Requirement, len(in)) + copy(out, in) + return out +} + +func cloneModules(in []GeneratedModule) []GeneratedModule { + out := make([]GeneratedModule, len(in)) + for i := range in { + out[i] = GeneratedModule{ + Name: in[i].Name, + Type: in[i].Type, + Satisfies: append([]string(nil), in[i].Satisfies...), + Config: cloneMap(in[i].Config), + DependsOn: append([]string(nil), in[i].DependsOn...), + } + } + return out +} + +func cloneMap(in map[string]any) map[string]any { + if in == nil { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/iac/derive/engine_test.go b/iac/derive/engine_test.go new file mode 100644 index 00000000..b914c058 --- /dev/null +++ b/iac/derive/engine_test.go @@ -0,0 +1,128 @@ +package derive + +import ( + "context" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/requirements" +) + +func TestDeriveProviderRuntimePrecedence(t *testing.T) { + cfg := &config.WorkflowConfig{Modules: []config.ModuleConfig{ + {Name: "do-provider", Type: "iac.provider", Config: map[string]any{"provider": "digitalocean"}}, + }} + mapper := &fakeMapper{modules: []GeneratedModule{{Name: "api", Type: "infra.container_service"}}} + + result, err := Derive(context.Background(), cfg, nil, mapper, Options{ + Provider: "aws", + Runtime: requirements.RuntimeECS, + Environment: "prod", + NonInteractive: true, + }) + if err != nil { + t.Fatalf("derive: %v", err) + } + if mapper.last.Provider != "aws" { + t.Fatalf("provider = %q, want aws", mapper.last.Provider) + } + if mapper.last.Runtime != requirements.RuntimeECS { + t.Fatalf("runtime = %q, want ecs", mapper.last.Runtime) + } + if result.Provider != "aws" || result.Runtime != requirements.RuntimeECS { + t.Fatalf("result provider/runtime = %q/%q", result.Provider, result.Runtime) + } +} + +func TestDeriveNonInteractiveAmbiguousProvider(t *testing.T) { + cfg := &config.WorkflowConfig{Modules: []config.ModuleConfig{ + {Name: "aws-provider", Type: "iac.provider", Config: map[string]any{"provider": "aws"}}, + {Name: "do-provider", Type: "iac.provider", Config: map[string]any{"provider": "digitalocean"}}, + }} + _, err := Derive(context.Background(), cfg, nil, &fakeMapper{}, Options{NonInteractive: true}) + if err == nil { + t.Fatalf("derive succeeded, want ambiguity error") + } + msg := err.Error() + if !strings.Contains(msg, "aws") || !strings.Contains(msg, "digitalocean") || !strings.Contains(msg, "--provider") { + t.Fatalf("ambiguity error did not include sorted choices and flag guidance: %v", err) + } +} + +func TestDeriveRejectsPlaintextSecretLikeConfig(t *testing.T) { + cfg := &config.WorkflowConfig{} + mapper := &fakeMapper{modules: []GeneratedModule{{ + Name: "datadog-agent", + Type: "infra.container_service", + Satisfies: []string{"observability.telemetry.default"}, + Config: map[string]any{"api_key": "plain-value"}, + }}} + _, err := Derive(context.Background(), cfg, nil, mapper, Options{Provider: "datadog", NonInteractive: true}) + if err == nil { + t.Fatalf("derive succeeded, want plaintext secret rejection") + } + msg := err.Error() + if !strings.Contains(msg, "datadog-agent") || !strings.Contains(msg, "api_key") { + t.Fatalf("secret error should name module and key, got: %v", err) + } + if strings.Contains(msg, "plain-value") { + t.Fatalf("secret error leaked value: %v", err) + } +} + +func TestDeriveAcceptsSecretPlaceholders(t *testing.T) { + cfg := &config.WorkflowConfig{} + mapper := &fakeMapper{modules: []GeneratedModule{{ + Name: "datadog-agent", + Type: "infra.container_service", + Config: map[string]any{"api_key": "${DATADOG_API_KEY}"}, + }}} + if _, err := Derive(context.Background(), cfg, nil, mapper, Options{Provider: "datadog", NonInteractive: true}); err != nil { + t.Fatalf("derive rejected placeholder secret: %v", err) + } +} + +func TestDeriveGeneratedModulesInheritAcceptedKeysAndSurfaceDiagnostics(t *testing.T) { + cfg := &config.WorkflowConfig{} + mapper := &fakeMapper{ + accepted: []string{"observability.telemetry.default"}, + modules: []GeneratedModule{{Name: "otel", Type: "infra.container_service"}}, + rejected: []Diagnostic{{Key: "database.default", Code: "unsupported", Message: "no database mapper"}}, + notes: []Note{{Key: "observability.telemetry.default", Message: "sidecar selected", Interactive: true}}, + } + result, err := Derive(context.Background(), cfg, []requirements.Requirement{{ + Key: "observability.telemetry.default", + Kind: requirements.KindObservability, + }}, mapper, Options{Provider: "digitalocean", NonInteractive: true}) + if err != nil { + t.Fatalf("derive: %v", err) + } + if got := result.Modules[0].Satisfies; len(got) != 1 || got[0] != "observability.telemetry.default" { + t.Fatalf("module satisfies = %v", got) + } + if len(result.Rejected) != 1 || result.Rejected[0].Key != "database.default" { + t.Fatalf("rejected diagnostics not surfaced: %#v", result.Rejected) + } + if len(result.Notes) != 1 || !result.Notes[0].Interactive { + t.Fatalf("notes not surfaced: %#v", result.Notes) + } +} + +type fakeMapper struct { + last MapRequest + accepted []string + modules []GeneratedModule + rejected []Diagnostic + notes []Note +} + +func (m *fakeMapper) MapRequirements(_ context.Context, req MapRequest) (MapResult, error) { + m.last = req + return MapResult{ + AcceptedKeys: m.accepted, + Modules: m.modules, + Rejected: m.rejected, + Notes: m.notes, + }, nil +} diff --git a/iac/derive/provider.go b/iac/derive/provider.go new file mode 100644 index 00000000..f527592b --- /dev/null +++ b/iac/derive/provider.go @@ -0,0 +1,97 @@ +package derive + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/GoCodeAlone/workflow/iac/requirements" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +type ExternalProviderMapper struct { + Client pb.IaCProviderRequirementMapperClient +} + +func (m ExternalProviderMapper) MapRequirements(ctx context.Context, req MapRequest) (MapResult, error) { + if m.Client == nil { + return MapResult{}, fmt.Errorf("iac provider requirement mapper client is nil") + } + protoReqs := make([]*pb.IaCRequirement, 0, len(req.Requirements)) + for i := range req.Requirements { + protoReq, err := req.Requirements[i].ToProto() + if err != nil { + return MapResult{}, err + } + protoReqs = append(protoReqs, protoReq) + } + resp, err := m.Client.MapRequirements(ctx, &pb.MapRequirementsRequest{ + Provider: req.Provider, + Runtime: runtimeToProto(req.Runtime), + Environment: req.Environment, + Requirements: protoReqs, + }) + if err != nil { + return MapResult{}, err + } + return mapResultFromProto(resp) +} + +func mapResultFromProto(resp *pb.MapRequirementsResponse) (MapResult, error) { + if resp == nil { + return MapResult{}, fmt.Errorf("map requirements response is nil") + } + out := MapResult{ + AcceptedKeys: append([]string(nil), resp.GetAcceptedKeys()...), + Rejected: make([]Diagnostic, 0, len(resp.GetRejected())), + Notes: make([]Note, 0, len(resp.GetNotes())), + Modules: make([]GeneratedModule, 0, len(resp.GetModules())), + } + for _, diag := range resp.GetRejected() { + out.Rejected = append(out.Rejected, Diagnostic{ + Key: diag.GetKey(), + Code: diag.GetCode(), + Message: diag.GetMessage(), + }) + } + for _, note := range resp.GetNotes() { + out.Notes = append(out.Notes, Note{ + Key: note.GetKey(), + Message: note.GetMessage(), + Interactive: note.GetInteractive(), + }) + } + for _, mod := range resp.GetModules() { + cfg := make(map[string]any) + if len(mod.GetConfigJson()) > 0 { + if err := json.Unmarshal(mod.GetConfigJson(), &cfg); err != nil { + return MapResult{}, fmt.Errorf("derived module %q config_json: %w", mod.GetName(), err) + } + } + out.Modules = append(out.Modules, GeneratedModule{ + Name: mod.GetName(), + Type: mod.GetType(), + Satisfies: append([]string(nil), mod.GetSatisfies()...), + Config: cfg, + DependsOn: append([]string(nil), mod.GetDependsOn()...), + }) + } + return out, nil +} + +func runtimeToProto(runtime requirements.Runtime) pb.RequirementRuntime { + switch runtime { + case requirements.RuntimeKubernetes: + return pb.RequirementRuntime_REQUIREMENT_RUNTIME_KUBERNETES + case requirements.RuntimeECS: + return pb.RequirementRuntime_REQUIREMENT_RUNTIME_ECS + case requirements.RuntimeCloudRun: + return pb.RequirementRuntime_REQUIREMENT_RUNTIME_CLOUD_RUN + case requirements.RuntimeAzureContainerApps: + return pb.RequirementRuntime_REQUIREMENT_RUNTIME_AZURE_CONTAINER_APPS + case requirements.RuntimeDigitalOceanAppPlatform: + return pb.RequirementRuntime_REQUIREMENT_RUNTIME_DIGITALOCEAN_APP_PLATFORM + default: + return pb.RequirementRuntime_REQUIREMENT_RUNTIME_UNSPECIFIED + } +} diff --git a/iac/derive/provider_test.go b/iac/derive/provider_test.go new file mode 100644 index 00000000..05c67f7a --- /dev/null +++ b/iac/derive/provider_test.go @@ -0,0 +1,73 @@ +package derive + +import ( + "context" + "encoding/json" + "net" + "testing" + + "github.com/GoCodeAlone/workflow/iac/requirements" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" +) + +func TestExternalProviderMapperUsesStrictProtoClient(t *testing.T) { + listener := bufconn.Listen(1024 * 1024) + srv := grpc.NewServer() + pb.RegisterIaCProviderRequirementMapperServer(srv, mapperServer{}) + go func() { + _ = srv.Serve(listener) + }() + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient( + "passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return listener.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("grpc client: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + mapper := ExternalProviderMapper{Client: pb.NewIaCProviderRequirementMapperClient(conn)} + result, err := mapper.MapRequirements(context.Background(), MapRequest{ + Provider: "digitalocean", + Runtime: requirements.RuntimeDigitalOceanAppPlatform, + Environment: "prod", + Requirements: []requirements.Requirement{{ + Key: "observability.telemetry.default", + Kind: requirements.KindObservability, + }}, + }) + if err != nil { + t.Fatalf("map requirements: %v", err) + } + if len(result.Modules) != 1 || result.Modules[0].Name != "otel" { + t.Fatalf("modules = %#v", result.Modules) + } + if result.Modules[0].Config["image"] != "otel/opentelemetry-collector-contrib:latest" { + t.Fatalf("module config = %#v", result.Modules[0].Config) + } +} + +type mapperServer struct { + pb.UnimplementedIaCProviderRequirementMapperServer +} + +func (mapperServer) MapRequirements(_ context.Context, req *pb.MapRequirementsRequest) (*pb.MapRequirementsResponse, error) { + cfg, _ := json.Marshal(map[string]any{"image": "otel/opentelemetry-collector-contrib:latest"}) + return &pb.MapRequirementsResponse{ + AcceptedKeys: []string{req.GetRequirements()[0].GetKey()}, + Modules: []*pb.DerivedModuleSpec{{ + Name: "otel", + Type: "infra.container_service", + Satisfies: []string{req.GetRequirements()[0].GetKey()}, + ConfigJson: cfg, + }}, + }, nil +}