1
0
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:
Marek Siarkowicz
2026-01-10 09:02:17 +01:00
committed by GitHub
8 changed files with 426 additions and 1 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View 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

View 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

View 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

View 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
}