diff --git a/.changes/unreleased/INTERNAL-20241128-145742.yaml b/.changes/unreleased/INTERNAL-20241128-145742.yaml new file mode 100644 index 000000000..6df8c856e --- /dev/null +++ b/.changes/unreleased/INTERNAL-20241128-145742.yaml @@ -0,0 +1,6 @@ +kind: INTERNAL +body: Add tests for Stacks feature +time: 2024-11-28T14:57:42.823414+01:00 +custom: + Issue: "1879" + Repository: terraform-ls diff --git a/internal/features/stacks/jobs/parse_test.go b/internal/features/stacks/jobs/parse_test.go new file mode 100644 index 000000000..e2d761c13 --- /dev/null +++ b/internal/features/stacks/jobs/parse_test.go @@ -0,0 +1,102 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package jobs + +import ( + "context" + "path/filepath" + "testing" + + lsctx "github.com/hashicorp/terraform-ls/internal/context" + "github.com/hashicorp/terraform-ls/internal/features/stacks/ast" + "github.com/hashicorp/terraform-ls/internal/features/stacks/state" + "github.com/hashicorp/terraform-ls/internal/filesystem" + "github.com/hashicorp/terraform-ls/internal/job" + ilsp "github.com/hashicorp/terraform-ls/internal/lsp" + globalState "github.com/hashicorp/terraform-ls/internal/state" + globalAst "github.com/hashicorp/terraform-ls/internal/terraform/ast" + "github.com/hashicorp/terraform-ls/internal/uri" +) + +func TestParseStackConfiguration(t *testing.T) { + ctx := context.Background() + gs, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + ss, err := state.NewStackStore(gs.ChangeStore, gs.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + + testData, err := filepath.Abs("testdata") + if err != nil { + t.Fatal(err) + } + testFs := filesystem.NewFilesystem(gs.DocumentStore) + + simpleStackPath := filepath.Join(testData, "simple-stack") + + err = ss.Add(simpleStackPath) + if err != nil { + t.Fatal(err) + } + + ctx = lsctx.WithDocumentContext(ctx, lsctx.Document{}) + err = ParseStackConfiguration(ctx, testFs, ss, simpleStackPath) + if err != nil { + t.Fatal(err) + } + + before, err := ss.StackRecordByPath(simpleStackPath) + if err != nil { + t.Fatal(err) + } + + // ignore job state + ctx = job.WithIgnoreState(ctx, true) + + // say we're coming from did_change request + componentsURI, err := filepath.Abs(filepath.Join(simpleStackPath, "components.tfstack.hcl")) + if err != nil { + t.Fatal(err) + } + x := lsctx.Document{ + Method: "textDocument/didChange", + LanguageID: ilsp.Stacks.String(), + URI: uri.FromPath(componentsURI), + } + ctx = lsctx.WithDocumentContext(ctx, x) + err = ParseStackConfiguration(ctx, testFs, ss, simpleStackPath) + if err != nil { + t.Fatal(err) + } + + after, err := ss.StackRecordByPath(simpleStackPath) + if err != nil { + t.Fatal(err) + } + + componentsFile := ast.StackFilename("components.tfstack.hcl") + // test if components.tfstack.hcl is not the same as first seen + if before.ParsedFiles[componentsFile] == after.ParsedFiles[componentsFile] { + t.Fatal("file should mismatch") + } + + variablesFile := ast.StackFilename("variables.tfstack.hcl") + // test if variables.tfstack.hcl is the same as first seen + if before.ParsedFiles[variablesFile] != after.ParsedFiles[variablesFile] { + t.Fatal("file mismatch") + } + + // examine diags should change for components.tfstack.hcl + if before.Diagnostics[globalAst.HCLParsingSource][componentsFile][0] == after.Diagnostics[globalAst.HCLParsingSource][componentsFile][0] { + t.Fatal("diags should mismatch") + } + + // examine diags should not change for variables.tfstack.hcl + if before.Diagnostics[globalAst.HCLParsingSource][variablesFile][0] != after.Diagnostics[globalAst.HCLParsingSource][variablesFile][0] { + t.Fatal("diags should match") + } +} diff --git a/internal/features/stacks/jobs/schema_test.go b/internal/features/stacks/jobs/schema_test.go new file mode 100644 index 000000000..0bd22a118 --- /dev/null +++ b/internal/features/stacks/jobs/schema_test.go @@ -0,0 +1,357 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package jobs + +import ( + "bytes" + "compress/gzip" + "context" + "errors" + "io/fs" + "log" + "path/filepath" + "sync" + "testing" + "testing/fstest" + + "github.com/hashicorp/go-version" + lsctx "github.com/hashicorp/terraform-ls/internal/context" + "github.com/hashicorp/terraform-ls/internal/document" + "github.com/hashicorp/terraform-ls/internal/features/stacks/state" + "github.com/hashicorp/terraform-ls/internal/job" + globalState "github.com/hashicorp/terraform-ls/internal/state" + tfaddr "github.com/hashicorp/terraform-registry-address" +) + +func TestPreloadEmbeddedSchema_basic(t *testing.T) { + ctx := context.Background() + dataDir := "data" + schemasFS := fstest.MapFS{ + dataDir: &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io": &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io/hashicorp": &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io/hashicorp/random": &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io/hashicorp/random/1.0.0": &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io/hashicorp/random/1.0.0/schema.json.gz": &fstest.MapFile{ + Data: gzipCompressBytes(t, []byte(randomSchemaJSON)), + }, + } + + gs, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + ss, err := state.NewStackStore(gs.ChangeStore, gs.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + stackPath := "teststack" + + cfgFS := fstest.MapFS{ + // These are somewhat awkward double entries + // to account for io/fs and our own path separator differences + // See https://github.com/hashicorp/terraform-ls/issues/1025 + stackPath + "/providers.tfstack.hcl": &fstest.MapFile{ + Data: []byte{}, + }, + filepath.Join(stackPath, "providers.tfstack.hcl"): &fstest.MapFile{ + Data: []byte(`required_providers { + random = { + source = "hashicorp/random" + version = "1.0.0" + } +} +`), + }, + } + + err = ss.Add(stackPath) + if err != nil { + t.Fatal(err) + } + ctx = lsctx.WithDocumentContext(ctx, lsctx.Document{}) + err = ParseStackConfiguration(ctx, cfgFS, ss, stackPath) + if err != nil { + t.Fatal(err) + } + err = LoadStackMetadata(ctx, ss, stackPath) + if err != nil { + t.Fatal(err) + } + + err = PreloadEmbeddedSchema(ctx, log.Default(), schemasFS, ss, gs.ProviderSchemas, stackPath) + if err != nil { + t.Fatal(err) + } + + // verify schema was loaded + pAddr := tfaddr.MustParseProviderSource("hashicorp/random") + vc := version.MustConstraints(version.NewConstraint(">= 1.0.0")) + + // ask for schema for an unrelated stack to avoid path-based matching + s, err := gs.ProviderSchemas.ProviderSchema("unknown-path", pAddr, vc) + if err != nil { + t.Fatal(err) + } + if s == nil { + t.Fatalf("expected non-nil schema for %s %s", pAddr, vc) + } + + _, ok := s.Provider.Attributes["test"] + if !ok { + t.Fatalf("expected test attribute in provider schema, not found") + } +} + +func TestPreloadEmbeddedSchema_unknownProviderOnly(t *testing.T) { + ctx := context.Background() + dataDir := "data" + schemasFS := fstest.MapFS{ + dataDir: &fstest.MapFile{Mode: fs.ModeDir}, + } + + gs, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + ss, err := state.NewStackStore(gs.ChangeStore, gs.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + stackPath := "teststack" + + cfgFS := fstest.MapFS{ + // These are somewhat awkward double entries + // to account for io/fs and our own path separator differences + // See https://github.com/hashicorp/terraform-ls/issues/1025 + stackPath + "/providers.tfstack.hcl": &fstest.MapFile{ + Data: []byte{}, + }, + filepath.Join(stackPath, "providers.tfstack.hcl"): &fstest.MapFile{ + Data: []byte(`required_providers { + unknown = { + source = "hashicorp/unknown" + version = "1.0.0" + } +} +`), + }, + } + + err = ss.Add(stackPath) + if err != nil { + t.Fatal(err) + } + ctx = lsctx.WithDocumentContext(ctx, lsctx.Document{}) + err = ParseStackConfiguration(ctx, cfgFS, ss, stackPath) + if err != nil { + t.Fatal(err) + } + err = LoadStackMetadata(ctx, ss, stackPath) + if err != nil { + t.Fatal(err) + } + + err = PreloadEmbeddedSchema(ctx, log.Default(), schemasFS, ss, gs.ProviderSchemas, stackPath) + if err != nil { + t.Fatal(err) + } +} + +func TestPreloadEmbeddedSchema_idempotency(t *testing.T) { + ctx := context.Background() + dataDir := "data" + schemasFS := fstest.MapFS{ + dataDir: &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io": &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io/hashicorp": &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io/hashicorp/random": &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io/hashicorp/random/1.0.0": &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io/hashicorp/random/1.0.0/schema.json.gz": &fstest.MapFile{ + Data: gzipCompressBytes(t, []byte(randomSchemaJSON)), + }, + } + + gs, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + ss, err := state.NewStackStore(gs.ChangeStore, gs.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + stackPath := "teststack" + + cfgFS := fstest.MapFS{ + // These are somewhat awkward two entries + // to account for io/fs and our own path separator differences + // See https://github.com/hashicorp/terraform-ls/issues/1025 + stackPath + "/providers.tfstack.hcl": &fstest.MapFile{ + Data: []byte{}, + }, + filepath.Join(stackPath, "providers.tfstack.hcl"): &fstest.MapFile{ + Data: []byte(`required_providers { + random = { + source = "hashicorp/random" + version = "1.0.0" + } + unknown = { + source = "hashicorp/unknown" + version = "5.0.0" + } +} +`), + }, + } + + err = ss.Add(stackPath) + if err != nil { + t.Fatal(err) + } + ctx = lsctx.WithDocumentContext(ctx, lsctx.Document{}) + err = ParseStackConfiguration(ctx, cfgFS, ss, stackPath) + if err != nil { + t.Fatal(err) + } + err = LoadStackMetadata(ctx, ss, stackPath) + if err != nil { + t.Fatal(err) + } + + // first + err = PreloadEmbeddedSchema(ctx, log.Default(), schemasFS, ss, gs.ProviderSchemas, stackPath) + if err != nil { + t.Fatal(err) + } + + // second - testing stack state + err = PreloadEmbeddedSchema(ctx, log.Default(), schemasFS, ss, gs.ProviderSchemas, stackPath) + if err != nil { + if !errors.Is(err, job.StateNotChangedErr{Dir: document.DirHandleFromPath(stackPath)}) { + t.Fatal(err) + } + } + + ctx = job.WithIgnoreState(ctx, true) + // third - testing requirement matching + err = PreloadEmbeddedSchema(ctx, log.Default(), schemasFS, ss, gs.ProviderSchemas, stackPath) + if err != nil { + t.Fatal(err) + } +} + +func TestPreloadEmbeddedSchema_raceCondition(t *testing.T) { + ctx := context.Background() + dataDir := "data" + schemasFS := fstest.MapFS{ + dataDir: &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io": &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io/hashicorp": &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io/hashicorp/random": &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io/hashicorp/random/1.0.0": &fstest.MapFile{Mode: fs.ModeDir}, + dataDir + "/registry.terraform.io/hashicorp/random/1.0.0/schema.json.gz": &fstest.MapFile{ + Data: gzipCompressBytes(t, []byte(randomSchemaJSON)), + }, + } + + gs, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + ss, err := state.NewStackStore(gs.ChangeStore, gs.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + stackPath := "teststack" + + cfgFS := fstest.MapFS{ + // These are somewhat awkward two entries + // to account for io/fs and our own path separator differences + // See https://github.com/hashicorp/terraform-ls/issues/1025 + stackPath + "/providers.tfstack.hcl": &fstest.MapFile{ + Data: []byte{}, + }, + filepath.Join(stackPath, "providers.tfstack.hcl"): &fstest.MapFile{ + Data: []byte(`required_providers { + random = { + source = "hashicorp/random" + version = "1.0.0" + } + unknown = { + source = "hashicorp/unknown" + version = "5.0.0" + } +} +`), + }, + } + + err = ss.Add(stackPath) + if err != nil { + t.Fatal(err) + } + ctx = lsctx.WithDocumentContext(ctx, lsctx.Document{}) + err = ParseStackConfiguration(ctx, cfgFS, ss, stackPath) + if err != nil { + t.Fatal(err) + } + err = LoadStackMetadata(ctx, ss, stackPath) + if err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + err := PreloadEmbeddedSchema(ctx, log.Default(), schemasFS, ss, gs.ProviderSchemas, stackPath) + if err != nil && !errors.Is(err, job.StateNotChangedErr{Dir: document.DirHandleFromPath(stackPath)}) { + t.Error(err) + } + }() + go func() { + defer wg.Done() + err := PreloadEmbeddedSchema(ctx, log.Default(), schemasFS, ss, gs.ProviderSchemas, stackPath) + if err != nil && !errors.Is(err, job.StateNotChangedErr{Dir: document.DirHandleFromPath(stackPath)}) { + t.Error(err) + } + }() + wg.Wait() +} + +func gzipCompressBytes(t *testing.T, b []byte) []byte { + var compressedBytes bytes.Buffer + gw := gzip.NewWriter(&compressedBytes) + _, err := gw.Write(b) + if err != nil { + t.Fatal(err) + } + err = gw.Close() + if err != nil { + t.Fatal(err) + } + return compressedBytes.Bytes() +} + +var randomSchemaJSON = `{ + "format_version": "1.0", + "provider_schemas": { + "registry.terraform.io/hashicorp/random": { + "provider": { + "version": 0, + "block": { + "attributes": { + "test": { + "type": "string", + "description": "Test description", + "description_kind": "markdown", + "optional": true + } + }, + "description_kind": "plain" + } + } + } + } +}` diff --git a/internal/features/stacks/jobs/testdata/invalid-stack/deployments.tfdeploy.hcl b/internal/features/stacks/jobs/testdata/invalid-stack/deployments.tfdeploy.hcl new file mode 100644 index 000000000..3ff5b6e72 --- /dev/null +++ b/internal/features/stacks/jobs/testdata/invalid-stack/deployments.tfdeploy.hcl @@ -0,0 +1,7 @@ +deployment "demostack" { + inputs = { + region = "eu-west-2" + } +} + +test {} diff --git a/internal/features/stacks/jobs/testdata/invalid-stack/variables.tfstack.hcl b/internal/features/stacks/jobs/testdata/invalid-stack/variables.tfstack.hcl new file mode 100644 index 000000000..fd761bb42 --- /dev/null +++ b/internal/features/stacks/jobs/testdata/invalid-stack/variables.tfstack.hcl @@ -0,0 +1,11 @@ +variable { + type = string +} + +locals { + test = 1 +} + +provider "aws" { + region = var.region +} diff --git a/internal/features/stacks/jobs/testdata/simple-stack/components.tfstack.hcl b/internal/features/stacks/jobs/testdata/simple-stack/components.tfstack.hcl new file mode 100644 index 000000000..21502e60a --- /dev/null +++ b/internal/features/stacks/jobs/testdata/simple-stack/components.tfstack.hcl @@ -0,0 +1,6 @@ +component "networking" { + source = "./networking" + + inputs = { + region = var.region + } diff --git a/internal/features/stacks/jobs/testdata/simple-stack/deployments.tfdeploy.hcl b/internal/features/stacks/jobs/testdata/simple-stack/deployments.tfdeploy.hcl new file mode 100644 index 000000000..18337febe --- /dev/null +++ b/internal/features/stacks/jobs/testdata/simple-stack/deployments.tfdeploy.hcl @@ -0,0 +1,4 @@ +deployment "demostack" { + inputs = { + region = "eu-west-2" + } diff --git a/internal/features/stacks/jobs/testdata/simple-stack/variables.tfstack.hcl b/internal/features/stacks/jobs/testdata/simple-stack/variables.tfstack.hcl new file mode 100644 index 000000000..7b46abfc5 --- /dev/null +++ b/internal/features/stacks/jobs/testdata/simple-stack/variables.tfstack.hcl @@ -0,0 +1,2 @@ +variable "region" { + type = string diff --git a/internal/features/stacks/jobs/validation_test.go b/internal/features/stacks/jobs/validation_test.go new file mode 100644 index 000000000..bd09b3c2c --- /dev/null +++ b/internal/features/stacks/jobs/validation_test.go @@ -0,0 +1,137 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package jobs + +import ( + "context" + "path/filepath" + "testing" + + "github.com/hashicorp/go-version" + lsctx "github.com/hashicorp/terraform-ls/internal/context" + "github.com/hashicorp/terraform-ls/internal/features/stacks/state" + "github.com/hashicorp/terraform-ls/internal/filesystem" + ilsp "github.com/hashicorp/terraform-ls/internal/lsp" + globalState "github.com/hashicorp/terraform-ls/internal/state" + "github.com/hashicorp/terraform-ls/internal/terraform/ast" + tfmod "github.com/hashicorp/terraform-schema/module" +) + +type ModuleReaderMock struct{} + +func (m ModuleReaderMock) LocalModuleMeta(modulePath string) (*tfmod.Meta, error) { + return nil, nil +} + +type RootReaderMock struct{} + +func (r RootReaderMock) InstalledModuleCalls(modPath string) (map[string]tfmod.InstalledModuleCall, error) { + return nil, nil +} + +func (r RootReaderMock) TerraformVersion(modPath string) *version.Version { + return nil +} + +func (r RootReaderMock) InstalledModulePath(rootPath string, normalizedSource string) (string, bool) { + return "", false +} + +func TestSchemaStackValidation_FullStack(t *testing.T) { + ctx := context.Background() + gs, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + ms, err := state.NewStackStore(gs.ChangeStore, gs.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + + testData, err := filepath.Abs("testdata") + if err != nil { + t.Fatal(err) + } + stackPath := filepath.Join(testData, "invalid-stack") + + err = ms.Add(stackPath) + if err != nil { + t.Fatal(err) + } + + fs := filesystem.NewFilesystem(gs.DocumentStore) + ctx = lsctx.WithDocumentContext(ctx, lsctx.Document{ + Method: "textDocument/didOpen", + LanguageID: ilsp.Stacks.String(), + URI: "file:///test/variables.tfstack.hcl", + }) + err = ParseStackConfiguration(ctx, fs, ms, stackPath) + if err != nil { + t.Fatal(err) + } + err = SchemaStackValidation(ctx, ms, ModuleReaderMock{}, RootReaderMock{}, stackPath) + if err != nil { + t.Fatal(err) + } + + record, err := ms.StackRecordByPath(stackPath) + if err != nil { + t.Fatal(err) + } + + expectedCount := 3 + diagsCount := record.Diagnostics[ast.SchemaValidationSource].Count() + if diagsCount != expectedCount { + t.Fatalf("expected %d diagnostics, %d given", expectedCount, diagsCount) + } +} + +func TestSchemaStackValidation_SingleFile(t *testing.T) { + ctx := context.Background() + gs, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + ss, err := state.NewStackStore(gs.ChangeStore, gs.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + + testData, err := filepath.Abs("testdata") + if err != nil { + t.Fatal(err) + } + stackPath := filepath.Join(testData, "invalid-stack") + + err = ss.Add(stackPath) + if err != nil { + t.Fatal(err) + } + + fs := filesystem.NewFilesystem(gs.DocumentStore) + ctx = lsctx.WithDocumentContext(ctx, lsctx.Document{ + Method: "textDocument/didChange", + LanguageID: ilsp.Stacks.String(), + URI: "file:///test/variables.tfstack.hcl", + }) + err = ParseStackConfiguration(ctx, fs, ss, stackPath) + if err != nil { + t.Fatal(err) + } + err = SchemaStackValidation(ctx, ss, ModuleReaderMock{}, RootReaderMock{}, stackPath) + if err != nil { + t.Fatal(err) + } + + record, err := ss.StackRecordByPath(stackPath) + if err != nil { + t.Fatal(err) + } + + expectedCount := 2 + diagsCount := record.Diagnostics[ast.SchemaValidationSource].Count() + if diagsCount != expectedCount { + t.Fatalf("expected %d diagnostics, %d given", expectedCount, diagsCount) + } +} diff --git a/internal/features/stacks/state/stack_store_test.go b/internal/features/stacks/state/stack_store_test.go new file mode 100644 index 000000000..5e93fc856 --- /dev/null +++ b/internal/features/stacks/state/stack_store_test.go @@ -0,0 +1,383 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package state + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclparse" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform-ls/internal/features/stacks/ast" + globalState "github.com/hashicorp/terraform-ls/internal/state" + globalAst "github.com/hashicorp/terraform-ls/internal/terraform/ast" + "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" + tfaddr "github.com/hashicorp/terraform-registry-address" + tfstack "github.com/hashicorp/terraform-schema/stack" + "github.com/zclconf/go-cty-debug/ctydebug" +) + +var cmpOpts = cmp.Options{ + cmp.AllowUnexported(StackRecord{}), + cmp.AllowUnexported(hclsyntax.Body{}), + cmp.Comparer(func(x, y version.Constraint) bool { + return x.String() == y.String() + }), + cmp.Comparer(func(x, y hcl.File) bool { + return (x.Body == y.Body && + cmp.Equal(x.Bytes, y.Bytes)) + }), + ctydebug.CmpOptions, +} + +func TestStackStore_Add_duplicate(t *testing.T) { + globalStore, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + s, err := NewStackStore(globalStore.ChangeStore, globalStore.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + + stackPath := t.TempDir() + + err = s.Add(stackPath) + if err != nil { + t.Fatal(err) + } + + err = s.Add(stackPath) + if err == nil { + t.Fatal("expected error for duplicate entry") + } + existsError := &globalState.AlreadyExistsError{} + if !errors.As(err, &existsError) { + t.Fatalf("unexpected error: %s", err) + } +} + +func TestStackStore_StackRecordByPath(t *testing.T) { + globalStore, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + s, err := NewStackStore(globalStore.ChangeStore, globalStore.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + + stackPath := t.TempDir() + + err = s.Add(stackPath) + if err != nil { + t.Fatal(err) + } + + record, err := s.StackRecordByPath(stackPath) + if err != nil { + t.Fatal(err) + } + + expectedRecord := &StackRecord{ + path: stackPath, + DiagnosticsState: globalAst.DiagnosticSourceState{ + globalAst.HCLParsingSource: operation.OpStateUnknown, + globalAst.SchemaValidationSource: operation.OpStateUnknown, + globalAst.ReferenceValidationSource: operation.OpStateUnknown, + globalAst.TerraformValidateSource: operation.OpStateUnknown, + }, + } + if diff := cmp.Diff(expectedRecord, record, cmpOpts); diff != "" { + t.Fatalf("unexpected record: %s", diff) + } +} + +func TestStackStore_List(t *testing.T) { + globalStore, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + s, err := NewStackStore(globalStore.ChangeStore, globalStore.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + + tmpDir := t.TempDir() + + stackPaths := []string{ + filepath.Join(tmpDir, "alpha"), + filepath.Join(tmpDir, "beta"), + filepath.Join(tmpDir, "gamma"), + } + for _, stackPath := range stackPaths { + err := s.Add(stackPath) + if err != nil { + t.Fatal(err) + } + } + + stacks, err := s.List() + if err != nil { + t.Fatal(err) + } + + expectedRecords := []*StackRecord{ + { + path: filepath.Join(tmpDir, "alpha"), + DiagnosticsState: globalAst.DiagnosticSourceState{ + globalAst.HCLParsingSource: operation.OpStateUnknown, + globalAst.SchemaValidationSource: operation.OpStateUnknown, + globalAst.ReferenceValidationSource: operation.OpStateUnknown, + globalAst.TerraformValidateSource: operation.OpStateUnknown, + }, + }, + { + path: filepath.Join(tmpDir, "beta"), + DiagnosticsState: globalAst.DiagnosticSourceState{ + globalAst.HCLParsingSource: operation.OpStateUnknown, + globalAst.SchemaValidationSource: operation.OpStateUnknown, + globalAst.ReferenceValidationSource: operation.OpStateUnknown, + globalAst.TerraformValidateSource: operation.OpStateUnknown, + }, + }, + { + path: filepath.Join(tmpDir, "gamma"), + DiagnosticsState: globalAst.DiagnosticSourceState{ + globalAst.HCLParsingSource: operation.OpStateUnknown, + globalAst.SchemaValidationSource: operation.OpStateUnknown, + globalAst.ReferenceValidationSource: operation.OpStateUnknown, + globalAst.TerraformValidateSource: operation.OpStateUnknown, + }, + }, + } + + if diff := cmp.Diff(expectedRecords, stacks, cmpOpts); diff != "" { + t.Fatalf("unexpected records: %s", diff) + } +} + +func TestStackStore_UpdateMetadata(t *testing.T) { + globalStore, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + s, err := NewStackStore(globalStore.ChangeStore, globalStore.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + + tmpDir := t.TempDir() + + metadata := &tfstack.Meta{ + Path: tmpDir, + ProviderRequirements: map[string]tfstack.ProviderRequirement{ + "aws": {Source: tfaddr.MustParseProviderSource("hashicorp/aws"), VersionConstraints: testConstraint(t, "~> 5.7.0")}, + "google": {Source: tfaddr.MustParseProviderSource("hashicorp/random"), VersionConstraints: testConstraint(t, "~> 3.5.1")}, + }, + } + + err = s.Add(tmpDir) + if err != nil { + t.Fatal(err) + } + + err = s.UpdateMetadata(tmpDir, metadata, nil) + if err != nil { + t.Fatal(err) + } + + record, err := s.StackRecordByPath(tmpDir) + if err != nil { + t.Fatal(err) + } + + expectedRecord := &StackRecord{ + path: tmpDir, + Meta: StackMetadata{ + ProviderRequirements: map[string]tfstack.ProviderRequirement{ + "aws": {Source: tfaddr.MustParseProviderSource("hashicorp/aws"), VersionConstraints: testConstraint(t, "~> 5.7.0")}, + "google": {Source: tfaddr.MustParseProviderSource("hashicorp/random"), VersionConstraints: testConstraint(t, "~> 3.5.1")}, + }, + }, + MetaState: operation.OpStateLoaded, + DiagnosticsState: globalAst.DiagnosticSourceState{ + globalAst.HCLParsingSource: operation.OpStateUnknown, + globalAst.SchemaValidationSource: operation.OpStateUnknown, + globalAst.ReferenceValidationSource: operation.OpStateUnknown, + globalAst.TerraformValidateSource: operation.OpStateUnknown, + }, + } + + if diff := cmp.Diff(expectedRecord, record, cmpOpts); diff != "" { + t.Fatalf("unexpected record data: %s", diff) + } +} + +func TestStackStore_SetTerraformVersion(t *testing.T) { + globalStore, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + s, err := NewStackStore(globalStore.ChangeStore, globalStore.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + + tmpDir := t.TempDir() + + err = s.Add(tmpDir) + if err != nil { + t.Fatal(err) + } + + version := version.Must(version.NewVersion("1.10.0")) + + err = s.SetTerraformVersion(tmpDir, version) + if err != nil { + t.Fatal(err) + } + + record, err := s.StackRecordByPath(tmpDir) + if err != nil { + t.Fatal(err) + } + + expectedRecord := &StackRecord{ + path: tmpDir, + RequiredTerraformVersion: version, + RequiredTerraformVersionState: operation.OpStateLoaded, + DiagnosticsState: globalAst.DiagnosticSourceState{ + globalAst.HCLParsingSource: operation.OpStateUnknown, + globalAst.SchemaValidationSource: operation.OpStateUnknown, + globalAst.ReferenceValidationSource: operation.OpStateUnknown, + globalAst.TerraformValidateSource: operation.OpStateUnknown, + }, + } + + if diff := cmp.Diff(expectedRecord, record, cmpOpts); diff != "" { + t.Fatalf("unexpected record data: %s", diff) + } +} + +func TestStackStore_UpdateParsedFiles(t *testing.T) { + globalStore, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + s, err := NewStackStore(globalStore.ChangeStore, globalStore.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + + tmpDir := t.TempDir() + err = s.Add(tmpDir) + if err != nil { + t.Fatal(err) + } + + p := hclparse.NewParser() + testFile, diags := p.ParseHCL([]byte(` +variable "blah" { + type = string +} +`), "variables.tfstack.hcl") + if len(diags) > 0 { + t.Fatal(diags) + } + + err = s.UpdateParsedFiles(tmpDir, ast.Files{ + ast.StackFilename("variables.tfstack.hcl"): testFile, + }, nil) + if err != nil { + t.Fatal(err) + } + + record, err := s.StackRecordByPath(tmpDir) + if err != nil { + t.Fatal(err) + } + + expectedParsedFiles := ast.Files{ + ast.StackFilename("variables.tfstack.hcl"): testFile, + } + if diff := cmp.Diff(expectedParsedFiles, record.ParsedFiles, cmpOpts); diff != "" { + t.Fatalf("unexpected parsed files: %s", diff) + } +} + +func TestStackStore_UpdateDiagnostics(t *testing.T) { + globalStore, err := globalState.NewStateStore() + if err != nil { + t.Fatal(err) + } + s, err := NewStackStore(globalStore.ChangeStore, globalStore.ProviderSchemas) + if err != nil { + t.Fatal(err) + } + + tmpDir := t.TempDir() + err = s.Add(tmpDir) + if err != nil { + t.Fatal(err) + } + + p := hclparse.NewParser() + _, diags := p.ParseHCL([]byte(` +variable "blah" { + type = string +`), "variables.tfstack.hcl") + + err = s.UpdateDiagnostics(tmpDir, globalAst.HCLParsingSource, ast.DiagnosticsFromMap(map[string]hcl.Diagnostics{ + "variables.tfstack.hcl": diags, + })) + if err != nil { + t.Fatal(err) + } + + record, err := s.StackRecordByPath(tmpDir) + if err != nil { + t.Fatal(err) + } + + expectedDiags := ast.SourceDiagnostics{ + globalAst.HCLParsingSource: ast.DiagnosticsFromMap(map[string]hcl.Diagnostics{ + "variables.tfstack.hcl": { + { + Severity: hcl.DiagError, + Summary: "Unclosed configuration block", + Detail: "There is no closing brace for this block before the end of the file. This may be caused by incorrect brace nesting elsewhere in this file.", + Subject: &hcl.Range{ + Filename: "variables.tfstack.hcl", + Start: hcl.Pos{ + Line: 2, + Column: 17, + Byte: 17, + }, + End: hcl.Pos{ + Line: 2, + Column: 18, + Byte: 18, + }, + }, + }, + }, + }), + } + if diff := cmp.Diff(expectedDiags, record.Diagnostics, cmpOpts); diff != "" { + t.Fatalf("unexpected diagnostics: %s", diff) + } +} + +func testConstraint(t *testing.T, v string) version.Constraints { + constraints, err := version.NewConstraint(v) + if err != nil { + t.Fatal(err) + } + return constraints +}