mirror of
https://github.com/etcd-io/etcd.git
synced 2026-02-05 06:46:49 +01:00
Merge pull request #21094 from aojea/grpc_experimental
verify grpc experimental apis
This commit is contained in:
6
Makefile
6
Makefile
@@ -99,7 +99,7 @@ fuzz:
|
||||
verify: verify-bom verify-lint verify-dep verify-shellcheck verify-mod-tidy \
|
||||
verify-shellws verify-proto-annotations verify-genproto verify-yamllint \
|
||||
verify-markdown-marker verify-go-versions verify-gomodguard \
|
||||
verify-go-workspace
|
||||
verify-go-workspace verify-grpc-experimental
|
||||
|
||||
.PHONY: fix
|
||||
fix: fix-mod-tidy fix-bom fix-lint fix-yamllint sync-toolchain-directive \
|
||||
@@ -224,6 +224,10 @@ verify-gomodguard:
|
||||
verify-go-workspace:
|
||||
PASSES="go_workspace" ./scripts/test.sh
|
||||
|
||||
.PHONY: verify-grpc-experimental
|
||||
verify-grpc-experimental:
|
||||
./scripts/verify_grpc_experimental.sh
|
||||
|
||||
.PHONY: sync-toolchain-directive
|
||||
sync-toolchain-directive:
|
||||
./scripts/sync_go_toolchain_directive.sh
|
||||
|
||||
@@ -723,6 +723,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"project": "golang.org/x/mod/semver",
|
||||
"licenses": [
|
||||
{
|
||||
"type": "BSD 3-clause \"New\" or \"Revised\" License",
|
||||
"confidence": 0.9663865546218487
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"project": "golang.org/x/net",
|
||||
"licenses": [
|
||||
@@ -768,6 +777,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"project": "golang.org/x/tools",
|
||||
"licenses": [
|
||||
{
|
||||
"type": "BSD 3-clause \"New\" or \"Revised\" License",
|
||||
"confidence": 0.9663865546218487
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"project": "google.golang.org/genproto/googleapis/api",
|
||||
"licenses": [
|
||||
|
||||
3
go.mod
3
go.mod
@@ -35,6 +35,7 @@ require (
|
||||
go.etcd.io/raft/v3 v3.6.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/time v0.14.0
|
||||
golang.org/x/tools v0.40.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
@@ -97,7 +98,9 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -200,6 +200,8 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -242,6 +244,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
34
scripts/verify_grpc_experimental.sh
Executable file
34
scripts/verify_grpc_experimental.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# Ensure we are at the root of the repo
|
||||
ROOT_DIR=$(git rev-parse --show-toplevel)
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
source ./scripts/test_lib.sh
|
||||
|
||||
TOOL_SRC="${ETCD_ROOT_DIR}/tools/check-grpc-experimental"
|
||||
ALLOWLIST="${TOOL_SRC}/allowlist.txt"
|
||||
|
||||
FAILURES=0
|
||||
|
||||
for MOD_DIR in $(module_dirs); do
|
||||
echo "------------------------------------------------"
|
||||
echo "Checking module: ${MOD_DIR}"
|
||||
pushd "${MOD_DIR}" > /dev/null
|
||||
if ! go run "${TOOL_SRC}" -allow-list="${ALLOWLIST}" ./...; then
|
||||
echo "ERROR: Experimental usage found in ${MOD_DIR}"
|
||||
FAILURES=$((FAILURES+1))
|
||||
fi
|
||||
popd > /dev/null
|
||||
done
|
||||
|
||||
echo "------------------------------------------------"
|
||||
if [ "$FAILURES" -eq 0 ]; then
|
||||
echo "SUCCESS: No experimental gRPC APIs found in any module."
|
||||
exit 0
|
||||
else
|
||||
echo "FAILURE: Found experimental gRPC API usage in ${FAILURES} module(s)."
|
||||
exit 1
|
||||
fi
|
||||
21
tools/check-grpc-experimental/allowlist.txt
Normal file
21
tools/check-grpc-experimental/allowlist.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
# Allowlist for experimental gRPC APIs
|
||||
# Format: PackageName.Symbol
|
||||
# Remove items from this list as they are migrated or stabilized.
|
||||
|
||||
grpc.NewContextWithServerTransportStream
|
||||
grpc.ServeHTTP
|
||||
grpc.WithResolvers
|
||||
resolver.Address
|
||||
resolver.BuildOptions
|
||||
resolver.Builder
|
||||
resolver.ClientConn
|
||||
resolver.Endpoint
|
||||
resolver.ParseServiceConfig
|
||||
resolver.ResolveNowOptions
|
||||
resolver.Resolver
|
||||
resolver.State
|
||||
resolver.Target
|
||||
resolver.URL
|
||||
resolver.UpdateState
|
||||
serviceconfig.Err
|
||||
serviceconfig.ParseResult
|
||||
16
tools/check-grpc-experimental/doc.go
Normal file
16
tools/check-grpc-experimental/doc.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright 2026 The etcd Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// check-grpc-experimental checks for experimental gRPC APIs in etcd.
|
||||
package main
|
||||
325
tools/check-grpc-experimental/main.go
Normal file
325
tools/check-grpc-experimental/main.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright 2026 The etcd Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
var (
|
||||
debugMode = flag.Bool("debug", false, "enable verbose debug logging")
|
||||
allowListFile = flag.String("allow-list", "", "path to a file containing allowed APIs (one per line)")
|
||||
)
|
||||
|
||||
// Map to store allowed signatures (e.g., "grpc.WithResolvers" -> true)
|
||||
var allowList = make(map[string]bool)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
patterns := flag.Args()
|
||||
if len(patterns) == 0 {
|
||||
patterns = []string{"./..."}
|
||||
}
|
||||
|
||||
if *allowListFile != "" {
|
||||
if err := loadAllowList(*allowListFile); err != nil {
|
||||
log.Fatalf("Failed to load allow list: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load source with type info.
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedImports | packages.NeedDeps,
|
||||
Tests: true,
|
||||
}
|
||||
|
||||
if *debugMode {
|
||||
log.Println("Loading packages...")
|
||||
}
|
||||
|
||||
pkgs, err := packages.Load(cfg, patterns...)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load packages: %v", err)
|
||||
}
|
||||
if n := packages.PrintErrors(pkgs); n > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *debugMode {
|
||||
log.Printf("Loaded %d packages. Scanning for gRPC usage...", len(pkgs))
|
||||
}
|
||||
|
||||
foundExperimental := false
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
for _, file := range pkg.Syntax {
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
sel, ok := n.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
obj := pkg.TypesInfo.Uses[sel.Sel]
|
||||
if obj == nil || obj.Pkg() == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Strict filter for gRPC
|
||||
if !strings.Contains(obj.Pkg().Path(), "google.golang.org/grpc") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check Allowlist
|
||||
// Construct the signature: PackageName.Symbol (e.g. "grpc.WithResolvers", "resolver.Address")
|
||||
signature := obj.Pkg().Name() + "." + obj.Name()
|
||||
if allowList[signature] {
|
||||
if *debugMode {
|
||||
log.Printf("Ignoring allowed usage: %s", signature)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if *debugMode {
|
||||
log.Printf("Checking reference: %s", signature)
|
||||
}
|
||||
|
||||
if isExperimental(pkg.Fset, obj) {
|
||||
pos := pkg.Fset.Position(sel.Pos())
|
||||
fmt.Printf("%s:%d:%d: usage of experimental gRPC API: %s\n",
|
||||
pos.Filename, pos.Line, pos.Column, signature)
|
||||
foundExperimental = true
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if foundExperimental {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
experimentalRegex = regexp.MustCompile(`(?i)(#\s*Experimental|All APIs in this package are experimental|This API is EXPERIMENTAL|is currently experimental)`)
|
||||
)
|
||||
|
||||
func loadAllowList(fpath string) error {
|
||||
f, err := os.Open(fpath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
allowList[line] = true
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func isExperimental(mainFset *token.FileSet, obj types.Object) bool {
|
||||
pos := obj.Pos()
|
||||
if !pos.IsValid() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get the absolute path to the dependency file
|
||||
position := mainFset.Position(pos)
|
||||
filename := position.Filename
|
||||
|
||||
// Skip if it's not a Go file (e.g. built-in types might have no file)
|
||||
if !strings.HasSuffix(filename, ".go") {
|
||||
return false
|
||||
}
|
||||
|
||||
if *debugMode {
|
||||
// Log checking definition
|
||||
log.Printf(" -> Definition found at: %s:%d", filename, position.Line)
|
||||
}
|
||||
|
||||
return checkFileForExperimental(filename, position.Line)
|
||||
}
|
||||
|
||||
// Cached file structure
|
||||
type cachedFile struct {
|
||||
f *ast.File
|
||||
fset *token.FileSet
|
||||
src []byte
|
||||
hasDocs bool
|
||||
}
|
||||
|
||||
var (
|
||||
cacheMu sync.RWMutex
|
||||
advancedCache = make(map[string]*cachedFile)
|
||||
)
|
||||
|
||||
func getParsedFile(filename string) (*cachedFile, error) {
|
||||
cacheMu.RLock()
|
||||
if v, ok := advancedCache[filename]; ok {
|
||||
cacheMu.RUnlock()
|
||||
return v, nil
|
||||
}
|
||||
cacheMu.RUnlock()
|
||||
|
||||
// Parse the file
|
||||
fset := token.NewFileSet()
|
||||
// We read the file content manually to help with debugging if needed
|
||||
src, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
if *debugMode {
|
||||
log.Printf("ERROR reading file %s: %v", filename, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := parser.ParseFile(fset, filename, src, parser.ParseComments|parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &cachedFile{f: f, fset: fset, src: src, hasDocs: f.Doc != nil}
|
||||
|
||||
cacheMu.Lock()
|
||||
advancedCache[filename] = res
|
||||
cacheMu.Unlock()
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func checkFileForExperimental(filename string, targetLine int) bool {
|
||||
cf, err := getParsedFile(filename)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// check package-level comments
|
||||
if cf.f.Doc != nil {
|
||||
if experimentalRegex.MatchString(cf.f.Doc.Text()) {
|
||||
if *debugMode {
|
||||
log.Printf(" -> [MATCH] Package experimental (doc in file): %s", filename)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// check package-level comment in doc.go
|
||||
// If the current file didn't have the experimental tag, check if a doc.go exists in the same folder
|
||||
dir := filepath.Dir(filename)
|
||||
docPath := filepath.Join(dir, "doc.go")
|
||||
// Only check doc.go if we aren't already looking at it
|
||||
if docPath != filename {
|
||||
if docContent, err := os.ReadFile(docPath); err == nil {
|
||||
if experimentalRegex.Match(docContent) {
|
||||
if *debugMode {
|
||||
log.Printf(" -> [MATCH] Package experimental (found in doc.go): %s", docPath)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check specific object comments
|
||||
found := false
|
||||
|
||||
ast.Inspect(cf.f, func(n ast.Node) bool {
|
||||
if found {
|
||||
return false
|
||||
}
|
||||
if n == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Helper to check a comment group
|
||||
checkDoc := func(doc *ast.CommentGroup, name string) {
|
||||
if doc != nil && experimentalRegex.MatchString(doc.Text()) {
|
||||
found = true
|
||||
if *debugMode {
|
||||
log.Printf(" -> [MATCH] Object experimental: %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch decl := n.(type) {
|
||||
case *ast.FuncDecl:
|
||||
// Match if the target line is within the function declaration lines
|
||||
// Actually, we want the definition line exactly, or close to it.
|
||||
start := cf.fset.Position(decl.Pos()).Line
|
||||
// The object.Pos() points to the name, not the 'func' keyword, usually.
|
||||
namePos := cf.fset.Position(decl.Name.Pos()).Line
|
||||
|
||||
if namePos == targetLine || start == targetLine {
|
||||
checkDoc(decl.Doc, decl.Name.Name)
|
||||
}
|
||||
|
||||
case *ast.GenDecl:
|
||||
// GenDecl covers `type X struct`, `var X`, `const X`
|
||||
// The GenDecl doc applies to all specs inside it usually.
|
||||
|
||||
// If the GenDecl itself starts on the line (e.g. `type ( ...`)
|
||||
// or if it contains our line.
|
||||
start := cf.fset.Position(decl.Pos()).Line
|
||||
end := cf.fset.Position(decl.End()).Line
|
||||
|
||||
if targetLine >= start && targetLine <= end {
|
||||
// Check the top-level GenDecl doc (e.g. "// Experimental\n var ( ... )")
|
||||
if decl.Doc != nil && experimentalRegex.MatchString(decl.Doc.Text()) {
|
||||
found = true
|
||||
if *debugMode {
|
||||
log.Printf(" -> [MATCH] GenDecl experimental block around line %d", targetLine)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Check individual specs
|
||||
for _, spec := range decl.Specs {
|
||||
switch s := spec.(type) {
|
||||
case *ast.TypeSpec:
|
||||
if cf.fset.Position(s.Name.Pos()).Line == targetLine {
|
||||
checkDoc(s.Doc, s.Name.Name)
|
||||
}
|
||||
case *ast.ValueSpec: // var/const
|
||||
for _, name := range s.Names {
|
||||
if cf.fset.Position(name.Pos()).Line == targetLine {
|
||||
checkDoc(s.Doc, name.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return found
|
||||
}
|
||||
Reference in New Issue
Block a user