From 906d2016b4aebba39d18de7a89d682994d59bad7 Mon Sep 17 00:00:00 2001 From: Matt White <16320656+matt-FFFFFF@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:51:08 +0100 Subject: [PATCH 1/5] feat: azapi rules --- .../conventional-commit.instructions.md | 73 +++++ .github/instructions/go.instructions.md | 292 ++++++++++++++++++ .vscode/mcp.json | 28 ++ README.md | 18 ++ RULES.md | 35 +++ cmd/rulesdoc/main.go | 85 +++++ common/either.go | 35 ++- common/either_test.go | 30 +- generate.go | 7 + outputs/no_entire_resource_output.go | 176 +++++++++++ outputs/no_entire_resource_output_test.go | 157 ++++++++++ outputs/outputs.go | 1 + rules/disallowed_provider.go | 106 +++++++ rules/disallowed_provider_test.go | 73 +++++ rules/required_attribute.go | 149 +++++++++ rules/required_attribute_test.go | 68 ++++ rules/rule_register.go | 31 ++ 17 files changed, 1338 insertions(+), 26 deletions(-) create mode 100644 .github/instructions/conventional-commit.instructions.md create mode 100644 .github/instructions/go.instructions.md create mode 100644 .vscode/mcp.json create mode 100644 RULES.md create mode 100644 cmd/rulesdoc/main.go create mode 100644 generate.go create mode 100644 outputs/no_entire_resource_output.go create mode 100644 outputs/no_entire_resource_output_test.go create mode 100644 rules/disallowed_provider.go create mode 100644 rules/disallowed_provider_test.go create mode 100644 rules/required_attribute.go create mode 100644 rules/required_attribute_test.go diff --git a/.github/instructions/conventional-commit.instructions.md b/.github/instructions/conventional-commit.instructions.md new file mode 100644 index 0000000..a5e056f --- /dev/null +++ b/.github/instructions/conventional-commit.instructions.md @@ -0,0 +1,73 @@ +--- +description: 'Prompt and workflow for generating conventional commit messages using a structured XML format. Guides users to create standardized, descriptive commit messages in line with the Conventional Commits specification, including instructions, examples, and validation.' +tools: ['run_in_terminal', 'get_terminal_output'] +--- + +### Instructions + +```xml + This file contains a prompt template for generating conventional commit messages. It provides instructions, examples, and formatting guidelines to help users write standardized, descriptive commit messages in accordance with the Conventional Commits specification. + +``` + +### Workflow + +**Follow these steps:** + +1. Run `git status` to review changed files. +2. Run `git diff` or `git diff --cached` to inspect changes. +3. Stage your changes with `git add `. +4. Construct your commit message using the following XML structure. +5. After generating your commit message, Copilot will automatically run the following command in your integrated terminal (no confirmation needed): + +```bash +git commit -m "type(scope): description" +``` + +6. Just execute this prompt and Copilot will handle the commit for you in the terminal. + +### Commit Message Structure + +```xml + + feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert + () + A short, imperative summary of the change + (optional: more detailed explanation) +
(optional: e.g. BREAKING CHANGE: details, or issue references)
+
+``` + +### Examples + +```xml + + feat(parser): add ability to parse arrays + fix(ui): correct button alignment + docs: update README with usage instructions + refactor: improve performance of data processing + chore: update dependencies + feat!: send email on registration (BREAKING CHANGE: email service required) + +``` + +### Validation + +```xml + + Must be one of the allowed types. See https://www.conventionalcommits.org/en/v1.0.0/#specification + Optional, but recommended for clarity. + Required. Use the imperative mood (e.g., "add", not "added"). + Optional. Use for additional context. +
Use for breaking changes or issue references.
+
+``` + +### Final Step + +```xml + + git commit -m "type(scope): description" + Replace with your constructed message. Include body and footer if needed. + +``` diff --git a/.github/instructions/go.instructions.md b/.github/instructions/go.instructions.md new file mode 100644 index 0000000..ce4cdba --- /dev/null +++ b/.github/instructions/go.instructions.md @@ -0,0 +1,292 @@ +--- +description: 'Instructions for writing Go code following idiomatic Go practices and community standards' +applyTo: '**/*.go,**/go.mod,**/go.sum' +--- + +# Go Development Instructions + +Follow idiomatic Go practices and community standards when writing Go code. These instructions are based on [Effective Go](https://go.dev/doc/effective_go), [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments), and [Google's Go Style Guide](https://google.github.io/styleguide/go/). + +## General Instructions + +- Write simple, clear, and idiomatic Go code +- Favor clarity and simplicity over cleverness +- Follow the principle of least surprise +- Keep the happy path left-aligned (minimize indentation) +- Return early to reduce nesting +- Make the zero value useful +- Document exported types, functions, methods, and packages +- Use Go modules for dependency management + +## Naming Conventions + +### Packages + +- Use lowercase, single-word package names +- Avoid underscores, hyphens, or mixedCaps +- Choose names that describe what the package provides, not what it contains +- Avoid generic names like `util`, `common`, or `base` +- Package names should be singular, not plural + +### Variables and Functions + +- Use mixedCaps or MixedCaps (camelCase) rather than underscores +- Keep names short but descriptive +- Use single-letter variables only for very short scopes (like loop indices) +- Exported names start with a capital letter +- Unexported names start with a lowercase letter +- Avoid stuttering (e.g., avoid `http.HTTPServer`, prefer `http.Server`) + +### Interfaces + +- Name interfaces with -er suffix when possible (e.g., `Reader`, `Writer`, `Formatter`) +- Single-method interfaces should be named after the method (e.g., `Read` → `Reader`) +- Keep interfaces small and focused + +### Constants + +- Use MixedCaps for exported constants +- Use mixedCaps for unexported constants +- Group related constants using `const` blocks +- Consider using typed constants for better type safety + +## Code Style and Formatting + +### Formatting + +- Always use `gofmt` to format code +- Use `goimports` to manage imports automatically +- Keep line length reasonable (no hard limit, but consider readability) +- Add blank lines to separate logical groups of code + +### Comments + +- Write comments in complete sentences +- Start sentences with the name of the thing being described +- Package comments should start with "Package [name]" +- Use line comments (`//`) for most comments +- Use block comments (`/* */`) sparingly, mainly for package documentation +- Document why, not what, unless the what is complex + +### Error Handling + +- Check errors immediately after the function call +- Don't ignore errors using `_` unless you have a good reason (document why) +- Wrap errors with context using `fmt.Errorf` with `%w` verb +- Create custom error types when you need to check for specific errors +- Place error returns as the last return value +- Name error variables `err` +- Keep error messages lowercase and don't end with punctuation + +## Architecture and Project Structure + +### Package Organization + +- Follow standard Go project layout conventions +- Keep `main` packages in `cmd/` directory +- Put reusable packages in `pkg/` or `internal/` +- Use `internal/` for packages that shouldn't be imported by external projects +- Group related functionality into packages +- Avoid circular dependencies + +### Dependency Management + +- Use Go modules (`go.mod` and `go.sum`) +- Keep dependencies minimal +- Regularly update dependencies for security patches +- Use `go mod tidy` to clean up unused dependencies +- Vendor dependencies only when necessary + +## Type Safety and Language Features + +### Type Definitions + +- Define types to add meaning and type safety +- Use struct tags for JSON, XML, database mappings +- Prefer explicit type conversions +- Use type assertions carefully and check the second return value + +### Pointers vs Values + +- Use pointers for large structs or when you need to modify the receiver +- Use values for small structs and when immutability is desired +- Be consistent within a type's method set +- Consider the zero value when choosing pointer vs value receivers + +### Interfaces and Composition + +- Accept interfaces, return concrete types +- Keep interfaces small (1-3 methods is ideal) +- Use embedding for composition +- Define interfaces close to where they're used, not where they're implemented +- Don't export interfaces unless necessary + +## Concurrency + +### Goroutines + +- Don't create goroutines in libraries; let the caller control concurrency +- Always know how a goroutine will exit +- Use `sync.WaitGroup` or channels to wait for goroutines +- Avoid goroutine leaks by ensuring cleanup + +### Channels + +- Use channels to communicate between goroutines +- Don't communicate by sharing memory; share memory by communicating +- Close channels from the sender side, not the receiver +- Use buffered channels when you know the capacity +- Use `select` for non-blocking operations + +### Synchronization + +- Use `sync.Mutex` for protecting shared state +- Keep critical sections small +- Use `sync.RWMutex` when you have many readers +- Prefer channels over mutexes when possible +- Use `sync.Once` for one-time initialization + +## Error Handling Patterns + +### Creating Errors + +- Use `errors.New` for simple static errors +- Use `fmt.Errorf` for dynamic errors +- Create custom error types for domain-specific errors +- Export error variables for sentinel errors +- Use `errors.Is` and `errors.As` for error checking + +### Error Propagation + +- Add context when propagating errors up the stack +- Don't log and return errors (choose one) +- Handle errors at the appropriate level +- Consider using structured errors for better debugging + +## API Design + +### HTTP Handlers + +- Use `http.HandlerFunc` for simple handlers +- Implement `http.Handler` for handlers that need state +- Use middleware for cross-cutting concerns +- Set appropriate status codes and headers +- Handle errors gracefully and return appropriate error responses + +### JSON APIs + +- Use struct tags to control JSON marshaling +- Validate input data +- Use pointers for optional fields +- Consider using `json.RawMessage` for delayed parsing +- Handle JSON errors appropriately + +## Performance Optimization + +### Memory Management + +- Minimize allocations in hot paths +- Reuse objects when possible (consider `sync.Pool`) +- Use value receivers for small structs +- Preallocate slices when size is known +- Avoid unnecessary string conversions + +### Profiling + +- Use built-in profiling tools (`pprof`) +- Benchmark critical code paths +- Profile before optimizing +- Focus on algorithmic improvements first +- Consider using `testing.B` for benchmarks + +## Testing + +### Test Organization + +- Keep tests in the same package (white-box testing) +- Use `_test` package suffix for black-box testing +- Name test files with `_test.go` suffix +- Place test files next to the code they test + +### Writing Tests + +- Use table-driven tests for multiple test cases +- Name tests descriptively using `Test_functionName_scenario` +- Use subtests with `t.Run` for better organization +- Test both success and error cases +- Use `testify` or similar libraries sparingly + +### Test Helpers + +- Mark helper functions with `t.Helper()` +- Create test fixtures for complex setup +- Use `testing.TB` interface for functions used in tests and benchmarks +- Clean up resources using `t.Cleanup()` + +## Security Best Practices + +### Input Validation + +- Validate all external input +- Use strong typing to prevent invalid states +- Sanitize data before using in SQL queries +- Be careful with file paths from user input +- Validate and escape data for different contexts (HTML, SQL, shell) + +### Cryptography + +- Use standard library crypto packages +- Don't implement your own cryptography +- Use crypto/rand for random number generation +- Store passwords using bcrypt or similar +- Use TLS for network communication + +## Documentation + +### Code Documentation + +- Document all exported symbols +- Start documentation with the symbol name +- Use examples in documentation when helpful +- Keep documentation close to code +- Update documentation when code changes + +### README and Documentation Files + +- Include clear setup instructions +- Document dependencies and requirements +- Provide usage examples +- Document configuration options +- Include troubleshooting section + +## Tools and Development Workflow + +### Essential Tools + +- `go fmt`: Format code +- `go vet`: Find suspicious constructs +- `golint` or `golangci-lint`: Additional linting +- `go test`: Run tests +- `go mod`: Manage dependencies +- `go generate`: Code generation + +### Development Practices + +- Run tests before committing +- Use pre-commit hooks for formatting and linting +- Keep commits focused and atomic +- Write meaningful commit messages +- Review diffs before committing + +## Common Pitfalls to Avoid + +- Not checking errors +- Ignoring race conditions +- Creating goroutine leaks +- Not using defer for cleanup +- Modifying maps concurrently +- Not understanding nil interfaces vs nil pointers +- Forgetting to close resources (files, connections) +- Using global variables unnecessarily +- Over-using empty interfaces (`interface{}`) +- Not considering the zero value of types diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..bff38cf --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,28 @@ +{ + "servers": { + "godoc": { + "command": "godoc-mcp", + "args": [], + "env": {}, + }, + "terraform-mcp-eva": { + "type": "stdio", + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-v", + "${workspaceFolder}:/workspace", + "-w", + "/workspace", + "-e", + "TRANSPORT_MODE=stdio", + "-e", + "GITHUB_TOKEN", + "--pull=always", + "ghcr.io/lonegunmanb/terraform-mcp-eva" + ] + } + } +} diff --git a/README.md b/README.md index 7d8b857..cd5d6bf 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,24 @@ ALGvKXPLwggdNA86RIQc9tc3z/uJrBGSA2n6UkJbV1gFZDETjHzVtgDqqEQwap7D > *TBC* +### Regenerating the Rules Documentation + +The full list of rules is generated automatically into `RULES.md` using a small helper program. + +To regenerate after adding, removing, or modifying rules: + +```bash +go generate ./... +``` + +This runs the generator in `cmd/rulesdoc` (triggered by the `go:generate` directive in `generate.go`) which: + +1. Loads the registered rules from `rules.Rules`. +2. Sorts them by name for deterministic output. +3. Writes a markdown table to `RULES.md` at the repository root. + +Please commit the updated `RULES.md` alongside any rule changes. + ## Building the plugin Clone the repository locally and run the following command: diff --git a/RULES.md b/RULES.md new file mode 100644 index 0000000..f1f6245 --- /dev/null +++ b/RULES.md @@ -0,0 +1,35 @@ +# Rules Reference + +This document lists all rules currently registered in this ruleset. The Enabled column reflects the default state (some external rules are wrapped to be disabled by default). + +| Name | Enabled | Severity | Link | +| ---- | ------- | -------- | ---- | +| azapi_data_response_export_values | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/tf/a...](https://azure.github.io/Azure-Verified-Modules/specs/tf/azapi/#response_export_values-required) | +| azapi_replace_triggers_refs | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/tf/a...](https://azure.github.io/Azure-Verified-Modules/specs/tf/azapi/#replace_triggers_refs) | +| azapi_response_export_values | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/tf/a...](https://azure.github.io/Azure-Verified-Modules/specs/tf/azapi/#response_export_values-required) | +| azurerm_resource_tag | false | NOTICE | [https://github.com/Azure/tflint-ruleset-azurerm-ext/blob/...](https://github.com/Azure/tflint-ruleset-azurerm-ext/blob/v0.6.0/docs/rules/azurerm_resource_tag.md) | +| customer_managed_key | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/shar...](https://azure.github.io/Azure-Verified-Modules/specs/shared/interfaces/#customer-managed-keys) | +| diagnostic_settings | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/tf/i...](https://azure.github.io/Azure-Verified-Modules/specs/tf/interfaces/#diagnostic-settings) | +| location | true | ERROR | - | +| lock | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/tf/i...](https://azure.github.io/Azure-Verified-Modules/specs/tf/interfaces/#resource-locks) | +| managed_identities | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/tf/i...](https://azure.github.io/Azure-Verified-Modules/specs/tf/interfaces/#managed-identities) | +| no_entire_resource_output_tffr2 | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/tf/r...](https://azure.github.io/Azure-Verified-Modules/specs/tf/res/#id-tffr2---category-outputs---additional-terraform-outputs) | +| private_endpoints | true | ERROR | - | +| provider_azapi_version_constraint | true | ERROR | - | +| provider_azurerm_disallowed | true | ERROR | - | +| provider_azurerm_version_constraint | true | ERROR | - | +| provider_modtm_version_constraint | true | ERROR | - | +| required_module_source_tffr1 | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/terr...](https://azure.github.io/Azure-Verified-Modules/specs/terraform/#id-tffr1---category-composition---cross-referencing-modules) | +| required_module_source_tfnfr10 | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/terr...](https://azure.github.io/Azure-Verified-Modules/specs/terraform/#id-tfnfr10---category-code-style---no-double-quotes-in-ignore_changes) | +| required_output_rmfr7 | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/shar...](https://azure.github.io/Azure-Verified-Modules/specs/shared/#id-rmfr7---category-outputs---minimum-required-outputs) | +| role_assignments | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/tf/i...](https://azure.github.io/Azure-Verified-Modules/specs/tf/interfaces/#role-assignments) | +| tags | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/tf/i...](https://azure.github.io/Azure-Verified-Modules/specs/tf/interfaces/#tags) | +| terraform_heredoc_usage | false | NOTICE | - | +| terraform_module_provider_declaration | false | WARNING | - | +| terraform_output_separate | false | NOTICE | [https://github.com/Azure/tflint-ruleset-basic-ext/blob/v0...](https://github.com/Azure/tflint-ruleset-basic-ext/blob/v0.6.0/docs/rules/terraform_output_separate.md) | +| terraform_required_providers_declaration | false | NOTICE | - | +| terraform_required_version_declaration | false | NOTICE | - | +| terraform_sensitive_variable_no_default | false | WARNING | - | +| terraform_variable_nullable_false | false | NOTICE | [https://github.com/Azure/tflint-ruleset-basic-ext/blob/v0...](https://github.com/Azure/tflint-ruleset-basic-ext/blob/v0.6.0/docs/rules/terraform_variable_nullable_false.md) | +| terraform_variable_separate | false | NOTICE | [https://github.com/Azure/tflint-ruleset-basic-ext/blob/v0...](https://github.com/Azure/tflint-ruleset-basic-ext/blob/v0.6.0/docs/rules/terraform_variable_separate.md) | +| tfnfr26 | false | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/terr...](https://azure.github.io/Azure-Verified-Modules/specs/terraform/#id-tfnfr26---category-code-style---providers-must-be-declared-in-the-required_providers-block-in-terraformtf-and-must-have-a-constraint-on-minimum-and-maximum-major-version) | diff --git a/cmd/rulesdoc/main.go b/cmd/rulesdoc/main.go new file mode 100644 index 0000000..8a4cc4d --- /dev/null +++ b/cmd/rulesdoc/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "sort" + "strings" + + "github.com/Azure/tflint-ruleset-avm/rules" + "github.com/terraform-linters/tflint-plugin-sdk/tflint" +) + +// generator collects rule metadata from the central rule register and writes +// a markdown table to RULES.md in the repo root. Intended to be invoked via +// `go generate` (see generate.go in the root package). +func main() { + // Copy slice so we can sort without mutating original backing array (defensive) + rs := append([]tflint.Rule(nil), rules.Rules...) + + // Sort by rule.Name() ascending (stable for deterministic output) + sort.SliceStable(rs, func(i, j int) bool { return rs[i].Name() < rs[j].Name() }) + + f, err := os.Create("RULES.md") + if err != nil { + panic(err) + } + defer f.Close() + + w := bufio.NewWriter(f) + defer w.Flush() + + fmt.Fprintln(w, "# Rules Reference") + fmt.Fprintln(w) + fmt.Fprintln(w, "This document lists all rules currently registered in this ruleset. The Enabled column reflects the default state (some external rules are wrapped to be disabled by default).") + fmt.Fprintln(w) + fmt.Fprintln(w, "| Name | Enabled | Severity | Link |") + fmt.Fprintln(w, "| ---- | ------- | -------- | ---- |") + + for _, r := range rs { + name := r.Name() + enabled := "false" + if r.Enabled() { // default state + enabled = "true" + } + // Normalize severity textual representation. + severity := severityString(r.Severity()) + link := linkOrDash(r) + fmt.Fprintf(w, "| %s | %s | %s | %s |\n", name, enabled, severity, link) + } +} + +func linkOrDash(r tflint.Rule) string { + // Link() may return empty string. Some rules may not implement Link(); rely on interface existence. + // Detect empty and return '-'. Escape pipes by replacing with %7C (very unlikely). + l := r.Link() + if l == "" { + return "-" + } + // Prevent table breakage (simple sanitization) + l = strings.ReplaceAll(l, "|", "%7C") + return fmt.Sprintf("[%s](%s)", displayLinkText(l), l) +} + +func displayLinkText(l string) string { + // Show domain/path tail for brevity + if len(l) > 60 { + return l[:57] + "..." + } + return l +} + +func severityString(s tflint.Severity) string { + // Mirror tflint severities if possible; fallback to numeric. + switch s { + case tflint.ERROR: + return "ERROR" + case tflint.WARNING: + return "WARNING" + case tflint.NOTICE: + return "NOTICE" + default: + return fmt.Sprintf("%d", s) + } +} diff --git a/common/either.go b/common/either.go index 0f7c758..f6610a2 100644 --- a/common/either.go +++ b/common/either.go @@ -8,20 +8,18 @@ var _ tflint.Rule = new(EitherCheckRule) type EitherCheckRule struct { tflint.DefaultRule - primaryRule tflint.Rule - secondaryRule tflint.Rule - name string - enabled bool - severity tflint.Severity + rules []tflint.Rule + name string + enabled bool + severity tflint.Severity } -func NewEitherCheckRule(name string, enabled bool, severity tflint.Severity, primaryRule tflint.Rule, secondary tflint.Rule) *EitherCheckRule { +func NewEitherCheckRule(name string, enabled bool, severity tflint.Severity, rules ...tflint.Rule) *EitherCheckRule { return &EitherCheckRule{ - name: name, - enabled: enabled, - severity: severity, - primaryRule: primaryRule, - secondaryRule: secondary, + name: name, + enabled: enabled, + severity: severity, + rules: rules, } } @@ -40,7 +38,9 @@ func (e *EitherCheckRule) Severity() tflint.Severity { func (e *EitherCheckRule) Check(runner tflint.Runner) error { runners := map[tflint.Rule]*subRunner{} - for _, r := range []tflint.Rule{e.primaryRule, e.secondaryRule} { + issues := []issue{} + var failingRule tflint.Rule + for _, r := range e.rules { sr := &subRunner{ Runner: runner, } @@ -51,12 +51,19 @@ func (e *EitherCheckRule) Check(runner tflint.Runner) error { if len(sr.issues) == 0 { return nil } + + if len(issues) == 0 && failingRule == nil { + issues = sr.issues + failingRule = r + } } - sr := runners[e.primaryRule] + + sr := runners[e.rules[0]] for _, issue := range sr.issues { - if err := runner.EmitIssue(e.primaryRule, issue.message, issue.issueRange); err != nil { + if err := runner.EmitIssue(failingRule, issue.message, issue.issueRange); err != nil { return err } } + return nil } diff --git a/common/either_test.go b/common/either_test.go index cc48b7c..ac36114 100644 --- a/common/either_test.go +++ b/common/either_test.go @@ -1,13 +1,14 @@ package common_test import ( + "testing" + "github.com/Azure/tflint-ruleset-avm/common" "github.com/hashicorp/hcl/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/terraform-linters/tflint-plugin-sdk/helper" "github.com/terraform-linters/tflint-plugin-sdk/tflint" - "testing" ) var _ tflint.Rule = &mockRule{} @@ -43,34 +44,39 @@ func (m *mockRule) Link() string { func TestEitherPrivateEndpoints(t *testing.T) { cases := []struct { name string - rule1 tflint.Rule - rule2 tflint.Rule + rules []tflint.Rule expectedIssue bool }{ { name: "correct", - rule1: &mockRule{success: true}, - rule2: &mockRule{success: true}, + rules: []tflint.Rule{&mockRule{success: true}, &mockRule{success: true}}, expectedIssue: false, }, { name: "correct2", - rule1: &mockRule{success: true}, - rule2: &mockRule{success: false}, + rules: []tflint.Rule{&mockRule{success: true}, &mockRule{success: false}}, expectedIssue: false, }, { name: "correct3", - rule1: &mockRule{success: false}, - rule2: &mockRule{success: true}, + rules: []tflint.Rule{&mockRule{success: false}, &mockRule{success: true}}, expectedIssue: false, }, { name: "incorrect", - rule1: &mockRule{success: false}, - rule2: &mockRule{success: false}, + rules: []tflint.Rule{&mockRule{success: false}, &mockRule{success: false}}, expectedIssue: true, }, + { + name: "threeRulesCorrect", + rules: []tflint.Rule{&mockRule{success: false}, &mockRule{success: false}, &mockRule{success: false}}, + expectedIssue: true, + }, + { + name: "threeRulesIncorrect", + rules: []tflint.Rule{&mockRule{success: false}, &mockRule{success: false}, &mockRule{success: true}}, + expectedIssue: false, + }, } for _, tc := range cases { @@ -81,7 +87,7 @@ func TestEitherPrivateEndpoints(t *testing.T) { runner := helper.TestRunner(t, map[string]string{filename: ""}) - sut := common.NewEitherCheckRule("either", true, tflint.ERROR, tc.rule1, tc.rule2) + sut := common.NewEitherCheckRule("either", true, tflint.ERROR, tc.rules...) err := sut.Check(runner) require.NoError(t, err) diff --git a/generate.go b/generate.go new file mode 100644 index 0000000..968d409 --- /dev/null +++ b/generate.go @@ -0,0 +1,7 @@ +package main + +//go:generate go run ./cmd/rulesdoc + +// This file hosts the go:generate directive for producing RULES.md from the +// registered rules. Run `go generate ./...` at the repository root after +// adding or modifying rules to refresh the documentation. diff --git a/outputs/no_entire_resource_output.go b/outputs/no_entire_resource_output.go new file mode 100644 index 0000000..d13afb4 --- /dev/null +++ b/outputs/no_entire_resource_output.go @@ -0,0 +1,176 @@ +package outputs + +import ( + "fmt" + + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/terraform-linters/tflint-plugin-sdk/hclext" + "github.com/terraform-linters/tflint-plugin-sdk/tflint" +) + +// Schema to capture resource and output blocks. +var noEntireResourceOutputBodySchema = &hclext.BodySchema{ + Blocks: []hclext.BlockSchema{ + { // resource "type" "name" {} + Type: "resource", + LabelNames: []string{"type", "name"}, + }, + { // output "name" { value = ... } + Type: "output", + LabelNames: []string{"name"}, + Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}, + }, + }, +} + +var _ tflint.Rule = new(NoEntireResourceOutputRule) + +// NoEntireResourceOutputRule checks that an output does not expose an entire resource object. +type NoEntireResourceOutputRule struct { + tflint.DefaultRule + ruleName string + link string +} + +// NewNoEntireResourceOutputRule returns the rule. +func NewNoEntireResourceOutputRule(ruleName, link string) *NoEntireResourceOutputRule { + return &NoEntireResourceOutputRule{ruleName: ruleName, link: link} +} + +func (r *NoEntireResourceOutputRule) Name() string { return r.ruleName } +func (r *NoEntireResourceOutputRule) Link() string { return r.link } +func (r *NoEntireResourceOutputRule) Enabled() bool { return true } +func (r *NoEntireResourceOutputRule) Severity() tflint.Severity { return tflint.ERROR } + +func (r *NoEntireResourceOutputRule) Check(run tflint.Runner) error { + path, err := run.GetModulePath() + if err != nil { + return err + } + if !path.IsRoot() { // only evaluate root module like other rules + return nil + } + + content, err := run.GetModuleContent(noEntireResourceOutputBodySchema, &tflint.GetModuleContentOption{ExpandMode: tflint.ExpandModeNone}) + if err != nil { + return err + } + + // Build a set of resource identifiers: type.name + resources := map[string]struct{}{} + for _, b := range content.Blocks { + if b.Type == "resource" && len(b.Labels) == 2 { // type, name + resources[fmt.Sprintf("%s.%s", b.Labels[0], b.Labels[1])] = struct{}{} + } + } + + var errs error + for _, b := range content.Blocks { + if b.Type != "output" { + continue + } + attr, ok := b.Body.Attributes["value"] + if !ok { // nothing to check + continue + } + if isWholeResourceExpression(attr.Expr, resources) { + if e := run.EmitIssue(r, "authors SHOULD NOT output entire resource objects; expose specific computed attributes instead", attr.Range); e != nil { + errs = multierror.Append(errs, e) + } + } + } + return errs +} + +// isWholeResourceTraversal returns true if traversal represents a resource base object optionally followed by only index or splat traversers (i.e. still the entire object or collection of objects). +func isWholeResourceTraversal(traversal hcl.Traversal, resources map[string]struct{}) bool { + if len(traversal) < 2 { + return false + } + root, ok := traversal[0].(hcl.TraverseRoot) + if !ok { + return false + } + nameAttr, ok := traversal[1].(hcl.TraverseAttr) + if !ok { + return false + } + key := fmt.Sprintf("%s.%s", root.Name, nameAttr.Name) + if _, exists := resources[key]; !exists { + return false + } + // (debug tracing removed in finalized implementation) + // Remaining traversers (if any) must all be index or splat; BUT if an attribute appears after a splat/index chain we treat it as narrowed (acceptable) + encounteredAttr := false + for _, tr := range traversal[2:] { + switch tr.(type) { + case hcl.TraverseIndex, hcl.TraverseSplat: + if encounteredAttr { // once narrowed by attr, further index/splat shouldn't re-widen + return false + } + continue + case hcl.TraverseAttr: + // attribute access narrows to a specific property => not whole resource object(s) + return false + default: + return false + } + } + return true +} + +// isCompositeWholeResource unwraps index/splat expression layers until reaching a traversal and evaluates it. +func isWholeResourceExpression(expr hcl.Expression, resources map[string]struct{}) bool { + switch e := expr.(type) { + case *hclsyntax.ScopeTraversalExpr: + return isWholeResourceTraversal(e.Traversal, resources) + case *hclsyntax.RelativeTraversalExpr: + // RelativeTraversalExpr has a source expression and additional traversers appended. + if base := isWholeResourceExpression(e.Source, resources); base { + // If base already whole, any added index/splat keeps it whole; any attr makes it partial. + for _, tr := range e.Traversal { + switch tr.(type) { + case hcl.TraverseIndex, hcl.TraverseSplat: + continue + default: + return false + } + } + return true + } + // Or base not whole: attempt to treat combined traversal if source is plain + if se, ok := e.Source.(*hclsyntax.ScopeTraversalExpr); ok { + combined := append(se.Traversal, e.Traversal...) + return isWholeResourceTraversal(combined, resources) + } + return false + case *hclsyntax.IndexExpr: + return isWholeResourceExpression(e.Collection, resources) + case *hclsyntax.SplatExpr: + // Determine if this is a full splat (exposes entire resource objects) or a projection. + // Full splat: collection[*] + // Projection: collection[*].attribute => represented by Each being a RelativeTraversalExpr whose Source is the element symbol. + baseWhole := isWholeResourceExpression(e.Source, resources) + if !baseWhole { + return false + } + // Classify Each + switch each := e.Each.(type) { + case *hclsyntax.AnonSymbolExpr: + // Full splat: still whole + return true + case *hclsyntax.RelativeTraversalExpr: + if _, ok := each.Source.(*hclsyntax.AnonSymbolExpr); ok { + // Attribute (or further) traversal off element => projection (narrowed) + return false + } + return true // Unexpected shape; be conservative and treat as whole + default: + // Unexpected Each expression kind; conservatively treat as whole + return true + } + } + return false +} diff --git a/outputs/no_entire_resource_output_test.go b/outputs/no_entire_resource_output_test.go new file mode 100644 index 0000000..78e7589 --- /dev/null +++ b/outputs/no_entire_resource_output_test.go @@ -0,0 +1,157 @@ +package outputs_test + +import ( + "testing" + + "github.com/Azure/tflint-ruleset-avm/outputs" + "github.com/hashicorp/hcl/v2" + "github.com/terraform-linters/tflint-plugin-sdk/helper" +) + +func TestNoEntireResourceOutputRule(t *testing.T) { + cases := []struct { + desc string + config string + issues helper.Issues + }{ + { + desc: "output attribute only (ok)", + config: `resource "azurerm_resource_group" "rg" { + location = "westeurope" + name = "rg-test" +} + +output "rg_id" { + value = azurerm_resource_group.rg.id +}`, + issues: helper.Issues{}, + }, + { + desc: "entire resource output (not ok)", + config: `resource "azurerm_resource_group" "rg" { + location = "westeurope" + name = "rg-test" +} + +output "rg" { + value = azurerm_resource_group.rg +}`, + issues: helper.Issues{{ + Rule: outputs.NewNoEntireResourceOutputRule("no_entire_resource_output", ""), + Message: "authors SHOULD NOT output entire resource objects; expose specific computed attributes instead", + Range: hcl.Range{Filename: "variables.tf"}, + }}, + }, + { + desc: "entire resource index output (not ok)", + config: `resource "azurerm_resource_group" "rg" { + count = 1 + location = "westeurope" + name = "rg-test" +} + +output "rg" { + value = azurerm_resource_group.rg[0] +}`, + issues: helper.Issues{{ + Rule: outputs.NewNoEntireResourceOutputRule("no_entire_resource_output", ""), + Message: "authors SHOULD NOT output entire resource objects; expose specific computed attributes instead", + Range: hcl.Range{Filename: "variables.tf"}, + }}, + }, + { + desc: "for_each attribute only (ok)", + config: `resource "azurerm_resource_group" "rg" { + for_each = { a = "one", b = "two" } + location = "westeurope" + name = "rg-${each.key}" +} + +output "rg_ids" { + value = [for v in azurerm_resource_group.rg : v.id] +}`, + issues: helper.Issues{}, + }, + { + desc: "for_each whole instance (not ok)", + config: `resource "azurerm_resource_group" "rg" { + for_each = { a = "one" } + location = "westeurope" + name = "rg-${each.key}" +} + +output "rg" { + value = azurerm_resource_group.rg["a"] +}`, + issues: helper.Issues{{ + Rule: outputs.NewNoEntireResourceOutputRule("no_entire_resource_output", ""), + Message: "authors SHOULD NOT output entire resource objects; expose specific computed attributes instead", + Range: hcl.Range{Filename: "variables.tf"}, + }}, + }, + { + desc: "for_each whole collection splat (not ok)", + config: `resource "azurerm_resource_group" "rg" { + for_each = { a = "one", b = "two" } + location = "westeurope" + name = "rg-${each.key}" +} + +output "rgs" { + value = azurerm_resource_group.rg[*] +}`, + issues: helper.Issues{{ + Rule: outputs.NewNoEntireResourceOutputRule("no_entire_resource_output", ""), + Message: "authors SHOULD NOT output entire resource objects; expose specific computed attributes instead", + Range: hcl.Range{Filename: "variables.tf"}, + }}, + }, + { + desc: "for_each base map reference (not ok)", + config: `resource "azurerm_resource_group" "rg" { + for_each = { a = "one", b = "two" } + location = "westeurope" + name = "rg-${each.key}" +} + +output "rgs" { + value = azurerm_resource_group.rg +}`, + issues: helper.Issues{{ + Rule: outputs.NewNoEntireResourceOutputRule("no_entire_resource_output", ""), + Message: "authors SHOULD NOT output entire resource objects; expose specific computed attributes instead", + Range: hcl.Range{Filename: "variables.tf"}, + }}, + }, + { + desc: "for_each splat attribute access (ok)", + config: `resource "azurerm_resource_group" "rg" { + for_each = { a = "one", b = "two" } + location = "westeurope" + name = "rg-${each.key}" +} + +output "rg_ids" { + value = azurerm_resource_group.rg[*].id +}`, + issues: helper.Issues{}, + }, + } + + for _, tc := range cases { + c := tc + // nolint:scopelint // parallel subtests closure capture + t.Run(c.desc, func(t *testing.T) { + t.Parallel() + rule := outputs.NewNoEntireResourceOutputRule("no_entire_resource_output", "") + filename := "variables.tf" + runner := helper.TestRunner(t, map[string]string{filename: c.config}) + + if err := rule.Check(runner); err != nil { + t.Fatalf("Unexpected error occurred: %s", err) + } + + helper.AssertIssuesWithoutRange(t, c.issues, runner.Issues) + }) + } +} diff --git a/outputs/outputs.go b/outputs/outputs.go index f1e9018..9ca9e17 100644 --- a/outputs/outputs.go +++ b/outputs/outputs.go @@ -6,4 +6,5 @@ import "github.com/terraform-linters/tflint-plugin-sdk/tflint" var Rules = []tflint.Rule{ NewRequiredOutputRule("required_output_rmfr7", "resource_id", "https://azure.github.io/Azure-Verified-Modules/specs/shared/#id-rmfr7---category-outputs---minimum-required-outputs"), + NewNoEntireResourceOutputRule("no_entire_resource_output_tffr2", "https://azure.github.io/Azure-Verified-Modules/specs/tf/res/#id-tffr2---category-outputs---additional-terraform-outputs"), } diff --git a/rules/disallowed_provider.go b/rules/disallowed_provider.go new file mode 100644 index 0000000..d1ecf49 --- /dev/null +++ b/rules/disallowed_provider.go @@ -0,0 +1,106 @@ +package rules + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-multierror" + "github.com/terraform-linters/tflint-plugin-sdk/hclext" + "github.com/terraform-linters/tflint-plugin-sdk/tflint" + "github.com/zclconf/go-cty/cty" +) + +// DisallowedProviderRule checks that a specific provider is not used in the module. +// It performs two checks when ProviderName is "azurerm": +// 1. The `required_providers` block must not declare a provider whose source is hashicorp/azurerm. +// 2. No resource / data / ephemeral blocks whose first label starts with "azurerm_" should exist. +// +// If any are found, an issue is emitted. +// The rule is generic for potential future expansion by changing ProviderName/ProviderSource/Prefix. +// Currently only azurerm is required per user request. +var _ tflint.Rule = new(DisallowedProviderRule) + +type DisallowedProviderRule struct { + tflint.DefaultRule + ProviderName string + ProviderSource string +} + +func NewDisallowedProviderRule(providerName, providerSource string) *DisallowedProviderRule { + return &DisallowedProviderRule{ProviderName: providerName, ProviderSource: providerSource} +} + +func (r *DisallowedProviderRule) Name() string { + // naming convention: provider__disallowed + return fmt.Sprintf("provider_%s_disallowed", r.ProviderName) +} + +func (r *DisallowedProviderRule) Enabled() bool { return true } + +func (r *DisallowedProviderRule) Severity() tflint.Severity { return tflint.ERROR } + +// requiredProvidersBodySchema dynamically requests the attribute for the provider name inside required_providers. +func (r *DisallowedProviderRule) requiredProvidersBodySchema() *hclext.BodySchema { + return &hclext.BodySchema{Blocks: []hclext.BlockSchema{{Type: "terraform", Body: &hclext.BodySchema{Blocks: []hclext.BlockSchema{{Type: "required_providers", Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: r.ProviderName}}}}}}}}} +} + +var disallowedProviderUsageSchema = &hclext.BodySchema{Blocks: []hclext.BlockSchema{ + {Type: "resource", LabelNames: []string{"type", "name"}}, + {Type: "data", LabelNames: []string{"type", "name"}}, + {Type: "ephemeral", LabelNames: []string{"type", "name"}}, +}} + +func (r *DisallowedProviderRule) Check(run tflint.Runner) error { + path, err := run.GetModulePath() + if err != nil { + return err + } + if !path.IsRoot() { // only root module enforcement for consistency with other rules + return nil + } + + // 1. Check required_providers for disallowed provider with matching source + content, err := run.GetModuleContent(r.requiredProvidersBodySchema(), &tflint.GetModuleContentOption{ExpandMode: tflint.ExpandModeNone}) + if err != nil { + return err + } + for _, tb := range content.Blocks { // terraform blocks + for _, rp := range tb.Body.Blocks { // required_providers blocks + if attr, ok := rp.Body.Attributes[r.ProviderName]; ok { + provider := struct { + Source string `cty:"source"` + }{} + wantType := cty.Object(map[string]cty.Type{"source": cty.String}) + if evalErr := run.EvaluateExpr(attr.Expr, &provider, &tflint.EvaluateExprOption{WantType: &wantType}); evalErr == nil { + if strings.EqualFold(provider.Source, r.ProviderSource) { + if issueErr := run.EmitIssue(r, fmt.Sprintf("provider '%s' (source %s) is disallowed", r.ProviderName, r.ProviderSource), attr.Range); issueErr != nil { + return issueErr + } + } + } + } + } + } + + // 2. Scan for any usage blocks with provider name prefix + usage, err := run.GetModuleContent(disallowedProviderUsageSchema, &tflint.GetModuleContentOption{ExpandMode: tflint.ExpandModeNone}) + if err != nil { + return err + } + + var errs error + for _, blk := range usage.Blocks { + if len(blk.Labels) == 0 { // defensive + continue + } + first := blk.Labels[0] + if strings.HasPrefix(first, r.ProviderName+"_") { + if e := run.EmitIssue(r, fmt.Sprintf("%s block type '%s' is disallowed (provider '%s')", blk.Type, first, r.ProviderName), blk.DefRange); e != nil { + errs = multierror.Append(errs, e) + } + } + } + return errs +} + +func (r *DisallowedProviderRule) Link() string { return "" } diff --git a/rules/disallowed_provider_test.go b/rules/disallowed_provider_test.go new file mode 100644 index 0000000..f618a6c --- /dev/null +++ b/rules/disallowed_provider_test.go @@ -0,0 +1,73 @@ +package rules_test + +import ( + "testing" + + rules "github.com/Azure/tflint-ruleset-avm/rules" + "github.com/terraform-linters/tflint-plugin-sdk/helper" +) + +func TestDisallowedProviderRule_RequiredProviders(t *testing.T) { + const content = `terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +}` + rule := rules.NewDisallowedProviderRule("azurerm", "hashicorp/azurerm") + runner := helper.TestRunner(t, map[string]string{"main.tf": content}) + if err := rule.Check(runner); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if len(runner.Issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(runner.Issues)) + } + if runner.Issues[0].Rule.Name() != rule.Name() { + t.Fatalf("unexpected rule name: %s", runner.Issues[0].Rule.Name()) + } +} + +func TestDisallowedProviderRule_ResourceUsage(t *testing.T) { + const content = `resource "random_string" "allowed" { + length = 8 +} +resource "azurerm_resource_group" "rg" { + name = "rg1" + location = "uksouth" +}` + rule := rules.NewDisallowedProviderRule("azurerm", "hashicorp/azurerm") + runner := helper.TestRunner(t, map[string]string{"main.tf": content}) + if err := rule.Check(runner); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if len(runner.Issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(runner.Issues)) + } + if runner.Issues[0].Rule.Name() != rule.Name() { + t.Fatalf("unexpected rule name: %s", runner.Issues[0].Rule.Name()) + } +} + +func TestDisallowedProviderRule_NoIssues(t *testing.T) { + const content = `terraform { + required_providers { + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} +resource "random_string" "s" { + length = 4 +}` + rule := rules.NewDisallowedProviderRule("azurerm", "hashicorp/azurerm") + runner := helper.TestRunner(t, map[string]string{"main.tf": content}) + if err := rule.Check(runner); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if len(runner.Issues) != 0 { + t.Fatalf("expected no issues, got %d", len(runner.Issues)) + } +} diff --git a/rules/required_attribute.go b/rules/required_attribute.go new file mode 100644 index 0000000..2ef0fdd --- /dev/null +++ b/rules/required_attribute.go @@ -0,0 +1,149 @@ +package rules + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-multierror" + "github.com/terraform-linters/tflint-plugin-sdk/hclext" + "github.com/terraform-linters/tflint-plugin-sdk/tflint" + "github.com/zclconf/go-cty/cty" +) + +// RequiredAttributeRule is a generic rule to enforce that a particular attribute is present +// on blocks of a given type whose first label (usually the resource type) matches an allow list. +// This is intentionally generic so future required-attribute rules can be added with a single +// line registration instead of bespoke implementations. +// +// Example enforced block shape: resource "" "name" { = <...> } +// Only blocks whose first label value appears in allowedFirstLabelValues are checked. +// When the attribute is missing an issue is emitted including a suggested default value text. +type RequiredAttributeRule struct { + tflint.DefaultRule + + // blockType is the HCL block type (e.g. "resource", "data") + blockType string + // allowedFirstLabelValues restricts enforcement to blocks whose first label value is in the set. + allowedFirstLabelValues map[string]struct{} + // attributeName is the attribute that must be present. + attributeName string + // defaultSuggestion is text describing a default value (NOT auto-fix) shown in the issue message. + defaultSuggestion string + // name is the unique rule name. + name string + // link is documentation URL. + link string + // severity of the rule (defaults to ERROR when empty). + severity tflint.Severity + // optional value validator executed when attribute is present + valueValidator func(run tflint.Runner, rule *RequiredAttributeRule, attr *hclext.Attribute) error +} + +// NewAzapiRequiredAttributeRule constructs a new required attribute rule. +type RequiredAttributeOption func(*RequiredAttributeRule) + +// WithValueValidator registers a callback to validate attribute value (only runs if attribute exists). +func WithValueValidator(fn func(run tflint.Runner, rule *RequiredAttributeRule, attr *hclext.Attribute) error) RequiredAttributeOption { + return func(r *RequiredAttributeRule) { r.valueValidator = fn } +} + +// DisallowWildcardList returns an option that forbids a single-item list containing "*" for the given attribute. +func DisallowWildcardList(attributeName string) RequiredAttributeOption { + return WithValueValidator(func(run tflint.Runner, rule *RequiredAttributeRule, attr *hclext.Attribute) error { + var values []string + wantType := cty.List(cty.String) + if err := run.EvaluateExpr(attr.Expr, &values, &tflint.EvaluateExprOption{WantType: &wantType}); err == nil { + if len(values) == 1 && values[0] == "*" { + msg := fmt.Sprintf("`%s` must not contain the wildcard \"*\"; explicitly list required fields or use empty list []", attributeName) + return run.EmitIssue(rule, msg, attr.Range) + } + } + return nil + }) +} + +func NewRequiredAttributeRule(name, link, blockType string, allowedFirstLabels []string, attributeName, defaultSuggestion string, severity tflint.Severity, opts ...RequiredAttributeOption) *RequiredAttributeRule { + set := make(map[string]struct{}, len(allowedFirstLabels)) + for _, v := range allowedFirstLabels { + set[v] = struct{}{} + } + if severity == 0 { // treat unknown as ERROR + severity = tflint.ERROR + } + r := &RequiredAttributeRule{ + blockType: blockType, + allowedFirstLabelValues: set, + attributeName: attributeName, + defaultSuggestion: defaultSuggestion, + name: name, + link: link, + severity: severity, + } + for _, o := range opts { + o(r) + } + return r +} + +func (r *RequiredAttributeRule) Name() string { return r.name } +func (r *RequiredAttributeRule) Link() string { return r.link } +func (r *RequiredAttributeRule) Enabled() bool { return true } +func (r *RequiredAttributeRule) Severity() tflint.Severity { return r.severity } + +// dynamic schema built per rule (we only care about presence, not value shape). +func (r *RequiredAttributeRule) schema() *hclext.BodySchema { + return &hclext.BodySchema{ // e.g. resource "type" "name" {} + Blocks: []hclext.BlockSchema{{ + Type: r.blockType, + LabelNames: []string{"type", "name"}, + Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{ + Name: r.attributeName, + }}}, + }}, + } +} + +func (r *RequiredAttributeRule) Check(run tflint.Runner) error { + path, err := run.GetModulePath() + if err != nil { + return err + } + if !path.IsRoot() { // only enforce at root, consistent with existing azapi rules + return nil + } + + content, err := run.GetModuleContent(r.schema(), &tflint.GetModuleContentOption{ExpandMode: tflint.ExpandModeNone}) + if err != nil { + return err + } + + var errs error + for _, b := range content.Blocks { + if b.Type != r.blockType || len(b.Labels) < 2 { // defensive + continue + } + first := b.Labels[0] + if _, ok := r.allowedFirstLabelValues[first]; !ok { + continue + } + attr, ok := b.Body.Attributes[r.attributeName] + if ok { + if r.valueValidator != nil { + if e := r.valueValidator(run, r, attr); e != nil { + errs = multierror.Append(errs, e) + } + } + continue + } + + suggestion := r.defaultSuggestion + if strings.TrimSpace(suggestion) != "" { + suggestion = fmt.Sprintf(" (suggested default: %s)", suggestion) + } + msg := fmt.Sprintf("`%s` attribute must be specified%s", r.attributeName, suggestion) + if e := run.EmitIssue(r, msg, b.DefRange); e != nil { + errs = multierror.Append(errs, e) + } + } + return errs +} diff --git a/rules/required_attribute_test.go b/rules/required_attribute_test.go new file mode 100644 index 0000000..4623f1b --- /dev/null +++ b/rules/required_attribute_test.go @@ -0,0 +1,68 @@ +package rules + +import ( + "testing" + + "github.com/terraform-linters/tflint-plugin-sdk/helper" + "github.com/terraform-linters/tflint-plugin-sdk/tflint" +) + +// TestAzapiRequiredAttributeRule validates the generic rule behaviour using an artificial attribute. +func TestAzapiRequiredAttributeRule(t *testing.T) { + rule := NewRequiredAttributeRule( + "azapi_resource_required_fake_attr", // name + "https://example.invalid/docs/fake", // link + "resource", // block type + []string{"azapi_resource"}, // first label allow list + "fake_required", // attribute name + "\"default\"", // suggestion text (string literal) + tflint.ERROR, + ) + + cases := []struct { + desc string + config string + expected helper.Issues + }{ + { + desc: "no matching resources => no issue", + config: `resource "random_pet" "example" {}`, + expected: helper.Issues{}, + }, + { + desc: "matching resource missing attribute => issue", + config: `resource "azapi_resource" "ex" { + type = "Microsoft.Example/examples@2023-06-01" + name = "ex1" + location = "westus2" + parent_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg1" +}`, + expected: helper.Issues{{ + Rule: rule, + Message: "`fake_required` attribute must be specified (suggested default: \"default\")", + }}, + }, + { + desc: "matching resource with attribute => ok", + config: `resource "azapi_resource" "ex" { + type = "Microsoft.Example/examples@2023-06-01" + name = "ex1" + location = "westus2" + parent_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg1" + fake_required = "custom" +}`, + expected: helper.Issues{}, + }, + } + + for _, c := range cases { + c := c + t.Run(c.desc, func(t *testing.T) { + runner := helper.TestRunner(t, map[string]string{"main.tf": c.config}) + if err := rule.Check(runner); err != nil { + t.Fatalf("unexpected error: %s", err) + } + helper.AssertIssuesWithoutRange(t, c.expected, runner.Issues) + }) + } +} diff --git a/rules/rule_register.go b/rules/rule_register.go index 6e99df1..b197905 100644 --- a/rules/rule_register.go +++ b/rules/rule_register.go @@ -25,9 +25,40 @@ var Rules = func() []tflint.Rule { NewTerraformDotTfRule(), NewModuleSourceRule(), NewNoDoubleQuotesInIgnoreChangesRule(), + NewDisallowedProviderRule("azurerm", "hashicorp/azurerm"), NewProviderVersionRule("modtm", "Azure/modtm", "0.3.0", "~> 0.3", true), NewProviderVersionRule("azapi", "Azure/azapi", "2.999.0", "~> 2.0", false), NewProviderVersionRule("azurerm", "hashicorp/azurerm", "4.999.0", "~> 4.0", false), + // Generic azapi required attribute rules replacing bespoke implementations + NewRequiredAttributeRule( + "azapi_response_export_values", + "https://azure.github.io/Azure-Verified-Modules/specs/tf/azapi/#response_export_values-required", + "resource", + []string{"azapi_resource", "azapi_resource_action", "azapi_update_resource"}, + "response_export_values", + "[]", + tflint.ERROR, + DisallowWildcardList("response_export_values"), + ), + NewRequiredAttributeRule( + "azapi_data_response_export_values", + "https://azure.github.io/Azure-Verified-Modules/specs/tf/azapi/#response_export_values-required", + "data", + []string{"azapi_resource", "azapi_resource_action", "azapi_resource_list"}, + "response_export_values", + "[]", + tflint.ERROR, + DisallowWildcardList("response_export_values"), + ), + NewRequiredAttributeRule( + "azapi_replace_triggers_refs", + "https://azure.github.io/Azure-Verified-Modules/specs/tf/azapi/#replace_triggers_refs", + "resource", + []string{"azapi_resource"}, + "replace_triggers_refs", + "[]", + tflint.ERROR, + ), }, interfaces.Rules, outputs.Rules, From 0057a04c8613633b957e32300ad933f72a574693 Mon Sep 17 00:00:00 2001 From: Matt White <16320656+matt-FFFFFF@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:12:03 +0000 Subject: [PATCH 2/5] feat: update lock with notes field --- .vscode/mcp.json | 3 ++- interfaces/lock.go | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.vscode/mcp.json b/.vscode/mcp.json index bff38cf..b474710 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -2,8 +2,9 @@ "servers": { "godoc": { "command": "godoc-mcp", + "type": "stdio", "args": [], - "env": {}, + "env": {} }, "terraform-mcp-eva": { "type": "stdio", diff --git a/interfaces/lock.go b/interfaces/lock.go index 830cfa7..33d0b1d 100644 --- a/interfaces/lock.go +++ b/interfaces/lock.go @@ -10,8 +10,9 @@ import ( // When updating the type constraint string, make sure to also update the two // private endpoint interfaces (the one with subresource and the one without). var LockTypeString = `object({ - kind = string - name = optional(string, null) + kind = string + name = optional(string, null) + notes = optional(string, null) })` var lockType = StringToTypeConstraintWithDefaults(LockTypeString) From f6f4e27855df2139717b5b82fe5158de67a078b8 Mon Sep 17 00:00:00 2001 From: Matt White <16320656+matt-FFFFFF@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:16:32 +0000 Subject: [PATCH 3/5] feat: update role assignments to include name --- interfaces/doc.go | 2 ++ interfaces/private_endpoints.go | 1 + interfaces/role_assignments.go | 1 + 3 files changed, 4 insertions(+) create mode 100644 interfaces/doc.go diff --git a/interfaces/doc.go b/interfaces/doc.go new file mode 100644 index 0000000..3fb7e35 --- /dev/null +++ b/interfaces/doc.go @@ -0,0 +1,2 @@ +// Package interfaces defines the reuable interfaces for Azure Verified Modules. +package interfaces diff --git a/interfaces/private_endpoints.go b/interfaces/private_endpoints.go index 546dad7..79bb34e 100644 --- a/interfaces/private_endpoints.go +++ b/interfaces/private_endpoints.go @@ -8,6 +8,7 @@ import ( var PrivateEndpointTypeString = `map(object({ name = optional(string, null) role_assignments = optional(map(object({ + name = optional(string, null) role_definition_id_or_name = string principal_id = string description = optional(string, null) diff --git a/interfaces/role_assignments.go b/interfaces/role_assignments.go index 6014492..4a0a58d 100644 --- a/interfaces/role_assignments.go +++ b/interfaces/role_assignments.go @@ -9,6 +9,7 @@ import ( // When updating the type constraint string, make sure to also update the two // private endpoint interfaces (the one with subresource and the one without). var RoleAssignmentsTypeString = `map(object({ + name = optional(string, null) role_definition_id_or_name = string principal_id = string description = optional(string, null) From 19113484bb0f9af9a11600ee8b32a348ce2d26ea Mon Sep 17 00:00:00 2001 From: Matt White <16320656+matt-FFFFFF@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:25:44 +0000 Subject: [PATCH 4/5] feat: simplify private endpoints --- interfaces/interfaces.go | 7 +-- interfaces/private_endpoints.go | 1 + ...private_endpoints_with_subresource_name.go | 46 ------------------- ...te_endpoints_with_subresource_name_test.go | 43 ----------------- 4 files changed, 2 insertions(+), 95 deletions(-) delete mode 100644 interfaces/private_endpoints_with_subresource_name.go delete mode 100644 interfaces/private_endpoints_with_subresource_name_test.go diff --git a/interfaces/interfaces.go b/interfaces/interfaces.go index f1b0013..018864d 100644 --- a/interfaces/interfaces.go +++ b/interfaces/interfaces.go @@ -1,7 +1,6 @@ package interfaces import ( - "github.com/Azure/tflint-ruleset-avm/common" "github.com/terraform-linters/tflint-plugin-sdk/tflint" ) @@ -13,9 +12,5 @@ var Rules = []tflint.Rule{ NewVarCheckRuleFromAvmInterface(ManagedIdentities), NewVarCheckRuleFromAvmInterface(RoleAssignments), NewVarCheckRuleFromAvmInterface(Tags), - func() tflint.Rule { - return common.NewEitherCheckRule("private_endpoints", true, tflint.ERROR, - NewVarCheckRuleFromAvmInterface(PrivateEndpoints), - NewVarCheckRuleFromAvmInterface(PrivateEndpointsWithSubresourceName)) - }(), + NewVarCheckRuleFromAvmInterface(PrivateEndpoints), } diff --git a/interfaces/private_endpoints.go b/interfaces/private_endpoints.go index 79bb34e..710b7ba 100644 --- a/interfaces/private_endpoints.go +++ b/interfaces/private_endpoints.go @@ -23,6 +23,7 @@ var PrivateEndpointTypeString = `map(object({ name = optional(string, null) }), null) tags = optional(map(string), null) + subresource_name = optional(string, null) subnet_resource_id = string private_dns_zone_group_name = optional(string, "default") private_dns_zone_resource_ids = optional(set(string), []) diff --git a/interfaces/private_endpoints_with_subresource_name.go b/interfaces/private_endpoints_with_subresource_name.go deleted file mode 100644 index e8c2f45..0000000 --- a/interfaces/private_endpoints_with_subresource_name.go +++ /dev/null @@ -1,46 +0,0 @@ -package interfaces - -import ( - "github.com/matt-FFFFFF/tfvarcheck/varcheck" - "github.com/zclconf/go-cty/cty" -) - -var PrivateEndpointWithSubresourceNameTypeString = `map(object({ - name = optional(string, null) - role_assignments = optional(map(object({ - role_definition_id_or_name = string - principal_id = string - description = optional(string, null) - skip_service_principal_aad_check = optional(bool, false) - condition = optional(string, null) - condition_version = optional(string, null) - delegated_managed_identity_resource_id = optional(string, null) - principal_type = optional(string, null) - })), {}) - lock = optional(object({ - kind = string - name = optional(string, null) - }), null) - tags = optional(map(string), null) - subnet_resource_id = string - subresource_name = string - private_dns_zone_group_name = optional(string, "default") - private_dns_zone_resource_ids = optional(set(string), []) - application_security_group_associations = optional(map(string), {}) - private_service_connection_name = optional(string, null) - network_interface_name = optional(string, null) - location = optional(string, null) - resource_group_name = optional(string, null) - ip_configurations = optional(map(object({ - name = string - private_ip_address = string - })), {}) -}))` - -var PrivateEndpointsWithSubresourceName = AvmInterface{ - VarCheck: varcheck.NewVarCheck(StringToTypeConstraintWithDefaults(PrivateEndpointWithSubresourceNameTypeString), cty.EmptyObjectVal, false), - RuleName: "private_endpoints", - VarTypeString: PrivateEndpointWithSubresourceNameTypeString, - RuleEnabled: true, - RuleLink: "https://azure.github.io/Azure-Verified-Modules/specs/tf/interfaces/#private-endpoints", -} diff --git a/interfaces/private_endpoints_with_subresource_name_test.go b/interfaces/private_endpoints_with_subresource_name_test.go deleted file mode 100644 index 6c881c0..0000000 --- a/interfaces/private_endpoints_with_subresource_name_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package interfaces_test - -import ( - "github.com/Azure/tflint-ruleset-avm/interfaces" - "github.com/terraform-linters/tflint-plugin-sdk/helper" - "testing" -) - -func TestPrivateEndpointsWithSubresourceName(t *testing.T) { - cases := []struct { - Name string - Content string - JSON bool - Expected helper.Issues - }{ - { - Name: "correct", - Content: toTerraformVarType(interfaces.PrivateEndpointsWithSubresourceName), - Expected: helper.Issues{}, - }, - } - - rule := interfaces.NewVarCheckRuleFromAvmInterface(interfaces.PrivateEndpointsWithSubresourceName) - - for _, tc := range cases { - tc := tc - t.Run(tc.Name, func(t *testing.T) { - t.Parallel() - filename := "variables.tf" - if tc.JSON { - filename += ".json" - } - - runner := helper.TestRunner(t, map[string]string{filename: tc.Content}) - - if err := rule.Check(runner); err != nil { - t.Fatalf("Unexpected error occurred: %s", err) - } - - helper.AssertIssues(t, tc.Expected, runner.Issues) - }) - } -} From abe9e816c48bea5729addd0cc50c5ce445fb4655 Mon Sep 17 00:00:00 2001 From: Matt White <16320656+matt-FFFFFF@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:28:19 +0000 Subject: [PATCH 5/5] docs: gen RULES.md --- RULES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RULES.md b/RULES.md index f1f6245..f26d564 100644 --- a/RULES.md +++ b/RULES.md @@ -14,7 +14,7 @@ This document lists all rules currently registered in this ruleset. The Enabled | lock | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/tf/i...](https://azure.github.io/Azure-Verified-Modules/specs/tf/interfaces/#resource-locks) | | managed_identities | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/tf/i...](https://azure.github.io/Azure-Verified-Modules/specs/tf/interfaces/#managed-identities) | | no_entire_resource_output_tffr2 | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/tf/r...](https://azure.github.io/Azure-Verified-Modules/specs/tf/res/#id-tffr2---category-outputs---additional-terraform-outputs) | -| private_endpoints | true | ERROR | - | +| private_endpoints | true | ERROR | [https://azure.github.io/Azure-Verified-Modules/specs/tf/i...](https://azure.github.io/Azure-Verified-Modules/specs/tf/interfaces/#private-endpoints) | | provider_azapi_version_constraint | true | ERROR | - | | provider_azurerm_disallowed | true | ERROR | - | | provider_azurerm_version_constraint | true | ERROR | - |