mirror of
https://github.com/microsoft/terraform-provider-fabric.git
synced 2026-11-04 12:02:31 +00:00
added check spn script
This commit is contained in:
1 parent
53aaced40c
commit
7e747df0e5
10 files changed
+1437
-639
No files matched your search
+7
-2
@@ -105,6 +105,11 @@ tasks:
|
||||
cmds:
|
||||
- go run ./tools/previewcheck {{.CLI_ARGS}}
|
||||
|
||||
check:spn:
|
||||
desc: Verify each resource/data source IsSPNSupported flag matches the fabric-sdk-go API service principal support
|
||||
cmds:
|
||||
- go run ./tools/spncheck {{.CLI_ARGS}}
|
||||
|
||||
check:gaps:
|
||||
desc: Detect SDK control-plane entities with no corresponding Terraform resource
|
||||
cmds:
|
||||
@@ -301,9 +306,9 @@ tasks:
|
||||
# CGO_ENABLED: 1
|
||||
|
||||
test:gaptools:
|
||||
desc: Run unit tests for the gapcheck/previewcheck developer tools
|
||||
desc: Run unit tests for the gapcheck/previewcheck/spncheck developer tools
|
||||
cmds:
|
||||
- go test -count=1 -timeout 5m ./tools/gapcheck/... ./tools/previewcheck/... ./tools/internal/toolutil/...
|
||||
- go test -count=1 -timeout 5m ./tools/gapcheck/... ./tools/previewcheck/... ./tools/spncheck/... ./tools/internal/toolutil/...
|
||||
env:
|
||||
# Fail loudly if the fabric-sdk-go module cannot be resolved instead of
|
||||
# silently skipping the SDK-backed guard tests.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright Microsoft Corporation 2026
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package toolutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// Exclusion suppresses a check for a single service package, together with the
|
||||
// reason the mismatch is intentional.
|
||||
type Exclusion struct {
|
||||
Service string `json:"service" yaml:"service"`
|
||||
Reason string `json:"reason" yaml:"reason"`
|
||||
}
|
||||
|
||||
// ExclusionsFile is the top-level structure of an exclusions YAML file.
|
||||
type ExclusionsFile struct {
|
||||
Exclusions []Exclusion `yaml:"exclusions"`
|
||||
}
|
||||
|
||||
// LoadExclusions reads an exclusions YAML file and returns a map of service
|
||||
// package name to reason. Every entry must set both service and reason.
|
||||
func LoadExclusions(path string) (map[string]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading exclusions file %s: %w", path, err)
|
||||
}
|
||||
|
||||
var ef ExclusionsFile
|
||||
|
||||
err = yaml.Unmarshal(data, &ef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing exclusions file %s: %w", path, err)
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(ef.Exclusions))
|
||||
|
||||
for _, e := range ef.Exclusions {
|
||||
if e.Service == "" || e.Reason == "" {
|
||||
return nil, fmt.Errorf("exclusion entry missing required field (service, reason): %+v", e)
|
||||
}
|
||||
|
||||
result[e.Service] = e.Reason
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// Copyright Microsoft Corporation 2026
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package toolutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
// SDKCall identifies one distinct fabric-sdk-go function invoked by a service
|
||||
// package, along with the position of its declaration in the SDK source so the
|
||||
// caller can inspect the doc comment that documents the API.
|
||||
type SDKCall struct {
|
||||
ID string // "pkgName.FuncName", e.g. "core.CreateShortcut"
|
||||
Pos token.Position // position of the SDK function declaration
|
||||
}
|
||||
|
||||
// CollectSDKCalls walks every call expression in the package and returns the
|
||||
// distinct fabric-sdk-go functions it invokes. The package must be loaded with
|
||||
// full type information and dependencies (see LoadServicePackages) so selector
|
||||
// expressions can be resolved to their declared objects (e.g. SDK client methods
|
||||
// reached through local variables, parameters, or embedding).
|
||||
func CollectSDKCalls(pkg *packages.Package) []SDKCall {
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
var calls []SDKCall
|
||||
|
||||
for _, file := range pkg.Syntax {
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// Only x.Y(...) style calls; SDK funcs are always reached via a selector.
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// Resolve the selector to its real declared object via the type checker.
|
||||
fn, ok := pkg.TypesInfo.ObjectOf(sel.Sel).(*types.Func)
|
||||
if !ok || fn.Pkg() == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(fn.Pkg().Path(), SDKModulePath) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Identify the callee as "pkgName.FuncName" and skip duplicates.
|
||||
id := fn.Pkg().Name() + "." + fn.Name()
|
||||
if _, dup := seen[id]; dup {
|
||||
return true
|
||||
}
|
||||
|
||||
seen[id] = struct{}{}
|
||||
calls = append(calls, SDKCall{ID: id, Pos: pkg.Fset.Position(fn.Pos())})
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
// ClientMethodDoc pairs an SDK client method name with the doc comment block
|
||||
// that immediately precedes its declaration.
|
||||
type ClientMethodDoc struct {
|
||||
Name string
|
||||
Doc string
|
||||
}
|
||||
|
||||
// DocCache lazily reads SDK source files and caches their lines, the doc comment
|
||||
// above declarations, and per-package CRUD method scans.
|
||||
type DocCache struct {
|
||||
files map[string][]string
|
||||
docs map[string]string
|
||||
crud map[string][]ClientMethodDoc
|
||||
}
|
||||
|
||||
// NewDocCache returns an empty DocCache ready for use.
|
||||
func NewDocCache() *DocCache {
|
||||
return &DocCache{
|
||||
files: map[string][]string{},
|
||||
docs: map[string]string{},
|
||||
crud: map[string][]ClientMethodDoc{},
|
||||
}
|
||||
}
|
||||
|
||||
// ReadFile returns the lines of path, reading and caching them on first access.
|
||||
func (d *DocCache) ReadFile(path string) ([]string, error) {
|
||||
if lines, ok := d.files[path]; ok {
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
d.files[path] = lines
|
||||
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// DocCommentAt returns the contiguous //-comment block directly above the
|
||||
// declaration at pos (joined by newlines), or "" if pos is unknown or the file
|
||||
// cannot be read. Results are cached per position.
|
||||
func (d *DocCache) DocCommentAt(pos token.Position) string {
|
||||
if pos.Filename == "" || pos.Line <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s:%d", pos.Filename, pos.Line)
|
||||
if v, ok := d.docs[key]; ok {
|
||||
return v
|
||||
}
|
||||
|
||||
doc := ""
|
||||
|
||||
lines, err := d.ReadFile(pos.Filename)
|
||||
if err == nil {
|
||||
doc = DocCommentAbove(lines, pos.Line)
|
||||
}
|
||||
|
||||
d.docs[key] = doc
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
// ScanItemCRUD scans the *_client.go files in an SDK package directory and
|
||||
// returns the item's exported CRUD methods together with their doc comments,
|
||||
// sorted by method name. Results are cached per (directory, item).
|
||||
func (d *DocCache) ScanItemCRUD(pkgDir, item string) []ClientMethodDoc {
|
||||
cacheKey := pkgDir + "\x00" + item
|
||||
if v, ok := d.crud[cacheKey]; ok {
|
||||
return v
|
||||
}
|
||||
|
||||
var methods []ClientMethodDoc
|
||||
|
||||
entries, err := os.ReadDir(pkgDir)
|
||||
if err != nil {
|
||||
d.crud[cacheKey] = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), "_client.go") {
|
||||
continue
|
||||
}
|
||||
|
||||
lines, rErr := d.ReadFile(filepath.Join(pkgDir, e.Name()))
|
||||
if rErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for i, line := range lines {
|
||||
_, name := ParseClientMethod(line)
|
||||
if name == "" || !IsItemCRUD(name, item) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Line numbers passed to DocCommentAbove are 1-indexed.
|
||||
methods = append(methods, ClientMethodDoc{Name: name, Doc: DocCommentAbove(lines, i+1)})
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(methods, func(a, b ClientMethodDoc) int { return strings.Compare(a.Name, b.Name) })
|
||||
|
||||
d.crud[cacheKey] = methods
|
||||
|
||||
return methods
|
||||
}
|
||||
|
||||
// DocCommentAbove returns the contiguous //-style comment block that sits
|
||||
// directly above the 1-indexed declLine, joined by newlines in source order.
|
||||
func DocCommentAbove(lines []string, declLine int) string {
|
||||
// declLine is 1-indexed; the line directly above it is at slice index declLine-2.
|
||||
end := declLine - 2
|
||||
if end < 0 || end >= len(lines) || !strings.HasPrefix(strings.TrimSpace(lines[end]), "//") {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Walk up to the top of the contiguous //-comment block.
|
||||
start := end
|
||||
for start-1 >= 0 && strings.HasPrefix(strings.TrimSpace(lines[start-1]), "//") {
|
||||
start--
|
||||
}
|
||||
|
||||
doc := make([]string, 0, end-start+1)
|
||||
for i := start; i <= end; i++ {
|
||||
doc = append(doc, strings.TrimSpace(lines[i]))
|
||||
}
|
||||
|
||||
return strings.Join(doc, "\n")
|
||||
}
|
||||
|
||||
// ExtractItemTypeInfoBool returns the value of the named boolean field inside the
|
||||
// package's ItemTypeInfo composite literal, and whether the field was found with
|
||||
// a literal true/false value.
|
||||
func ExtractItemTypeInfoBool(pkg *packages.Package, field string) (bool, bool) { //nolint:revive // value + found
|
||||
for _, file := range pkg.Syntax {
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.VAR {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, spec := range gen.Specs {
|
||||
vs, ok := spec.(*ast.ValueSpec)
|
||||
if !ok || !HasName(vs.Names, "ItemTypeInfo") {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, val := range vs.Values {
|
||||
lit, ok := val.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if v, ok := extractBoolField(lit, field); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, false
|
||||
}
|
||||
|
||||
func extractBoolField(lit *ast.CompositeLit, field string) (bool, bool) { //nolint:revive // value + found
|
||||
for _, elt := range lit.Elts {
|
||||
kv, ok := elt.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
key, ok := kv.Key.(*ast.Ident)
|
||||
if !ok || key.Name != field {
|
||||
continue
|
||||
}
|
||||
|
||||
ident, ok := kv.Value.(*ast.Ident)
|
||||
if !ok || (ident.Name != "true" && ident.Name != "false") {
|
||||
continue
|
||||
}
|
||||
|
||||
return ident.Name == "true", true
|
||||
}
|
||||
|
||||
return false, false
|
||||
}
|
||||
|
||||
// FindFabricItemType locates the FabricItemType const (= fabcore.ItemType<Name>)
|
||||
// in the package and returns the SDK constant name plus a file in the fabcore
|
||||
// package (used to derive the SDK fabric/ directory).
|
||||
func FindFabricItemType(pkg *packages.Package) (string, string) { //nolint:revive // SDK const name + fabcore file path
|
||||
for _, file := range pkg.Syntax {
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.CONST {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, spec := range gen.Specs {
|
||||
vs, ok := spec.(*ast.ValueSpec)
|
||||
if !ok || !HasName(vs.Names, "FabricItemType") {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, val := range vs.Values {
|
||||
sel, ok := val.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
obj := pkg.TypesInfo.ObjectOf(sel.Sel)
|
||||
if obj == nil || obj.Pkg() == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return obj.Name(), pkg.Fset.Position(obj.Pos()).Filename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// FabricRoot returns the fabric-sdk-go "fabric" directory given a file inside the
|
||||
// fabric/core package (e.g. .../fabric-sdk-go@v/fabric/core/x.go -> .../fabric).
|
||||
func FabricRoot(coreFile string) string {
|
||||
dir := filepath.Dir(coreFile)
|
||||
if filepath.Base(dir) != "core" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return filepath.Dir(dir)
|
||||
}
|
||||
|
||||
// SDKPackageDir resolves the package's FabricItemType constant to its dedicated
|
||||
// fabric-sdk-go package directory, returning the directory and the item name.
|
||||
// Overrides maps an item name to a non-default SDK package directory name when
|
||||
// the default lowercase derivation does not match.
|
||||
func SDKPackageDir(pkg *packages.Package, overrides map[string]string) (string, string, bool) { //nolint:revive // dir + item name + ok
|
||||
constName, anyFile := FindFabricItemType(pkg)
|
||||
if constName == "" || anyFile == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// ItemType constants are named ItemType<Name>; the SDK package is fabric/<name>.
|
||||
item := strings.TrimPrefix(constName, "ItemType")
|
||||
if item == constName || item == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
fabricDir := FabricRoot(anyFile)
|
||||
if fabricDir == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
pkgName := strings.ToLower(item)
|
||||
if override, has := overrides[item]; has {
|
||||
pkgName = override
|
||||
}
|
||||
|
||||
return filepath.Join(fabricDir, pkgName), item, true
|
||||
}
|
||||
|
||||
// IsItemCRUD reports whether method is a CRUD operation on the item itself,
|
||||
// e.g. GetNotebook, BeginCreateNotebook, ListNotebooks, UpdateNotebook.
|
||||
func IsItemCRUD(method, item string) bool {
|
||||
if !strings.Contains(strings.ToLower(method), strings.ToLower(item)) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, verb := range []string{"Get", "List", "Create", "Update", "Delete"} {
|
||||
if strings.Contains(method, verb) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// HasName reports whether target appears among names.
|
||||
func HasName(names []*ast.Ident, target string) bool {
|
||||
for _, n := range names {
|
||||
if n.Name == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// PkgName returns the package's name, falling back to the import-path base.
|
||||
func PkgName(pkg *packages.Package) string {
|
||||
if pkg.Name != "" {
|
||||
return pkg.Name
|
||||
}
|
||||
|
||||
return filepath.Base(pkg.PkgPath)
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
package toolutil_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/microsoft/terraform-provider-fabric/tools/internal/toolutil"
|
||||
@@ -42,3 +45,224 @@ func TestParseClientMethod(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentAbove(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lines []string
|
||||
declLine int
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"contiguous doc block",
|
||||
[]string{
|
||||
"// GetFoo retrieves a foo.",
|
||||
"// This API is currently in preview.",
|
||||
"func (client *FooClient) GetFoo() {}",
|
||||
},
|
||||
3,
|
||||
"// GetFoo retrieves a foo.\n// This API is currently in preview.",
|
||||
},
|
||||
{
|
||||
"blank line breaks the block",
|
||||
[]string{
|
||||
"// GetFoo retrieves a foo.",
|
||||
"",
|
||||
"func (client *FooClient) GetFoo() {}",
|
||||
},
|
||||
3,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"non-comment line breaks the block",
|
||||
[]string{
|
||||
"import \"context\"",
|
||||
"func (client *FooClient) GetFoo() {}",
|
||||
},
|
||||
2,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"declaration on first line does not panic",
|
||||
[]string{"func (client *FooClient) GetFoo() {}"},
|
||||
1,
|
||||
"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := toolutil.DocCommentAbove(tt.lines, tt.declLine); got != tt.want {
|
||||
t.Errorf("DocCommentAbove(%d) = %q, want %q", tt.declLine, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsItemCRUD(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
item string
|
||||
want bool
|
||||
}{
|
||||
{"GetNotebook", "Notebook", true},
|
||||
{"ListNotebooks", "Notebook", true},
|
||||
{"BeginCreateNotebook", "Notebook", true},
|
||||
{"UpdateNotebook", "Notebook", true},
|
||||
{"DeleteNotebook", "Notebook", true},
|
||||
// Item name matches but the verb is not a CRUD verb.
|
||||
{"PublishNotebook", "Notebook", false},
|
||||
// CRUD verb but the method is about a different entity.
|
||||
{"GetWorkspace", "Notebook", false},
|
||||
// Neither item nor CRUD verb.
|
||||
{"ApplyTags", "Notebook", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+"/"+tt.item, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := toolutil.IsItemCRUD(tt.method, tt.item); got != tt.want {
|
||||
t.Errorf("IsItemCRUD(%q, %q) = %v, want %v", tt.method, tt.item, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFabricRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
coreFile := filepath.Join("home", "user", "sdk", "fabric", "core", "zz_generated_models.go")
|
||||
want := filepath.Join("home", "user", "sdk", "fabric")
|
||||
|
||||
if got := toolutil.FabricRoot(coreFile); got != want {
|
||||
t.Errorf("FabricRoot(%q) = %q, want %q", coreFile, got, want)
|
||||
}
|
||||
|
||||
notCore := filepath.Join("home", "user", "sdk", "fabric", "admin", "client.go")
|
||||
if got := toolutil.FabricRoot(notCore); got != "" {
|
||||
t.Errorf("FabricRoot(%q) = %q, want empty (parent dir is not core)", notCore, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanItemCRUD(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
content := "package notebook\n\n" +
|
||||
"// GetNotebook gets a notebook.\n" +
|
||||
"// This API is currently in preview.\n" +
|
||||
"func (client *ItemsClient) GetNotebook(ctx context.Context) (Response, error) {}\n\n" +
|
||||
"// ListNotebooks lists notebooks.\n" +
|
||||
"func (client *ItemsClient) ListNotebooks(ctx context.Context) (Response, error) {}\n\n" +
|
||||
"// ApplyLabel applies a label (not the item).\n" +
|
||||
"func (client *ItemsClient) ApplyLabel(ctx context.Context) (Response, error) {}\n\n" +
|
||||
"// getInternal is unexported and must be ignored.\n" +
|
||||
"func (client *ItemsClient) getInternal() {}\n"
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, "items_client.go"), []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
methods := toolutil.NewDocCache().ScanItemCRUD(dir, "Notebook")
|
||||
|
||||
names := make([]string, 0, len(methods))
|
||||
for _, m := range methods {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
|
||||
wantNames := []string{"GetNotebook", "ListNotebooks"}
|
||||
if !slices.Equal(names, wantNames) {
|
||||
t.Fatalf("CRUD methods = %v, want %v", names, wantNames)
|
||||
}
|
||||
|
||||
if got := methods[0].Doc; got != "// GetNotebook gets a notebook.\n// This API is currently in preview." {
|
||||
t.Errorf("GetNotebook doc = %q, want the preview marker block", got)
|
||||
}
|
||||
|
||||
if got := methods[1].Doc; got != "// ListNotebooks lists notebooks." {
|
||||
t.Errorf("ListNotebooks doc = %q, want its own doc line", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExclusions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeExclusions(t, "exclusions:\n - service: foo\n reason: GA, stale marker\n")
|
||||
|
||||
m, err := toolutil.LoadExclusions(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadExclusions() error = %v", err)
|
||||
}
|
||||
|
||||
if m["foo"] != "GA, stale marker" {
|
||||
t.Errorf("m[foo] = %q, want the configured reason", m["foo"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing service", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeExclusions(t, "exclusions:\n - reason: no service\n")
|
||||
|
||||
_, err := toolutil.LoadExclusions(path)
|
||||
if err == nil {
|
||||
t.Error("LoadExclusions() error = nil, want error for missing service")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing reason", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeExclusions(t, "exclusions:\n - service: foo\n")
|
||||
|
||||
_, err := toolutil.LoadExclusions(path)
|
||||
if err == nil {
|
||||
t.Error("LoadExclusions() error = nil, want error for missing reason")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed yaml", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeExclusions(t, "exclusions: [::::\n")
|
||||
|
||||
_, err := toolutil.LoadExclusions(path)
|
||||
if err == nil {
|
||||
t.Error("LoadExclusions() error = nil, want parse error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent path", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := toolutil.LoadExclusions(filepath.Join(t.TempDir(), "nope.yaml"))
|
||||
if err == nil {
|
||||
t.Error("LoadExclusions() error = nil, want read error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func writeExclusions(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "exclusions.yaml")
|
||||
|
||||
err := os.WriteFile(path, []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
+21
-426
@@ -38,17 +38,12 @@ package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"github.com/microsoft/terraform-provider-fabric/tools/internal/toolutil"
|
||||
)
|
||||
@@ -81,46 +76,6 @@ var (
|
||||
exclusionsFlag = flag.String("exclusions", "", "path to exclusions YAML file (default: auto-detected)") //nolint:gochecknoglobals
|
||||
)
|
||||
|
||||
// previewExclusion suppresses the preview check for a single service package,
|
||||
// typically a GA item whose called SDK API still carries a stale preview marker.
|
||||
type previewExclusion struct {
|
||||
Service string `json:"service" yaml:"service"`
|
||||
Reason string `json:"reason" yaml:"reason"`
|
||||
}
|
||||
|
||||
// exclusionsFile is the top-level YAML structure of the exclusions file.
|
||||
type exclusionsFile struct {
|
||||
Exclusions []previewExclusion `yaml:"exclusions"`
|
||||
}
|
||||
|
||||
// loadExclusions reads the exclusions YAML file and returns a map of service
|
||||
// package name to reason.
|
||||
func loadExclusions(path string) (map[string]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading exclusions file %s: %w", path, err)
|
||||
}
|
||||
|
||||
var ef exclusionsFile
|
||||
|
||||
err = yaml.Unmarshal(data, &ef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing exclusions file %s: %w", path, err)
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(ef.Exclusions))
|
||||
|
||||
for _, e := range ef.Exclusions {
|
||||
if e.Service == "" || e.Reason == "" {
|
||||
return nil, fmt.Errorf("exclusion entry missing required field (service, reason): %+v", e)
|
||||
}
|
||||
|
||||
result[e.Service] = e.Reason
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
@@ -147,14 +102,14 @@ func run() int {
|
||||
exclusionsPath = filepath.Join(root, "tools", "previewcheck", "exclusions.yaml")
|
||||
}
|
||||
|
||||
exclusions, err := loadExclusions(exclusionsPath)
|
||||
exclusions, err := toolutil.LoadExclusions(exclusionsPath)
|
||||
if err != nil {
|
||||
toolutil.Errf("error loading exclusions: %v\n", err)
|
||||
|
||||
return exitError
|
||||
}
|
||||
|
||||
dc := newDocCache()
|
||||
dc := toolutil.NewDocCache()
|
||||
|
||||
results := make([]result, 0, len(pkgs))
|
||||
|
||||
@@ -380,16 +335,22 @@ func printPreviewAPIs(r result) {
|
||||
|
||||
// analyzePackage inspects a service package: it locates ItemTypeInfo.IsPreview
|
||||
// and collects every fabric-sdk-go function it calls, flagging preview ones.
|
||||
func analyzePackage(pkg *packages.Package, dc *docCache) (result, bool) {
|
||||
res := result{service: pkgName(pkg)}
|
||||
func analyzePackage(pkg *packages.Package, dc *toolutil.DocCache) (result, bool) {
|
||||
res := result{service: toolutil.PkgName(pkg)}
|
||||
|
||||
findIsPreview(pkg, &res)
|
||||
if v, ok := toolutil.ExtractItemTypeInfoBool(pkg, "IsPreview"); ok {
|
||||
res.hasDeclaration = true
|
||||
res.declared = v
|
||||
}
|
||||
|
||||
previewSet := map[string]struct{}{}
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
for _, file := range pkg.Syntax {
|
||||
collectSDKCalls(pkg, file, dc, &res, previewSet, seen)
|
||||
for _, call := range toolutil.CollectSDKCalls(pkg) {
|
||||
res.sdkCalls++
|
||||
|
||||
if containsMarker(dc.DocCommentAt(call.Pos)) {
|
||||
previewSet[call.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: generic fabricitem resources make no direct SDK calls in-package.
|
||||
@@ -412,370 +373,24 @@ func analyzePackage(pkg *packages.Package, dc *docCache) (result, bool) {
|
||||
// determineViaItemType resolves the FabricItemType constant to its dedicated
|
||||
// fabric-sdk-go package and scans that package's exported client methods for
|
||||
// preview markers, closing the gap for generic fabricitem resources.
|
||||
func determineViaItemType(pkg *packages.Package, dc *docCache, res *result, previewSet map[string]struct{}) {
|
||||
pkgDir, item, ok := sdkPackageDir(pkg)
|
||||
func determineViaItemType(pkg *packages.Package, dc *toolutil.DocCache, res *result, previewSet map[string]struct{}) {
|
||||
pkgDir, item, ok := toolutil.SDKPackageDir(pkg, sdkPackageOverrides)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
crud, methods := dc.scanCRUD(pkgDir, item)
|
||||
if crud == 0 {
|
||||
methods := dc.ScanItemCRUD(pkgDir, item)
|
||||
if len(methods) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
res.sdkCalls += crud
|
||||
res.sdkCalls += len(methods)
|
||||
|
||||
for _, m := range methods {
|
||||
previewSet[strings.ToLower(item)+"."+m] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// sdkPackageDir resolves a package's FabricItemType constant to its dedicated
|
||||
// fabric-sdk-go package directory, returning the directory and the item name.
|
||||
func sdkPackageDir(pkg *packages.Package) (string, string, bool) { //nolint:revive // dir + item name + ok
|
||||
constName, anyFile := findFabricItemType(pkg)
|
||||
if constName == "" || anyFile == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// ItemType constants are named ItemType<Name>; the SDK package is fabric/<name>.
|
||||
item := strings.TrimPrefix(constName, "ItemType")
|
||||
if item == constName || item == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
fabricDir := fabricRoot(anyFile)
|
||||
if fabricDir == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
pkgName := strings.ToLower(item)
|
||||
if override, ok := sdkPackageOverrides[item]; ok {
|
||||
pkgName = override
|
||||
}
|
||||
|
||||
return filepath.Join(fabricDir, pkgName), item, true
|
||||
}
|
||||
|
||||
// findFabricItemType locates the FabricItemType const (= fabcore.ItemType<Name>)
|
||||
// and returns the SDK constant name plus a file in the fabcore package (used to
|
||||
// derive the SDK fabric/ directory).
|
||||
func findFabricItemType(pkg *packages.Package) (string, string) { //nolint:revive // two strings: SDK const name and fabcore file path
|
||||
for _, file := range pkg.Syntax {
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.CONST {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, spec := range gen.Specs {
|
||||
vs, ok := spec.(*ast.ValueSpec)
|
||||
if !ok || !hasName(vs.Names, "FabricItemType") {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, val := range vs.Values {
|
||||
sel, ok := val.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
obj := pkg.TypesInfo.ObjectOf(sel.Sel)
|
||||
if obj == nil || obj.Pkg() == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return obj.Name(), pkg.Fset.Position(obj.Pos()).Filename
|
||||
}
|
||||
}
|
||||
if containsMarker(m.Doc) {
|
||||
previewSet[strings.ToLower(item)+"."+m.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// fabricRoot returns the fabric-sdk-go "fabric" directory given a file inside
|
||||
// the fabric/core package (e.g. .../fabric-sdk-go@v/fabric/core/x.go -> .../fabric).
|
||||
func fabricRoot(coreFile string) string {
|
||||
dir := filepath.Dir(coreFile)
|
||||
if filepath.Base(dir) != "core" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return filepath.Dir(dir)
|
||||
}
|
||||
|
||||
// findIsPreview locates the IsPreview field inside the ItemTypeInfo composite
|
||||
// literal and records its value.
|
||||
func findIsPreview(pkg *packages.Package, res *result) {
|
||||
for _, file := range pkg.Syntax {
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.VAR {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, spec := range gen.Specs {
|
||||
vs, ok := spec.(*ast.ValueSpec)
|
||||
if !ok || !hasName(vs.Names, "ItemTypeInfo") {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, val := range vs.Values {
|
||||
lit, ok := val.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if extractIsPreview(lit, res) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractIsPreview(lit *ast.CompositeLit, res *result) bool {
|
||||
for _, elt := range lit.Elts {
|
||||
kv, ok := elt.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
key, ok := kv.Key.(*ast.Ident)
|
||||
if !ok || key.Name != "IsPreview" {
|
||||
continue
|
||||
}
|
||||
|
||||
ident, ok := kv.Value.(*ast.Ident)
|
||||
if !ok || (ident.Name != "true" && ident.Name != "false") {
|
||||
continue
|
||||
}
|
||||
|
||||
res.hasDeclaration = true
|
||||
res.declared = ident.Name == "true"
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// collectSDKCalls walks call expressions in a file, resolves each callee, and
|
||||
// records distinct fabric-sdk-go functions (flagging preview ones). The seen set
|
||||
// is shared across the package's files so each callee is counted at most once.
|
||||
func collectSDKCalls(pkg *packages.Package, file *ast.File, dc *docCache, res *result, previewSet, seen map[string]struct{}) {
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// Only x.Y(...) style calls; SDK funcs are always reached via a selector.
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// Resolve the selector to its real declared object via the type checker
|
||||
// (relies on NeedTypes/NeedTypesInfo/NeedDeps when loading packages).
|
||||
obj := pkg.TypesInfo.ObjectOf(sel.Sel)
|
||||
|
||||
fn, ok := obj.(*types.Func)
|
||||
if !ok || fn.Pkg() == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(fn.Pkg().Path(), toolutil.SDKModulePath) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Identify the callee as "pkgName.FuncName" and skip duplicates.
|
||||
id := fn.Pkg().Name() + "." + fn.Name()
|
||||
if _, dup := seen[id]; dup {
|
||||
return true
|
||||
}
|
||||
|
||||
seen[id] = struct{}{}
|
||||
res.sdkCalls++
|
||||
|
||||
// fn.Pos() points at the SDK declaration; check its doc comment for a
|
||||
// preview marker and record the func if it is preview.
|
||||
if dc.isPreview(pkg.Fset.Position(fn.Pos())) {
|
||||
previewSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// docCache reads SDK source files lazily and caches the preview status of the
|
||||
// doc comment directly above a declaration position.
|
||||
type docCache struct {
|
||||
files map[string][]string
|
||||
results map[string]bool
|
||||
methods map[string][]string
|
||||
crud map[string]int
|
||||
}
|
||||
|
||||
func newDocCache() *docCache {
|
||||
return &docCache{
|
||||
files: map[string][]string{},
|
||||
results: map[string]bool{},
|
||||
methods: map[string][]string{},
|
||||
crud: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
// scanCRUD scans the *_client.go files in an SDK package directory and returns
|
||||
// the count of the item's exported CRUD methods plus the names of those flagged
|
||||
// preview. A non-zero count means the item status is determinable. Cached per
|
||||
// directory and item.
|
||||
func (d *docCache) scanCRUD(pkgDir, item string) (int, []string) {
|
||||
cacheKey := pkgDir + "\x00" + item
|
||||
if v, ok := d.methods[cacheKey]; ok {
|
||||
return d.crud[cacheKey], v
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(pkgDir)
|
||||
if err != nil {
|
||||
d.crud[cacheKey] = 0
|
||||
d.methods[cacheKey] = nil
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var (
|
||||
crud int
|
||||
preview []string
|
||||
)
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), "_client.go") {
|
||||
continue
|
||||
}
|
||||
|
||||
lines, rErr := d.readFile(filepath.Join(pkgDir, e.Name()))
|
||||
if rErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
c, p := scanItemMethods(lines, item)
|
||||
crud += c
|
||||
|
||||
preview = append(preview, p...)
|
||||
}
|
||||
|
||||
slices.Sort(preview)
|
||||
|
||||
d.crud[cacheKey] = crud
|
||||
d.methods[cacheKey] = preview
|
||||
|
||||
return crud, preview
|
||||
}
|
||||
|
||||
// isPreview reports whether the doc comment immediately above the declaration at
|
||||
// pos contains a preview marker.
|
||||
func (d *docCache) isPreview(pos token.Position) bool {
|
||||
if pos.Filename == "" || pos.Line <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s:%d", pos.Filename, pos.Line)
|
||||
if v, ok := d.results[key]; ok {
|
||||
return v
|
||||
}
|
||||
|
||||
lines, err := d.readFile(pos.Filename)
|
||||
if err != nil {
|
||||
d.results[key] = false
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
doc := docCommentAbove(lines, pos.Line)
|
||||
preview := containsMarker(doc)
|
||||
d.results[key] = preview
|
||||
|
||||
return preview
|
||||
}
|
||||
|
||||
func (d *docCache) readFile(path string) ([]string, error) {
|
||||
if lines, ok := d.files[path]; ok {
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
d.files[path] = lines
|
||||
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// docCommentAbove returns the contiguous //-style comment block that sits
|
||||
// directly above the 1-indexed declLine.
|
||||
func docCommentAbove(lines []string, declLine int) string {
|
||||
// declLine is 1-indexed; the line above it is at slice index declLine-2.
|
||||
var doc []string
|
||||
|
||||
for i := declLine - 2; i >= 0; i-- {
|
||||
trimmed := strings.TrimSpace(lines[i])
|
||||
if strings.HasPrefix(trimmed, "//") {
|
||||
doc = append(doc, trimmed)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return strings.Join(doc, "\n")
|
||||
}
|
||||
|
||||
// scanItemMethods returns the count of the item's CRUD client methods in the
|
||||
// file and the names of those whose preceding doc comment is marked preview.
|
||||
func scanItemMethods(lines []string, item string) (int, []string) {
|
||||
var (
|
||||
crud int
|
||||
preview []string
|
||||
)
|
||||
|
||||
for i, line := range lines {
|
||||
_, name := toolutil.ParseClientMethod(line)
|
||||
if name == "" || !isItemCRUD(name, item) {
|
||||
continue
|
||||
}
|
||||
|
||||
crud++
|
||||
|
||||
// Line numbers passed to docCommentAbove are 1-indexed.
|
||||
if containsMarker(docCommentAbove(lines, i+1)) {
|
||||
preview = append(preview, name)
|
||||
}
|
||||
}
|
||||
|
||||
return crud, preview
|
||||
}
|
||||
|
||||
// isItemCRUD reports whether method is a CRUD operation on the item itself,
|
||||
// e.g. GetNotebook, BeginCreateNotebook, ListNotebooks, UpdateNotebook.
|
||||
func isItemCRUD(method, item string) bool {
|
||||
if !strings.Contains(strings.ToLower(method), strings.ToLower(item)) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, verb := range []string{"Get", "List", "Create", "Update", "Delete"} {
|
||||
if strings.Contains(method, verb) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func containsMarker(doc string) bool {
|
||||
@@ -789,23 +404,3 @@ func containsMarker(doc string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// loadServicePackages loads all packages under the services directory with full
|
||||
// type information.
|
||||
func pkgName(pkg *packages.Package) string {
|
||||
if pkg.Name != "" {
|
||||
return pkg.Name
|
||||
}
|
||||
|
||||
return filepath.Base(pkg.PkgPath)
|
||||
}
|
||||
|
||||
func hasName(names []*ast.Ident, target string) bool {
|
||||
for _, n := range names {
|
||||
if n.Name == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -4,8 +4,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
@@ -37,125 +35,6 @@ func Test_containsMarker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_docCommentAbove(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lines []string
|
||||
declLine int
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"contiguous doc block",
|
||||
[]string{
|
||||
"// GetFoo retrieves a foo.",
|
||||
"// This API is currently in preview.",
|
||||
"func (client *FooClient) GetFoo() {}",
|
||||
},
|
||||
3,
|
||||
"// This API is currently in preview.\n// GetFoo retrieves a foo.",
|
||||
},
|
||||
{
|
||||
"blank line breaks the block",
|
||||
[]string{
|
||||
"// GetFoo retrieves a foo.",
|
||||
"",
|
||||
"func (client *FooClient) GetFoo() {}",
|
||||
},
|
||||
3,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"non-comment line breaks the block",
|
||||
[]string{
|
||||
"import \"context\"",
|
||||
"func (client *FooClient) GetFoo() {}",
|
||||
},
|
||||
2,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"declaration on first line does not panic",
|
||||
[]string{"func (client *FooClient) GetFoo() {}"},
|
||||
1,
|
||||
"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := docCommentAbove(tt.lines, tt.declLine); got != tt.want {
|
||||
t.Errorf("docCommentAbove(%d) = %q, want %q", tt.declLine, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_isItemCRUD(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
item string
|
||||
want bool
|
||||
}{
|
||||
{"GetNotebook", "Notebook", true},
|
||||
{"ListNotebooks", "Notebook", true},
|
||||
{"BeginCreateNotebook", "Notebook", true},
|
||||
{"UpdateNotebook", "Notebook", true},
|
||||
{"DeleteNotebook", "Notebook", true},
|
||||
// Item name matches but the verb is not a CRUD verb.
|
||||
{"PublishNotebook", "Notebook", false},
|
||||
// CRUD verb but the method is about a different entity.
|
||||
{"GetWorkspace", "Notebook", false},
|
||||
// Neither item nor CRUD verb.
|
||||
{"ApplyTags", "Notebook", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+"/"+tt.item, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := isItemCRUD(tt.method, tt.item); got != tt.want {
|
||||
t.Errorf("isItemCRUD(%q, %q) = %v, want %v", tt.method, tt.item, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_scanItemMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lines := []string{
|
||||
"// GetNotebook gets a notebook.",
|
||||
"// This API is currently in preview.",
|
||||
"func (client *ItemsClient) GetNotebook(ctx context.Context) (Response, error) {",
|
||||
"",
|
||||
"// ListNotebooks lists notebooks.",
|
||||
"func (client *ItemsClient) ListNotebooks(ctx context.Context) (Response, error) {",
|
||||
"",
|
||||
"// ApplyLabel applies a label (not the item).",
|
||||
"func (client *ItemsClient) ApplyLabel(ctx context.Context) (Response, error) {",
|
||||
"",
|
||||
"// getInternal is unexported and must be ignored.",
|
||||
"func (client *ItemsClient) getInternal() {}",
|
||||
}
|
||||
|
||||
crud, preview := scanItemMethods(lines, "Notebook")
|
||||
|
||||
if crud != 2 {
|
||||
t.Errorf("crud count = %d, want 2 (GetNotebook, ListNotebooks)", crud)
|
||||
}
|
||||
|
||||
want := []string{"GetNotebook"}
|
||||
if !slices.Equal(preview, want) {
|
||||
t.Errorf("preview methods = %v, want %v", preview, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_categorize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -254,80 +133,6 @@ func Test_classify(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_loadExclusions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeExclusions(t, "exclusions:\n - service: foo\n reason: GA, stale marker\n")
|
||||
|
||||
m, err := loadExclusions(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loadExclusions() error = %v", err)
|
||||
}
|
||||
|
||||
if m["foo"] != "GA, stale marker" {
|
||||
t.Errorf("m[foo] = %q, want the configured reason", m["foo"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing service", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeExclusions(t, "exclusions:\n - reason: no service\n")
|
||||
|
||||
_, err := loadExclusions(path)
|
||||
if err == nil {
|
||||
t.Error("loadExclusions() error = nil, want error for missing service")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing reason", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeExclusions(t, "exclusions:\n - service: foo\n")
|
||||
|
||||
_, err := loadExclusions(path)
|
||||
if err == nil {
|
||||
t.Error("loadExclusions() error = nil, want error for missing reason")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed yaml", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeExclusions(t, "exclusions: [::::\n")
|
||||
|
||||
_, err := loadExclusions(path)
|
||||
if err == nil {
|
||||
t.Error("loadExclusions() error = nil, want parse error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent path", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := loadExclusions(filepath.Join(t.TempDir(), "nope.yaml"))
|
||||
if err == nil {
|
||||
t.Error("loadExclusions() error = nil, want read error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func writeExclusions(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "exclusions.yaml")
|
||||
|
||||
err := os.WriteFile(path, []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func Test_result_expected_determinable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -347,19 +152,3 @@ func Test_result_expected_determinable(t *testing.T) {
|
||||
t.Error("result with SDK calls should be determinable")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_fabricRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
coreFile := filepath.Join("home", "user", "sdk", "fabric", "core", "zz_generated_models.go")
|
||||
want := filepath.Join("home", "user", "sdk", "fabric")
|
||||
|
||||
if got := fabricRoot(coreFile); got != want {
|
||||
t.Errorf("fabricRoot(%q) = %q, want %q", coreFile, got, want)
|
||||
}
|
||||
|
||||
notCore := filepath.Join("home", "user", "sdk", "fabric", "admin", "client.go")
|
||||
if got := fabricRoot(notCore); got != "" {
|
||||
t.Errorf("fabricRoot(%q) = %q, want empty (parent dir is not core)", notCore, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# spncheck
|
||||
|
||||
`spncheck` verifies that every Terraform resource/data source is correctly
|
||||
marked as **supporting service principal (SPN) authentication vs. not**. It
|
||||
cross-checks each service package's declared support (`IsSPNSupported` in
|
||||
`base.go`'s `ItemTypeInfo`) against the actual service-principal support of the
|
||||
[`fabric-sdk-go`](https://github.com/microsoft/fabric-sdk-go) APIs the package
|
||||
calls.
|
||||
|
||||
It shares its scanning engine with [`previewcheck`](../previewcheck/): both tools
|
||||
resolve SDK call sites, read SDK doc comments, and compare the result against a
|
||||
declared `ItemTypeInfo` flag via the common
|
||||
[`tools/internal/toolutil`](../internal/toolutil/) package. Only the marker they
|
||||
look for (and the declared field they compare) differ.
|
||||
|
||||
## How it works
|
||||
|
||||
A package should be `IsSPNSupported = true` only when **every** SDK client
|
||||
function it invokes supports service principal. Each SDK client function
|
||||
documents its supported identities in a table under this heading in the doc
|
||||
comment directly above the function:
|
||||
|
||||
```text
|
||||
MICROSOFT ENTRA SUPPORTED IDENTITIES
|
||||
| Identity | Support | |-|-| | User | Yes | | Service principal ... | Yes |
|
||||
```
|
||||
|
||||
The **Service principal** row's Support cell is one of:
|
||||
|
||||
- `Yes` — the API supports service principal
|
||||
- `No` — the API does **not** support service principal
|
||||
- a conditional sentence — supported only under stated conditions
|
||||
|
||||
`spncheck` resolves each SDK call site to the exact SDK function (using the Go
|
||||
type checker, so import aliases and receivers resolve correctly), reads its
|
||||
identity table, and flags the package when **any** called API has a hard `No`.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
go run ./tools/spncheck # report findings, exit 1 if any
|
||||
go run ./tools/spncheck -dir DIR # scan a different services directory
|
||||
go run ./tools/spncheck -exclusions PATH # use a specific exclusions file
|
||||
```
|
||||
|
||||
Exit codes: `0` = all consistent, `1` = mismatch (or stale exclusion) found,
|
||||
`2` = error.
|
||||
|
||||
## Reading the output
|
||||
|
||||
Findings are split by confidence, because the SDK's identity tables are not
|
||||
always present (a missing table does **not** prove an API supports SPN):
|
||||
|
||||
- **OVER-MARKED** (high confidence) — declared `IsSPNSupported = true`, but a
|
||||
called SDK API is documented as **not** supporting service principal. The item
|
||||
**should** be `false`. Fix these.
|
||||
- **REVIEW** (low confidence) — declared `IsSPNSupported = false`, but every
|
||||
called API supports service principal. *Possibly* promotable to `true`, but
|
||||
confirm manually.
|
||||
- **EXCLUDED** — a failing item suppressed via `exclusions.yaml` (see below).
|
||||
- **STALE EXCLUSIONS** — an excluded item that is no longer a mismatch; remove
|
||||
the entry. Stale entries also fail the run (exit `1`).
|
||||
- **UNDETERMINED** — no `fabric-sdk-go` calls found (e.g. generic `fabricitem`
|
||||
resources whose CRUD runs through the shared abstraction). Informational.
|
||||
|
||||
## Acting on a failure
|
||||
|
||||
- **OVER-MARKED** → set `IsSPNSupported = false` on the item's `ItemTypeInfo` in
|
||||
its `base.go`, **or** — if the item genuinely supports SPN and the SDK
|
||||
annotation is stale/conservative — exclude it (see below).
|
||||
- **REVIEW** → manually verify against Fabric docs; flip `IsSPNSupported` to
|
||||
`true` only if every operation truly supports service principal.
|
||||
|
||||
If an item calls an SDK package whose directory can't be derived from the item
|
||||
name automatically, add a mapping to `sdkPackageOverrides` in
|
||||
[`main.go`](./main.go).
|
||||
|
||||
## Excluding items with stale SDK annotations
|
||||
|
||||
The SDK sometimes annotates an API more conservatively than reality. That makes
|
||||
an item show up as **OVER-MARKED** even though keeping it `true` is correct. List
|
||||
such items in [`exclusions.yaml`](./exclusions.yaml) with a reason:
|
||||
|
||||
```yaml
|
||||
exclusions:
|
||||
- service: someitem
|
||||
reason: SPN-supported; someclient.DoThing carries a stale SDK identity annotation
|
||||
```
|
||||
|
||||
`service` is the service package name (the value shown in the report). An
|
||||
excluded item moves to the **EXCLUDED** section instead of failing the run.
|
||||
Exclusion is deliberately narrow — a stale entry (the item is no longer a
|
||||
mismatch, e.g. the SDK corrected the annotation) is reported as **STALE** so it
|
||||
can be removed. Note that a service-level exclusion also suppresses *future*
|
||||
genuine non-SPN APIs that item may start calling, so keep the list minimal and
|
||||
reasoned.
|
||||
|
||||
The tool's unit tests run via `task test:gaptools`.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Exclusions for spncheck — service packages whose service-principal support
|
||||
# should NOT be flagged, typically items whose called fabric-sdk-go APIs carry a
|
||||
# stale or overly conservative identity annotation in their doc comments.
|
||||
#
|
||||
# Format: service (the service package name) + reason.
|
||||
# When the SDK later corrects the annotation (so the service is no longer a
|
||||
# mismatch), spncheck reports the entry as STALE so it can be removed.
|
||||
|
||||
exclusions: []
|
||||
@@ -0,0 +1,456 @@
|
||||
// Copyright Microsoft Corporation 2026
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Command spncheck audits every Terraform resource/data source service package
|
||||
// and verifies that its declared service-principal support (the IsSPNSupported
|
||||
// field in base.go's ItemTypeInfo) matches the actual service-principal support
|
||||
// of the Microsoft Fabric Go SDK APIs that the package calls.
|
||||
//
|
||||
// An item supports service principal (IsSPNSupported = true) only when ALL of
|
||||
// the fabric-sdk-go client functions it invokes support service principal. Each
|
||||
// SDK client function documents its supported identities in a table under the
|
||||
// following heading in the doc comment directly above the function:
|
||||
//
|
||||
// MICROSOFT ENTRA SUPPORTED IDENTITIES
|
||||
// | Identity | Support | |-|-| | User | Yes | | Service principal ... | Yes |
|
||||
//
|
||||
// The "Service principal" row's Support cell is one of:
|
||||
//
|
||||
// Yes -> the API supports service principal
|
||||
// No -> the API does NOT support service principal
|
||||
// <conditional sentence> -> supported only under certain conditions
|
||||
//
|
||||
// spncheck flags an item when ANY called SDK API has a hard "No" in its Service
|
||||
// principal cell: that item cannot fully support service principal. Because the
|
||||
// SDK's identity tables are not always present, findings are split by confidence:
|
||||
//
|
||||
// - OVER-MARKED (high confidence): declared IsSPNSupported = true but a called
|
||||
// API is documented as NOT supporting service principal. Should be false.
|
||||
// - REVIEW (low confidence): declared IsSPNSupported = false but every called
|
||||
// API supports service principal. Possibly promotable to true, but requires
|
||||
// manual confirmation because SDK annotations are incomplete.
|
||||
// - UNDETERMINED: no fabric-sdk-go calls found in the package (e.g. generic
|
||||
// fabricitem resources whose CRUD runs through the shared abstraction).
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./tools/spncheck # report findings, exit 1 if any
|
||||
// go run ./tools/spncheck -dir DIR # scan a different services directory
|
||||
// go run ./tools/spncheck -exclusions PATH # use a specific exclusions file
|
||||
//
|
||||
// An item whose called SDK API carries a stale or overly conservative identity
|
||||
// annotation can be suppressed by listing its service package in exclusions.yaml
|
||||
// (see that file).
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
|
||||
"github.com/microsoft/terraform-provider-fabric/tools/internal/toolutil"
|
||||
)
|
||||
|
||||
// spnSupport is the parsed service-principal support state of a single SDK API,
|
||||
// derived from the "Service principal" row of its MICROSOFT ENTRA SUPPORTED
|
||||
// IDENTITIES table.
|
||||
type spnSupport int
|
||||
|
||||
const (
|
||||
spnUnknown spnSupport = iota // no identity table (or unparsable)
|
||||
spnYes // "| Yes |"
|
||||
spnNo // "| No |"
|
||||
spnConditional // supported only under stated conditions
|
||||
)
|
||||
|
||||
// sdkPackageOverrides maps a FabricItemType item name to its dedicated
|
||||
// fabric-sdk-go package directory when the default lowercase derivation does not
|
||||
// match. Add entries here for any item whose SDK package cannot be resolved
|
||||
// automatically.
|
||||
var sdkPackageOverrides = map[string]string{ //nolint:gochecknoglobals
|
||||
"Map": "maps",
|
||||
}
|
||||
|
||||
// exit codes.
|
||||
const (
|
||||
exitOK = 0
|
||||
exitMismatch = 1
|
||||
exitError = 2
|
||||
)
|
||||
|
||||
// CLI flags.
|
||||
var (
|
||||
dirFlag = flag.String("dir", toolutil.DefaultServicesDir, "services directory to scan (relative to the module root)") //nolint:gochecknoglobals
|
||||
exclusionsFlag = flag.String("exclusions", "", "path to exclusions YAML file (default: auto-detected)") //nolint:gochecknoglobals
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
os.Exit(run())
|
||||
}
|
||||
|
||||
func run() int {
|
||||
root, err := toolutil.ModuleRoot()
|
||||
if err != nil {
|
||||
toolutil.Errf("error: %v\n", err)
|
||||
|
||||
return exitError
|
||||
}
|
||||
|
||||
pkgs, err := toolutil.LoadServicePackages(root, *dirFlag)
|
||||
if err != nil {
|
||||
toolutil.Errf("error loading packages: %v\n", err)
|
||||
|
||||
return exitError
|
||||
}
|
||||
|
||||
exclusionsPath := *exclusionsFlag
|
||||
if exclusionsPath == "" {
|
||||
exclusionsPath = filepath.Join(root, "tools", "spncheck", "exclusions.yaml")
|
||||
}
|
||||
|
||||
exclusions, err := toolutil.LoadExclusions(exclusionsPath)
|
||||
if err != nil {
|
||||
toolutil.Errf("error loading exclusions: %v\n", err)
|
||||
|
||||
return exitError
|
||||
}
|
||||
|
||||
dc := toolutil.NewDocCache()
|
||||
|
||||
results := make([]result, 0, len(pkgs))
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
if len(pkg.Errors) > 0 {
|
||||
for _, e := range pkg.Errors {
|
||||
toolutil.Errf("package %s: %v\n", pkg.PkgPath, e)
|
||||
}
|
||||
|
||||
return exitError
|
||||
}
|
||||
|
||||
res, ok := analyzePackage(pkg, dc)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, res)
|
||||
}
|
||||
|
||||
slices.SortFunc(results, func(a, b result) int { return strings.Compare(a.service, b.service) })
|
||||
|
||||
return report(results, exclusions)
|
||||
}
|
||||
|
||||
// result captures the analysis outcome for a single service package.
|
||||
type result struct {
|
||||
service string // package name (e.g. "lakehouse")
|
||||
declared bool // current IsSPNSupported value
|
||||
hasDeclaration bool // ItemTypeInfo.IsSPNSupported was found
|
||||
sdkCalls int // number of distinct SDK funcs called
|
||||
nonSPNAPIs []string // SDK funcs documented as NOT supporting SPN (pkg.Func)
|
||||
}
|
||||
|
||||
// expected returns the IsSPNSupported value implied by the SDK usage: an item
|
||||
// supports service principal only when none of its called APIs is a hard "No".
|
||||
func (r result) expected() bool { return len(r.nonSPNAPIs) == 0 }
|
||||
|
||||
// determinable is true when at least one SDK API call was found, so the
|
||||
// expected status can be trusted.
|
||||
func (r result) determinable() bool { return r.sdkCalls > 0 }
|
||||
|
||||
func report(results []result, exclusions map[string]string) int {
|
||||
b := categorize(results, exclusions)
|
||||
|
||||
for _, r := range b.undeclared {
|
||||
toolutil.Outf("%-40s no ItemTypeInfo.IsSPNSupported field found\n", r.service)
|
||||
}
|
||||
|
||||
printOverMarked(b.overMarked)
|
||||
|
||||
printReview(b.review)
|
||||
|
||||
printExcluded(b.excluded)
|
||||
|
||||
printStaleExclusions(b.staleExcl)
|
||||
|
||||
toolutil.Outf("\nScanned %d services: %d over-marked, %d to review, %d undeclared, %d excluded, %d stale, %d undetermined\n",
|
||||
len(results), len(b.overMarked), len(b.review), len(b.undeclared), len(b.excluded), len(b.staleExcl), b.undetermined)
|
||||
|
||||
if len(b.overMarked) > 0 || len(b.review) > 0 || len(b.undeclared) > 0 || len(b.staleExcl) > 0 {
|
||||
return exitMismatch
|
||||
}
|
||||
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// category is the service-principal-support classification of a single service.
|
||||
type category int
|
||||
|
||||
const (
|
||||
catConsistent category = iota // declared status matches SDK usage
|
||||
catUndetermined // no SDK calls, status not determinable
|
||||
catOverMarked // declared SPN-supported but a called API is not
|
||||
catReview // declared unsupported but every called API supports SPN
|
||||
catUndeclared // no ItemTypeInfo.IsSPNSupported field found
|
||||
)
|
||||
|
||||
// classify returns the service-principal-support category for a single result.
|
||||
func classify(r result) category {
|
||||
if !r.hasDeclaration {
|
||||
return catUndeclared
|
||||
}
|
||||
|
||||
if !r.determinable() {
|
||||
return catUndetermined
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.declared && !r.expected():
|
||||
return catOverMarked
|
||||
case !r.declared && r.expected():
|
||||
return catReview
|
||||
default:
|
||||
return catConsistent
|
||||
}
|
||||
}
|
||||
|
||||
// excludedResult is a failing service suppressed by the exclusions file.
|
||||
type excludedResult struct {
|
||||
res result
|
||||
reason string
|
||||
}
|
||||
|
||||
// buckets groups categorized services by confidence level.
|
||||
type buckets struct {
|
||||
overMarked []result // declared SPN-supported but a called API is not
|
||||
review []result // declared unsupported but every called API supports SPN
|
||||
undeclared []result // no ItemTypeInfo.IsSPNSupported field found
|
||||
excluded []excludedResult // failing but suppressed via exclusions.yaml
|
||||
staleExcl []string // excluded services that no longer mismatch
|
||||
undetermined int // no SDK calls, status not determinable
|
||||
}
|
||||
|
||||
// categorize splits services into over-marked, review, undeclared, excluded,
|
||||
// and undetermined groups, routing failing services listed in exclusions into
|
||||
// the excluded bucket and flagging exclusions that no longer apply as stale.
|
||||
func categorize(results []result, exclusions map[string]string) buckets {
|
||||
var b buckets
|
||||
|
||||
used := make(map[string]struct{})
|
||||
|
||||
for _, r := range results {
|
||||
switch cat := classify(r); cat {
|
||||
case catUndetermined:
|
||||
b.undetermined++
|
||||
case catConsistent:
|
||||
// declared status is correct; nothing to report.
|
||||
default:
|
||||
b.addFailing(r, cat, exclusions, used)
|
||||
}
|
||||
}
|
||||
|
||||
for svc := range exclusions {
|
||||
if _, ok := used[svc]; !ok {
|
||||
b.staleExcl = append(b.staleExcl, svc)
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(b.staleExcl)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// addFailing routes a failing result into the excluded bucket (if its service
|
||||
// is listed in exclusions) or its failing-category bucket.
|
||||
func (b *buckets) addFailing(r result, cat category, exclusions map[string]string, used map[string]struct{}) {
|
||||
if reason, ok := exclusions[r.service]; ok {
|
||||
b.excluded = append(b.excluded, excludedResult{res: r, reason: reason})
|
||||
used[r.service] = struct{}{}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch cat {
|
||||
case catOverMarked:
|
||||
b.overMarked = append(b.overMarked, r)
|
||||
case catReview:
|
||||
b.review = append(b.review, r)
|
||||
case catUndeclared:
|
||||
b.undeclared = append(b.undeclared, r)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func printOverMarked(overMarked []result) {
|
||||
if len(overMarked) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
toolutil.Outf("\nOVER-MARKED — declared SPN-supported but the SDK marks a called API as NOT supporting service principal (should be IsSPNSupported = false):\n")
|
||||
|
||||
for _, r := range overMarked {
|
||||
toolutil.Outf(" ✗ %-38s\n", r.service)
|
||||
printNonSPNAPIs(r)
|
||||
}
|
||||
}
|
||||
|
||||
func printReview(review []result) {
|
||||
if len(review) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
toolutil.Outf("\nREVIEW — declared NOT SPN-supported but every called API supports service principal (possibly IsSPNSupported = true, confirm manually):\n")
|
||||
|
||||
for _, r := range review {
|
||||
toolutil.Outf(" ? %-38s\n", r.service)
|
||||
}
|
||||
}
|
||||
|
||||
func printExcluded(excluded []excludedResult) {
|
||||
if len(excluded) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
toolutil.Outf("\nEXCLUDED — suppressed via exclusions.yaml (confirmed intentional):\n")
|
||||
|
||||
for _, e := range excluded {
|
||||
toolutil.Outf(" - %-38s %s\n", e.res.service, e.reason)
|
||||
}
|
||||
}
|
||||
|
||||
func printStaleExclusions(stale []string) {
|
||||
if len(stale) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
toolutil.Outf("\nSTALE EXCLUSIONS — no longer mismatched, remove from exclusions.yaml:\n")
|
||||
|
||||
for _, svc := range stale {
|
||||
toolutil.Outf(" %s\n", svc)
|
||||
}
|
||||
}
|
||||
|
||||
func printNonSPNAPIs(r result) {
|
||||
if len(r.nonSPNAPIs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, api := range r.nonSPNAPIs {
|
||||
toolutil.Outf(" non-SPN API: %s\n", api)
|
||||
}
|
||||
}
|
||||
|
||||
// analyzePackage inspects a service package: it locates ItemTypeInfo.IsSPNSupported
|
||||
// and collects every fabric-sdk-go function it calls, flagging those that do not
|
||||
// support service principal.
|
||||
func analyzePackage(pkg *packages.Package, dc *toolutil.DocCache) (result, bool) {
|
||||
res := result{service: toolutil.PkgName(pkg)}
|
||||
|
||||
if v, ok := toolutil.ExtractItemTypeInfoBool(pkg, "IsSPNSupported"); ok {
|
||||
res.hasDeclaration = true
|
||||
res.declared = v
|
||||
}
|
||||
|
||||
nonSPNSet := map[string]struct{}{}
|
||||
|
||||
for _, call := range toolutil.CollectSDKCalls(pkg) {
|
||||
res.sdkCalls++
|
||||
|
||||
if parseSPNSupport(dc.DocCommentAt(call.Pos)) == spnNo {
|
||||
nonSPNSet[call.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: generic fabricitem resources make no direct SDK calls in-package.
|
||||
// Resolve their FabricItemType to the dedicated SDK package and scan its
|
||||
// exported client methods for service-principal support.
|
||||
if res.sdkCalls == 0 {
|
||||
determineViaItemType(pkg, dc, &res, nonSPNSet)
|
||||
}
|
||||
|
||||
res.nonSPNAPIs = toolutil.SortedKeys(nonSPNSet)
|
||||
|
||||
// Skip packages that have neither a declaration nor any relevance.
|
||||
if !res.hasDeclaration && res.sdkCalls == 0 {
|
||||
return res, false
|
||||
}
|
||||
|
||||
return res, true
|
||||
}
|
||||
|
||||
// determineViaItemType resolves the FabricItemType constant to its dedicated
|
||||
// fabric-sdk-go package and scans that package's exported client methods for
|
||||
// service-principal support, closing the gap for generic fabricitem resources.
|
||||
func determineViaItemType(pkg *packages.Package, dc *toolutil.DocCache, res *result, nonSPNSet map[string]struct{}) {
|
||||
pkgDir, item, ok := toolutil.SDKPackageDir(pkg, sdkPackageOverrides)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
methods := dc.ScanItemCRUD(pkgDir, item)
|
||||
if len(methods) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
res.sdkCalls += len(methods)
|
||||
|
||||
for _, m := range methods {
|
||||
if parseSPNSupport(m.Doc) == spnNo {
|
||||
nonSPNSet[strings.ToLower(item)+"."+m.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseSPNSupport reads the "Service principal" row of the MICROSOFT ENTRA
|
||||
// SUPPORTED IDENTITIES table in doc and returns its support state. It normalizes
|
||||
// the doc by stripping all whitespace (the table wraps across comment lines) and
|
||||
// inspecting the support cell that immediately follows the "service principal"
|
||||
// identity label.
|
||||
func parseSPNSupport(doc string) spnSupport {
|
||||
// Collapse all whitespace so the wrapped, multi-line table becomes a single
|
||||
// contiguous string that is trivial to slice on the "|" cell delimiters.
|
||||
norm := strings.Join(strings.Fields(strings.ToLower(doc)), "")
|
||||
|
||||
if !strings.Contains(norm, "microsoftentrasupportedidentities") {
|
||||
return spnUnknown
|
||||
}
|
||||
|
||||
// Use LastIndex: "service principal" can appear in free-text (e.g.
|
||||
// PERMISSIONS lines) before the table. The last occurrence is always the
|
||||
// one inside the actual identity table row.
|
||||
idx := strings.LastIndex(norm, "serviceprincipal")
|
||||
if idx < 0 {
|
||||
return spnUnknown
|
||||
}
|
||||
|
||||
// The support cell is the first "| value |" after the (bracketed) identity
|
||||
// label. Identity labels and URL references never contain "|".
|
||||
rest := norm[idx:]
|
||||
|
||||
_, after, ok := strings.Cut(rest, "|")
|
||||
if !ok {
|
||||
return spnUnknown
|
||||
}
|
||||
|
||||
cell := after
|
||||
|
||||
before0, _, ok0 := strings.Cut(cell, "|")
|
||||
if !ok0 {
|
||||
return spnUnknown
|
||||
}
|
||||
|
||||
switch before0 {
|
||||
case "yes":
|
||||
return spnYes
|
||||
case "no":
|
||||
return spnNo
|
||||
default:
|
||||
return spnConditional
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright Microsoft Corporation 2026
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_parseSPNSupport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const heading = "// MICROSOFT ENTRA SUPPORTED IDENTITIES This API supports the Microsoft identities listed in this section.\n"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
doc string
|
||||
want spnSupport
|
||||
}{
|
||||
{
|
||||
"service principal yes",
|
||||
heading + "// | Identity | Support | |-|-| | User | Yes | | Service principal [/entra/foo] and Managed identities [/entra/bar] | Yes |",
|
||||
spnYes,
|
||||
},
|
||||
{
|
||||
"service principal no",
|
||||
heading + "// | Identity | Support | |-|-| | User | Yes | | Service principal [/entra/foo] | No |",
|
||||
spnNo,
|
||||
},
|
||||
{
|
||||
"conditional support",
|
||||
heading + "// | Identity | Support | |-|-| | User | Yes | | Service principal [/entra/foo] and Managed identities [/entra/bar] | When the item type in the call is supported. |",
|
||||
spnConditional,
|
||||
},
|
||||
{
|
||||
"table wrapped across comment lines",
|
||||
heading +
|
||||
"// | Identity | Support | |-|-| | User | Yes | | Service principal [/entra/foo]\n" +
|
||||
"// and Managed identities\n" +
|
||||
"// [/entra/bar] | No |",
|
||||
spnNo,
|
||||
},
|
||||
{
|
||||
"no identity table",
|
||||
"// GetFoo retrieves a foo. It does not document identities.",
|
||||
spnUnknown,
|
||||
},
|
||||
{
|
||||
"heading present but no service principal row",
|
||||
heading + "// | Identity | Support | |-|-| | User | Yes |",
|
||||
spnUnknown,
|
||||
},
|
||||
{
|
||||
"service principal in free text before table",
|
||||
"// PERMISSIONS The caller must authenticate using a service principal.\n" +
|
||||
heading +
|
||||
"// | Identity | Support | |-|-| | User | Yes | | Service principal [/entra/foo] | Yes |",
|
||||
spnYes,
|
||||
},
|
||||
{
|
||||
"empty",
|
||||
"",
|
||||
spnUnknown,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := parseSPNSupport(tt.doc); got != tt.want {
|
||||
t.Errorf("parseSPNSupport() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_categorize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
results := []result{
|
||||
{service: "no-decl", hasDeclaration: false},
|
||||
{service: "undetermined", hasDeclaration: true, sdkCalls: 0},
|
||||
{service: "over-marked", hasDeclaration: true, sdkCalls: 2, declared: true, nonSPNAPIs: []string{"core.GetX"}},
|
||||
{service: "review", hasDeclaration: true, sdkCalls: 2, declared: false, nonSPNAPIs: nil},
|
||||
{service: "correct-supported", hasDeclaration: true, sdkCalls: 2, declared: true, nonSPNAPIs: nil},
|
||||
{service: "correct-unsupported", hasDeclaration: true, sdkCalls: 2, declared: false, nonSPNAPIs: []string{"core.GetX"}},
|
||||
}
|
||||
|
||||
b := categorize(results, nil)
|
||||
|
||||
if len(b.undeclared) != 1 || b.undeclared[0].service != "no-decl" {
|
||||
t.Errorf("undeclared = %+v, want [no-decl]", b.undeclared)
|
||||
}
|
||||
|
||||
if b.undetermined != 1 {
|
||||
t.Errorf("undetermined = %d, want 1", b.undetermined)
|
||||
}
|
||||
|
||||
if len(b.overMarked) != 1 || b.overMarked[0].service != "over-marked" {
|
||||
t.Errorf("overMarked = %+v, want [over-marked]", b.overMarked)
|
||||
}
|
||||
|
||||
if len(b.review) != 1 || b.review[0].service != "review" {
|
||||
t.Errorf("review = %+v, want [review]", b.review)
|
||||
}
|
||||
|
||||
if len(b.excluded) != 0 || len(b.staleExcl) != 0 {
|
||||
t.Errorf("excluded/stale should be empty without exclusions, got %+v / %+v", b.excluded, b.staleExcl)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_categorize_exclusions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
results := []result{
|
||||
{service: "over-marked", hasDeclaration: true, sdkCalls: 2, declared: true, nonSPNAPIs: []string{"core.GetX"}},
|
||||
{service: "review", hasDeclaration: true, sdkCalls: 2, declared: false, nonSPNAPIs: nil},
|
||||
{service: "correct-supported", hasDeclaration: true, sdkCalls: 2, declared: true, nonSPNAPIs: nil},
|
||||
}
|
||||
|
||||
exclusions := map[string]string{
|
||||
"over-marked": "SPN-supported, stale SDK annotation",
|
||||
"correct-supported": "stale exclusion, service is already consistent",
|
||||
}
|
||||
|
||||
b := categorize(results, exclusions)
|
||||
|
||||
if len(b.overMarked) != 0 {
|
||||
t.Errorf("overMarked = %+v, want empty (excluded)", b.overMarked)
|
||||
}
|
||||
|
||||
if len(b.excluded) != 1 || b.excluded[0].res.service != "over-marked" {
|
||||
t.Errorf("excluded = %+v, want [over-marked]", b.excluded)
|
||||
}
|
||||
|
||||
if b.excluded[0].reason != "SPN-supported, stale SDK annotation" {
|
||||
t.Errorf("excluded reason = %q, want the configured reason", b.excluded[0].reason)
|
||||
}
|
||||
|
||||
if len(b.review) != 1 || b.review[0].service != "review" {
|
||||
t.Errorf("review = %+v, want [review]", b.review)
|
||||
}
|
||||
|
||||
if !slices.Contains(b.staleExcl, "correct-supported") {
|
||||
t.Errorf("staleExcl = %+v, want to contain correct-supported", b.staleExcl)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_classify(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
res result
|
||||
want category
|
||||
}{
|
||||
{"undeclared", result{hasDeclaration: false}, catUndeclared},
|
||||
{"undetermined", result{hasDeclaration: true, sdkCalls: 0}, catUndetermined},
|
||||
{"over-marked", result{hasDeclaration: true, sdkCalls: 1, declared: true, nonSPNAPIs: []string{"core.X"}}, catOverMarked},
|
||||
{"review", result{hasDeclaration: true, sdkCalls: 1, declared: false}, catReview},
|
||||
{"consistent-supported", result{hasDeclaration: true, sdkCalls: 1, declared: true}, catConsistent},
|
||||
{"consistent-unsupported", result{hasDeclaration: true, sdkCalls: 1, declared: false, nonSPNAPIs: []string{"core.X"}}, catConsistent},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := classify(tt.res); got != tt.want {
|
||||
t.Errorf("%s: classify = %d, want %d", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Test_result_expected_determinable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !(result{}).expected() {
|
||||
t.Error("result with no non-SPN APIs should be expected SPN-supported")
|
||||
}
|
||||
|
||||
if (result{nonSPNAPIs: []string{"core.GetX"}}).expected() {
|
||||
t.Error("result with a non-SPN API should not be expected SPN-supported")
|
||||
}
|
||||
|
||||
if (result{sdkCalls: 0}).determinable() {
|
||||
t.Error("result with no SDK calls should not be determinable")
|
||||
}
|
||||
|
||||
if !(result{sdkCalls: 1}).determinable() {
|
||||
t.Error("result with SDK calls should be determinable")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user