Skip to content
Closed
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
1,041 changes: 1,041 additions & 0 deletions provisioner/terraform/convertstate.go

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion provisioner/terraform/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ func hasExternalAgentResources(graph *gographviz.Graph) bool {
// ConvertState consumes Terraform state and a GraphViz representation
// produced by `terraform graph` to produce resources consumable by Coder.
// nolint:gocognit // This function makes more sense being large for now, until refactored.
func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph string, logger slog.Logger) (*State, error) {
func ConvertState2(ctx context.Context, modules []*tfjson.StateModule, rawGraph string, logger slog.Logger) (*State, error) {
parsedGraph, err := gographviz.ParseString(rawGraph)
if err != nil {
return nil, xerrors.Errorf("parse graph: %w", err)
Expand Down
128 changes: 128 additions & 0 deletions provisioner/terraform/resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ package terraform_test

import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"slices"
"sort"
"strings"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
tfjson "github.com/hashicorp/terraform-json"
"github.com/stretchr/testify/require"
protobuf "google.golang.org/protobuf/proto"
Expand All @@ -30,6 +33,116 @@ func ctxAndLogger(t *testing.T) (context.Context, slog.Logger) {
return context.Background(), testutil.Logger(t)
}

// 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) {
// Load the paths before t.Parallel()
testDirectoryPath := filepath.Join(testResourceDirectories, testDirectory.Name())
planFile := filepath.Join(testDirectoryPath, testFiles[srcIdc].Name())
dotFile := filepath.Join(testDirectoryPath, testFiles[dotIdx].Name())

t.Parallel()
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")
})
}
}
}

func TestConvertResources(t *testing.T) {
t.Parallel()
// nolint:dogsled
Expand Down Expand Up @@ -1669,3 +1782,18 @@ func sortExternalAuthProviders(providers []*proto.ExternalAuthProviderResource)
return strings.Compare(providers[i].Id, providers[j].Id) == -1
})
}

// deterministicAppIDs handles setting agent app ids to something deterministic.
// In plan files, ids are not present. In state files, they are.
// It is simpler for comparisons if we just set it to something deterministic.
func deterministicAppIDs(resources []*proto.Resource) {
for _, resource := range resources {
for _, agent := range resource.Agents {
for _, app := range agent.Apps {
data := sha256.Sum256([]byte(app.Slug + app.DisplayName))
id, _ := uuid.FromBytes(data[:16])
app.Id = id.String()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"Resources": [
{
"name": "a",
"type": "coder_ai_task"
}
],
"Parameters": [],
"Presets": [],
"ExternalAuthProviders": [],
"AITasks": [
{
"sidebar_app": {
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
},
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
}
],
"HasAITasks": true,
"HasExternalAgents": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"Resources": [
{
"name": "a",
"type": "coder_ai_task"
}
],
"Parameters": [],
"Presets": [],
"ExternalAuthProviders": [],
"AITasks": [
{
"id": "c4f032b8-97e4-42b0-aa2f-30a9e698f8d4",
"sidebar_app": {
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
},
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
}
],
"HasAITasks": true,
"HasExternalAgents": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"Resources": [
{
"name": "a",
"type": "coder_ai_task"
},
{
"name": "b",
"type": "coder_ai_task"
}
],
"Parameters": [],
"Presets": [],
"ExternalAuthProviders": [],
"AITasks": [
{
"sidebar_app": {
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
},
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
},
{
"sidebar_app": {
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
},
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
}
],
"HasAITasks": true,
"HasExternalAgents": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"Resources": [
{
"name": "a",
"type": "coder_ai_task"
},
{
"name": "b",
"type": "coder_ai_task"
}
],
"Parameters": [],
"Presets": [],
"ExternalAuthProviders": [],
"AITasks": [
{
"id": "89e6ab36-2e98-4d13-9b4c-69b7588b7e1d",
"sidebar_app": {
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
},
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
},
{
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd",
"sidebar_app": {
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
},
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
}
],
"HasAITasks": true,
"HasExternalAgents": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"Resources": [
{
"name": "a",
"type": "coder_ai_task"
}
],
"Parameters": [],
"Presets": [],
"ExternalAuthProviders": [],
"AITasks": [
{
"sidebar_app": {
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
},
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
}
],
"HasAITasks": true,
"HasExternalAgents": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"Resources": [
{
"name": "a",
"type": "coder_ai_task"
}
],
"Parameters": [],
"Presets": [],
"ExternalAuthProviders": [],
"AITasks": [
{
"id": "89e6ab36-2e98-4d13-9b4c-69b7588b7e1d",
"sidebar_app": {
"id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
},
"app_id": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
}
],
"HasAITasks": true,
"HasExternalAgents": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"Resources": [
{
"name": "example",
"type": "null_resource",
"agents": [
{
"name": "main",
"operating_system": "linux",
"architecture": "amd64",
"Auth": {
"Token": ""
},
"connection_timeout_seconds": 120,
"display_apps": {
"vscode": true,
"web_terminal": true,
"ssh_helper": true,
"port_forwarding_helper": true
},
"resources_monitoring": {},
"api_key_scope": "all"
}
],
"module_path": "module.module"
}
],
"Parameters": [],
"Presets": [],
"ExternalAuthProviders": [],
"AITasks": [],
"HasAITasks": false,
"HasExternalAgents": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"Resources": [
{
"name": "example",
"type": "null_resource",
"agents": [
{
"id": "8cb7c83a-eddb-45e9-a78c-4b50d0f10e5e",
"name": "main",
"operating_system": "linux",
"architecture": "amd64",
"Auth": {
"Token": "59bcf169-14fe-497d-9a97-709c1d837848"
},
"connection_timeout_seconds": 120,
"display_apps": {
"vscode": true,
"web_terminal": true,
"ssh_helper": true,
"port_forwarding_helper": true
},
"resources_monitoring": {},
"api_key_scope": "all"
}
],
"module_path": "module.module"
}
],
"Parameters": [],
"Presets": [],
"ExternalAuthProviders": [],
"AITasks": [],
"HasAITasks": false,
"HasExternalAgents": false
}
Loading
Loading