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
52 changes: 51 additions & 1 deletion cmd/wfctl/infra_import_all.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"github.com/GoCodeAlone/workflow/config"
"github.com/GoCodeAlone/workflow/dns/record"
"github.com/GoCodeAlone/workflow/interfaces"
)

Expand Down Expand Up @@ -46,6 +47,8 @@ func runInfraImportAll(args []string) error {
fs := flag.NewFlagSet("infra import-all", flag.ContinueOnError)
var configFile, envName, providerName, resourceType, pluginDirFlag, outputPath string
var dryRun bool
var format string
var sanitize bool
fs.StringVar(&configFile, "config", "", "Config file")
fs.StringVar(&configFile, "c", "", "Config file (short for --config)")
fs.StringVar(&envName, "env", "", "Environment name")
Expand All @@ -55,6 +58,8 @@ func runInfraImportAll(args []string) error {
fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)")
fs.StringVar(&outputPath, "output", "", "Optional: dump state-store contents to this file (in addition to the state backend)")
fs.StringVar(&outputPath, "o", "", "Output path (short for --output)")
fs.StringVar(&format, "format", "state", "Output format for --output: state|portfolio")
fs.BoolVar(&sanitize, "sanitize", false, "Portfolio only: redact TXT secrets + example-ize public IPs")
if err := fs.Parse(args); err != nil {
return err
}
Expand All @@ -64,6 +69,12 @@ func runInfraImportAll(args []string) error {
if resourceType == "" {
return fmt.Errorf("import-all requires --type (e.g. infra.dns)")
}
if format != "state" && format != "portfolio" {
return fmt.Errorf("import-all: --format %q is not valid; must be state or portfolio", format)
}
if sanitize && format != "portfolio" {
return fmt.Errorf("import-all: --sanitize requires --format portfolio")
}

// Plugin-dir flag follows the same scoped-override pattern used by
// runInfraImport: temporarily set the package-level
Expand Down Expand Up @@ -109,7 +120,13 @@ func runInfraImportAll(args []string) error {

n, dispatchErr := runInfraImportAllWithDeps(ctx, provider, providerType, store, resourceType, dryRun)
if outputPath != "" {
if werr := dumpStateToFile(ctx, store, outputPath); werr != nil {
var werr error
if format == "portfolio" {
werr = dumpPortfolioToFile(ctx, store, outputPath, sanitize)
} else {
werr = dumpStateToFile(ctx, store, outputPath)
}
if werr != nil {
// Output dump is auxiliary; surface as a warning rather than
// overwriting the dispatch error. Operators care about the
// import result first; the dump is a debug-trail bonus.
Expand Down Expand Up @@ -315,3 +332,36 @@ func dumpStateToFile(ctx context.Context, store infraStateStore, path string) er
}
return nil
}

// dumpPortfolioToFile converts the state-store contents to a canonical
// dns-portfolio export and writes it to path as JSON.
//
// This is an auxiliary dump (mirroring dumpStateToFile's auxiliary role):
// errors are surfaced as warnings by the caller and do not change the
// command exit code — the import + state persistence already succeeded.
//
// The portfolio is validated (structural only — unknown record types are
// preserved, per the design's open-set snapshot contract) before writing.
// If sanitize is true, TXT secrets + public IPs are redacted so the file
// can be committed to a public repository.
func dumpPortfolioToFile(ctx context.Context, store infraStateStore, path string, sanitize bool) error {
states, err := store.ListResources(ctx)
if err != nil {
return fmt.Errorf("list resources: %w", err)
}
p := record.FromResourceStates(states)
if sanitize {
record.Sanitize(&p)
}
if err := p.Validate(); err != nil {
return fmt.Errorf("portfolio validate: %w", err)
}
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
158 changes: 158 additions & 0 deletions cmd/wfctl/infra_import_all_format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package main

import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/GoCodeAlone/workflow/dns/record"
"github.com/GoCodeAlone/workflow/interfaces"
)

// TestDumpPortfolioToFile pins the --format portfolio output contract:
// dumpPortfolioToFile produces a JSON file whose top-level "schema" is
// "workflow.dns-portfolio.export.v1" and whose snapshots[0].records[0].value
// is set (alias-map collapsed the provider "data" key).
func TestDumpPortfolioToFile(t *testing.T) {
store := &fakeStateStore{}
_ = store.SaveResource(context.Background(), interfaces.ResourceState{
ID: "do-example-com",
Name: "do-example-com",
Type: "infra.dns",
Provider: "digitalocean",
ProviderID: "example.com",
Outputs: map[string]any{
"records": []any{
map[string]any{"type": "A", "name": "@", "data": "192.0.2.10", "ttl": 300},
},
},
})
dir := t.TempDir()
out := filepath.Join(dir, "portfolio.json")
if err := dumpPortfolioToFile(context.Background(), store, out, false); err != nil {
t.Fatalf("dumpPortfolioToFile: %v", err)
}
data, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read: %v", err)
}
// Must have the canonical schema field
if !strings.Contains(string(data), record.SchemaV1) {
t.Errorf("portfolio missing schema %q: %s", record.SchemaV1, data)
}
// Must use "value" key, not "data"
if !strings.Contains(string(data), `"value"`) {
t.Errorf("portfolio missing 'value' key: %s", data)
}
if strings.Contains(string(data), `"resources"`) {
t.Errorf("portfolio format must not have 'resources' key (that is --format state): %s", data)
}
// Verify snapshots[0].records[0].value is set
var p record.Portfolio
if err := json.Unmarshal(data, &p); err != nil {
t.Fatalf("unmarshal portfolio: %v", err)
}
if len(p.Snapshots) != 1 {
t.Fatalf("want 1 snapshot; got %d", len(p.Snapshots))
}
if len(p.Snapshots[0].Records) == 0 {
t.Fatal("want records in snapshot; got none")
}
if p.Snapshots[0].Records[0].Value == "" {
t.Fatal("snapshots[0].records[0].value is empty")
}
}

// TestDumpPortfolioToFile_WithSanitize pins I-3: dumpPortfolioToFile(...,true)
// with a genuinely-public IP in a record produces a portfolio with
// Sanitized==true and the record value redacted to an RFC-5737 example IP.
func TestDumpPortfolioToFile_WithSanitize(t *testing.T) {
store := &fakeStateStore{}
_ = store.SaveResource(context.Background(), interfaces.ResourceState{
ID: "do-example-com",
Name: "do-example-com",
Type: "infra.dns",
Provider: "digitalocean",
ProviderID: "example.com",
Outputs: map[string]any{
"records": []any{
// 8.8.8.8 is genuinely public (Google DNS), NOT RFC-5737.
map[string]any{"type": "A", "name": "@", "data": "8.8.8.8", "ttl": 300},
},
},
})
dir := t.TempDir()
out := filepath.Join(dir, "portfolio.json")
if err := dumpPortfolioToFile(context.Background(), store, out, true); err != nil {
t.Fatalf("dumpPortfolioToFile sanitize: %v", err)
}
data, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read: %v", err)
}
var p record.Portfolio
if err := json.Unmarshal(data, &p); err != nil {
t.Fatalf("unmarshal portfolio: %v", err)
}
if !p.Sanitized {
t.Error("sanitized portfolio must have sanitized==true")
}
if len(p.Snapshots) != 1 || len(p.Snapshots[0].Records) == 0 {
t.Fatalf("want 1 snapshot with records; got %d snapshots", len(p.Snapshots))
}
got := p.Snapshots[0].Records[0].Value
if got == "8.8.8.8" {
t.Error("public IP 8.8.8.8 was NOT redacted in sanitized dump")
}
if !strings.HasPrefix(got, "192.0.2.") && !strings.HasPrefix(got, "198.51.100.") && !strings.HasPrefix(got, "203.0.113.") {
t.Errorf("sanitized A value = %q; want an RFC-5737 example IP", got)
}
}

// TestDumpStateToFile_StillWorksAsDefault pins that --format state (the
// default) still produces {"resources":[...]} format unchanged.
func TestDumpStateToFile_StillWorksAsDefault(t *testing.T) {
store := &fakeStateStore{}
_ = store.SaveResource(context.Background(), interfaces.ResourceState{
Name: "alpha", Type: "infra.dns", ProviderID: "alpha.test",
})
dir := t.TempDir()
out := filepath.Join(dir, "state.json")
if err := dumpStateToFile(context.Background(), store, out); err != nil {
t.Fatalf("dumpStateToFile: %v", err)
}
data, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read: %v", err)
}
if !strings.Contains(string(data), `"resources"`) {
t.Errorf("state format missing 'resources' key: %s", data)
}
}

// TestImportAllFormatFlagRejectsUnknown pins that --format with an
// unrecognized value returns an error.
func TestImportAllFormatFlagRejectsUnknown(t *testing.T) {
err := runInfraImportAll([]string{"--provider", "do", "--type", "infra.dns", "--format", "xml"})
if err == nil {
t.Fatal("want error for unknown --format; got nil")
}
if !strings.Contains(err.Error(), "format") {
t.Errorf("error should mention 'format'; got: %v", err)
}
}

// TestImportAllSanitizeFlagRequiresPortfolioFormat pins that --sanitize
// is rejected unless --format portfolio is also set.
func TestImportAllSanitizeFlagRequiresPortfolioFormat(t *testing.T) {
err := runInfraImportAll([]string{"--provider", "do", "--type", "infra.dns", "--sanitize"})
if err == nil {
t.Fatal("want error for --sanitize without --format portfolio; got nil")
}
if !strings.Contains(err.Error(), "sanitize") {
t.Errorf("error should mention 'sanitize'; got: %v", err)
}
}
128 changes: 128 additions & 0 deletions dns/record/canonicalize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package record

import "github.com/GoCodeAlone/workflow/interfaces"

// FromResourceStates converts imported IaC state into a canonical Portfolio.
// Reads each infra.dns ResourceState's records (Outputs preferred, else
// AppliedConfig), renaming provider-specific value keys to the canonical "value".
//
// Provider value-key divergence (verified against provider drivers):
// - DigitalOcean + Cloudflare emit "data"
// - Hover emits "content" (workflow-plugin-hover/internal/drivers/dns.go:538)
// - Namecheap emits "address"
//
// The valueAlias helper resolves the first non-empty of: data → content → address → value.
// Non-infra.dns states are silently skipped.
func FromResourceStates(states []interfaces.ResourceState) Portfolio {
p := Portfolio{Schema: SchemaV1}
for i := range states {
st := &states[i]
if st.Type != "infra.dns" {
continue
}
recs := pickRecords(st.Outputs, st.AppliedConfig)
snap := Snapshot{
ID: st.ID,
Provider: st.Provider,
Domain: st.ProviderID,
}
// Fall back to AppliedConfig["domain"] if ProviderID is empty.
if snap.Domain == "" {
if d, ok := st.AppliedConfig["domain"].(string); ok {
snap.Domain = d
}
}
for _, raw := range recs {
m, ok := raw.(map[string]any)
if !ok {
continue
}
snap.Records = append(snap.Records, recordFromMap(m))
}
p.Snapshots = append(p.Snapshots, snap)
}
return p
}

// pickRecords returns the records slice from Outputs if non-empty,
// otherwise falls back to AppliedConfig.
func pickRecords(outputs, appliedConfig map[string]any) []any {
if recs, ok := outputs["records"].([]any); ok && len(recs) > 0 {
return recs
}
if recs, ok := appliedConfig["records"].([]any); ok {
return recs
}
return nil
}

// recordFromMap converts a provider record map to a canonical Record.
// Value is resolved by the first-non-empty alias chain:
// "data" → "content" → "address" → "value"
func recordFromMap(m map[string]any) Record {
r := Record{
Type: stringVal(m, "type"),
Name: stringVal(m, "name"),
Value: valueAlias(m),
TTL: intVal(m, "ttl"),
Tag: stringVal(m, "tag"),
}
// I-1: store the value when the KEY is present regardless of its numeric
// value — a present zero is meaningful (RFC-7505 null-MX priority=0,
// SRV weight=0, port=0). Dropping zeros would silently corrupt the record.
if v, ok := m["priority"]; ok {
n := toInt(v)
r.Priority = &n
}
if v, ok := m["port"]; ok {
n := toInt(v)
r.Port = &n
}
if v, ok := m["weight"]; ok {
n := toInt(v)
r.Weight = &n
}
if v, ok := m["flags"]; ok {
n := toInt(v)
r.Flags = &n
}
return r
}

// valueAlias resolves the canonical record value from provider-specific key names.
// DO/CF use "data", Hover uses "content", Namecheap uses "address"; canonical emits "value".
func valueAlias(m map[string]any) string {
for _, k := range []string{"data", "content", "address", "value"} {
if v, ok := m[k].(string); ok && v != "" {
return v
}
}
return ""
}

func stringVal(m map[string]any, key string) string {
v, _ := m[key].(string)
return v
}

func intVal(m map[string]any, key string) int {
v, ok := m[key]
if !ok {
return 0
}
return toInt(v)
}

func toInt(v any) int {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case float32:
return int(n)
}
return 0
}
Loading
Loading