331 lines
11 KiB
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 }
|