From a337cafe3f9ea40f94fd56d480cd0f666f2db1b3 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Fri, 31 Jul 2026 14:59:58 +0200 Subject: [PATCH 01/13] fix(tfwriteid): only flag AsyncActionHandler.WaitWithContext calls Prevents false positives when a handler is just created, like when extracting the timeout. --- tools/linters/tfwriteid/tfwriteid.go | 43 ++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/tools/linters/tfwriteid/tfwriteid.go b/tools/linters/tfwriteid/tfwriteid.go index 5981217c5..05615179d 100644 --- a/tools/linters/tfwriteid/tfwriteid.go +++ b/tools/linters/tfwriteid/tfwriteid.go @@ -2,7 +2,7 @@ package tfwriteid import ( "go/ast" - "strings" + "go/types" "github.com/golangci/plugin-module-register/register" "golang.org/x/tools/go/analysis" @@ -63,7 +63,8 @@ func run(pass *analysis.Pass) (interface{}, error) { } // Check if we've hit a STACKIT SDK wait handler call before the util function - if strings.HasPrefix(pkgPath, lintutils.StackitSdkModulePrefix) && strings.Contains(pkgPath, "wait") && !strings.HasPrefix(pkgPath, "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement") && pkgPath != "github.com/stackitcloud/stackit-sdk-go/core/wait" && !hasCalledUtil { + callsWait := isWaitCall(pass.TypesInfo, call, calledFuncName) + if callsWait && !hasCalledUtil { pass.Reportf( call.Pos(), "%s: call to wait handler from %s must happen AFTER %s.%s is called in %s %s", @@ -95,3 +96,41 @@ func (p *plugin) BuildAnalyzers() ([]*analysis.Analyzer, error) { func (p *plugin) GetLoadMode() string { return register.LoadModeSyntax } + +func isWaitCall(info *types.Info, call *ast.CallExpr, calledFuncName string) bool { + // must be a selector like handler.WaitWithContext() + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + obj := info.Uses[sel.Sel] + if obj == nil { + return false + } + sig, ok := obj.Type().(*types.Signature) + if !ok { + return false + } + // get full receiver type + recv := sig.Recv() + if recv == nil { + return false + } + recvType := recv.Type() + if ptr, ok := recvType.(*types.Pointer); ok { + recvType = ptr.Elem() + } + named, ok := recvType.(*types.Named) + if !ok { + return false + } + if named.Obj().Pkg() == nil { + return false + } + recvPkgName := named.Obj().Pkg().Path() + recvTypeName := named.Obj().Name() + // must be WaitWithContext on a wait.AsyncActionHandler receiver + return recvPkgName == "github.com/stackitcloud/stackit-sdk-go/core/wait" && + recvTypeName == "AsyncActionHandler" && + calledFuncName == "WaitWithContext" +} From be553eb3917e4573e28d47d0b928c8c26eaaba1b Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Sun, 2 Aug 2026 02:29:46 +0200 Subject: [PATCH 02/13] feat(linter): add serviceCall fact to funcs doing a SDK network call --- tools/go.mod | 5 + tools/go.sum | 6 + .../internal/facts/servicecall/servicecall.go | 161 ++++++++++++++++++ .../facts/servicecall/servicecall_test.go | 16 ++ tools/testdata/go.mod | 12 ++ tools/testdata/go.sum | 12 ++ tools/testdata/servicecall/servicecall.go | 27 +++ 7 files changed, 239 insertions(+) create mode 100644 tools/internal/facts/servicecall/servicecall.go create mode 100644 tools/internal/facts/servicecall/servicecall_test.go create mode 100644 tools/testdata/go.mod create mode 100644 tools/testdata/go.sum create mode 100644 tools/testdata/servicecall/servicecall.go diff --git a/tools/go.mod b/tools/go.mod index 22dd2b053..201808491 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -6,3 +6,8 @@ require ( github.com/golangci/plugin-module-register v0.1.2 golang.org/x/tools v0.45.0 ) + +require ( + golang.org/x/mod v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect +) diff --git a/tools/go.sum b/tools/go.sum index bdfff8dcb..fd1fd4c39 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -1,4 +1,10 @@ github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg= github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= diff --git a/tools/internal/facts/servicecall/servicecall.go b/tools/internal/facts/servicecall/servicecall.go new file mode 100644 index 000000000..21db2c6b8 --- /dev/null +++ b/tools/internal/facts/servicecall/servicecall.go @@ -0,0 +1,161 @@ +// Package servicecall identifies functions that make STACKIT SDK service calls. +package servicecall + +import ( + "go/ast" + "go/types" + "strings" + + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/passes/inspect" + "golang.org/x/tools/go/ast/inspector" + "golang.org/x/tools/go/types/typeutil" + "golang.org/x/tools/refactor/satisfy" + + "github.com/stackitcloud/terraform-provider-stackit/tools/internal/lintutils" +) + +var Analyzer = &analysis.Analyzer{ + Name: "servicecall", + Doc: "Publishes a fact for functions that call a STACKIT SDK service", + Requires: []*analysis.Analyzer{inspect.Analyzer}, + FactTypes: []analysis.Fact{ + new(IsServiceCall), + }, + Run: run, +} + +// IsServiceCall is a fact indicating that a function calls a STACKIT SDK service. +type IsServiceCall struct{} + +func (*IsServiceCall) AFact() {} + +func (*IsServiceCall) String() string { return "serviceCall" } + +func run(pass *analysis.Pass) (any, error) { + inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) + + // callers maps a callee to the functions that call it. Once a callee is + // known to call a service, the fact is propagated to all of its callers. + callers := make(map[*types.Func]map[*types.Func]struct{}) + var serviceCallers []*types.Func + + inspect.Preorder([]ast.Node{(*ast.FuncDecl)(nil)}, func(n ast.Node) { + decl := n.(*ast.FuncDecl) + if decl.Body == nil { + return + } + + caller, _ := pass.TypesInfo.Defs[decl.Name].(*types.Func) + if caller == nil { + return + } + caller = caller.Origin() + + ast.Inspect(decl.Body, func(n ast.Node) bool { + if _, ok := n.(*ast.FuncLit); ok { + // A call in a closure does not belong to the enclosing function. + return false + } + + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + callee, _ := typeutil.Callee(pass.TypesInfo, call).(*types.Func) + if callee == nil { + return true + } + callee = callee.Origin() + + if isSDKCallAPI(callee) || hasServiceCallFact(pass, callee) { + serviceCallers = append(serviceCallers, caller) + return true + } + if callee.Pkg() == pass.Pkg { + if callers[callee] == nil { + callers[callee] = make(map[*types.Func]struct{}) + } + callers[callee][caller] = struct{}{} + } + return true + }) + }) + + addInterfaceEdges(pass, callers) + + marked := make(map[*types.Func]struct{}) + var propagate func(*types.Func) + propagate = func(fn *types.Func) { + if _, ok := marked[fn]; ok { + return + } + marked[fn] = struct{}{} + pass.ExportObjectFact(fn, new(IsServiceCall)) + for caller := range callers[fn] { + propagate(caller) + } + } + for _, fn := range serviceCallers { + propagate(fn) + } + + return nil, nil +} + +// addInterfaceEdges models calls through interface methods as calls to each +// implementation established by an assignment in the current package. This +// permits facts from concrete SDK service methods to propagate through the +// request's interface field to its Execute method. +func addInterfaceEdges(pass *analysis.Pass, callers map[*types.Func]map[*types.Func]struct{}) { + var finder satisfy.Finder + finder.Find(pass.TypesInfo, pass.Files) + + for assignment := range finder.Result { + iface := assignment.LHS.Underlying().(*types.Interface) + for method := range iface.Methods() { + // Facts can only be exported for objects in the current package. + if method.Pkg() != pass.Pkg { + continue + } + + implementation, _, _ := types.LookupFieldOrMethod(assignment.RHS, false, pass.Pkg, method.Name()) + implementationFunc, ok := implementation.(*types.Func) + if !ok { + continue + } + + implementationFunc = implementationFunc.Origin() + method = method.Origin() + if callers[implementationFunc] == nil { + callers[implementationFunc] = make(map[*types.Func]struct{}) + } + callers[implementationFunc][method] = struct{}{} + } + } +} + +func hasServiceCallFact(pass *analysis.Pass, fn *types.Func) bool { + if fn.Pkg() == pass.Pkg { + return false + } + var fact IsServiceCall + return pass.ImportObjectFact(fn, &fact) +} + +func isSDKCallAPI(fn *types.Func) bool { + if fn.Name() != "callAPI" || fn.Pkg() == nil || !strings.HasPrefix(fn.Pkg().Path(), lintutils.StackitSdkModulePrefix) { + return false + } + + receiver := fn.Signature().Recv() + if receiver == nil { + return false + } + pointer, ok := receiver.Type().(*types.Pointer) + if !ok { + return false + } + named, ok := pointer.Elem().(*types.Named) + return ok && named.Obj().Name() == "APIClient" && named.Obj().Pkg() == fn.Pkg() +} diff --git a/tools/internal/facts/servicecall/servicecall_test.go b/tools/internal/facts/servicecall/servicecall_test.go new file mode 100644 index 000000000..092576146 --- /dev/null +++ b/tools/internal/facts/servicecall/servicecall_test.go @@ -0,0 +1,16 @@ +package servicecall + +import ( + "path/filepath" + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + dir, err := filepath.Abs("../../../testdata") + if err != nil { + t.Fatal(err) + } + analysistest.Run(t, dir, Analyzer, "facttests/servicecall") +} diff --git a/tools/testdata/go.mod b/tools/testdata/go.mod new file mode 100644 index 000000000..257bafe18 --- /dev/null +++ b/tools/testdata/go.mod @@ -0,0 +1,12 @@ +module facttests + +go 1.25.9 + +require github.com/stackitcloud/stackit-sdk-go/services/iaas v1.13.0 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/stackitcloud/stackit-sdk-go/core v0.26.0 // indirect + github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.0 // indirect +) diff --git a/tools/testdata/go.sum b/tools/testdata/go.sum new file mode 100644 index 000000000..9991823dd --- /dev/null +++ b/tools/testdata/go.sum @@ -0,0 +1,12 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/stackitcloud/stackit-sdk-go/core v0.26.0 h1:jQEb9gkehfp6VCP6TcYk7BI10cz4l0KM2L6hqYBH2QA= +github.com/stackitcloud/stackit-sdk-go/core v0.26.0/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA= +github.com/stackitcloud/stackit-sdk-go/services/iaas v1.13.0 h1:PQZ6n71CMadLU3DjJxJXiPHdK9Bz4hMttBREQD6no34= +github.com/stackitcloud/stackit-sdk-go/services/iaas v1.13.0/go.mod h1:AbPN9BGkdjc+tVsXEX9Vr8BPDjdlDmG26K1FwCKZQVU= +github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.0 h1:JPP6a0ME1tZXr4iB69d/LtJsCAr58ENBadFaK9f48/c= +github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.0/go.mod h1:NEz3f+GV5G++BE9/MmZCsXJyCih7jtg0pZuSyG2sLEs= diff --git a/tools/testdata/servicecall/servicecall.go b/tools/testdata/servicecall/servicecall.go new file mode 100644 index 000000000..9f5a474e4 --- /dev/null +++ b/tools/testdata/servicecall/servicecall.go @@ -0,0 +1,27 @@ +package servicecall + +import ( + "context" + + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api" +) + +func callsService(service *iaas.DefaultAPIService) { // want callsService:"serviceCall" + service.AddNetworkToServerExecute(iaas.ApiAddNetworkToServerRequest{}) +} + +func propagatesServiceCall(service *iaas.DefaultAPIService) { // want propagatesServiceCall:"serviceCall" + callsService(service) +} + +func propagatesAgain(service *iaas.DefaultAPIService) { // want propagatesAgain:"serviceCall" + propagatesServiceCall(service) +} + +func doesNotCallService(client iaas.DefaultAPI) { + client.AddNetworkToServer(context.Background(), "", "", "", "") +} + +func callsServiceInterface(client iaas.DefaultAPI) { // want callsServiceInterface:"serviceCall" + client.AddNetworkToServer(context.Background(), "", "", "", "").Execute() +} From 3055280a7bebc6cf80d1ce6d6fca0873437033f6 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Sun, 2 Aug 2026 20:20:11 +0200 Subject: [PATCH 03/13] chore(linter): add test for tfctxinit linter - rename testdata module from facttests to gh.com/s/tf-provider-stackit This is needed to add a core.InitProviderContext func without depending on the actual terraform provider. --- .../facts/servicecall/servicecall_test.go | 2 +- tools/linters/tfctxinit/tfctxinit_test.go | 16 ++++++++++++ tools/testdata/go.mod | 2 +- tools/testdata/stackit/internal/core/core.go | 7 +++++ .../internal/services/tfctxinit/tfctxinit.go | 26 +++++++++++++++++++ tools/testdata/testtypes/testtypes.go | 6 +++++ 6 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tools/linters/tfctxinit/tfctxinit_test.go create mode 100644 tools/testdata/stackit/internal/core/core.go create mode 100644 tools/testdata/stackit/internal/services/tfctxinit/tfctxinit.go create mode 100644 tools/testdata/testtypes/testtypes.go diff --git a/tools/internal/facts/servicecall/servicecall_test.go b/tools/internal/facts/servicecall/servicecall_test.go index 092576146..c2a693cce 100644 --- a/tools/internal/facts/servicecall/servicecall_test.go +++ b/tools/internal/facts/servicecall/servicecall_test.go @@ -12,5 +12,5 @@ func TestAnalyzer(t *testing.T) { if err != nil { t.Fatal(err) } - analysistest.Run(t, dir, Analyzer, "facttests/servicecall") + analysistest.Run(t, dir, Analyzer, "github.com/stackitcloud/terraform-provider-stackit/servicecall") } diff --git a/tools/linters/tfctxinit/tfctxinit_test.go b/tools/linters/tfctxinit/tfctxinit_test.go new file mode 100644 index 000000000..b764a696d --- /dev/null +++ b/tools/linters/tfctxinit/tfctxinit_test.go @@ -0,0 +1,16 @@ +package tfctxinit + +import ( + "path/filepath" + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + dir, err := filepath.Abs("../../testdata") + if err != nil { + t.Fatal(err) + } + analysistest.Run(t, dir, Analyzer, "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/tfctxinit") +} diff --git a/tools/testdata/go.mod b/tools/testdata/go.mod index 257bafe18..5e5a4be71 100644 --- a/tools/testdata/go.mod +++ b/tools/testdata/go.mod @@ -1,4 +1,4 @@ -module facttests +module github.com/stackitcloud/terraform-provider-stackit go 1.25.9 diff --git a/tools/testdata/stackit/internal/core/core.go b/tools/testdata/stackit/internal/core/core.go new file mode 100644 index 000000000..1b17b3ae1 --- /dev/null +++ b/tools/testdata/stackit/internal/core/core.go @@ -0,0 +1,7 @@ +package core + +import "context" + +func InitProviderContext(ctx context.Context) context.Context { + return ctx +} diff --git a/tools/testdata/stackit/internal/services/tfctxinit/tfctxinit.go b/tools/testdata/stackit/internal/services/tfctxinit/tfctxinit.go new file mode 100644 index 000000000..7061d67f5 --- /dev/null +++ b/tools/testdata/stackit/internal/services/tfctxinit/tfctxinit.go @@ -0,0 +1,26 @@ +package tfctxinit + +import ( + "context" + + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/testtypes" +) + +type resource struct{} + +func (r *resource) Create(ctx context.Context, req testtypes.CreateRequest, resp *testtypes.CreateResponse) { + // false positive: creating an API client before calling InitProviderContext is fine + iaas.NewAPIClient() // want "tfctxinit: call to github.com/stackitcloud/stackit-sdk-go must happen AFTER github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core.InitProviderContext is called in Create" + core.InitProviderContext(ctx) +} + +func (r *resource) Read(ctx context.Context, req testtypes.ReadRequest, resp *testtypes.ReadResponse) { + core.InitProviderContext(ctx) + iaas.NewAPIClient() +} + +func sdkCallOutsideLifecycleMethod() { + iaas.NewAPIClient() +} diff --git a/tools/testdata/testtypes/testtypes.go b/tools/testdata/testtypes/testtypes.go new file mode 100644 index 000000000..1eea025bf --- /dev/null +++ b/tools/testdata/testtypes/testtypes.go @@ -0,0 +1,6 @@ +package testtypes + +type CreateRequest struct{} +type CreateResponse struct{} +type ReadRequest struct{} +type ReadResponse struct{} From 5cca58a22f412f0d168e270d168bff9dc240b31b Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Sun, 2 Aug 2026 20:35:00 +0200 Subject: [PATCH 04/13] feat(linters): use servicecall fact in tfctxinit fixing false positive --- .../internal/facts/servicecall/servicecall.go | 22 +++++++++++++++++-- tools/linters/tfctxinit/tfctxinit.go | 16 ++++++++++---- .../internal/services/tfctxinit/tfctxinit.go | 7 ++++-- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/tools/internal/facts/servicecall/servicecall.go b/tools/internal/facts/servicecall/servicecall.go index 21db2c6b8..44a43c53c 100644 --- a/tools/internal/facts/servicecall/servicecall.go +++ b/tools/internal/facts/servicecall/servicecall.go @@ -4,6 +4,7 @@ package servicecall import ( "go/ast" "go/types" + "reflect" "strings" "golang.org/x/tools/go/analysis" @@ -22,7 +23,8 @@ var Analyzer = &analysis.Analyzer{ FactTypes: []analysis.Fact{ new(IsServiceCall), }, - Run: run, + ResultType: reflect.TypeOf((*Result)(nil)), + Run: run, } // IsServiceCall is a fact indicating that a function calls a STACKIT SDK service. @@ -32,12 +34,24 @@ func (*IsServiceCall) AFact() {} func (*IsServiceCall) String() string { return "serviceCall" } +// Result provides the service-call functions identified while analyzing a package. +type Result struct { + functions map[*types.Func]struct{} +} + +// HasServiceCall reports whether fn is marked as making a STACKIT SDK service call. +func (r *Result) HasServiceCall(fn *types.Func) bool { + _, ok := r.functions[fn.Origin()] + return ok +} + func run(pass *analysis.Pass) (any, error) { inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) // callers maps a callee to the functions that call it. Once a callee is // known to call a service, the fact is propagated to all of its callers. callers := make(map[*types.Func]map[*types.Func]struct{}) + serviceCalls := make(map[*types.Func]struct{}) var serviceCallers []*types.Func inspect.Preorder([]ast.Node{(*ast.FuncDecl)(nil)}, func(n ast.Node) { @@ -69,6 +83,7 @@ func run(pass *analysis.Pass) (any, error) { callee = callee.Origin() if isSDKCallAPI(callee) || hasServiceCallFact(pass, callee) { + serviceCalls[callee] = struct{}{} serviceCallers = append(serviceCallers, caller) return true } @@ -99,8 +114,11 @@ func run(pass *analysis.Pass) (any, error) { for _, fn := range serviceCallers { propagate(fn) } + for fn := range marked { + serviceCalls[fn] = struct{}{} + } - return nil, nil + return &Result{functions: serviceCalls}, nil } // addInterfaceEdges models calls through interface methods as calls to each diff --git a/tools/linters/tfctxinit/tfctxinit.go b/tools/linters/tfctxinit/tfctxinit.go index 7d0692f6d..df0041c35 100644 --- a/tools/linters/tfctxinit/tfctxinit.go +++ b/tools/linters/tfctxinit/tfctxinit.go @@ -2,10 +2,12 @@ package tfctxinit import ( "go/ast" - "strings" + "go/types" "golang.org/x/tools/go/ast/inspector" + "golang.org/x/tools/go/types/typeutil" + "github.com/stackitcloud/terraform-provider-stackit/tools/internal/facts/servicecall" "github.com/stackitcloud/terraform-provider-stackit/tools/internal/lintutils" "github.com/golangci/plugin-module-register/register" @@ -20,7 +22,7 @@ const ( var Analyzer = &analysis.Analyzer{ Name: analyzerName, Doc: "Ensures core.InitProviderContext is called before any SDK call in a Terraform resource lifecycle (CRUD) implementation", - Requires: []*analysis.Analyzer{inspect.Analyzer}, + Requires: []*analysis.Analyzer{inspect.Analyzer, servicecall.Analyzer}, Run: run, } @@ -32,6 +34,7 @@ func run(pass *analysis.Pass) (any, error) { ) inspectNode := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) + serviceCalls := pass.ResultOf[servicecall.Analyzer].(*servicecall.Result) // Filter only for function declarations (Terraform CRUD methods) nodeFilter := []ast.Node{ @@ -64,8 +67,8 @@ func run(pass *analysis.Pass) (any, error) { hasCalledUtil = true } - // Check if we've hit a STACKIT SDK call before the util function - if strings.HasPrefix(pkgPath, lintutils.StackitSdkModulePrefix) && !hasCalledUtil { + // Check if we've hit a service call before the util function + if !hasCalledUtil && hasServiceCall(serviceCalls, pass, call) { pass.Reportf( call.Pos(), "%s: call to %s must happen AFTER %s.%s is called in %s", @@ -80,6 +83,11 @@ func run(pass *analysis.Pass) (any, error) { return nil, nil } +func hasServiceCall(serviceCalls *servicecall.Result, pass *analysis.Pass, call *ast.CallExpr) bool { + callee, _ := typeutil.Callee(pass.TypesInfo, call).(*types.Func) + return callee != nil && serviceCalls.HasServiceCall(callee) +} + func init() { register.Plugin(analyzerName, New) } diff --git a/tools/testdata/stackit/internal/services/tfctxinit/tfctxinit.go b/tools/testdata/stackit/internal/services/tfctxinit/tfctxinit.go index 7061d67f5..076f57e97 100644 --- a/tools/testdata/stackit/internal/services/tfctxinit/tfctxinit.go +++ b/tools/testdata/stackit/internal/services/tfctxinit/tfctxinit.go @@ -11,8 +11,11 @@ import ( type resource struct{} func (r *resource) Create(ctx context.Context, req testtypes.CreateRequest, resp *testtypes.CreateResponse) { - // false positive: creating an API client before calling InitProviderContext is fine - iaas.NewAPIClient() // want "tfctxinit: call to github.com/stackitcloud/stackit-sdk-go must happen AFTER github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core.InitProviderContext is called in Create" + // Creating an API client before calling InitProviderContext is fine. + iaas.NewAPIClient() + + var service *iaas.DefaultAPIService + service.AddNetworkToServerExecute(iaas.ApiAddNetworkToServerRequest{}) // want "tfctxinit: call to github.com/stackitcloud/stackit-sdk-go must happen AFTER github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core.InitProviderContext is called in Create" core.InitProviderContext(ctx) } From cc39946739fbd7719055e37a448676f5d8070048 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Sun, 2 Aug 2026 21:06:12 +0200 Subject: [PATCH 05/13] fix(linters): fix tfwriteid with regards to serviceenablement waiter --- tools/linters/tfwriteid/tfwriteid.go | 7 +++++++ tools/linters/tfwriteid/tfwriteid_test.go | 16 ++++++++++++++++ tools/testdata/go.mod | 5 ++++- tools/testdata/go.sum | 2 ++ .../internal/services/tfwriteid/tfwriteid.go | 18 ++++++++++++++++++ 5 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tools/linters/tfwriteid/tfwriteid_test.go create mode 100644 tools/testdata/stackit/internal/services/tfwriteid/tfwriteid.go diff --git a/tools/linters/tfwriteid/tfwriteid.go b/tools/linters/tfwriteid/tfwriteid.go index 05615179d..899407421 100644 --- a/tools/linters/tfwriteid/tfwriteid.go +++ b/tools/linters/tfwriteid/tfwriteid.go @@ -103,6 +103,13 @@ func isWaitCall(info *types.Info, call *ast.CallExpr, calledFuncName string) boo if !ok { return false } + // serviceenablement itself is used in other resources and does not have an ID itself + if waiterCall, ok := sel.X.(*ast.CallExpr); ok { + _, waiterFuncName := lintutils.GetCallInfo(waiterCall, info) + if waiterFuncName == "EnableServiceWaitHandler" { + return false + } + } obj := info.Uses[sel.Sel] if obj == nil { return false diff --git a/tools/linters/tfwriteid/tfwriteid_test.go b/tools/linters/tfwriteid/tfwriteid_test.go new file mode 100644 index 000000000..c068deb6e --- /dev/null +++ b/tools/linters/tfwriteid/tfwriteid_test.go @@ -0,0 +1,16 @@ +package tfwriteid + +import ( + "path/filepath" + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + dir, err := filepath.Abs("../../testdata") + if err != nil { + t.Fatal(err) + } + analysistest.Run(t, dir, Analyzer, "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/tfwriteid") +} diff --git a/tools/testdata/go.mod b/tools/testdata/go.mod index 5e5a4be71..f3751a636 100644 --- a/tools/testdata/go.mod +++ b/tools/testdata/go.mod @@ -2,7 +2,10 @@ module github.com/stackitcloud/terraform-provider-stackit go 1.25.9 -require github.com/stackitcloud/stackit-sdk-go/services/iaas v1.13.0 +require ( + github.com/stackitcloud/stackit-sdk-go/services/iaas v1.13.0 + github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.7.0 +) require ( github.com/golang-jwt/jwt/v5 v5.3.1 // indirect diff --git a/tools/testdata/go.sum b/tools/testdata/go.sum index 9991823dd..7c933a504 100644 --- a/tools/testdata/go.sum +++ b/tools/testdata/go.sum @@ -10,3 +10,5 @@ github.com/stackitcloud/stackit-sdk-go/services/iaas v1.13.0 h1:PQZ6n71CMadLU3Dj github.com/stackitcloud/stackit-sdk-go/services/iaas v1.13.0/go.mod h1:AbPN9BGkdjc+tVsXEX9Vr8BPDjdlDmG26K1FwCKZQVU= github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.0 h1:JPP6a0ME1tZXr4iB69d/LtJsCAr58ENBadFaK9f48/c= github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.0/go.mod h1:NEz3f+GV5G++BE9/MmZCsXJyCih7jtg0pZuSyG2sLEs= +github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.7.0 h1:TNZHrunhsXRbuqZcucLs2Gqy1sEyvabufM7pB5Tscmo= +github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.7.0/go.mod h1:fXq3TmVLb4JMSve989NFFViMFoYa83s7M3hJWgN6mdQ= diff --git a/tools/testdata/stackit/internal/services/tfwriteid/tfwriteid.go b/tools/testdata/stackit/internal/services/tfwriteid/tfwriteid.go new file mode 100644 index 000000000..8007ef0a8 --- /dev/null +++ b/tools/testdata/stackit/internal/services/tfwriteid/tfwriteid.go @@ -0,0 +1,18 @@ +package tfwriteid + +import ( + "context" + + corewait "github.com/stackitcloud/stackit-sdk-go/core/wait" + serviceenablementwait "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/v2api/wait" + "github.com/stackitcloud/terraform-provider-stackit/testtypes" +) + +type resource struct{} + +func (r *resource) Create(ctx context.Context, req testtypes.CreateRequest, resp *testtypes.CreateResponse) { + _, _ = serviceenablementwait.EnableServiceWaitHandler(ctx, nil, "", "", "").WaitWithContext(ctx) + + waiter := corewait.New(func() (bool, *struct{}, error) { return true, nil, nil }) + _, _ = waiter.WaitWithContext(ctx) // want "tfwriteid: call to wait handler from github.com/stackitcloud/stackit-sdk-go must happen AFTER github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils.SetAndLogStateFields is called in Create github.com/stackitcloud/stackit-sdk-go/core/wait" +} From fb2a2cdcf1bea4c57a52b0eb54b6ae6ec6553e5a Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Sun, 2 Aug 2026 21:23:38 +0200 Subject: [PATCH 06/13] fix(linters): remove fixed false positive linter exceptions --- stackit/internal/services/dns/recordset/resource.go | 6 +++--- stackit/internal/services/dns/zone/resource.go | 6 +++--- stackit/internal/services/dremio/instance/resource.go | 6 +++--- stackit/internal/services/dremio/user/resource.go | 4 ++-- stackit/internal/services/iaas/image/resource.go | 7 ++++--- .../services/iaasalpha/vpcnetworkrange/resource.go | 6 +++--- stackit/internal/services/iaasalpha/vpcregion/resource.go | 4 ++-- 7 files changed, 20 insertions(+), 19 deletions(-) diff --git a/stackit/internal/services/dns/recordset/resource.go b/stackit/internal/services/dns/recordset/resource.go index 6e1baa3d1..6ed634ca3 100644 --- a/stackit/internal/services/dns/recordset/resource.go +++ b/stackit/internal/services/dns/recordset/resource.go @@ -211,7 +211,7 @@ func (r *recordSetResource) Create(ctx context.Context, req resource.CreateReque return } - waiterTimeout := wait.CreateRecordSetWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := wait.CreateRecordSetWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() createTimeout, diags := model.Timeouts.Create(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -347,7 +347,7 @@ func (r *recordSetResource) Update(ctx context.Context, req resource.UpdateReque return } - waiterTimeout := wait.PartialUpdateRecordSetWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := wait.PartialUpdateRecordSetWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() updateTimeout, diags := model.Timeouts.Update(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -409,7 +409,7 @@ func (r *recordSetResource) Delete(ctx context.Context, req resource.DeleteReque return } - waiterTimeout := wait.DeleteRecordSetWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := wait.DeleteRecordSetWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() deleteTimeout, diags := model.Timeouts.Delete(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { diff --git a/stackit/internal/services/dns/zone/resource.go b/stackit/internal/services/dns/zone/resource.go index a31b660c2..114071e12 100644 --- a/stackit/internal/services/dns/zone/resource.go +++ b/stackit/internal/services/dns/zone/resource.go @@ -304,7 +304,7 @@ func (r *zoneResource) Create(ctx context.Context, req resource.CreateRequest, r return } - waiterTimeout := wait.CreateZoneWaitHandler(ctx, r.client.DefaultAPI, "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := wait.CreateZoneWaitHandler(ctx, r.client.DefaultAPI, "", "").GetTimeout() createTimeout, diags := model.Timeouts.Create(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -435,7 +435,7 @@ func (r *zoneResource) Update(ctx context.Context, req resource.UpdateRequest, r return } - waiterTimeout := wait.PartialUpdateZoneWaitHandler(ctx, r.client.DefaultAPI, "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := wait.PartialUpdateZoneWaitHandler(ctx, r.client.DefaultAPI, "", "").GetTimeout() updateTimeout, diags := model.Timeouts.Update(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -495,7 +495,7 @@ func (r *zoneResource) Delete(ctx context.Context, req resource.DeleteRequest, r return } - waiterTimeout := wait.DeleteZoneWaitHandler(ctx, r.client.DefaultAPI, "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := wait.DeleteZoneWaitHandler(ctx, r.client.DefaultAPI, "", "").GetTimeout() deleteTimeout, diags := model.Timeouts.Delete(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { diff --git a/stackit/internal/services/dremio/instance/resource.go b/stackit/internal/services/dremio/instance/resource.go index 425b1d435..842ab8ba5 100644 --- a/stackit/internal/services/dremio/instance/resource.go +++ b/stackit/internal/services/dremio/instance/resource.go @@ -396,7 +396,7 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques return } - waiterTimeout := dremioWaiter.CreateDremioWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := dremioWaiter.CreateDremioWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() createTimeout, diags := model.Timeouts.Create(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -521,7 +521,7 @@ func (r *instanceResource) Update(ctx context.Context, req resource.UpdateReques return } - waiterTimeout := dremioWaiter.UpdateDremioWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := dremioWaiter.UpdateDremioWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() updateTimeout, diags := model.Timeouts.Update(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -592,7 +592,7 @@ func (r *instanceResource) Delete(ctx context.Context, req resource.DeleteReques return } - waiterTimeout := dremioWaiter.DeleteDremioWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := dremioWaiter.DeleteDremioWaitHandler(ctx, r.client.DefaultAPI, "", "", "").GetTimeout() deleteTimeout, diags := model.Timeouts.Delete(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { diff --git a/stackit/internal/services/dremio/user/resource.go b/stackit/internal/services/dremio/user/resource.go index 8cd0bca0b..e22c3d62f 100644 --- a/stackit/internal/services/dremio/user/resource.go +++ b/stackit/internal/services/dremio/user/resource.go @@ -243,7 +243,7 @@ func (r *userResource) Create(ctx context.Context, req resource.CreateRequest, r return } - waiterTimeout := dremioWaiter.CreateDremioUserWaitHandler(ctx, r.client.DefaultAPI, "", "", "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := dremioWaiter.CreateDremioUserWaitHandler(ctx, r.client.DefaultAPI, "", "", "", "").GetTimeout() createTimeout, diags := model.Timeouts.Create(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -376,7 +376,7 @@ func (r *userResource) Delete(ctx context.Context, req resource.DeleteRequest, r return } - waiterTimeout := dremioWaiter.DeleteDremioUserWaitHandler(ctx, r.client.DefaultAPI, "", "", "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := dremioWaiter.DeleteDremioUserWaitHandler(ctx, r.client.DefaultAPI, "", "", "", "").GetTimeout() deleteTimeout, diags := model.Timeouts.Delete(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { diff --git a/stackit/internal/services/iaas/image/resource.go b/stackit/internal/services/iaas/image/resource.go index 2441818b4..1d8fbb28c 100644 --- a/stackit/internal/services/iaas/image/resource.go +++ b/stackit/internal/services/iaas/image/resource.go @@ -475,9 +475,10 @@ func (r *imageResource) Create(ctx context.Context, req resource.CreateRequest, } // Wait for image to become available - waiter := wait.UploadImageWaitHandler(ctx, r.client.DefaultAPI, projectId, region, imageCreateResp.Id) //nolint:tfwriteid // false positive - id fields are actually stored already using the mapFields() call above - waiter = waiter.SetTimeout(7 * 24 * time.Hour) // Set timeout to one week, to make the timeout useless - waitResp, err := waiter.WaitWithContext(ctx) + waiter := wait.UploadImageWaitHandler(ctx, r.client.DefaultAPI, projectId, region, imageCreateResp.Id) + // Set timeout to one week, to make the timeout useless + waiter = waiter.SetTimeout(7 * 24 * time.Hour) + waitResp, err := waiter.WaitWithContext(ctx) //nolint:tfwriteid // false positive - id fields are actually stored already using the mapFields() call above if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Waiting for image to become available: %v", err)) return diff --git a/stackit/internal/services/iaasalpha/vpcnetworkrange/resource.go b/stackit/internal/services/iaasalpha/vpcnetworkrange/resource.go index 77d3f2c2a..69eda90e2 100644 --- a/stackit/internal/services/iaasalpha/vpcnetworkrange/resource.go +++ b/stackit/internal/services/iaasalpha/vpcnetworkrange/resource.go @@ -256,7 +256,7 @@ func (r *vpcNetworkRangeResource) Create(ctx context.Context, req resource.Creat return } - waiterTimeout := wait.CreateVPCNetworkRangeWaitHandler(ctx, r.client.DefaultAPI, "", "", "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := wait.CreateVPCNetworkRangeWaitHandler(ctx, r.client.DefaultAPI, "", "", "", "").GetTimeout() createTimeout, diags := model.Timeouts.Create(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -396,7 +396,7 @@ func (r *vpcNetworkRangeResource) Update(ctx context.Context, req resource.Updat return } - waiterTimeout := wait.UpdateVPCNetworkRangeWaitHandler(ctx, r.client.DefaultAPI, "", "", "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := wait.UpdateVPCNetworkRangeWaitHandler(ctx, r.client.DefaultAPI, "", "", "", "").GetTimeout() updateTimeout, diags := model.Timeouts.Update(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -468,7 +468,7 @@ func (r *vpcNetworkRangeResource) Delete(ctx context.Context, req resource.Delet return } - waiterTimeout := wait.DeleteVPCNetworkRangeWaitHandler(ctx, r.client.DefaultAPI, "", "", "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := wait.DeleteVPCNetworkRangeWaitHandler(ctx, r.client.DefaultAPI, "", "", "", "").GetTimeout() updateTimeout, diags := model.Timeouts.Delete(ctx, waiterTimeout+core.DefaultTimeoutMargin) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { diff --git a/stackit/internal/services/iaasalpha/vpcregion/resource.go b/stackit/internal/services/iaasalpha/vpcregion/resource.go index 505256fa3..af0eb650b 100644 --- a/stackit/internal/services/iaasalpha/vpcregion/resource.go +++ b/stackit/internal/services/iaasalpha/vpcregion/resource.go @@ -166,7 +166,7 @@ func (v *vpcRegion) Create(ctx context.Context, request resource.CreateRequest, return } - waiterTimeout := wait.CreateVPCRegionWaitHandler(ctx, v.client.DefaultAPI, "", "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := wait.CreateVPCRegionWaitHandler(ctx, v.client.DefaultAPI, "", "", "").GetTimeout() createTimeout, diags := model.Timeouts.Create(ctx, waiterTimeout+core.DefaultTimeoutMargin) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { @@ -331,7 +331,7 @@ func (v *vpcRegion) Delete(ctx context.Context, request resource.DeleteRequest, return } - waiterTimeout := wait.DeleteVPCRegionWaitHandler(ctx, v.client.DefaultAPI, "", "", "").GetTimeout() //nolint:tfctxinit,tfwriteid // false positive - only called to get default wait handler timeout value + waiterTimeout := wait.DeleteVPCRegionWaitHandler(ctx, v.client.DefaultAPI, "", "", "").GetTimeout() deleteTimeout, diags := model.Timeouts.Delete(ctx, waiterTimeout+core.DefaultTimeoutMargin) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { From 01690a4849c52bd1a44138c016eda4677a34c840 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Sun, 2 Aug 2026 21:57:56 +0200 Subject: [PATCH 07/13] chore(linters): test tflogresponse --- .../tflogresponse/tflogresponse_test.go | 17 ++++++ tools/testdata/stackit/internal/core/core.go | 4 ++ .../services/tflogresponse/tflogresponse.go | 52 +++++++++++++++++++ tools/testdata/testtypes/testtypes.go | 4 ++ 4 files changed, 77 insertions(+) create mode 100644 tools/linters/tflogresponse/tflogresponse_test.go create mode 100644 tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go diff --git a/tools/linters/tflogresponse/tflogresponse_test.go b/tools/linters/tflogresponse/tflogresponse_test.go new file mode 100644 index 000000000..ef1c7416d --- /dev/null +++ b/tools/linters/tflogresponse/tflogresponse_test.go @@ -0,0 +1,17 @@ +package tflogresponse + +import ( + "path/filepath" + "testing" + + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + dir, err := filepath.Abs("../../testdata") + if err != nil { + t.Fatal(err) + } + + analysistest.Run(t, dir, Analyzer, "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/tflogresponse") +} diff --git a/tools/testdata/stackit/internal/core/core.go b/tools/testdata/stackit/internal/core/core.go index 1b17b3ae1..bb3fc9459 100644 --- a/tools/testdata/stackit/internal/core/core.go +++ b/tools/testdata/stackit/internal/core/core.go @@ -5,3 +5,7 @@ import "context" func InitProviderContext(ctx context.Context) context.Context { return ctx } + +func LogResponse(ctx context.Context) context.Context { + return ctx +} diff --git a/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go b/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go new file mode 100644 index 000000000..a4c4097b4 --- /dev/null +++ b/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go @@ -0,0 +1,52 @@ +package tflogresponse + +import ( + "context" + + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/testtypes" +) + +type resource struct{} + +func (r *resource) Create(ctx context.Context, req testtypes.CreateRequest, resp *testtypes.CreateResponse) { + ctx = core.InitProviderContext(ctx) + iaas.NewAPIClient() + ctx = core.LogResponse(ctx) +} + +func (r *resource) Read(ctx context.Context, req testtypes.ReadRequest, resp *testtypes.ReadResponse) { + ctx = core.InitProviderContext(ctx) + ctx = core.LogResponse(ctx) // want "tflogresponse: invalid sequence: LogResponse called without an intermediate call to github.com/stackitcloud/stackit-sdk-go after InitProviderContext" +} + +func (r *resource) Update(ctx context.Context, req testtypes.UpdateRequest, resp *testtypes.UpdateResponse) { + ctx = core.InitProviderContext(ctx) // want "tflogresponse: invalid sequence: InitProviderContext was called, but LogResponse was never called afterwards" + iaas.NewAPIClient() +} + +func (r *resource) Delete(ctx context.Context, req testtypes.DeleteRequest, resp *testtypes.DeleteResponse) { + ctx = core.InitProviderContext(ctx) + iaas.NewAPIClient() + ctx = core.LogResponse(ctx) +} + +func nonLifecycleMethod(ctx context.Context) { + ctx = core.InitProviderContext(ctx) + iaas.NewAPIClient() +} + +// fals positive, SDK call through helper func + +type falsePositive struct{} + +func (f *falsePositive) Read(ctx context.Context, req testtypes.ReadRequest, resp *testtypes.ReadResponse) { + ctx = core.InitProviderContext(ctx) + indirection() + ctx = core.LogResponse(ctx) // want "tflogresponse: invalid sequence: LogResponse called without an intermediate call to github.com/stackitcloud/stackit-sdk-go after InitProviderContext" +} + +func indirection() { + iaas.NewAPIClient() +} diff --git a/tools/testdata/testtypes/testtypes.go b/tools/testdata/testtypes/testtypes.go index 1eea025bf..1f7133c9e 100644 --- a/tools/testdata/testtypes/testtypes.go +++ b/tools/testdata/testtypes/testtypes.go @@ -4,3 +4,7 @@ type CreateRequest struct{} type CreateResponse struct{} type ReadRequest struct{} type ReadResponse struct{} +type UpdateRequest struct{} +type UpdateResponse struct{} +type DeleteRequest struct{} +type DeleteResponse struct{} From b325f9f31312d85f63cdd97b18c1486f005044a4 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Sun, 2 Aug 2026 22:09:45 +0200 Subject: [PATCH 08/13] fix(linters): use servicecall fact in tflogresponse --- tools/linters/tflogresponse/tflogresponse.go | 15 ++++++++--- .../services/tflogresponse/tflogresponse.go | 26 +++++++++++-------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/tools/linters/tflogresponse/tflogresponse.go b/tools/linters/tflogresponse/tflogresponse.go index e75fb8434..506f525c6 100644 --- a/tools/linters/tflogresponse/tflogresponse.go +++ b/tools/linters/tflogresponse/tflogresponse.go @@ -2,13 +2,15 @@ package tflogresponse import ( "go/ast" - "strings" + "go/types" "github.com/golangci/plugin-module-register/register" "golang.org/x/tools/go/analysis" "golang.org/x/tools/go/analysis/passes/inspect" "golang.org/x/tools/go/ast/inspector" + "golang.org/x/tools/go/types/typeutil" + "github.com/stackitcloud/terraform-provider-stackit/tools/internal/facts/servicecall" "github.com/stackitcloud/terraform-provider-stackit/tools/internal/lintutils" ) @@ -19,7 +21,7 @@ const ( var Analyzer = &analysis.Analyzer{ Name: analyzerName, Doc: "Ensures that core.LogResponse is called in every resource/datasource CRUD method after ctx.InitProviderContext was called and at least one STACKIT SDK call was made.", - Requires: []*analysis.Analyzer{inspect.Analyzer}, + Requires: []*analysis.Analyzer{inspect.Analyzer, servicecall.Analyzer}, Run: run, } @@ -31,6 +33,7 @@ func run(pass *analysis.Pass) (any, error) { ) inspectNode := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) + serviceCalls := pass.ResultOf[servicecall.Analyzer].(*servicecall.Result) // Filter only for function declarations (Terraform CRUD methods) nodeFilter := []ast.Node{ @@ -76,8 +79,7 @@ func run(pass *analysis.Pass) (any, error) { } case stateLookingForSdkOrLogResponseCall: - // Check if this call belongs to STACKIT SDK modules - if strings.HasPrefix(pkgPath, lintutils.StackitSdkModulePrefix) { + if hasServiceCall(serviceCalls, pass, call) { foundIntermediateSdkModuleCall = true } else if pkgPath == utilPkg && calledFuncName == funcLogResponse { if !foundIntermediateSdkModuleCall { @@ -100,6 +102,11 @@ func run(pass *analysis.Pass) (any, error) { return nil, nil } +func hasServiceCall(serviceCalls *servicecall.Result, pass *analysis.Pass, call *ast.CallExpr) bool { + callee, _ := typeutil.Callee(pass.TypesInfo, call).(*types.Func) + return callee != nil && serviceCalls.HasServiceCall(callee) +} + func init() { register.Plugin(analyzerName, New) } diff --git a/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go b/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go index a4c4097b4..283b3693b 100644 --- a/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go +++ b/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go @@ -11,8 +11,9 @@ import ( type resource struct{} func (r *resource) Create(ctx context.Context, req testtypes.CreateRequest, resp *testtypes.CreateResponse) { + c, _ := iaas.NewAPIClient() ctx = core.InitProviderContext(ctx) - iaas.NewAPIClient() + c.DefaultAPI.AddNetworkToServer(ctx, "", "", "", "").Execute() ctx = core.LogResponse(ctx) } @@ -22,31 +23,34 @@ func (r *resource) Read(ctx context.Context, req testtypes.ReadRequest, resp *te } func (r *resource) Update(ctx context.Context, req testtypes.UpdateRequest, resp *testtypes.UpdateResponse) { + var service iaas.DefaultAPIService ctx = core.InitProviderContext(ctx) // want "tflogresponse: invalid sequence: InitProviderContext was called, but LogResponse was never called afterwards" - iaas.NewAPIClient() + service.AddNetworkToServerExecute(iaas.ApiAddNetworkToServerRequest{}) } func (r *resource) Delete(ctx context.Context, req testtypes.DeleteRequest, resp *testtypes.DeleteResponse) { + var service iaas.DefaultAPIService ctx = core.InitProviderContext(ctx) - iaas.NewAPIClient() + service.AddNetworkToServerExecute(iaas.ApiAddNetworkToServerRequest{}) ctx = core.LogResponse(ctx) } func nonLifecycleMethod(ctx context.Context) { + var service iaas.DefaultAPIService ctx = core.InitProviderContext(ctx) - iaas.NewAPIClient() + service.AddNetworkToServerExecute(iaas.ApiAddNetworkToServerRequest{}) } -// fals positive, SDK call through helper func +// SDK service call through helper func -type falsePositive struct{} +type resource2 struct{} -func (f *falsePositive) Read(ctx context.Context, req testtypes.ReadRequest, resp *testtypes.ReadResponse) { +func (f *resource2) Read(ctx context.Context, req testtypes.ReadRequest, resp *testtypes.ReadResponse) { ctx = core.InitProviderContext(ctx) - indirection() - ctx = core.LogResponse(ctx) // want "tflogresponse: invalid sequence: LogResponse called without an intermediate call to github.com/stackitcloud/stackit-sdk-go after InitProviderContext" + indirection(nil) + ctx = core.LogResponse(ctx) } -func indirection() { - iaas.NewAPIClient() +func indirection(service *iaas.DefaultAPIService) { + service.AddNetworkToServerExecute(iaas.ApiAddNetworkToServerRequest{}) } From 449ed6f7ca51933d2d600183bd635ca87ba824e2 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Sun, 2 Aug 2026 22:20:36 +0200 Subject: [PATCH 09/13] fix(linters): remove tflogresponse ignores from fixed false positive --- stackit/internal/services/objectstorage/credential/resource.go | 2 +- .../services/objectstorage/credentialsgroup/datasource.go | 2 +- .../services/objectstorage/credentialsgroup/resource.go | 2 +- stackit/internal/services/postgresflex/database/datasource.go | 2 +- stackit/internal/services/postgresflex/database/resource.go | 2 +- stackit/internal/services/ske/kubeconfig/resource.go | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/stackit/internal/services/objectstorage/credential/resource.go b/stackit/internal/services/objectstorage/credential/resource.go index 996e77fdc..36ad7b7cf 100644 --- a/stackit/internal/services/objectstorage/credential/resource.go +++ b/stackit/internal/services/objectstorage/credential/resource.go @@ -381,7 +381,7 @@ func (r *credentialResource) Read(ctx context.Context, req resource.ReadRequest, return } - ctx = core.LogResponse(ctx) //nolint:tflogresponse // false positive - SDK call is actually done inside readCredentials() + ctx = core.LogResponse(ctx) if !found { resp.State.RemoveResource(ctx) diff --git a/stackit/internal/services/objectstorage/credentialsgroup/datasource.go b/stackit/internal/services/objectstorage/credentialsgroup/datasource.go index 18c952544..cd090c745 100644 --- a/stackit/internal/services/objectstorage/credentialsgroup/datasource.go +++ b/stackit/internal/services/objectstorage/credentialsgroup/datasource.go @@ -129,7 +129,7 @@ func (r *credentialsGroupDataSource) Read(ctx context.Context, req datasource.Re return } - ctx = core.LogResponse(ctx) //nolint:tflogresponse // false positive - SDK call is actually done inside readCredentialsGroups() + ctx = core.LogResponse(ctx) if !found { resp.State.RemoveResource(ctx) diff --git a/stackit/internal/services/objectstorage/credentialsgroup/resource.go b/stackit/internal/services/objectstorage/credentialsgroup/resource.go index 3a46711af..e0c34f284 100644 --- a/stackit/internal/services/objectstorage/credentialsgroup/resource.go +++ b/stackit/internal/services/objectstorage/credentialsgroup/resource.go @@ -266,7 +266,7 @@ func (r *credentialsGroupResource) Read(ctx context.Context, req resource.ReadRe return } - ctx = core.LogResponse(ctx) //nolint:tflogresponse // false positive - SDK call is hidden in readCredentialsGroups() + ctx = core.LogResponse(ctx) if !found { resp.State.RemoveResource(ctx) diff --git a/stackit/internal/services/postgresflex/database/datasource.go b/stackit/internal/services/postgresflex/database/datasource.go index c7f7edb94..5d56048b9 100644 --- a/stackit/internal/services/postgresflex/database/datasource.go +++ b/stackit/internal/services/postgresflex/database/datasource.go @@ -161,7 +161,7 @@ func (r *databaseDataSource) Read(ctx context.Context, req datasource.ReadReques return } - ctx = core.LogResponse(ctx) //nolint:tflogresponse // false positive - sdk call is hidden in getDatabase() + ctx = core.LogResponse(ctx) // Map response body to schema and populate Computed attribute values err = mapFields(databaseResp, &model, region) diff --git a/stackit/internal/services/postgresflex/database/resource.go b/stackit/internal/services/postgresflex/database/resource.go index 4e5232186..c54063aef 100644 --- a/stackit/internal/services/postgresflex/database/resource.go +++ b/stackit/internal/services/postgresflex/database/resource.go @@ -301,7 +301,7 @@ func (r *databaseResource) Read(ctx context.Context, req resource.ReadRequest, r return } - ctx = core.LogResponse(ctx) //nolint:tflogresponse // false positive - sdk call is hidden in getDatabase() + ctx = core.LogResponse(ctx) // Map response body to schema err = mapFields(databaseResp, &model, region) diff --git a/stackit/internal/services/ske/kubeconfig/resource.go b/stackit/internal/services/ske/kubeconfig/resource.go index 348b45955..cb2d9ea65 100644 --- a/stackit/internal/services/ske/kubeconfig/resource.go +++ b/stackit/internal/services/ske/kubeconfig/resource.go @@ -266,7 +266,7 @@ func (r *kubeconfigResource) Create(ctx context.Context, req resource.CreateRequ err := r.createKubeconfig(ctx, &model) - ctx = core.LogResponse(ctx) //nolint:tflogresponse // SDK call is hidden in r.createKubeconfig() + ctx = core.LogResponse(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating kubeconfig", fmt.Sprintf("Creating kubeconfig: %v", err)) From e959bdf1cbbdafa4139f131c68cbd97835547324 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Sun, 2 Aug 2026 23:37:34 +0200 Subject: [PATCH 10/13] fix(linters): passing a serviceCall marked func as argument marks the call --- .../internal/facts/servicecall/servicecall.go | 49 ++++++++++++++++++- tools/linters/tfctxinit/tfctxinit.go | 9 +--- tools/linters/tflogresponse/tflogresponse.go | 9 +--- .../internal/services/tfctxinit/tfctxinit.go | 10 ++++ .../services/tflogresponse/tflogresponse.go | 13 +++++ 5 files changed, 73 insertions(+), 17 deletions(-) diff --git a/tools/internal/facts/servicecall/servicecall.go b/tools/internal/facts/servicecall/servicecall.go index 44a43c53c..b6b2abcb2 100644 --- a/tools/internal/facts/servicecall/servicecall.go +++ b/tools/internal/facts/servicecall/servicecall.go @@ -45,6 +45,22 @@ func (r *Result) HasServiceCall(fn *types.Func) bool { return ok } +// HasServiceCall reports whether call invokes a marked service-call function +// directly or receives one as a function reference argument. +func HasServiceCall(call *ast.CallExpr, info *types.Info, result *Result) bool { + callee, _ := typeutil.Callee(info, call).(*types.Func) + if callee != nil && result.HasServiceCall(callee) { + return true + } + + for _, argument := range call.Args { + if referencedFunction := functionReference(argument, info); referencedFunction != nil && result.HasServiceCall(referencedFunction) { + return true + } + } + return false +} + func run(pass *analysis.Pass) (any, error) { inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) @@ -76,13 +92,20 @@ func run(pass *analysis.Pass) (any, error) { if !ok { return true } + for _, argument := range call.Args { + referencedFunction := functionReference(argument, pass.TypesInfo) + if referencedFunction != nil && isKnownServiceCall(pass, referencedFunction) { + serviceCalls[referencedFunction.Origin()] = struct{}{} + } + } + callee, _ := typeutil.Callee(pass.TypesInfo, call).(*types.Func) if callee == nil { return true } callee = callee.Origin() - if isSDKCallAPI(callee) || hasServiceCallFact(pass, callee) { + if isKnownServiceCall(pass, callee) { serviceCalls[callee] = struct{}{} serviceCallers = append(serviceCallers, caller) return true @@ -153,6 +176,30 @@ func addInterfaceEdges(pass *analysis.Pass, callers map[*types.Func]map[*types.F } } +// functionReference returns the function denoted by expr, if expr is a +// statically identifiable function value. +func functionReference(expr ast.Expr, info *types.Info) *types.Func { + switch expr := expr.(type) { + case *ast.Ident: + fn, _ := info.ObjectOf(expr).(*types.Func) + return fn + case *ast.SelectorExpr: + fn, _ := info.ObjectOf(expr.Sel).(*types.Func) + return fn + case *ast.ParenExpr: + return functionReference(expr.X, info) + case *ast.IndexExpr: + return functionReference(expr.X, info) + case *ast.IndexListExpr: + return functionReference(expr.X, info) + } + return nil +} + +func isKnownServiceCall(pass *analysis.Pass, fn *types.Func) bool { + return isSDKCallAPI(fn) || hasServiceCallFact(pass, fn) +} + func hasServiceCallFact(pass *analysis.Pass, fn *types.Func) bool { if fn.Pkg() == pass.Pkg { return false diff --git a/tools/linters/tfctxinit/tfctxinit.go b/tools/linters/tfctxinit/tfctxinit.go index df0041c35..055472e97 100644 --- a/tools/linters/tfctxinit/tfctxinit.go +++ b/tools/linters/tfctxinit/tfctxinit.go @@ -2,10 +2,8 @@ package tfctxinit import ( "go/ast" - "go/types" "golang.org/x/tools/go/ast/inspector" - "golang.org/x/tools/go/types/typeutil" "github.com/stackitcloud/terraform-provider-stackit/tools/internal/facts/servicecall" "github.com/stackitcloud/terraform-provider-stackit/tools/internal/lintutils" @@ -68,7 +66,7 @@ func run(pass *analysis.Pass) (any, error) { } // Check if we've hit a service call before the util function - if !hasCalledUtil && hasServiceCall(serviceCalls, pass, call) { + if !hasCalledUtil && servicecall.HasServiceCall(call, pass.TypesInfo, serviceCalls) { pass.Reportf( call.Pos(), "%s: call to %s must happen AFTER %s.%s is called in %s", @@ -83,11 +81,6 @@ func run(pass *analysis.Pass) (any, error) { return nil, nil } -func hasServiceCall(serviceCalls *servicecall.Result, pass *analysis.Pass, call *ast.CallExpr) bool { - callee, _ := typeutil.Callee(pass.TypesInfo, call).(*types.Func) - return callee != nil && serviceCalls.HasServiceCall(callee) -} - func init() { register.Plugin(analyzerName, New) } diff --git a/tools/linters/tflogresponse/tflogresponse.go b/tools/linters/tflogresponse/tflogresponse.go index 506f525c6..1b94f4b50 100644 --- a/tools/linters/tflogresponse/tflogresponse.go +++ b/tools/linters/tflogresponse/tflogresponse.go @@ -2,13 +2,11 @@ package tflogresponse import ( "go/ast" - "go/types" "github.com/golangci/plugin-module-register/register" "golang.org/x/tools/go/analysis" "golang.org/x/tools/go/analysis/passes/inspect" "golang.org/x/tools/go/ast/inspector" - "golang.org/x/tools/go/types/typeutil" "github.com/stackitcloud/terraform-provider-stackit/tools/internal/facts/servicecall" "github.com/stackitcloud/terraform-provider-stackit/tools/internal/lintutils" @@ -79,7 +77,7 @@ func run(pass *analysis.Pass) (any, error) { } case stateLookingForSdkOrLogResponseCall: - if hasServiceCall(serviceCalls, pass, call) { + if servicecall.HasServiceCall(call, pass.TypesInfo, serviceCalls) { foundIntermediateSdkModuleCall = true } else if pkgPath == utilPkg && calledFuncName == funcLogResponse { if !foundIntermediateSdkModuleCall { @@ -102,11 +100,6 @@ func run(pass *analysis.Pass) (any, error) { return nil, nil } -func hasServiceCall(serviceCalls *servicecall.Result, pass *analysis.Pass, call *ast.CallExpr) bool { - callee, _ := typeutil.Callee(pass.TypesInfo, call).(*types.Func) - return callee != nil && serviceCalls.HasServiceCall(callee) -} - func init() { register.Plugin(analyzerName, New) } diff --git a/tools/testdata/stackit/internal/services/tfctxinit/tfctxinit.go b/tools/testdata/stackit/internal/services/tfctxinit/tfctxinit.go index 076f57e97..8c0c30f3e 100644 --- a/tools/testdata/stackit/internal/services/tfctxinit/tfctxinit.go +++ b/tools/testdata/stackit/internal/services/tfctxinit/tfctxinit.go @@ -27,3 +27,13 @@ func (r *resource) Read(ctx context.Context, req testtypes.ReadRequest, resp *te func sdkCallOutsideLifecycleMethod() { iaas.NewAPIClient() } + +func wrapper[T any](fn func() (*T, error)) (*T, error) { + return fn() +} + +func (r *resource) Delete(ctx context.Context, req testtypes.DeleteRequest, resp *testtypes.DeleteResponse) { + var service iaas.DefaultAPIService + wrapper(service.CreateAffinityGroup(ctx, "", "").Execute) // want "tfctxinit: call to github.com/stackitcloud/stackit-sdk-go must happen AFTER github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core.InitProviderContext is called in Delete" + core.InitProviderContext(ctx) +} diff --git a/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go b/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go index 283b3693b..9da6c7f7b 100644 --- a/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go +++ b/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go @@ -54,3 +54,16 @@ func (f *resource2) Read(ctx context.Context, req testtypes.ReadRequest, resp *t func indirection(service *iaas.DefaultAPIService) { service.AddNetworkToServerExecute(iaas.ApiAddNetworkToServerRequest{}) } + +func wrapper[T any](fn func() (*T, error)) (*T, error) { + return fn() +} + +type resource3 struct{} + +func (r *resource3) Delete(ctx context.Context, req testtypes.DeleteRequest, resp *testtypes.DeleteResponse) { + c, _ := iaas.NewAPIClient() + ctx = core.InitProviderContext(ctx) + wrapper(c.DefaultAPI.CreateAffinityGroup(ctx, "", "").Execute) + ctx = core.LogResponse(ctx) +} From 60ba45f9562353cc8f6156b7605a12005092fb23 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Sun, 2 Aug 2026 23:52:13 +0200 Subject: [PATCH 11/13] fix(linters): mark WaitWithContext as serviceCall --- .../internal/facts/servicecall/servicecall.go | 23 ++++++--- tools/internal/lintutils/lintutil.go | 40 ++++++++++++++++ tools/linters/tfwriteid/tfwriteid.go | 48 +------------------ tools/testdata/servicecall/servicecall.go | 11 +++++ 4 files changed, 68 insertions(+), 54 deletions(-) diff --git a/tools/internal/facts/servicecall/servicecall.go b/tools/internal/facts/servicecall/servicecall.go index b6b2abcb2..06e097a9f 100644 --- a/tools/internal/facts/servicecall/servicecall.go +++ b/tools/internal/facts/servicecall/servicecall.go @@ -70,6 +70,16 @@ func run(pass *analysis.Pass) (any, error) { serviceCalls := make(map[*types.Func]struct{}) var serviceCallers []*types.Func + // A function that receives a known service-call function will invoke that + // function indirectly, so it is itself a service-call function. + markReferencedServiceCall := func(caller, referencedFunction *types.Func) { + if referencedFunction == nil || !isKnownServiceCall(pass, nil, referencedFunction) { + return + } + serviceCalls[referencedFunction.Origin()] = struct{}{} + serviceCallers = append(serviceCallers, caller) + } + inspect.Preorder([]ast.Node{(*ast.FuncDecl)(nil)}, func(n ast.Node) { decl := n.(*ast.FuncDecl) if decl.Body == nil { @@ -93,10 +103,7 @@ func run(pass *analysis.Pass) (any, error) { return true } for _, argument := range call.Args { - referencedFunction := functionReference(argument, pass.TypesInfo) - if referencedFunction != nil && isKnownServiceCall(pass, referencedFunction) { - serviceCalls[referencedFunction.Origin()] = struct{}{} - } + markReferencedServiceCall(caller, functionReference(argument, pass.TypesInfo)) } callee, _ := typeutil.Callee(pass.TypesInfo, call).(*types.Func) @@ -105,7 +112,7 @@ func run(pass *analysis.Pass) (any, error) { } callee = callee.Origin() - if isKnownServiceCall(pass, callee) { + if isKnownServiceCall(pass, call, callee) { serviceCalls[callee] = struct{}{} serviceCallers = append(serviceCallers, caller) return true @@ -196,8 +203,10 @@ func functionReference(expr ast.Expr, info *types.Info) *types.Func { return nil } -func isKnownServiceCall(pass *analysis.Pass, fn *types.Func) bool { - return isSDKCallAPI(fn) || hasServiceCallFact(pass, fn) +func isKnownServiceCall(pass *analysis.Pass, call *ast.CallExpr, fn *types.Func) bool { + return isSDKCallAPI(fn) || + (call != nil && lintutils.IsWaitCall(pass.TypesInfo, call, fn.Name())) || + hasServiceCallFact(pass, fn) } func hasServiceCallFact(pass *analysis.Pass, fn *types.Func) bool { diff --git a/tools/internal/lintutils/lintutil.go b/tools/internal/lintutils/lintutil.go index f4ee80564..ac2efa5e7 100644 --- a/tools/internal/lintutils/lintutil.go +++ b/tools/internal/lintutils/lintutil.go @@ -9,6 +9,46 @@ import ( const StackitSdkModulePrefix = "github.com/stackitcloud/stackit-sdk-go" +// IsWaitCall reports whether call executes an SDK async-action wait handler. +func IsWaitCall(info *types.Info, call *ast.CallExpr, calledFuncName string) bool { + // Must be a selector like handler.WaitWithContext(). + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + // serviceenablement itself is used in other resources and does not have an ID itself. + if waiterCall, ok := sel.X.(*ast.CallExpr); ok { + _, waiterFuncName := GetCallInfo(waiterCall, info) + if waiterFuncName == "EnableServiceWaitHandler" { + return false + } + } + obj := info.Uses[sel.Sel] + if obj == nil { + return false + } + sig, ok := obj.Type().(*types.Signature) + if !ok { + return false + } + recv := sig.Recv() + if recv == nil { + return false + } + recvType := recv.Type() + if ptr, ok := recvType.(*types.Pointer); ok { + recvType = ptr.Elem() + } + named, ok := recvType.(*types.Named) + if !ok || named.Obj().Pkg() == nil { + return false + } + // Must be WaitWithContext on a wait.AsyncActionHandler receiver. + return named.Obj().Pkg().Path() == "github.com/stackitcloud/stackit-sdk-go/core/wait" && + named.Obj().Name() == "AsyncActionHandler" && + calledFuncName == "WaitWithContext" +} + // GetCallInfo resolves the package path and function name of a CallExpr. func GetCallInfo(call *ast.CallExpr, info *types.Info) (string, string) { var ident *ast.Ident diff --git a/tools/linters/tfwriteid/tfwriteid.go b/tools/linters/tfwriteid/tfwriteid.go index 899407421..77f97df55 100644 --- a/tools/linters/tfwriteid/tfwriteid.go +++ b/tools/linters/tfwriteid/tfwriteid.go @@ -2,7 +2,6 @@ package tfwriteid import ( "go/ast" - "go/types" "github.com/golangci/plugin-module-register/register" "golang.org/x/tools/go/analysis" @@ -63,7 +62,7 @@ func run(pass *analysis.Pass) (interface{}, error) { } // Check if we've hit a STACKIT SDK wait handler call before the util function - callsWait := isWaitCall(pass.TypesInfo, call, calledFuncName) + callsWait := lintutils.IsWaitCall(pass.TypesInfo, call, calledFuncName) if callsWait && !hasCalledUtil { pass.Reportf( call.Pos(), @@ -96,48 +95,3 @@ func (p *plugin) BuildAnalyzers() ([]*analysis.Analyzer, error) { func (p *plugin) GetLoadMode() string { return register.LoadModeSyntax } - -func isWaitCall(info *types.Info, call *ast.CallExpr, calledFuncName string) bool { - // must be a selector like handler.WaitWithContext() - sel, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return false - } - // serviceenablement itself is used in other resources and does not have an ID itself - if waiterCall, ok := sel.X.(*ast.CallExpr); ok { - _, waiterFuncName := lintutils.GetCallInfo(waiterCall, info) - if waiterFuncName == "EnableServiceWaitHandler" { - return false - } - } - obj := info.Uses[sel.Sel] - if obj == nil { - return false - } - sig, ok := obj.Type().(*types.Signature) - if !ok { - return false - } - // get full receiver type - recv := sig.Recv() - if recv == nil { - return false - } - recvType := recv.Type() - if ptr, ok := recvType.(*types.Pointer); ok { - recvType = ptr.Elem() - } - named, ok := recvType.(*types.Named) - if !ok { - return false - } - if named.Obj().Pkg() == nil { - return false - } - recvPkgName := named.Obj().Pkg().Path() - recvTypeName := named.Obj().Name() - // must be WaitWithContext on a wait.AsyncActionHandler receiver - return recvPkgName == "github.com/stackitcloud/stackit-sdk-go/core/wait" && - recvTypeName == "AsyncActionHandler" && - calledFuncName == "WaitWithContext" -} diff --git a/tools/testdata/servicecall/servicecall.go b/tools/testdata/servicecall/servicecall.go index 9f5a474e4..11dc1b110 100644 --- a/tools/testdata/servicecall/servicecall.go +++ b/tools/testdata/servicecall/servicecall.go @@ -4,6 +4,7 @@ import ( "context" iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api/wait" ) func callsService(service *iaas.DefaultAPIService) { // want callsService:"serviceCall" @@ -25,3 +26,13 @@ func doesNotCallService(client iaas.DefaultAPI) { func callsServiceInterface(client iaas.DefaultAPI) { // want callsServiceInterface:"serviceCall" client.AddNetworkToServer(context.Background(), "", "", "", "").Execute() } + +func waiterCallIsServiceCall(client iaas.DefaultAPI) { // want waiterCallIsServiceCall:"serviceCall" + ctx := context.Background() + wait.CreateSnapshotWaitHandler(ctx, client, "", "", "").WaitWithContext(ctx) +} + +func waiterCreationIsNotAServiceCall(client iaas.DefaultAPI) { + ctx := context.Background() + wait.CreateSnapshotWaitHandler(ctx, client, "", "", "") +} From 972a479477174853d775c8f11f5c8e883203c604 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Mon, 3 Aug 2026 00:10:54 +0200 Subject: [PATCH 12/13] chore(linters): add false negative test to tflogresponse --- .../internal/services/tflogresponse/tflogresponse.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go b/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go index 9da6c7f7b..b8472d150 100644 --- a/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go +++ b/tools/testdata/stackit/internal/services/tflogresponse/tflogresponse.go @@ -67,3 +67,14 @@ func (r *resource3) Delete(ctx context.Context, req testtypes.DeleteRequest, res wrapper(c.DefaultAPI.CreateAffinityGroup(ctx, "", "").Execute) ctx = core.LogResponse(ctx) } + +type falseNegative struct{} + +func (f *falseNegative) Read(ctx context.Context, req testtypes.ReadRequest, resp *testtypes.ReadResponse) { + var service iaas.DefaultAPIService + ctx = core.InitProviderContext(ctx) + service.AddNetworkToServerExecute(iaas.ApiAddNetworkToServerRequest{}) + ctx = core.LogResponse(ctx) + service.AddNetworkToServerExecute(iaas.ApiAddNetworkToServerRequest{}) + // this should fail because the traceID of the 2nd AddNetworkToServerExecute will never be logged +} From eb94b0cc22fa1e2f3e0e7faca1000e1d5ebdaff4 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Mon, 31 Aug 2026 16:53:08 +0200 Subject: [PATCH 13/13] fix(linter): download test deps before testing --- scripts/project.sh | 2 ++ tools/testdata/go.mod | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/project.sh b/scripts/project.sh index 8c2879cd7..b21b04f89 100755 --- a/scripts/project.sh +++ b/scripts/project.sh @@ -15,6 +15,8 @@ elif [ "$action" = "tools" ]; then cd ${ROOT_DIR} go mod download + (cd tools && go mod download) + (cd tools/testdata && go mod download) go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@v0.24.0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 diff --git a/tools/testdata/go.mod b/tools/testdata/go.mod index f3751a636..1b4779fba 100644 --- a/tools/testdata/go.mod +++ b/tools/testdata/go.mod @@ -3,6 +3,7 @@ module github.com/stackitcloud/terraform-provider-stackit go 1.25.9 require ( + github.com/stackitcloud/stackit-sdk-go/core v0.26.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.13.0 github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.7.0 ) @@ -10,6 +11,5 @@ require ( require ( github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/stackitcloud/stackit-sdk-go/core v0.26.0 // indirect github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.0 // indirect )