diff --git a/docs/rules/terraform_private_module_reference.md b/docs/rules/terraform_private_module_reference.md new file mode 100644 index 0000000..478d3bc --- /dev/null +++ b/docs/rules/terraform_private_module_reference.md @@ -0,0 +1,37 @@ +# terraform_private_module_reference + +According to the [Standard Module Structure](https://developer.hashicorp.com/terraform/language/modules/develop/structure): + +> Nested modules should exist under the modules/ subdirectory. Any nested module with a README.md is considered usable by an external user. + +This rule only checks local path references and ignores remote module references. + +## Example + +```hcl +module "foo" { + source = "../../another-root/foo" +} +``` + +```plain +$ tflint +1 issue(s) found: + +Warning: Private modules should not be referenced externally. Add a README.md to make the referenced module public or remove the reference. (terraform_private_module_reference) + + on main.tf line 2: + 2: module "foo" { + +Reference: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/v0.9.2/docs/rules/terraform_private_module_reference.md +``` + +## Why + +Terraform does not enforce the convention described by the [Standard Module Structure](https://developer.hashicorp.com/terraform/language/modules/develop/structure). This `tflint` rule can be used to enforce the described convention. + +It is best not to have consumers of a module that was not intended to be used externally. + +## How To Fix + +Either add a README.md to the private module to make it public or remove the reference to the private module. diff --git a/rules/preset.go b/rules/preset.go index 17e217b..a0bf8c0 100644 --- a/rules/preset.go +++ b/rules/preset.go @@ -15,6 +15,7 @@ var PresetRules = map[string][]tflint.Rule{ NewTerraformModulePinnedSourceRule(), NewTerraformModuleVersionRule(), NewTerraformNamingConventionRule(), + NewTerraformPrivateModuleReferenceRule(), NewTerraformRequiredProvidersRule(), NewTerraformRequiredVersionRule(), NewTerraformStandardModuleStructureRule(), diff --git a/rules/terraform_private_module_reference.go b/rules/terraform_private_module_reference.go new file mode 100644 index 0000000..92c97b7 --- /dev/null +++ b/rules/terraform_private_module_reference.go @@ -0,0 +1,104 @@ +package rules + +import ( + "os" + "path/filepath" + "strings" + + "github.com/terraform-linters/tflint-plugin-sdk/tflint" + "github.com/terraform-linters/tflint-ruleset-terraform/project" + "github.com/terraform-linters/tflint-ruleset-terraform/terraform" +) + +type StatFunc func(name string) (os.FileInfo, error) + +// TerraformPrivateModuleReferenceRule checks whether private are referenced externally +type TerraformPrivateModuleReferenceRule struct { + tflint.DefaultRule + statFunc StatFunc +} + +func NewTerraformPrivateModuleReferenceRule() *TerraformPrivateModuleReferenceRule { + return &TerraformPrivateModuleReferenceRule{ + statFunc: os.Stat, + } +} + +func (r *TerraformPrivateModuleReferenceRule) Name() string { + return "terraform_private_module_reference" +} + +func (r *TerraformPrivateModuleReferenceRule) Enabled() bool { + return true +} + +func (r *TerraformPrivateModuleReferenceRule) Severity() tflint.Severity { + return tflint.WARNING +} + +func (r *TerraformPrivateModuleReferenceRule) Link() string { + return project.ReferenceLink(r.Name()) +} + +func (r *TerraformPrivateModuleReferenceRule) Check(rr tflint.Runner) error { + runner := rr.(*terraform.Runner) + + moduleCalls, diags := runner.GetModuleCalls() + if diags.HasErrors() { + return diags + } + + for _, call := range moduleCalls { + // Get the current file path + currentFile := call.DefRange.Filename + + // Get the module source path + modulePath := call.Source + + // If modulePath is not a local path its a remote reference and we should not continue checking. + if _, err := r.statFunc(modulePath); os.IsNotExist(err) { + return nil + } + + // Check if the module is referenced from outside the root + isSubDir, err := isSubdirectory(currentFile, modulePath) + if err != nil { + return err + } + + if !isSubDir { + // Check for README.md + readmePath := filepath.Join(modulePath, "README.md") + if _, err := r.statFunc(readmePath); os.IsNotExist(err) { + runner.EmitIssue( + r, + "Private modules should not be referenced externally. Add a README.md to make the referenced module public or remove the reference.", + call.DefRange, + ) + } + } + } + + return nil +} + +func isSubdirectory(currentFile, modulePath string) (bool, error) { + absCurrentFile, err := filepath.Abs(currentFile) + if err != nil { + return false, err + } + + absCurrentFilePath := filepath.Dir(absCurrentFile) + + absModulePath, err := filepath.Abs(modulePath) + if err != nil { + return false, err + } + + relPath, err := filepath.Rel(absCurrentFilePath, absModulePath) + if err != nil { + return false, err + } + + return !strings.HasPrefix(relPath, ".."), nil +} diff --git a/rules/terraform_private_module_reference_test.go b/rules/terraform_private_module_reference_test.go new file mode 100644 index 0000000..69519e3 --- /dev/null +++ b/rules/terraform_private_module_reference_test.go @@ -0,0 +1,123 @@ +package rules + +import ( + "os" + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/terraform-linters/tflint-plugin-sdk/helper" +) + +func Test_TerraformPrivateModuleReferenceRule(t *testing.T) { + cases := []struct { + Name string + Content string + Expected helper.Issues + }{ + { + Name: "valid private module reference", + Content: ` +module "foo" { + source = "modules/foo" +} +`, + Expected: helper.Issues{}, + }, + { + Name: "peer private module reference", + Content: ` +module "foo" { + source = "../foo" +} +`, + Expected: helper.Issues{ + { + Rule: NewTerraformPrivateModuleReferenceRule(), + Message: "Private modules should not be referenced externally. Add a README.md to make the referenced module public or remove the reference.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{ + Line: 2, + Column: 1, + }, + End: hcl.Pos{ + Line: 2, + Column: 13, + }, + }, + }, + }, + }, + { + Name: "valid private module reference without correct modules subdir", + Content: ` +module "foo" { + source = "./foo" +} +`, + Expected: helper.Issues{}, + }, + { + Name: "external private module reference", + Content: ` +module "bar" { + source = "../another-root/modules/bar" +} +`, + Expected: helper.Issues{ + { + Rule: NewTerraformPrivateModuleReferenceRule(), + Message: "Private modules should not be referenced externally. Add a README.md to make the referenced module public or remove the reference.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{ + Line: 2, + Column: 1, + }, + End: hcl.Pos{ + Line: 2, + Column: 13, + }, + }, + }, + }, + }, + { + Name: "valid public submodule reference", + Content: ` +module "baz" { + source = "../another-root/modules/baz" +} +`, + Expected: helper.Issues{}, + }, + } + + mockStat := func(name string) (os.FileInfo, error) { + switch name { + case "../another-root/modules/baz/README.md", "../foo", "./foo", "modules/foo", "modules/foo/bar", "../another-root/modules/bar": + return nil, nil // File exists + default: + return nil, os.ErrNotExist // File doesn't exist + } + } + + rule := NewTerraformPrivateModuleReferenceRule() + rule.statFunc = mockStat + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + files := map[string]string{} + if tc.Content != "" { + files = map[string]string{"module.tf": tc.Content} + } + runner := testRunner(t, files) + + if err := rule.Check(runner); err != nil { + t.Fatalf("Unexpected error occurred: %s", err) + } + + helper.AssertIssues(t, tc.Expected, runner.Runner.(*helper.Runner).Issues) + }) + } +}