Skip to content

Add rule for external private module references #208

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
37 changes: 37 additions & 0 deletions docs/rules/terraform_private_module_reference.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions rules/preset.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ var PresetRules = map[string][]tflint.Rule{
NewTerraformModulePinnedSourceRule(),
NewTerraformModuleVersionRule(),
NewTerraformNamingConventionRule(),
NewTerraformPrivateModuleReferenceRule(),
NewTerraformRequiredProvidersRule(),
NewTerraformRequiredVersionRule(),
NewTerraformStandardModuleStructureRule(),
Expand Down
104 changes: 104 additions & 0 deletions rules/terraform_private_module_reference.go
Original file line number Diff line number Diff line change
@@ -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
}
123 changes: 123 additions & 0 deletions rules/terraform_private_module_reference_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}