mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-27 18:11:29 +02:00
## Describe your changes This adds an extension point to the management server for registering additional gRPC services. We already have a generic integrations system and dependency injection for server components. This closes the gap on being able to also extend the gRPC API cleanly. ## Issue ticket number and link N/A ## Stack <!-- branch-stack --> ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) No docs needed. This is strictly a small internal plumbing enhancement / refactor. <!-- codesmith:footer --> --- <a href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6894"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img alt="View with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a> <a href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787582002&installation_model_id=427504&pr_number=6894&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6894&signature=3288061677db243031830964fec8f0f34c82f7fc63a39298cd0b4e3490551060"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img alt="Autofix with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a> <sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you need. Autofix is disabled.</sup> <!-- codesmith:autofix:disabled --> <!-- /codesmith:footer --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a gRPC extension mechanism to contribute additional services and automatically chain extra unary and stream interceptors. * Extension shutdown hooks now run as part of server stop. * Added exported proxy token generation via `GenerateProxyToken()` for external integrations. * **Tests** * Added coverage for extension interceptor/service wiring, extension shutdown execution, and proxy token generation validation (including hash consistency and prefix). <!-- end of auto-generated comment: release notes by coderabbit.ai -->
161 lines
5.0 KiB
Go
161 lines
5.0 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/health"
|
|
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
|
|
"google.golang.org/grpc/test/bufconn"
|
|
)
|
|
|
|
// Test that an extension's interceptors and service registration are actually
|
|
// wired onto a real in-process gRPC server via the helpers, and that shutdown
|
|
// hooks run. This validates the load-bearing assumption that
|
|
// grpc.ChainUnaryInterceptor is additive (extension interceptors run in
|
|
// addition to any base chain).
|
|
func TestGRPCExtensionAppliedToServer(t *testing.T) {
|
|
var unaryCalls atomic.Int32
|
|
var streamShutdownCalled atomic.Bool
|
|
|
|
ext := GRPCExtension{
|
|
Register: func(reg grpc.ServiceRegistrar) {
|
|
healthgrpc.RegisterHealthServer(reg, health.NewServer())
|
|
},
|
|
UnaryInterceptors: []grpc.UnaryServerInterceptor{
|
|
func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
|
unaryCalls.Add(1)
|
|
return handler(ctx, req)
|
|
},
|
|
},
|
|
Shutdown: func(ctx context.Context) { streamShutdownCalled.Store(true) },
|
|
}
|
|
exts := []GRPCExtension{ext}
|
|
|
|
// Base options mimic GRPCServer(): a pre-existing chain the extension appends to.
|
|
var baseUnaryCalls atomic.Int32
|
|
opts := []grpc.ServerOption{
|
|
grpc.ChainUnaryInterceptor(func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
|
baseUnaryCalls.Add(1)
|
|
return handler(ctx, req)
|
|
}),
|
|
}
|
|
opts = appendExtensionInterceptors(opts, exts)
|
|
|
|
srv := grpc.NewServer(opts...)
|
|
registerExtensions(srv, exts)
|
|
|
|
lis := bufconn.Listen(1024 * 1024)
|
|
go func() { _ = srv.Serve(lis) }()
|
|
t.Cleanup(srv.Stop)
|
|
|
|
conn, err := grpc.NewClient("passthrough:///bufnet",
|
|
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }),
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
|
|
_, err = healthgrpc.NewHealthClient(conn).Check(context.Background(), &healthgrpc.HealthCheckRequest{})
|
|
if err != nil {
|
|
t.Fatalf("health check via extension-registered service failed: %v", err)
|
|
}
|
|
if baseUnaryCalls.Load() != 1 {
|
|
t.Errorf("base interceptor calls = %d, want 1 (base chain must be preserved)", baseUnaryCalls.Load())
|
|
}
|
|
if unaryCalls.Load() != 1 {
|
|
t.Errorf("extension interceptor calls = %d, want 1", unaryCalls.Load())
|
|
}
|
|
|
|
runExtensionShutdownHooks(context.Background(), exts)
|
|
if !streamShutdownCalled.Load() {
|
|
t.Error("extension shutdown hook was not called")
|
|
}
|
|
}
|
|
|
|
// TestGRPCExtensionShutdownHookReceivesCallerContext asserts that each hook receives
|
|
// a non-nil context and that it is the very same context the caller passed
|
|
// in, so hooks can rely on values/deadlines placed on it by Stop().
|
|
func TestGRPCExtensionShutdownHookReceivesCallerContext(t *testing.T) {
|
|
type sentinelKey struct{}
|
|
want := "shutdown-ctx-sentinel"
|
|
ctx := context.WithValue(context.Background(), sentinelKey{}, want)
|
|
|
|
var called bool
|
|
ext := GRPCExtension{
|
|
Shutdown: func(hookCtx context.Context) {
|
|
called = true
|
|
if hookCtx == nil {
|
|
t.Fatal("hook received a nil context")
|
|
}
|
|
got, _ := hookCtx.Value(sentinelKey{}).(string)
|
|
if got != want {
|
|
t.Errorf("hook context sentinel = %q, want %q (not the caller's context)", got, want)
|
|
}
|
|
},
|
|
}
|
|
|
|
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
|
|
if !called {
|
|
t.Fatal("shutdown hook was not called")
|
|
}
|
|
}
|
|
|
|
// TestGRPCExtensionShutdownHookObservesCancellation documents, by test, that
|
|
// hooks can honor cancellation/deadlines: a hook given an already-cancelled
|
|
// context must see ctx.Err() != nil and a closed Done() channel.
|
|
func TestGRPCExtensionShutdownHookObservesCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
var called bool
|
|
ext := GRPCExtension{
|
|
Shutdown: func(hookCtx context.Context) {
|
|
called = true
|
|
if hookCtx.Err() == nil {
|
|
t.Error("hook context Err() = nil, want non-nil for a cancelled context")
|
|
}
|
|
select {
|
|
case <-hookCtx.Done():
|
|
default:
|
|
t.Error("hook context Done() channel is not closed for a cancelled context")
|
|
}
|
|
},
|
|
}
|
|
|
|
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
|
|
if !called {
|
|
t.Fatal("shutdown hook was not called")
|
|
}
|
|
}
|
|
|
|
// TestGRPCExtensionShutdownHookNilSkipped asserts that an extension
|
|
// with a nil Shutdown hook is skipped without panicking, and that hooks for
|
|
// other extensions still run.
|
|
func TestGRPCExtensionShutdownHookNilSkipped(t *testing.T) {
|
|
var called atomic.Bool
|
|
exts := []GRPCExtension{
|
|
{Shutdown: nil},
|
|
{Shutdown: func(context.Context) { called.Store(true) }},
|
|
}
|
|
|
|
runExtensionShutdownHooks(context.Background(), exts)
|
|
if !called.Load() {
|
|
t.Error("shutdown hook for non-nil extension was not called")
|
|
}
|
|
}
|
|
|
|
func TestRegisterGRPCExtensionAccumulates(t *testing.T) {
|
|
s := &BaseServer{}
|
|
s.RegisterGRPCExtension(GRPCExtension{})
|
|
s.RegisterGRPCExtension(GRPCExtension{})
|
|
if len(s.grpcExtensions) != 2 {
|
|
t.Fatalf("grpcExtensions len = %d, want 2", len(s.grpcExtensions))
|
|
}
|
|
}
|