Skip to content
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
5 changes: 5 additions & 0 deletions .custom-gcl.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ plugins:
- module: 'github.com/stackitcloud/terraform-provider-stackit/tools'
import: 'github.com/stackitcloud/terraform-provider-stackit/tools/linters/tfwriteid'
path: ./tools
- module: 'github.com/stackitcloud/terraform-provider-stackit/tools'
import: 'github.com/stackitcloud/terraform-provider-stackit/tools/linters/tfmodifyplan'
path: ./tools
# WARNING: when working with custom linting rules make sure to clear your cache when linting:
# golangci-lint cache clean && make lint
4 changes: 4 additions & 0 deletions golang-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ linters:
- tfctxinit # custom local linter
- tflogresponse # custom local linter
- tfwriteid # custom local linter
- tfmodifyplan # custom local linter
- bodyclose
- depguard
- errorlint
Expand Down Expand Up @@ -42,6 +43,9 @@ linters:
tfwriteid:
type: module
description: "A custom local linter"
tfmodifyplan:
type: module
description: "A custom local linter"
depguard:
rules:
main:
Expand Down
175 changes: 175 additions & 0 deletions tools/linters/tfmodifyplan/tfmodifyplan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package tfmodifyplan

import (
"go/ast"
"go/token"
"go/types"

"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"

"github.com/golangci/plugin-module-register/register"
)

var Analyzer = &analysis.Analyzer{
Name: "tfmodifyplan",
Doc: "Ensures every resource with a region field implements the ModifyPlan method.",
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: run,
}

const (
targetPkgName = "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
targetFuncName = "AdaptRegion"
)

func run(pass *analysis.Pass) (interface{}, error) {
ins := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

// structData keeps track of methods attached to a struct receiver
type structData struct {
schemaMethod *ast.FuncDecl
modifyPlanMethod *ast.FuncDecl
isResource bool // True if it has Create, Update, or Delete methods
}

resources := make(map[string]*structData)

// Filter down to only function declarations
nodeFilter := []ast.Node{
(*ast.FuncDecl)(nil),
}

// Pass 1: Collect and group all methods by their receiver struct
ins.Preorder(nodeFilter, func(n ast.Node) {
fn := n.(*ast.FuncDecl)

// Skip if it doesn't have a receiver (not a struct method)
if fn.Recv == nil || len(fn.Recv.List) == 0 {
return
}

// Extract receiver struct name
var recvName string
switch t := fn.Recv.List[0].Type.(type) {
case *ast.StarExpr: // Pointer receiver: *MyResource
if ident, ok := t.X.(*ast.Ident); ok {
recvName = ident.Name
}
case *ast.Ident: // Value receiver: MyResource
recvName = t.Name
}

if recvName == "" {
return
}

if resources[recvName] == nil {
resources[recvName] = &structData{}
}

// Identify the role of the method
switch fn.Name.Name {
case "Schema":
resources[recvName].schemaMethod = fn
case "ModifyPlan":
resources[recvName].modifyPlanMethod = fn
case "Create", "Update", "Delete":
// Data sources do not have Create/Update/Delete in the TF Plugin Framework.
// This heuristic guarantees we are looking at a Resource.
resources[recvName].isResource = true
}
})

// Pass 2: Analyze the collected data against your business logic rules
for recvName, data := range resources {
// Only analyze valid TF Resources that have a Schema method
if !data.isResource || data.schemaMethod == nil {
continue
}

// Check if the string "region" is defined anywhere in the Schema method
hasRegion := false
ast.Inspect(data.schemaMethod, func(n ast.Node) bool {
if lit, ok := n.(*ast.BasicLit); ok {
if lit.Kind == token.STRING && lit.Value == `"region"` {
hasRegion = true
return false // Found it, stop walking this branch
}
}
return true
})

// If it doesn't have a region attribute, skip it.
if !hasRegion {
continue
}

// Has region, but no ModifyPlan method
if data.modifyPlanMethod == nil {
pass.Reportf(
data.schemaMethod.Pos(),
"Terraform resource '%s' defines a 'region' field but does not implement the ModifyPlan method.",
recvName,
)
continue
}

// Check if the specific function is called inside ModifyPlan
hasRequiredFuncCall := false
ast.Inspect(data.modifyPlanMethod, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
// Check if the method being called matches our target function name
if sel.Sel.Name == targetFuncName {
if ident, ok := sel.X.(*ast.Ident); ok {
// Use type info to resolve the identifier to its actual package
obj := pass.TypesInfo.Uses[ident]
if pkgName, ok := obj.(*types.PkgName); ok {
// Compare the actual import path
if pkgName.Imported().Path() == targetPkgName {
hasRequiredFuncCall = true
return false // Found it, stop walking this branch
}
}
}
}
}
}
return true
})

// Has ModifyPlan, but missing the required package/function call
if !hasRequiredFuncCall {
pass.Reportf(
data.modifyPlanMethod.Pos(),
"Terraform resource '%s' ModifyPlan method must call %s.%s().",
recvName,
targetPkgName,
targetFuncName,
)
}
}

return nil, nil
}

func init() {
register.Plugin("tfmodifyplan", New)
}

func New(settings any) (register.LinterPlugin, error) {
return &plugin{}, nil
}

type plugin struct{}

func (p *plugin) BuildAnalyzers() ([]*analysis.Analyzer, error) {
return []*analysis.Analyzer{Analyzer}, nil
}

func (p *plugin) GetLoadMode() string {
// LoadModeSyntax is required because we need to inspect the AST (Syntax trees)
return register.LoadModeSyntax
}
Loading