OpenTofu Specific Code Override: Add support to .tofu files (#1738)

Signed-off-by: Ronny Orot <ronny.orot@gmail.com>
This commit is contained in:
Ronny Orot 2024-06-24 16:10:35 +03:00 committed by GitHub
parent 1ecb2dcae3
commit ab289fc07c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 575 additions and 12 deletions

View File

@ -32,6 +32,9 @@ linters-settings:
cyclop:
max-complexity: 15
gocognit:
min-complexity: 50
issues:
exclude-rules:
- path: (.+)_test.go

View File

@ -7,9 +7,11 @@ package configs
import (
"fmt"
"log"
"os"
"path"
"path/filepath"
"slices"
"strings"
"github.com/hashicorp/hcl/v2"
@ -19,6 +21,17 @@ const (
DefaultTestDirectory = "tests"
)
const (
tfExt = ".tf"
tofuExt = ".tofu"
tfJSONExt = ".tf.json"
tofuJSONExt = ".tofu.json"
tfTestExt = ".tftest.hcl"
tofuTestExt = ".tofutest.hcl"
tfTestJSONExt = ".tftest.json"
tofuTestJSONExt = ".tofutest.json"
)
// LoadConfigDir reads the .tf and .tf.json files in the given directory
// as config files (using LoadConfigFile) and then combines these files into
// a single Module.
@ -178,7 +191,8 @@ func (p *Parser) dirFiles(dir string, testsDir string) (primary, override, tests
continue
}
if strings.HasSuffix(testInfo.Name(), ".tftest.hcl") || strings.HasSuffix(testInfo.Name(), ".tftest.json") {
ext := fileExt(testInfo.Name())
if isTestFileExt(ext) {
tests = append(tests, filepath.Join(testPath, testInfo.Name()))
}
}
@ -208,7 +222,7 @@ func (p *Parser) dirFiles(dir string, testsDir string) (primary, override, tests
continue
}
if ext == ".tftest.hcl" || ext == ".tftest.json" {
if isTestFileExt(ext) {
if includeTests {
tests = append(tests, filepath.Join(dir, name))
}
@ -226,7 +240,44 @@ func (p *Parser) dirFiles(dir string, testsDir string) (primary, override, tests
}
}
return
return filterTfPathsWithTofuAlternatives(primary), filterTfPathsWithTofuAlternatives(override), filterTfPathsWithTofuAlternatives(tests), diags
}
// filterTfPathsWithTofuAlternatives filters out .tf files if they have an
// alternative .tofu file with the same name.
// For example, if there are both 'resources.tf.json' and
// 'resources.tofu.json' files, the 'resources.tf.json' file will be ignored,
// and only the 'resources.tofu.json' file will be returned as a relevant path.
func filterTfPathsWithTofuAlternatives(paths []string) []string {
var ignoredPaths []string
var relevantPaths []string
for _, p := range paths {
ext := tfFileExt(p)
if ext == "" {
relevantPaths = append(relevantPaths, p)
continue
}
parallelTofuExt := strings.ReplaceAll(ext, ".tf", ".tofu")
pathWithoutExt, _ := strings.CutSuffix(p, ext)
parallelTofuPath := pathWithoutExt + parallelTofuExt
// If the .tf file has a parallel .tofu file in the directory,
// we'll ignore the .tf file and only use the .tofu file
if slices.Contains(paths, parallelTofuPath) {
ignoredPaths = append(ignoredPaths, p)
} else {
relevantPaths = append(relevantPaths, p)
}
}
if len(ignoredPaths) > 0 {
log.Printf("[INFO] filterTfPathsWithTofuAlternatives: Ignored the following .tf files because a .tofu file alternative exists: %q", ignoredPaths)
}
return relevantPaths
}
func (p *Parser) loadTestFiles(basePath string, paths []string) (map[string]*TestFile, hcl.Diagnostics) {
@ -258,19 +309,53 @@ func (p *Parser) loadTestFiles(basePath string, paths []string) (map[string]*Tes
// fileExt returns the OpenTofu configuration extension of the given
// path, or a blank string if it is not a recognized extension.
func fileExt(path string) string {
if strings.HasSuffix(path, ".tf") {
return ".tf"
} else if strings.HasSuffix(path, ".tf.json") {
return ".tf.json"
} else if strings.HasSuffix(path, ".tftest.hcl") {
return ".tftest.hcl"
} else if strings.HasSuffix(path, ".tftest.json") {
return ".tftest.json"
} else {
extension := tfFileExt(path)
if extension == "" {
extension = tofuFileExt(path)
}
return extension
}
// tfFileExt returns the OpenTofu .tf configuration extension of the given
// path, or a blank string if it is not a recognized .tf extension.
func tfFileExt(path string) string {
switch {
case strings.HasSuffix(path, tfExt):
return tfExt
case strings.HasSuffix(path, tfJSONExt):
return tfJSONExt
case strings.HasSuffix(path, tfTestExt):
return tfTestExt
case strings.HasSuffix(path, tfTestJSONExt):
return tfTestJSONExt
default:
return ""
}
}
// tofuFileExt returns the OpenTofu .tofu configuration extension of the given
// path, or a blank string if it is not a recognized .tofu extension.
func tofuFileExt(path string) string {
switch {
case strings.HasSuffix(path, tofuExt):
return tofuExt
case strings.HasSuffix(path, tofuJSONExt):
return tofuJSONExt
case strings.HasSuffix(path, tofuTestExt):
return tofuTestExt
case strings.HasSuffix(path, tofuTestJSONExt):
return tofuTestJSONExt
}
return ""
}
func isTestFileExt(ext string) bool {
return ext == tfTestExt || ext == tfTestJSONExt || ext == tofuTestExt || ext == tofuTestJSONExt
}
// IsIgnoredFile returns true if the given filename (which must not have a
// directory path ahead of it) should be ignored as e.g. an editor swap file.
func IsIgnoredFile(name string) bool {

View File

@ -238,6 +238,79 @@ func TestParserLoadConfigDirFailure(t *testing.T) {
}
func TestParserLoadConfigDirWithTests_TofuFiles(t *testing.T) {
expectedVariablesToOverride := []string{"should_override", "should_override_json"}
expectedLoadedTestFiles := []string{"test/resources_test.tofutest.hcl", "test/resources_test_json.tofutest.json"}
tests := []struct {
name string
path string
expectedResources []string
}{
{
name: "only .tofu files",
path: "testdata/tofu-only-files",
expectedResources: []string{"aws_security_group.firewall_tofu", "aws_instance.web_tofu", "test_object.a_tofu", "test_object.b_tofu"},
},
{
name: ".tofu and .tf files",
path: "testdata/tofu-and-tf-files",
expectedResources: []string{"aws_security_group.firewall_tofu", "aws_instance.web_tofu", "test_object.a_tofu", "test_object.b_tofu", "tf_resource.first", "tf_json_resource.a"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewParser(nil)
path := tt.path
mod, diags := parser.LoadConfigDirWithTests(path, "test")
if len(diags) != 0 {
t.Errorf("unexpected diagnostics")
for _, diag := range diags {
t.Logf("- %s", diag)
}
}
if mod.SourceDir != path {
t.Errorf("wrong SourceDir value %q; want %s", mod.SourceDir, path)
}
if len(tt.expectedResources) != len(mod.ManagedResources) {
t.Errorf("expected to find %d resources but instead got %d resources", len(tt.expectedResources), len(mod.ManagedResources))
}
for _, expectedResource := range tt.expectedResources {
if mod.ManagedResources[expectedResource] == nil {
t.Errorf("expected to load %s resource as part of configuration but it is missing", expectedResource)
}
}
if len(expectedVariablesToOverride) != len(mod.Variables) {
t.Errorf("expected to find %d variables but instead got %d resources", len(expectedVariablesToOverride), len(mod.Variables))
}
for _, expectedVariable := range expectedVariablesToOverride {
variableInConfiguration := mod.Variables[expectedVariable]
if variableInConfiguration == nil {
t.Errorf("expected to load %s variable as part of configuration but it is missing", expectedVariable)
} else if variableInConfiguration.Default.AsString() != "overridden by tofu file" {
t.Errorf("expected variable default value %s to be overridden", expectedVariable)
}
}
if len(mod.Tests) != 2 {
t.Errorf("incorrect number of test files found: %d", len(mod.Tests))
}
for _, expectedTest := range expectedLoadedTestFiles {
if mod.Tests[expectedTest] == nil {
t.Errorf("expected to load %s test as part of configuration but it is missing", expectedTest)
}
}
})
}
}
func TestIsEmptyDir(t *testing.T) {
val, err := IsEmptyDir(filepath.Join("testdata", "valid-files"))
if err != nil {

View File

@ -0,0 +1,3 @@
resource "tf_resource" "first" {
test_string = "hello"
}

View File

@ -0,0 +1,10 @@
{
"resource": {
"tf_json_resource": {
"a": {
"count": 1,
"test_string": "first"
}
}
}
}

View File

@ -0,0 +1,43 @@
resource "aws_security_group" "firewall" {
lifecycle {
create_before_destroy = true
prevent_destroy = true
ignore_changes = [
description,
]
}
connection {
host = "127.0.0.1"
}
provisioner "local-exec" {
command = "echo hello"
connection {
host = "10.1.2.1"
}
}
provisioner "local-exec" {
command = "echo hello"
}
}
resource "aws_instance" "web" {
count = 2
ami = "ami-1234"
security_groups = [
"foo",
"bar",
]
network_interface {
device_index = 0
description = "Main network interface"
}
depends_on = [
aws_security_group.firewall,
]
}

View File

@ -0,0 +1,14 @@
{
"resource": {
"test_object": {
"a": {
"count": 1,
"test_string": "hello"
},
"b": {
"count": 1,
"test_string": "world"
}
}
}
}

View File

@ -0,0 +1,43 @@
resource "aws_security_group" "firewall_tofu" {
lifecycle {
create_before_destroy = true
prevent_destroy = true
ignore_changes = [
description,
]
}
connection {
host = "127.0.0.1"
}
provisioner "local-exec" {
command = "echo hello"
connection {
host = "10.1.2.1"
}
}
provisioner "local-exec" {
command = "echo hello"
}
}
resource "aws_instance" "web_tofu" {
count = 2
ami = "ami-1234"
security_groups = [
"foo",
"bar",
]
network_interface {
device_index = 0
description = "Main network interface"
}
depends_on = [
aws_security_group.firewall,
]
}

View File

@ -0,0 +1,14 @@
{
"resource": {
"test_object": {
"a_tofu": {
"count": 1,
"test_string": "hello"
},
"b_tofu": {
"count": 1,
"test_string": "world"
}
}
}
}

View File

@ -0,0 +1,27 @@
# test_run_one runs a partial plan
run "test_run_one" {
command = plan
plan_options {
target = [
test_object.b
]
}
assert {
condition = test_object.b.test_string == "world"
error_message = "invalid value"
}
}
# test_run_two does a complete apply operation
run "test_run_two" {
variables {
input = "custom"
}
assert {
condition = test_object.a.test_string == "hello"
error_message = "invalid value"
}
}

View File

@ -0,0 +1,27 @@
# test_run_one runs a partial plan
run "test_run_one" {
command = plan
plan_options {
target = [
test_object.b
]
}
assert {
condition = test_object.b.test_string == "world"
error_message = "invalid value"
}
}
# test_run_two does a complete apply operation
run "test_run_two" {
variables {
input = "custom"
}
assert {
condition = test_object.a.test_string == "hello"
error_message = "invalid value"
}
}

View File

@ -0,0 +1,29 @@
{
"variables": {
"input": "default"
},
"run": {
"test_run_one": {
"command": "plan",
"plan_options": {
"target": [
"test_object.a"
]
},
"assert": [
{
"condition": "${test_object.a.test_string} == hello",
"error_message": "invalid value"
}
]
},
"test_run_two": {
"assert": [
{
"condition": "${test_object.b.test_string} == world",
"error_message": "invalid value"
}
]
}
}
}

View File

@ -0,0 +1,29 @@
{
"variables": {
"input": "default"
},
"run": {
"test_run_one": {
"command": "plan",
"plan_options": {
"target": [
"test_object.a"
]
},
"assert": [
{
"condition": "${test_object.a.test_string} == hello",
"error_message": "invalid value"
}
]
},
"test_run_two": {
"assert": [
{
"condition": "${test_object.b.test_string} == world",
"error_message": "invalid value"
}
]
}
}
}

View File

@ -0,0 +1,3 @@
variable "should_override" {
default = "not overridden"
}

View File

@ -0,0 +1,7 @@
{
"variable": {
"should_override_json": {
"default": "not overridden"
}
}
}

View File

@ -0,0 +1,3 @@
variable "should_override" {
default = "overridden by tf file"
}

View File

@ -0,0 +1,7 @@
{
"variable": {
"should_override_json": {
"default": "overridden by tf file"
}
}
}

View File

@ -0,0 +1,3 @@
variable "should_override" {
default = "overridden by tofu file"
}

View File

@ -0,0 +1,7 @@
{
"variable": {
"should_override_json": {
"default": "overridden by tofu file"
}
}
}

View File

@ -0,0 +1,43 @@
resource "aws_security_group" "firewall_tofu" {
lifecycle {
create_before_destroy = true
prevent_destroy = true
ignore_changes = [
description,
]
}
connection {
host = "127.0.0.1"
}
provisioner "local-exec" {
command = "echo hello"
connection {
host = "10.1.2.1"
}
}
provisioner "local-exec" {
command = "echo hello"
}
}
resource "aws_instance" "web_tofu" {
count = 2
ami = "ami-1234"
security_groups = [
"foo",
"bar",
]
network_interface {
device_index = 0
description = "Main network interface"
}
depends_on = [
aws_security_group.firewall,
]
}

View File

@ -0,0 +1,14 @@
{
"resource": {
"test_object": {
"a_tofu": {
"count": 1,
"test_string": "hello"
},
"b_tofu": {
"count": 1,
"test_string": "world"
}
}
}
}

View File

@ -0,0 +1,27 @@
# test_run_one runs a partial plan
run "test_run_one" {
command = plan
plan_options {
target = [
test_object.b
]
}
assert {
condition = test_object.b.test_string == "world"
error_message = "invalid value"
}
}
# test_run_two does a complete apply operation
run "test_run_two" {
variables {
input = "custom"
}
assert {
condition = test_object.a.test_string == "hello"
error_message = "invalid value"
}
}

View File

@ -0,0 +1,29 @@
{
"variables": {
"input": "default"
},
"run": {
"test_run_one": {
"command": "plan",
"plan_options": {
"target": [
"test_object.a"
]
},
"assert": [
{
"condition": "${test_object.a.test_string} == hello",
"error_message": "invalid value"
}
]
},
"test_run_two": {
"assert": [
{
"condition": "${test_object.b.test_string} == world",
"error_message": "invalid value"
}
]
}
}
}

View File

@ -0,0 +1,3 @@
variable "should_override" {
default = "not overridden"
}

View File

@ -0,0 +1,7 @@
{
"variable": {
"should_override_json": {
"default": "not overridden"
}
}
}

View File

@ -0,0 +1,3 @@
variable "should_override" {
default = "overridden by tofu file"
}

View File

@ -0,0 +1,7 @@
{
"variable": {
"should_override_json": {
"default": "overridden by tofu file"
}
}
}