diff --git a/CHANGELOG.md b/CHANGELOG.md index c60b07c..f888769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Parser: `parser.ParseState` reads a Terraform state document + (`terraform show -json` with no plan argument), so resources carry the + identifiers state records, such as a bucket's real name. It is the + groundwork for `c3x usage sync` (#97); no command uses it yet. A plan or + a raw `terraform.tfstate` is rejected with an error rather than parsed to + nothing, and deposed objects are skipped so an address is one resource. - Catalog: `aws_keyspaces_table`, `aws_s3tables_table_bucket`, `aws_cloudfront_function`, `aws_ec2_transit_gateway_peering_attachment` and `aws_ami_copy` are priced, and 17 more AWS kinds are recognised as diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 318848a..3ac1f0d 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -216,6 +216,23 @@ func ParsePostApply(path string, opts Options) ([]domain.Resource, error) { return Parse(path, opts) } +// ParseState returns the resources recorded in a Terraform state document +// (`terraform show -json` with no plan argument), with the same +// enrichment as [Parse]. Resources carry real identifiers and the region +// the provider reported, which is what usage sync maps metrics onto. +func ParseState(path string, opts Options) ([]domain.Resource, error) { + if opts.Logger == nil { + opts.Logger = slog.Default() + } + out, err := plan.ParseStateFile(path, opts.Logger) + if err != nil { + return nil, err + } + applyInheritance(out) + applyResourceRegions(out) + return out, nil +} + func toTerraformOptions(o Options) terraform.Options { return terraform.Options{ VarFiles: o.VarFiles, diff --git a/internal/parser/plan/plan.go b/internal/parser/plan/plan.go index e270ae2..a03d5dc 100644 --- a/internal/parser/plan/plan.go +++ b/internal/parser/plan/plan.go @@ -6,6 +6,7 @@ package plan import ( "encoding/json" + "errors" "fmt" "log/slog" "os" @@ -195,16 +196,61 @@ func ParseBaselineBytes(raw []byte, logger *slog.Logger) ([]domain.Resource, boo return out, hasBaseline, nil } +// ParseStateFile reads a Terraform state document (`terraform show -json` +// with no plan argument) and returns the resources it records. +func ParseStateFile(path string, logger *slog.Logger) ([]domain.Resource, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + return ParseStateBytes(raw, logger) +} + +// ParseStateBytes parses an in-memory state document. Its resources carry +// the real identifiers (a bucket's name) rather than configuration +// expressions, which is why usage sync reads it. An empty state yields no +// resources; a plan or a raw terraform.tfstate is an error, since both +// would otherwise parse to nothing. Deposed objects are skipped so one +// address maps to one resource. +func ParseStateBytes(raw []byte, logger *slog.Logger) ([]domain.Resource, error) { + if logger == nil { + logger = slog.Default() + } + var doc planFile + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + if doc.Values == nil { + if doc.PlannedValues != nil || len(doc.ResourceChanges) > 0 { + return nil, errors.New("this is a plan, not a state: use `terraform show -json` without a plan file") + } + if doc.Resources != nil || doc.Version != nil { + return nil, errors.New("this is a raw Terraform state file, not `terraform show -json` output: " + + "run `terraform show -json > state.json` and pass that") + } + return nil, nil + } + var out []domain.Resource + walkModule(doc.Values.RootModule, defaultRegion(doc.Configuration), nil, true, &out) + logger.Debug("state parsed", "resources", len(out)) + return out, nil +} + // collectPlanned walks a planned_values module tree, appending every // managed resource. Data sources and null child-module entries (which // untrusted plan JSON can contain) are skipped rather than dereferenced. // Each resource is annotated with its PlanAction from the actions map. func collectPlanned(mod *plannedModule, region string, actions map[string]domain.PlanAction, out *[]domain.Resource) { + walkModule(mod, region, actions, false, out) +} + +// walkModule is [collectPlanned], optionally skipping deposed state objects. +func walkModule(mod *plannedModule, region string, actions map[string]domain.PlanAction, skipDeposed bool, out *[]domain.Resource) { if mod == nil { return } for _, pr := range mod.Resources { - if pr.Mode != "managed" { + if pr.Mode != "managed" || (skipDeposed && pr.DeposedKey != "") { continue } attrs, _ := pr.Values.(map[string]any) @@ -217,7 +263,7 @@ func collectPlanned(mod *plannedModule, region string, actions map[string]domain *out = append(*out, r) } for _, child := range mod.ChildModules { - collectPlanned(child, region, actions, out) + walkModule(child, region, actions, skipDeposed, out) } } @@ -353,7 +399,12 @@ func defaultRegion(cfg *configuration) string { // planFile mirrors the fields we care about from `terraform show -json`. // Anything not declared here is ignored at decode time. type planFile struct { + // Values is the top-level `values` of a state document; a plan has + // planned_values instead. + Values *plannedValues `json:"values"` PlannedValues *plannedValues `json:"planned_values"` + Version json.RawMessage `json:"version"` + Resources json.RawMessage `json:"resources"` ResourceChanges []resourceChange `json:"resource_changes"` Configuration *configuration `json:"configuration"` } @@ -369,11 +420,12 @@ type plannedModule struct { } type plannedResource struct { - Address string `json:"address"` - Mode string `json:"mode"` - Type string `json:"type"` - Name string `json:"name"` - Values any `json:"values"` + Address string `json:"address"` + Mode string `json:"mode"` + Type string `json:"type"` + Name string `json:"name"` + Values any `json:"values"` + DeposedKey string `json:"deposed_key"` } type resourceChange struct { diff --git a/internal/parser/plan/state_test.go b/internal/parser/plan/state_test.go new file mode 100644 index 0000000..1671ba0 --- /dev/null +++ b/internal/parser/plan/state_test.go @@ -0,0 +1,179 @@ +package plan_test + +import ( + "strings" + "testing" + + "github.com/c3xdev/c3x/internal/parser/plan" +) + +const stateJSON = `{ + "format_version": "1.0", + "terraform_version": "1.9.0", + "values": { + "root_module": { + "resources": [ + { + "address": "aws_s3_bucket.data", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "data", + "values": { "bucket": "acme-data", "region": "eu-west-1" } + }, + { + "address": "data.aws_caller_identity.me", + "mode": "data", + "type": "aws_caller_identity", + "name": "me", + "values": { "account_id": "123456789012" } + } + ], + "child_modules": [ + { + "address": "module.m", + "resources": [ + { + "address": "module.m.aws_s3_bucket.x[\"a\"]", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "x", + "index": "a", + "values": { "bucket": "acme-x-a" } + } + ], + "child_modules": [null] + } + ] + } + } +}` + +func TestParseState_ReadsResourcesAcrossModules(t *testing.T) { + t.Parallel() + + got, err := plan.ParseStateBytes([]byte(stateJSON), nil) + if err != nil { + t.Fatalf("ParseStateBytes: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 managed resources (data source skipped), got %d: %+v", len(got), got) + } + + root, nested := got[0], got[1] + if root.Ref.Label() != "aws_s3_bucket.data" { + t.Errorf("root label = %q", root.Ref.Label()) + } + if root.Attributes["bucket"] != "acme-data" { + t.Errorf("root bucket = %v, want the real name from state", root.Attributes["bucket"]) + } + if root.Region == nil || *root.Region != "eu-west-1" { + t.Errorf("root region = %v, want eu-west-1 from values.region", root.Region) + } + + // The label must equal the state address: it is the key a usage file + // is looked up by. + if want := `module.m.aws_s3_bucket.x["a"]`; nested.Ref.Label() != want { + t.Errorf("nested label = %q, want %q", nested.Ref.Label(), want) + } + if nested.Region != nil { + t.Errorf("nested region = %v, want nil when state carries none", *nested.Region) + } +} + +func TestParseState_EmptyStateIsNotAnError(t *testing.T) { + t.Parallel() + + for _, raw := range []string{`{"format_version":"1.0"}`, `{"values":{}}`, `{"values":{"root_module":{}}}`} { + got, err := plan.ParseStateBytes([]byte(raw), nil) + if err != nil || len(got) != 0 { + t.Errorf("%s: got %d resources, err %v; want none and no error", raw, len(got), err) + } + } +} + +func TestParseState_RejectsAPlan(t *testing.T) { + t.Parallel() + + for name, raw := range map[string]string{ + "planned_values": `{"planned_values":{"root_module":{}}}`, + "resource_changes": `{"resource_changes":[{"address":"a.b","type":"a","name":"b","change":{"actions":["create"]}}]}`, + } { + _, err := plan.ParseStateBytes([]byte(raw), nil) + if err == nil || !strings.Contains(err.Error(), "a plan, not a state") { + t.Errorf("%s: err = %v, want a plan-not-state error", name, err) + } + } +} + +func TestParseState_RejectsInvalidJSON(t *testing.T) { + t.Parallel() + + if _, err := plan.ParseStateBytes([]byte("{not json"), nil); err == nil { + t.Fatal("want an error for invalid JSON") + } +} + +// A raw terraform.tfstate has no `values` and used to parse to nothing. +func TestParseState_RejectsARawStateFile(t *testing.T) { + t.Parallel() + + for name, raw := range map[string]string{ + "with resources": `{"version":4,"terraform_version":"1.9.0","serial":3,"lineage":"abc","outputs":{}, + "resources":[{"mode":"managed","type":"aws_s3_bucket","name":"data","provider":"provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances":[{"schema_version":0,"attributes":{"bucket":"acme-data"}}]}]}`, + "empty": `{"version":4,"terraform_version":"1.9.0","serial":1,"lineage":"abc","outputs":{},"resources":[]}`, + "version only": `{"version":4}`, + } { + _, err := plan.ParseStateBytes([]byte(raw), nil) + if err == nil || !strings.Contains(err.Error(), "terraform show -json") || !strings.Contains(err.Error(), "raw Terraform state") { + t.Errorf("%s: err = %v, want an error telling the user to run terraform show -json", name, err) + } + } +} + +// A create_before_destroy replacement lists the old object (deposed_key) +// under the same address as the new one; only the current one is kept. +func TestParseState_SkipsDeposedObjects(t *testing.T) { + t.Parallel() + + current := `{"address":"aws_s3_bucket.data","mode":"managed","type":"aws_s3_bucket","name":"data", + "values":{"bucket":"acme-data-new"}}` + deposed := `{"address":"aws_s3_bucket.data","mode":"managed","type":"aws_s3_bucket","name":"data", + "deposed_key":"0f6b8c1d","values":{"bucket":"acme-data-old"}}` + + for name, resources := range map[string]string{ + "deposed first": deposed + "," + current, + "current first": current + "," + deposed, + } { + raw := `{"values":{"root_module":{"resources":[` + resources + `], + "child_modules":[{"address":"module.m","resources":[` + + strings.ReplaceAll(strings.ReplaceAll(deposed, "aws_s3_bucket.data", "module.m.aws_s3_bucket.data"), "acme-data-old", "acme-m-old") + + `]}]}}}` + got, err := plan.ParseStateBytes([]byte(raw), nil) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if len(got) != 1 { + t.Fatalf("%s: got %d resources, want 1 (deposed objects skipped): %+v", name, len(got), got) + } + if got[0].Attributes["bucket"] != "acme-data-new" { + t.Errorf("%s: bucket = %v, want the current object's name", name, got[0].Attributes["bucket"]) + } + } +} + +// The deposed filter applies to state only. +func TestParsePlan_IsUnaffectedByTheDeposedFilter(t *testing.T) { + t.Parallel() + + raw := `{"planned_values":{"root_module":{"resources":[ + {"address":"aws_s3_bucket.data","mode":"managed","type":"aws_s3_bucket","name":"data", + "deposed_key":"x","values":{"bucket":"acme-data"}}]}}}` + got, err := plan.ParseBytes([]byte(raw), nil) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Errorf("plan parsed %d resources, want 1: the deposed filter is state-only", len(got)) + } +} diff --git a/internal/parser/state_test.go b/internal/parser/state_test.go new file mode 100644 index 0000000..a03b89f --- /dev/null +++ b/internal/parser/state_test.go @@ -0,0 +1,43 @@ +package parser_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/c3xdev/c3x/internal/parser" +) + +func TestParseStateReadsAStateFile(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "state.json") + state := `{"values":{"root_module":{"resources":[{ + "address":"aws_s3_bucket.data","mode":"managed","type":"aws_s3_bucket","name":"data", + "values":{"bucket":"acme-data","region":"ap-northeast-1"}}]}}}` + if err := os.WriteFile(path, []byte(state), 0o600); err != nil { + t.Fatal(err) + } + + got, err := parser.ParseState(path, parser.Options{}) + if err != nil { + t.Fatalf("ParseState: %v", err) + } + if len(got) != 1 { + t.Fatalf("want 1 resource, got %d", len(got)) + } + if got[0].Attributes["bucket"] != "acme-data" { + t.Errorf("bucket = %v", got[0].Attributes["bucket"]) + } + if got[0].Region == nil || *got[0].Region != "ap-northeast-1" { + t.Errorf("region = %v, want ap-northeast-1", got[0].Region) + } +} + +func TestParseStateRejectsMissingFile(t *testing.T) { + t.Parallel() + + if _, err := parser.ParseState(filepath.Join(t.TempDir(), "nope.json"), parser.Options{}); err == nil { + t.Fatal("want an error for a missing file") + } +}