Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions internal/parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions internal/parser/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package plan

import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"`
Expand Down
114 changes: 114 additions & 0 deletions internal/parser/plan/state_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
43 changes: 43 additions & 0 deletions internal/parser/state_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading