Files
configcenter/internal/api/grpcapi/server.go

331 lines
11 KiB
Go

package grpcapi
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"unicode"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
authpkg "github.com/longpeng/configcenter/internal/auth"
"github.com/longpeng/configcenter/internal/domain"
"github.com/longpeng/configcenter/internal/gray"
metricspkg "github.com/longpeng/configcenter/internal/metrics"
runtimepkg "github.com/longpeng/configcenter/internal/runtime"
"github.com/longpeng/configcenter/internal/store"
"github.com/longpeng/configcenter/internal/watch"
configcenterv1 "github.com/longpeng/configcenter/pkg/proto/v1"
)
type Server struct {
configcenterv1.UnimplementedConfigServiceServer
configcenterv1.UnimplementedAdminServiceServer
store store.Store
runtime runtimepkg.Store
hub *watch.Hub
authorizer *authpkg.Manager
metrics *metricspkg.Collector
logger *slog.Logger
}
func New(repository store.Store, runtimeStore runtimepkg.Store, hub *watch.Hub, authorizer *authpkg.Manager, metrics *metricspkg.Collector, logger *slog.Logger) *Server {
return &Server{
store: repository,
runtime: runtimeStore,
hub: hub,
authorizer: authorizer,
metrics: metrics,
logger: logger,
}
}
func (s *Server) Register(registrar grpc.ServiceRegistrar) {
configcenterv1.RegisterConfigServiceServer(registrar, s)
configcenterv1.RegisterAdminServiceServer(registrar, s)
}
func (s *Server) UnaryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (response any, err error) {
started := time.Now()
defer func() {
s.metrics.ObserveGRPC(info.FullMethod, status.Code(err).String(), time.Since(started))
}()
if !publicMethod(info.FullMethod) {
ctx, err = s.authenticateContext(ctx)
if err != nil {
return nil, mapError(err)
}
}
return handler(ctx, req)
}
func (s *Server) StreamInterceptor(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) (err error) {
started := time.Now()
defer func() {
s.metrics.ObserveGRPC(info.FullMethod, status.Code(err).String(), time.Since(started))
}()
if publicMethod(info.FullMethod) {
return handler(srv, stream)
}
ctx, err := s.authenticateContext(stream.Context())
if err != nil {
return mapError(err)
}
return handler(srv, &contextServerStream{ServerStream: stream, ctx: ctx})
}
func (s *Server) GetConfig(ctx context.Context, request *configcenterv1.GetConfigRequest) (*configcenterv1.GetConfigResponse, error) {
key, appID, namespaceID, envID, err := s.resolveScope(ctx, request.GetEnv(), request.GetApp(), request.GetNamespace())
if err != nil {
return nil, mapError(err)
}
if err := s.authorizer.RequireAppRole(ctx, appID, authpkg.RoleViewer); err != nil {
return nil, mapError(err)
}
current, err := s.runtime.Get(ctx, key)
if err != nil {
return nil, s.internal("grpc get runtime config", err)
}
rules, err := s.store.ListGrayRules(ctx, appID, namespaceID, envID)
if err != nil {
return nil, mapError(err)
}
current.Items, current.GrayRuleIDs = gray.Apply(current.Items, rules, gray.Target{IP: strings.TrimSpace(request.GetIp()), Instance: strings.TrimSpace(request.GetInstance())})
s.metrics.RuntimeRead()
s.metrics.GrayMatched(len(current.GrayRuleIDs))
return &configcenterv1.GetConfigResponse{
Items: protoItems(current.Items),
Revision: current.Revision,
ReleaseVersion: int64(current.ReleaseVersion),
}, nil
}
func (s *Server) WatchConfig(request *configcenterv1.WatchConfigRequest, stream grpc.ServerStreamingServer[configcenterv1.ConfigEvent]) error {
if request.GetStartRevision() < 0 {
return status.Error(codes.InvalidArgument, "start_revision must be non-negative")
}
ctx := stream.Context()
key, appID, namespaceID, envID, err := s.resolveScope(ctx, request.GetEnv(), request.GetApp(), request.GetNamespace())
if err != nil {
return mapError(err)
}
if err := s.authorizer.RequireAppRole(ctx, appID, authpkg.RoleViewer); err != nil {
return mapError(err)
}
s.metrics.WatcherAdded()
defer s.metrics.WatcherRemoved()
updates := runtimepkg.StreamSnapshots(ctx, s.runtime, key, request.GetStartRevision())
refreshes, unsubscribe := s.hub.Subscribe(key, watch.Scope{ApplicationID: appID, NamespaceID: namespaceID, EnvironmentID: envID})
defer unsubscribe()
target := gray.Target{IP: strings.TrimSpace(request.GetIp()), Instance: strings.TrimSpace(request.GetInstance())}
for {
select {
case <-ctx.Done():
return nil
case event, open := <-updates:
if !open {
return nil
}
if err := s.sendConfigEvent(ctx, stream, event, appID, namespaceID, envID, target); err != nil {
return err
}
case event, open := <-refreshes:
if !open {
return nil
}
if !event.Force {
continue
}
if err := s.sendConfigEvent(ctx, stream, event, appID, namespaceID, envID, target); err != nil {
return err
}
}
}
}
func (s *Server) PublishConfig(ctx context.Context, request *configcenterv1.PublishRequest) (*configcenterv1.PublishResponse, error) {
if request.GetAppId() <= 0 || request.GetNamespaceId() <= 0 || request.GetEnvId() <= 0 {
return nil, status.Error(codes.InvalidArgument, "env_id, app_id and namespace_id must be positive")
}
if err := s.authorizer.RequireAppRole(ctx, request.GetAppId(), authpkg.RoleAppOwner); err != nil {
return nil, mapError(err)
}
release, err := s.store.Publish(ctx, domain.PublishRequest{
EnvironmentID: request.GetEnvId(),
AppID: request.GetAppId(),
NamespaceID: request.GetNamespaceId(),
Comment: strings.TrimSpace(request.GetComment()),
Operator: actor(ctx),
})
if err != nil {
return nil, mapError(err)
}
s.metrics.PublishAccepted()
return &configcenterv1.PublishResponse{ReleaseId: release.ID, ReleaseVersion: int64(release.Version), Status: release.Status}, nil
}
func (s *Server) RollbackConfig(ctx context.Context, request *configcenterv1.RollbackRequest) (*configcenterv1.RollbackResponse, error) {
if request.GetAppId() <= 0 || request.GetNamespaceId() <= 0 || request.GetEnvId() <= 0 || request.GetTargetVersion() <= 0 {
return nil, status.Error(codes.InvalidArgument, "env_id, app_id, namespace_id and target_version must be positive")
}
if err := s.authorizer.RequireAppRole(ctx, request.GetAppId(), authpkg.RoleAppOwner); err != nil {
return nil, mapError(err)
}
release, err := s.store.Rollback(ctx, domain.RollbackRequest{
EnvironmentID: request.GetEnvId(),
AppID: request.GetAppId(),
NamespaceID: request.GetNamespaceId(),
TargetVersion: int(request.GetTargetVersion()),
Operator: actor(ctx),
})
if err != nil {
return nil, mapError(err)
}
return &configcenterv1.RollbackResponse{ReleaseId: release.ID, NewReleaseVersion: int64(release.Version), Status: release.Status}, nil
}
func (s *Server) sendConfigEvent(ctx context.Context, stream grpc.ServerStreamingServer[configcenterv1.ConfigEvent], event domain.ConfigEvent, appID, namespaceID, envID int64, target gray.Target) error {
rules, err := s.store.ListGrayRules(ctx, appID, namespaceID, envID)
if err != nil {
return mapError(err)
}
items, matched := gray.Apply(event.Items, rules, target)
s.metrics.GrayMatched(len(matched))
eventType := configcenterv1.ConfigEvent_UPDATED
if event.Type == "FULL_SYNC" {
eventType = configcenterv1.ConfigEvent_FULL_SYNC
}
if err := stream.Send(&configcenterv1.ConfigEvent{Type: eventType, Items: protoItems(items), Revision: event.Revision}); err != nil {
return err
}
return nil
}
func (s *Server) resolveScope(ctx context.Context, env, app, namespace string) (string, int64, int64, int64, error) {
env = strings.ToLower(strings.TrimSpace(env))
app = strings.TrimSpace(app)
namespace = strings.TrimSpace(namespace)
if !validCode(env) || !validCode(app) || !validCode(namespace) {
return "", 0, 0, 0, status.Error(codes.InvalidArgument, "env, app and namespace have invalid format")
}
appID, namespaceID, envID, err := s.store.ResolveScope(ctx, env, app, namespace)
if err != nil {
return "", 0, 0, 0, err
}
return fmt.Sprintf("/config/%s/%s/%s", env, app, namespace), appID, namespaceID, envID, nil
}
func (s *Server) authenticateContext(ctx context.Context) (context.Context, error) {
values, _ := metadata.FromIncomingContext(ctx)
principal, err := s.authorizer.AuthenticateToken(bearerToken(values.Get("authorization")))
if err != nil {
return ctx, err
}
if !s.authorizer.Enabled() {
if users := values.Get("x-user"); len(users) > 0 {
if username := strings.TrimSpace(users[0]); username != "" && len(username) <= 64 {
principal.Username = username
principal.DisplayName = username
}
}
}
return authpkg.WithPrincipal(ctx, principal), nil
}
func bearerToken(values []string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if len(value) > len("Bearer ") && strings.EqualFold(value[:len("Bearer ")], "Bearer ") {
return strings.TrimSpace(value[len("Bearer "):])
}
}
return ""
}
func protoItems(items map[string]string) []*configcenterv1.ConfigItem {
keys := make([]string, 0, len(items))
for key := range items {
keys = append(keys, key)
}
sort.Strings(keys)
result := make([]*configcenterv1.ConfigItem, 0, len(keys))
for _, key := range keys {
result = append(result, &configcenterv1.ConfigItem{Key: key, Value: items[key]})
}
return result
}
func actor(ctx context.Context) string {
if principal, ok := authpkg.Principal(ctx); ok && principal.Username != "" {
return principal.Username
}
return "admin"
}
func validCode(value string) bool {
if value == "" || len(value) > 64 {
return false
}
for _, character := range value {
if unicode.IsLetter(character) || unicode.IsDigit(character) || character == '-' || character == '_' || character == '.' {
continue
}
return false
}
return true
}
func publicMethod(method string) bool {
return strings.HasPrefix(method, "/grpc.health.v1.Health/")
}
func mapError(err error) error {
if err == nil {
return nil
}
if _, ok := status.FromError(err); ok {
if status.Code(err) != codes.Unknown {
return err
}
}
switch {
case errors.Is(err, authpkg.ErrUnauthorized):
return status.Error(codes.Unauthenticated, "authentication required or token expired")
case errors.Is(err, authpkg.ErrForbidden):
return status.Error(codes.PermissionDenied, "permission denied")
case errors.Is(err, store.ErrNotFound):
return status.Error(codes.NotFound, "resource not found")
case errors.Is(err, store.ErrConflict):
return status.Error(codes.AlreadyExists, "resource already exists")
case errors.Is(err, store.ErrNoPendingChange):
return status.Error(codes.FailedPrecondition, "no pending configuration changes")
case errors.Is(err, store.ErrInvalidRollback):
return status.Error(codes.FailedPrecondition, "invalid rollback target")
default:
return status.Error(codes.Internal, "internal server error")
}
}
func (s *Server) internal(operation string, err error) error {
s.logger.Error(operation, "error", err)
return status.Error(codes.Internal, "internal server error")
}
type contextServerStream struct {
grpc.ServerStream
ctx context.Context
}
func (s *contextServerStream) Context() context.Context { return s.ctx }