mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
b23aed034f
All map iterations in ConvertState now use sorted helpers instead of ranging over Go maps directly. Previously only coder_env and coder_script were sorted (via sortedResourcesByType). This extends the pattern to coder_agent, coder_devcontainer, coder_agent_instance, coder_app, coder_metadata, coder_external_auth, and the main resource output list. Also fixes generate.sh writing version.txt to the wrong directory (resources/ instead of testdata/), which caused the Makefile version check to silently desync and trigger unnecessary regeneration. Adds TestConvertStateDeterministic that calls ConvertState 10 times per fixture and asserts byte-identical JSON output without any post-hoc sorting.
228 lines
7.3 KiB
Go
228 lines
7.3 KiB
Go
//go:build linux || darwin
|
|
|
|
package terraform_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
tfjson "github.com/hashicorp/terraform-json"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/provisioner/terraform"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// TestConvertStateGolden compares the output of ConvertState to a golden
|
|
// file to prevent regressions. If the logic changes, update the golden files
|
|
// accordingly.
|
|
//
|
|
// This was created to aid in refactoring `ConvertState`.
|
|
func TestConvertStateGolden(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testResourceDirectories := filepath.Join("testdata", "resources")
|
|
entries, err := os.ReadDir(testResourceDirectories)
|
|
require.NoError(t, err)
|
|
|
|
for _, testDirectory := range entries {
|
|
if !testDirectory.IsDir() {
|
|
continue
|
|
}
|
|
|
|
testFiles, err := os.ReadDir(filepath.Join(testResourceDirectories, testDirectory.Name()))
|
|
require.NoError(t, err)
|
|
|
|
// ConvertState works on both a plan file and a state file.
|
|
// The test should create a golden file for both.
|
|
for _, step := range []string{"plan", "state"} {
|
|
srcIdc := slices.IndexFunc(testFiles, func(entry os.DirEntry) bool {
|
|
return strings.HasSuffix(entry.Name(), fmt.Sprintf(".tf%s.json", step))
|
|
})
|
|
dotIdx := slices.IndexFunc(testFiles, func(entry os.DirEntry) bool {
|
|
return strings.HasSuffix(entry.Name(), fmt.Sprintf(".tf%s.dot", step))
|
|
})
|
|
|
|
// If the directory is missing these files, we cannot run ConvertState
|
|
// on it. So it's skipped.
|
|
if srcIdc == -1 || dotIdx == -1 {
|
|
continue
|
|
}
|
|
|
|
t.Run(step+"_"+testDirectory.Name(), func(t *testing.T) {
|
|
t.Parallel()
|
|
testDirectoryPath := filepath.Join(testResourceDirectories, testDirectory.Name())
|
|
planFile := filepath.Join(testDirectoryPath, testFiles[srcIdc].Name())
|
|
dotFile := filepath.Join(testDirectoryPath, testFiles[dotIdx].Name())
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
logger := slogtest.Make(t, nil)
|
|
|
|
// Gather plan
|
|
tfStepRaw, err := os.ReadFile(planFile)
|
|
require.NoError(t, err)
|
|
|
|
var modules []*tfjson.StateModule
|
|
switch step {
|
|
case "plan":
|
|
var tfPlan tfjson.Plan
|
|
err = json.Unmarshal(tfStepRaw, &tfPlan)
|
|
require.NoError(t, err)
|
|
|
|
modules = []*tfjson.StateModule{tfPlan.PlannedValues.RootModule}
|
|
if tfPlan.PriorState != nil {
|
|
modules = append(modules, tfPlan.PriorState.Values.RootModule)
|
|
}
|
|
case "state":
|
|
var tfState tfjson.State
|
|
err = json.Unmarshal(tfStepRaw, &tfState)
|
|
require.NoError(t, err)
|
|
modules = []*tfjson.StateModule{tfState.Values.RootModule}
|
|
default:
|
|
t.Fatalf("unknown step: %s", step)
|
|
}
|
|
|
|
// Gather graph
|
|
dotFileRaw, err := os.ReadFile(dotFile)
|
|
require.NoError(t, err)
|
|
|
|
// expectedOutput is `any` to support errors too. If `ConvertState` returns an
|
|
// error, that error is the golden file output.
|
|
var expectedOutput any
|
|
state, err := terraform.ConvertState(ctx, modules, string(dotFileRaw), logger)
|
|
if err == nil {
|
|
sortResources(state.Resources)
|
|
sortExternalAuthProviders(state.ExternalAuthProviders)
|
|
deterministicAppIDs(state.Resources)
|
|
expectedOutput = state
|
|
} else {
|
|
// Write the error to the file then. Track errors as much as valid paths.
|
|
expectedOutput = err.Error()
|
|
}
|
|
|
|
expPath := filepath.Join(testDirectoryPath, fmt.Sprintf("converted_state.%s.golden", step))
|
|
if *updateGoldenFiles {
|
|
gotBytes, err := json.MarshalIndent(expectedOutput, "", " ")
|
|
require.NoError(t, err, "marshaling converted state to JSON")
|
|
// Newline at end of file for git purposes
|
|
err = os.WriteFile(expPath, append(gotBytes, '\n'), 0o600)
|
|
require.NoError(t, err)
|
|
return
|
|
}
|
|
|
|
gotBytes, err := json.Marshal(expectedOutput)
|
|
require.NoError(t, err, "marshaling converted state to JSON")
|
|
|
|
expBytes, err := os.ReadFile(expPath)
|
|
require.NoError(t, err)
|
|
|
|
require.JSONEq(t, string(expBytes), string(gotBytes), "converted state")
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestConvertStateDeterministic verifies that ConvertState produces
|
|
// identical output across multiple runs. This catches non-deterministic
|
|
// map iteration in the implementation. Unlike TestConvertStateGolden,
|
|
// this test does NOT sort the output — it relies on ConvertState itself
|
|
// being deterministic.
|
|
func TestConvertStateDeterministic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testResourceDirectories := filepath.Join("testdata", "resources")
|
|
entries, err := os.ReadDir(testResourceDirectories)
|
|
require.NoError(t, err)
|
|
|
|
for _, testDirectory := range entries {
|
|
if !testDirectory.IsDir() {
|
|
continue
|
|
}
|
|
|
|
testFiles, err := os.ReadDir(filepath.Join(testResourceDirectories, testDirectory.Name()))
|
|
require.NoError(t, err)
|
|
|
|
for _, step := range []string{"plan", "state"} {
|
|
srcIdx := slices.IndexFunc(testFiles, func(entry os.DirEntry) bool {
|
|
return strings.HasSuffix(entry.Name(), fmt.Sprintf(".tf%s.json", step))
|
|
})
|
|
dotIdx := slices.IndexFunc(testFiles, func(entry os.DirEntry) bool {
|
|
return strings.HasSuffix(entry.Name(), fmt.Sprintf(".tf%s.dot", step))
|
|
})
|
|
|
|
if srcIdx == -1 || dotIdx == -1 {
|
|
continue
|
|
}
|
|
|
|
t.Run(step+"_"+testDirectory.Name(), func(t *testing.T) {
|
|
t.Parallel()
|
|
testDirectoryPath := filepath.Join(testResourceDirectories, testDirectory.Name())
|
|
planFile := filepath.Join(testDirectoryPath, testFiles[srcIdx].Name())
|
|
dotFile := filepath.Join(testDirectoryPath, testFiles[dotIdx].Name())
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
logger := slogtest.Make(t, nil)
|
|
|
|
tfStepRaw, err := os.ReadFile(planFile)
|
|
require.NoError(t, err)
|
|
|
|
var modules []*tfjson.StateModule
|
|
switch step {
|
|
case "plan":
|
|
var tfPlan tfjson.Plan
|
|
err = json.Unmarshal(tfStepRaw, &tfPlan)
|
|
require.NoError(t, err)
|
|
modules = []*tfjson.StateModule{tfPlan.PlannedValues.RootModule}
|
|
if tfPlan.PriorState != nil {
|
|
modules = append(modules, tfPlan.PriorState.Values.RootModule)
|
|
}
|
|
case "state":
|
|
var tfState tfjson.State
|
|
err = json.Unmarshal(tfStepRaw, &tfState)
|
|
require.NoError(t, err)
|
|
modules = []*tfjson.StateModule{tfState.Values.RootModule}
|
|
default:
|
|
t.Fatalf("unknown step: %s", step)
|
|
}
|
|
|
|
dotFileRaw, err := os.ReadFile(dotFile)
|
|
require.NoError(t, err)
|
|
|
|
// Run ConvertState 10 times and verify all runs
|
|
// produce byte-identical JSON without any sorting.
|
|
// We apply deterministicAppIDs because plan files
|
|
// lack provider-assigned IDs, causing ConvertState
|
|
// to generate random UUIDs as a fallback.
|
|
//
|
|
// Note: json.Marshal sorts map keys, so this test
|
|
// cannot catch non-determinism in map-valued fields
|
|
// like Agent.Env. Those are populated from static
|
|
// testdata today, so this is not a practical gap.
|
|
const runs = 10
|
|
outputs := make([][]byte, runs)
|
|
for i := range runs {
|
|
state, err := terraform.ConvertState(ctx, modules, string(dotFileRaw), logger)
|
|
if err != nil {
|
|
// Error strings are deterministic.
|
|
outputs[i] = []byte(err.Error())
|
|
continue
|
|
}
|
|
deterministicAppIDs(state.Resources)
|
|
outputs[i], err = json.Marshal(state)
|
|
require.NoError(t, err, "run %d: marshal state", i)
|
|
}
|
|
for i := 1; i < runs; i++ {
|
|
require.Equal(t, string(outputs[0]), string(outputs[i]),
|
|
"ConvertState produced different output on run %d vs run 0", i)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|