From 9ee18285f077b84241346cabe89ff443ff808b01 Mon Sep 17 00:00:00 2001 From: "A. Sakashita" Date: Fri, 2 Oct 2026 10:40:32 +0000 Subject: [PATCH] feat(parser): parse terraform show -json state files A state document has a top-level `values` where a plan has `planned_values`, so passing one to the plan parser returned zero resources and no error. Add ParseState, which reads `values` with the existing module walker and applies the usual inheritance and region enrichment, and reject a plan passed by mistake instead of silently returning nothing. State records the real identifiers (a bucket's name) that expressions in configuration often cannot resolve; `c3x usage sync` (#97, design in #99) needs them to map a Terraform address to a cloud resource. Nothing calls ParseState yet. --- CHANGELOG.md | 4 + internal/parser/parser.go | 17 +++++ internal/parser/plan/plan.go | 41 +++++++++++ internal/parser/plan/state_test.go | 114 +++++++++++++++++++++++++++++ internal/parser/state_test.go | 43 +++++++++++ 5 files changed, 219 insertions(+) create mode 100644 internal/parser/plan/state_test.go create mode 100644 internal/parser/state_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c60b07c..e311b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ 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. - 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..3bc9b4b 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,6 +196,43 @@ 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, a function's ARN) rather than the +// expressions in configuration, which is why usage sync reads it. The +// module tree has the same shape as a plan's planned_values, so it is +// walked by [collectPlanned]. A state records no intent, so Action stays +// unset. An empty state (no `values`) yields no resources; a plan is +// rejected, since silently returning nothing for it would hide a wrong file. +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") + } + return nil, nil + } + var out []domain.Resource + collectPlanned(doc.Values.RootModule, defaultRegion(doc.Configuration), nil, &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. @@ -353,6 +391,9 @@ 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"` ResourceChanges []resourceChange `json:"resource_changes"` Configuration *configuration `json:"configuration"` diff --git a/internal/parser/plan/state_test.go b/internal/parser/plan/state_test.go new file mode 100644 index 0000000..62a10ee --- /dev/null +++ b/internal/parser/plan/state_test.go @@ -0,0 +1,114 @@ +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") + } +} 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") + } +}