From 45439dafdbaf78564e54b281243a32fc64f322b4 Mon Sep 17 00:00:00 2001 From: Louis Garman Date: Fri, 5 Apr 2024 08:03:52 +0100 Subject: [PATCH] wip --- internal/state/file.go | 79 +++---- internal/state/file_test.go | 45 ++++ internal/state/resource.go | 28 +-- internal/state/service.go | 6 +- internal/state/sort.go | 2 +- internal/state/state.go | 2 +- internal/state/testdata/state.json | 211 ++++++++++++++++++ internal/state/testdata/state_empty.json | 3 + internal/task/task.go | 2 - .../testdata/configs/with_mods/child1/main.tf | 18 ++ .../configs/with_mods/child2/child3/main.tf | 17 ++ .../testdata/configs/with_mods/child2/main.tf | 21 ++ internal/testdata/configs/with_mods/main.tf | 34 +++ internal/tui/workspace/model.go | 2 + internal/tui/workspace/resources.go | 26 +-- 15 files changed, 398 insertions(+), 98 deletions(-) create mode 100644 internal/state/file_test.go create mode 100644 internal/state/testdata/state.json create mode 100644 internal/state/testdata/state_empty.json create mode 100644 internal/testdata/configs/with_mods/child1/main.tf create mode 100644 internal/testdata/configs/with_mods/child2/child3/main.tf create mode 100644 internal/testdata/configs/with_mods/child2/main.tf create mode 100644 internal/testdata/configs/with_mods/main.tf diff --git a/internal/state/file.go b/internal/state/file.go index e470653b..b4e27bcc 100644 --- a/internal/state/file.go +++ b/internal/state/file.go @@ -2,18 +2,19 @@ package state import ( "encoding/json" - "strings" ) type ( // StateFile is the terraform state file contents StateFile struct { - Version int + FormatVersion string `json:"format_version"` TerraformVersion string `json:"terraform_version"` - Serial int64 - Lineage string - Outputs map[string]StateFileOutput - FileResources []StateFileResource `json:"resources"` + Values StateFileValues + } + + StateFileValues struct { + Outputs map[string]StateFileOutput + RootModule StateFileModule `json:"root_module"` } // StateFileOutput is an output in the terraform state file @@ -22,57 +23,33 @@ type ( Sensitive bool } - StateFileResource struct { - Name string - ProviderURI string `json:"provider"` - Type string - Module string + StateFileModule struct { + Resources []StateFileResource + ChildModules []StateFileModule `json:"child_modules"` } -) -func (f StateFile) Resources() map[ResourceAddress]*Resource { - resources := make(map[ResourceAddress]*Resource, len(f.FileResources)) - for _, fr := range f.FileResources { - r := newResource(fr) - resources[r.Address] = r + StateFileResource struct { + Address ResourceAddress + Tainted bool } - return resources -} +) -func (r StateFileResource) ModuleName() string { - if r.Module == "" { - return "root" - } - return strings.TrimPrefix(r.Module, "module.") +func getResourcesFromFile(f StateFile) map[ResourceAddress]*Resource { + m := make(map[ResourceAddress]*Resource) + return getResourcesFromStateFileModule(f.Values.RootModule, m) } -// Type determines the HCL type of the output value -func (r StateFileOutput) Type() (string, error) { - var dst any - if err := json.Unmarshal(r.Value, &dst); err != nil { - return "", err +func getResourcesFromStateFileModule(mod StateFileModule, m map[ResourceAddress]*Resource) map[ResourceAddress]*Resource { + for _, res := range mod.Resources { + m[res.Address] = &Resource{ + Address: res.Address, + } + if res.Tainted { + m[res.Address].Status = Tainted + } } - - var typ string - switch dst.(type) { - case bool: - typ = "bool" - case float64: - typ = "number" - case string: - typ = "string" - case []any: - typ = "tuple" - case map[string]any: - typ = "object" - case nil: - typ = "null" - default: - typ = "unknown" + for _, child := range mod.ChildModules { + m = getResourcesFromStateFileModule(child, m) } - return typ, nil -} - -func (r StateFileOutput) StringValue() string { - return string(r.Value) + return m } diff --git a/internal/state/file_test.go b/internal/state/file_test.go new file mode 100644 index 00000000..b8cc9830 --- /dev/null +++ b/internal/state/file_test.go @@ -0,0 +1,45 @@ +package state + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_getResourcesFromFile(t *testing.T) { + b, err := os.ReadFile("./testdata/state.json") + require.NoError(t, err) + + var file StateFile + err = json.Unmarshal(b, &file) + require.NoError(t, err) + + got := getResourcesFromFile(file) + + assert.Len(t, got, 8) + + assert.Contains(t, got, ResourceAddress("random_pet.pet")) + assert.Contains(t, got, ResourceAddress("random_integer.suffix")) + assert.Contains(t, got, ResourceAddress("module.child1.random_pet.pet")) + assert.Contains(t, got, ResourceAddress("module.child1.random_integer.suffix")) + assert.Contains(t, got, ResourceAddress("module.child2.random_integer.suffix")) + assert.Contains(t, got, ResourceAddress("module.child2.module.child3.random_integer.suffix")) + assert.Contains(t, got, ResourceAddress("module.child2.module.child3.random_pet.pet")) + + assert.Equal(t, Tainted, got["module.child2.random_pet.pet"].Status) +} + +func Test_getResourcesFromFile_empty(t *testing.T) { + b, err := os.ReadFile("./testdata/state_empty.json") + require.NoError(t, err) + + var file StateFile + err = json.Unmarshal(b, &file) + require.NoError(t, err) + + got := getResourcesFromFile(file) + assert.Len(t, got, 0) +} diff --git a/internal/state/resource.go b/internal/state/resource.go index d18a89db..ee438f63 100644 --- a/internal/state/resource.go +++ b/internal/state/resource.go @@ -8,34 +8,16 @@ type Resource struct { type ResourceStatus string +type ResourceAddress string + const ( // Idle means the resource is idle (no tasks are currently operating on // it). Idle ResourceStatus = "idle" // Removing means the resource is in the process of being removed. - Removing = "removing" + Removing ResourceStatus = "removing" // Tainting means the resource is in the process of being tainted. - Tainting = "tainting" + Tainting ResourceStatus = "tainting" // Tainted means the resource is currently tainted - Tainted = "tainted" + Tainted ResourceStatus = "tainted" ) - -func newResource(sfr StateFileResource) *Resource { - return &Resource{ - Address: ResourceAddress{ - name: sfr.Name, - typ: sfr.Type, - }, - Status: Idle, - } -} - -// ResourceAddress is the path for a terraform resource, i.e. its type and name. -type ResourceAddress struct { - typ string - name string -} - -func (p ResourceAddress) String() string { - return p.typ + "." + p.name -} diff --git a/internal/state/service.go b/internal/state/service.go index cfecad09..d3bb68ba 100644 --- a/internal/state/service.go +++ b/internal/state/service.go @@ -87,7 +87,7 @@ func (s *Service) Reload(workspaceID resource.ID) (*task.Task, error) { } task, err := s.createTask(workspaceID, task.CreateOptions{ - Command: []string{"state", "pull"}, + Command: []string{"show", "-json"}, AfterError: func(t *task.Task) { s.logger.Error("reloading state", "error", t.Err, "workspace", ws) }, @@ -126,7 +126,7 @@ func (s *Service) Reload(workspaceID resource.ID) (*task.Task, error) { func (s *Service) Delete(workspaceID resource.ID, addrs ...ResourceAddress) (*task.Task, error) { addrStrings := make([]string, len(addrs)) for i, addr := range addrs { - addrStrings[i] = addr.String() + addrStrings[i] = string(addr) } return s.createTask(workspaceID, task.CreateOptions{ Blocking: true, @@ -157,7 +157,7 @@ func (s *Service) Taint(workspaceID resource.ID, addr ResourceAddress) (*task.Ta return s.createTask(workspaceID, task.CreateOptions{ Blocking: true, Command: []string{"taint"}, - Args: []string{addr.String()}, + Args: []string{string(addr)}, AfterCreate: func(t *task.Task) { s.updateResourceStatus(workspaceID, Tainting, addr) }, diff --git a/internal/state/sort.go b/internal/state/sort.go index 93171cf1..9eee2253 100644 --- a/internal/state/sort.go +++ b/internal/state/sort.go @@ -1,7 +1,7 @@ package state func Sort(i, j *Resource) int { - if i.Address.String() < j.Address.String() { + if i.Address < j.Address { return -1 } else { return 1 diff --git a/internal/state/state.go b/internal/state/state.go index 6546d791..3e4a0aaa 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -32,7 +32,7 @@ func EmptyState(workspaceID resource.ID) *State { func NewState(workspaceID resource.ID, file StateFile) *State { return &State{ WorkspaceID: workspaceID, - Resources: file.Resources(), + Resources: getResourcesFromFile(file), State: IdleState, } } diff --git a/internal/state/testdata/state.json b/internal/state/testdata/state.json new file mode 100644 index 00000000..7f707768 --- /dev/null +++ b/internal/state/testdata/state.json @@ -0,0 +1,211 @@ +{ + "format_version": "1.0", + "terraform_version": "1.6.2", + "values": { + "outputs": { + "child_pet1": { + "sensitive": false, + "value": "guiding-lemming", + "type": "string" + }, + "child_pet2": { + "sensitive": false, + "value": "worthy-monarch", + "type": "string" + }, + "pet1": { + "sensitive": false, + "value": "fun-corgi", + "type": "string" + } + }, + "root_module": { + "resources": [ + { + "address": "random_integer.suffix", + "mode": "managed", + "type": "random_integer", + "name": "suffix", + "provider_name": "registry.terraform.io/hashicorp/random", + "schema_version": 0, + "values": { + "id": "4116", + "keepers": { + "now": "2024-04-04T16:39:26Z" + }, + "max": 9999, + "min": 1000, + "result": 4116, + "seed": null + }, + "sensitive_values": { + "keepers": {} + } + }, + { + "address": "random_pet.pet", + "mode": "managed", + "type": "random_pet", + "name": "pet", + "provider_name": "registry.terraform.io/hashicorp/random", + "schema_version": 0, + "values": { + "id": "fun-corgi", + "keepers": { + "now": "2024-04-04T16:39:26Z" + }, + "length": 2, + "prefix": null, + "separator": "-" + }, + "sensitive_values": { + "keepers": {} + } + } + ], + "child_modules": [ + { + "resources": [ + { + "address": "module.child1.random_integer.suffix", + "mode": "managed", + "type": "random_integer", + "name": "suffix", + "provider_name": "registry.terraform.io/hashicorp/random", + "schema_version": 0, + "values": { + "id": "8458", + "keepers": { + "now": "2024-04-04T16:39:26Z" + }, + "max": 9999, + "min": 1000, + "result": 8458, + "seed": null + }, + "sensitive_values": { + "keepers": {} + } + }, + { + "address": "module.child1.random_pet.pet", + "mode": "managed", + "type": "random_pet", + "name": "pet", + "provider_name": "registry.terraform.io/hashicorp/random", + "schema_version": 0, + "values": { + "id": "guiding-lemming", + "keepers": { + "now": "2024-04-04T16:39:26Z" + }, + "length": 2, + "prefix": null, + "separator": "-" + }, + "sensitive_values": { + "keepers": {} + } + } + ], + "address": "module.child1" + }, + { + "resources": [ + { + "address": "module.child2.random_integer.suffix", + "mode": "managed", + "type": "random_integer", + "name": "suffix", + "provider_name": "registry.terraform.io/hashicorp/random", + "schema_version": 0, + "values": { + "id": "9439", + "keepers": { + "now": "2024-04-04T16:39:26Z" + }, + "max": 9999, + "min": 1000, + "result": 9439, + "seed": null + }, + "sensitive_values": { + "keepers": {} + } + }, + { + "address": "module.child2.random_pet.pet", + "mode": "managed", + "type": "random_pet", + "name": "pet", + "provider_name": "registry.terraform.io/hashicorp/random", + "schema_version": 0, + "values": { + "id": "worthy-monarch", + "keepers": { + "now": "2024-04-04T16:39:26Z" + }, + "length": 2, + "prefix": null, + "separator": "-" + }, + "sensitive_values": { + "keepers": {} + }, + "tainted": true + } + ], + "address": "module.child2", + "child_modules": [ + { + "resources": [ + { + "address": "module.child2.module.child3.random_integer.suffix", + "mode": "managed", + "type": "random_integer", + "name": "suffix", + "provider_name": "registry.terraform.io/hashicorp/random", + "schema_version": 0, + "values": { + "id": "1119", + "keepers": { + "now": "2024-04-04T16:39:26Z" + }, + "max": 9999, + "min": 1000, + "result": 1119, + "seed": null + }, + "sensitive_values": { + "keepers": {} + } + }, + { + "address": "module.child2.module.child3.random_pet.pet", + "mode": "managed", + "type": "random_pet", + "name": "pet", + "provider_name": "registry.terraform.io/hashicorp/random", + "schema_version": 0, + "values": { + "id": "arriving-yak", + "keepers": { + "now": "2024-04-04T16:39:26Z" + }, + "length": 2, + "prefix": null, + "separator": "-" + }, + "sensitive_values": { + "keepers": {} + } + } + ], + "address": "module.child2.module.child3" + } + ] + } + ] + } + } +} diff --git a/internal/state/testdata/state_empty.json b/internal/state/testdata/state_empty.json new file mode 100644 index 00000000..406d3b44 --- /dev/null +++ b/internal/state/testdata/state_empty.json @@ -0,0 +1,3 @@ +{ + "format_version": "1.0" +} diff --git a/internal/task/task.go b/internal/task/task.go index 620b413e..63448f4f 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -196,11 +196,9 @@ func (t *Task) Wait() error { func (t *Task) LogValue() slog.Value { return slog.GroupValue( - slog.Any("status", t.State), slog.String("id", t.String()), slog.Any("command", t.Command), slog.Any("args", t.Args), - slog.Bool("blocking", t.Blocking), ) } diff --git a/internal/testdata/configs/with_mods/child1/main.tf b/internal/testdata/configs/with_mods/child1/main.tf new file mode 100644 index 00000000..c8f1d7de --- /dev/null +++ b/internal/testdata/configs/with_mods/child1/main.tf @@ -0,0 +1,18 @@ +resource "random_integer" "suffix" { + min = "1000" + max = "9999" + keepers = { + now = timestamp() + } +} + +resource "random_pet" "pet" { + keepers = { + now = timestamp() + } +} + +output "pet" { + value = random_pet.pet.id +} + diff --git a/internal/testdata/configs/with_mods/child2/child3/main.tf b/internal/testdata/configs/with_mods/child2/child3/main.tf new file mode 100644 index 00000000..bf8bf0c9 --- /dev/null +++ b/internal/testdata/configs/with_mods/child2/child3/main.tf @@ -0,0 +1,17 @@ +resource "random_integer" "suffix" { + min = "1000" + max = "9999" + keepers = { + now = timestamp() + } +} + +resource "random_pet" "pet" { + keepers = { + now = timestamp() + } +} + +output "pet" { + value = random_pet.pet.id +} diff --git a/internal/testdata/configs/with_mods/child2/main.tf b/internal/testdata/configs/with_mods/child2/main.tf new file mode 100644 index 00000000..9bc00cc6 --- /dev/null +++ b/internal/testdata/configs/with_mods/child2/main.tf @@ -0,0 +1,21 @@ +resource "random_integer" "suffix" { + min = "1000" + max = "9999" + keepers = { + now = timestamp() + } +} + +resource "random_pet" "pet" { + keepers = { + now = timestamp() + } +} + +output "pet" { + value = random_pet.pet.id +} + +module "child3" { + source = "./child3" +} diff --git a/internal/testdata/configs/with_mods/main.tf b/internal/testdata/configs/with_mods/main.tf new file mode 100644 index 00000000..051cf7da --- /dev/null +++ b/internal/testdata/configs/with_mods/main.tf @@ -0,0 +1,34 @@ +resource "random_integer" "suffix" { + min = "1000" + max = "9999" + keepers = { + now = timestamp() + } +} + +resource "random_pet" "pet" { + keepers = { + now = timestamp() + } +} + +module "child1" { + source = "./child1" +} + +module "child2" { + source = "./child2" +} + +output "pet1" { + value = random_pet.pet.id +} + +output "child_pet1" { + value = module.child1.pet +} + +output "child_pet2" { + value = module.child2.pet +} + diff --git a/internal/tui/workspace/model.go b/internal/tui/workspace/model.go index eccddf3e..2598ff5b 100644 --- a/internal/tui/workspace/model.go +++ b/internal/tui/workspace/model.go @@ -84,6 +84,8 @@ func (m model) Update(msg tea.Msg) (tui.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch { + case key.Matches(msg, localKeys.Plan): + return m, tui.CreateRuns(m.runs, m.workspace.ID) case key.Matches(msg, keys.Common.Module): return m, tui.NavigateTo(tui.ModuleKind, tui.WithParent(*m.workspace.Module())) } diff --git a/internal/tui/workspace/resources.go b/internal/tui/workspace/resources.go index 4ce217be..4bc1bca6 100644 --- a/internal/tui/workspace/resources.go +++ b/internal/tui/workspace/resources.go @@ -39,7 +39,7 @@ func (m *resourceListMaker) Make(ws resource.Resource, width, height int) (tui.M } renderer := func(resource *state.Resource, inherit lipgloss.Style) table.RenderedRow { return table.RenderedRow{ - resourceColumn.Key: resource.Address.String(), + resourceColumn.Key: string(resource.Address), resourceStatusColumn.Key: string(resource.Status), } } @@ -95,12 +95,15 @@ func (m resources) Update(msg tea.Msg) (tui.Model, tea.Cmd) { case key.Matches(msg, resourcesKeys.Taint): addrs := m.table.HighlightedOrSelectedKeys() return m, func() tea.Msg { - tasks, errs := m.taintMany(m.workspace.ID, addrs...) - return tui.CreatedTasksMsg{ - Command: "state-taint", - Tasks: tasks, - CreateErrs: errs, + msg := tui.CreatedTasksMsg{Command: "taint"} + for _, addr := range addrs { + task, err := m.svc.Taint(m.workspace.ID, addr) + if err != nil { + msg.CreateErrs = append(msg.CreateErrs, err) + } + msg.Tasks = append(msg.Tasks, task) } + return msg } } case initState: @@ -148,17 +151,6 @@ func (m resources) HelpBindings() (bindings []key.Binding) { return keys.KeyMapToSlice(resourcesKeys) } -func (m resources) taintMany(workspaceID resource.ID, addrs ...state.ResourceAddress) (multi task.Multi, errs []error) { - for _, addr := range addrs { - task, err := m.svc.Taint(workspaceID, addr) - if err != nil { - errs = append(errs, err) - } - multi = append(multi, task) - } - return -} - type resourcesKeyMap struct { Delete key.Binding Taint key.Binding