chore: bump reva

This commit is contained in:
Ralf Haferkamp
2026-05-13 09:23:54 +02:00
committed by Ralf Haferkamp
parent 393926bd73
commit 59bd11d02a
102 changed files with 10788 additions and 4246 deletions
@@ -744,16 +744,6 @@ func (s *Service) Delete(ctx context.Context, req *provider.DeleteRequest) (*pro
}
ctx = ctxpkg.ContextSetLockID(ctx, req.LockId)
// check DeleteRequest for any known opaque properties.
// FIXME these should be part of the DeleteRequest object
if req.Opaque != nil {
if _, ok := req.Opaque.Map["deleting_shared_resource"]; ok {
// it is a binary key; its existence signals true. Although, do not assume.
ctx = appctx.WithDeletingSharedResource(ctx)
}
}
md, err := s.Storage.GetMD(ctx, req.Ref, []string{}, []string{"id", "status"})
if err != nil {
return &provider.DeleteResponse{
@@ -182,9 +182,12 @@ func (s *service) GetUser(ctx context.Context, req *userpb.GetUserRequest) (*use
user, err := s.usermgr.GetUser(ctx, req.UserId, req.SkipFetchingUserGroups)
if err != nil {
res := &userpb.GetUserResponse{}
if _, ok := err.(errtypes.NotFound); ok {
switch err.(type) {
case errtypes.NotFound:
res.Status = status.NewNotFound(ctx, "user not found")
} else {
case errtypes.Unavailable:
res.Status = status.NewUnavailable(ctx, "user provider temporarily unavailable")
default:
res.Status = status.NewInternal(ctx, "error getting user")
}
return res, nil
@@ -205,9 +208,12 @@ func (s *service) GetUserByClaim(ctx context.Context, req *userpb.GetUserByClaim
user, err := s.usermgr.GetUserByClaim(ctx, req.Claim, req.Value, tenantID, req.SkipFetchingUserGroups)
if err != nil {
res := &userpb.GetUserByClaimResponse{}
if _, ok := err.(errtypes.NotFound); ok {
switch err.(type) {
case errtypes.NotFound:
res.Status = status.NewNotFound(ctx, fmt.Sprintf("user not found %s %s", req.Claim, req.Value))
} else {
case errtypes.Unavailable:
res.Status = status.NewUnavailable(ctx, "user provider temporarily unavailable")
default:
res.Status = status.NewInternal(ctx, "error getting user by claim")
}
return res, nil
@@ -84,9 +84,9 @@ type service struct {
allowedPathsForShares []*regexp.Regexp
}
func getShareManager(c *config) (share.Manager, error) {
func getShareManager(c *config, logger *zerolog.Logger) (share.Manager, error) {
if f, ok := registry.NewFuncs[c.Driver]; ok {
return f(c.Drivers[c.Driver])
return f(c.Drivers[c.Driver], logger)
}
return nil, errtypes.NotFound("driver not found: " + c.Driver)
}
@@ -114,7 +114,7 @@ func parseConfig(m map[string]interface{}) (*config, error) {
}
// New creates a new user share provider svc initialized from defaults
func NewDefault(m map[string]interface{}, ss *grpc.Server, _ *zerolog.Logger) (rgrpc.Service, error) {
func NewDefault(m map[string]any, ss *grpc.Server, logger *zerolog.Logger) (rgrpc.Service, error) {
c, err := parseConfig(m)
if err != nil {
@@ -123,7 +123,7 @@ func NewDefault(m map[string]interface{}, ss *grpc.Server, _ *zerolog.Logger) (r
c.init()
sm, err := getShareManager(c)
sm, err := getShareManager(c, logger)
if err != nil {
return nil, err
}
@@ -155,6 +155,13 @@ func (s *svc) handleProppatch(ctx context.Context, w http.ResponseWriter, r *htt
}
for j := range patches[i].Props {
propNameXML := patches[i].Props[j].XMLName
// favorites are now managed by the Graph API and can no longer be set using PROPPATCH. To avoid confusion, we return a 403 Forbidden when clients try to set the oc:favorites property
if propNameXML.Local == "favorite" {
w.WriteHeader(http.StatusForbidden)
return nil, nil, false
}
// don't use path.Join. It removes the double slash! concatenate with a /
key := fmt.Sprintf("%s/%s", patches[i].Props[j].XMLName.Space, patches[i].Props[j].XMLName.Local)
value := string(patches[i].Props[j].InnerXML)
-10
View File
@@ -27,16 +27,6 @@ import (
"go.opentelemetry.io/otel/trace"
)
// deletingSharedResource flags to a storage a shared resource is being deleted not by the owner.
type deletingSharedResource struct{}
func WithDeletingSharedResource(ctx context.Context) context.Context {
return context.WithValue(ctx, deletingSharedResource{}, struct{}{})
}
func DeletingSharedResourceFromContext(ctx context.Context) bool {
return ctx.Value(deletingSharedResource{}) != nil
}
// WithLogger returns a context with an associated logger.
func WithLogger(ctx context.Context, l *zerolog.Logger) context.Context {
return l.WithContext(ctx)
+21
View File
@@ -203,6 +203,15 @@ func (e TooEarly) Error() string { return "error: too early: " + string(e) }
// IsTooEarly implements the IsTooEarly interface.
func (e TooEarly) IsTooEarly() {}
// Unavailable is the error to use when a backend service (e.g. LDAP, database) is
// temporarily unreachable. Callers should treat this as a transient failure and retry.
type Unavailable string
func (e Unavailable) Error() string { return "error: unavailable: " + string(e) }
// IsUnavailable implements the IsUnavailable interface.
func (e Unavailable) IsUnavailable() {}
// IsNotFound is the interface to implement
// to specify that a resource is not found.
type IsNotFound interface {
@@ -293,6 +302,12 @@ type IsTooEarly interface {
IsTooEarly()
}
// IsUnavailable is the interface to implement to specify that a backend service is
// temporarily unavailable and the caller should retry.
type IsUnavailable interface {
IsUnavailable()
}
// NewErrtypeFromStatus maps a rpc status to an errtype
func NewErrtypeFromStatus(status *rpc.Status) error {
switch status.Code {
@@ -329,6 +344,8 @@ func NewErrtypeFromStatus(status *rpc.Status) error {
return BadRequest(status.Message)
case rpc.Code_CODE_TOO_EARLY:
return TooEarly(status.Message)
case rpc.Code_CODE_UNAVAILABLE:
return Unavailable(status.Message)
default:
return InternalError(status.Message)
}
@@ -363,6 +380,8 @@ func NewErrtypeFromHTTPStatusCode(code int, message string) error {
return PartialContent(message)
case http.StatusTooEarly:
return TooEarly(message)
case http.StatusServiceUnavailable:
return Unavailable(message)
case StatusChecksumMismatch:
return ChecksumMismatch(message)
default:
@@ -399,6 +418,8 @@ func NewHTTPStatusCodeFromErrtype(err error) int {
return http.StatusPartialContent
case TooEarly:
return http.StatusTooEarly
case Unavailable:
return http.StatusServiceUnavailable
case ChecksumMismatch:
return StatusChecksumMismatch
default:
+25 -21
View File
@@ -71,9 +71,8 @@ type RawStream struct {
c Config
}
func FromConfig(ctx context.Context, name string, cfg Config) (Stream, error) {
var s Stream
b := backoff.NewExponentialBackOff()
func JetStream(ctx context.Context, name string, cfg Config) (jetstream.JetStream, error) {
var js jetstream.JetStream
connect := func() error {
var tlsConf *tls.Config
@@ -120,27 +119,32 @@ func FromConfig(ctx context.Context, name string, cfg Config) (Stream, error) {
return err
}
jsConn, err := jetstream.New(conn)
if err != nil {
return err
}
js, err := jsConn.Stream(ctx, events.MainQueueName)
if err != nil {
return err
}
s = &RawStream{
js: js,
c: cfg,
}
return nil
js, err = jetstream.New(conn)
return err
}
err := backoff.Retry(connect, b)
err := backoff.Retry(connect, backoff.NewExponentialBackOff())
if err != nil {
return s, errors.Wrap(err, "could not connect to nats jetstream")
return nil, errors.Wrap(err, "could not connect to nats jetstream")
}
return s, nil
return js, nil
}
func FromConfig(ctx context.Context, name string, cfg Config) (Stream, error) {
jsConn, err := JetStream(ctx, name, cfg)
if err != nil {
return nil, err
}
js, err := jsConn.Stream(ctx, events.MainQueueName)
if err != nil {
return nil, err
}
return &RawStream{
js: js,
c: cfg,
}, nil
}
func (s *RawStream) Consume(group string, evs ...events.Unmarshaller) (<-chan Event, error) {
+35 -1
View File
@@ -2,6 +2,7 @@ package stream
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"errors"
@@ -11,7 +12,9 @@ import (
"github.com/cenkalti/backoff"
"github.com/go-micro/plugins/v4/events/natsjs"
"github.com/nats-io/nats.go/jetstream"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/events/raw"
"github.com/opencloud-eu/reva/v2/pkg/logger"
)
@@ -65,7 +68,38 @@ func NatsFromConfig(connName string, disableDurability bool, cfg NatsConfig) (ev
opts = append(opts, natsjs.DisableDurableStreams())
}
return Nats(opts...)
s, err := Nats(opts...)
if err != nil {
return nil, err
}
// apply a MaxAge to the main queue to prevent it from filling up
ctx := context.Background()
jsConn, err := raw.JetStream(ctx, connName, raw.Config{
Endpoint: cfg.Endpoint,
Cluster: cfg.Cluster,
TLSInsecure: cfg.TLSInsecure,
TLSRootCACertificate: cfg.TLSRootCACertificate,
EnableTLS: cfg.EnableTLS,
AuthUsername: cfg.AuthUsername,
AuthPassword: cfg.AuthPassword,
})
if err != nil {
return nil, err
}
streamCfg := jetstream.StreamConfig{
Name: "main-queue",
MaxAge: 7 * 24 * time.Hour,
}
_, err = jsConn.CreateStream(ctx, streamCfg)
if err != nil {
// If the stream already exists, update its configuration
if err == jetstream.ErrStreamNameAlreadyInUse {
_, _ = jsConn.UpdateStream(ctx, streamCfg)
}
}
return s, nil
}
// nats returns a nats streaming client
+13
View File
@@ -68,6 +68,15 @@ func NewInternal(ctx context.Context, msg string) *rpc.Status {
}
}
// NewUnavailable returns a Status with CODE_UNAVAILABLE.
func NewUnavailable(ctx context.Context, msg string) *rpc.Status {
return &rpc.Status{
Code: rpc.Code_CODE_UNAVAILABLE,
Message: msg,
Trace: getTrace(ctx),
}
}
// NewUnauthenticated returns a Status with CODE_UNAUTHENTICATED.
func NewUnauthenticated(ctx context.Context, err error, msg string) *rpc.Status {
return &rpc.Status{
@@ -191,6 +200,10 @@ func NewStatusFromErrType(ctx context.Context, msg string, err error) *rpc.Statu
return NewUnimplemented(ctx, err, msg+":"+err.Error())
case errtypes.BadRequest:
return NewInvalid(ctx, msg+":"+err.Error())
case errtypes.Unavailable:
return NewUnavailable(ctx, msg+": "+err.Error())
case errtypes.IsUnavailable:
return NewUnavailable(ctx, msg+": "+err.Error())
}
// map GRPC status codes coming from the auth middleware
+170 -60
View File
@@ -36,9 +36,9 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/events/stream"
"github.com/opencloud-eu/reva/v2/pkg/logger"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/share"
migration "github.com/opencloud-eu/reva/v2/pkg/share/manager/jsoncs3/migrations"
"github.com/opencloud-eu/reva/v2/pkg/share/manager/jsoncs3/providercache"
"github.com/opencloud-eu/reva/v2/pkg/share/manager/jsoncs3/receivedsharecache"
"github.com/opencloud-eu/reva/v2/pkg/share/manager/jsoncs3/sharecache"
@@ -48,6 +48,7 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/pkg/errors"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/codes"
"golang.org/x/sync/errgroup"
"google.golang.org/genproto/protobuf/field_mask"
@@ -122,14 +123,20 @@ var (
)
type config struct {
GatewayAddr string `mapstructure:"gateway_addr"`
MaxConcurrency int `mapstructure:"max_concurrency"`
ProviderAddr string `mapstructure:"provider_addr"`
ServiceUserID string `mapstructure:"service_user_id"`
ServiceUserIdp string `mapstructure:"service_user_idp"`
MachineAuthAPIKey string `mapstructure:"machine_auth_apikey"`
CacheTTL int `mapstructure:"ttl"`
Events EventOptions `mapstructure:"events"`
GatewayAddr string `mapstructure:"gateway_addr"`
MaxConcurrency int `mapstructure:"max_concurrency"`
ProviderAddr string `mapstructure:"provider_addr"`
SystemUserID string `mapstructure:"system_user_id"`
SystemUserIdp string `mapstructure:"system_user_idp"`
MachineAuthAPIKey string `mapstructure:"machine_auth_apikey"`
ServiceAccountID string `mapstructure:"service_account_id"`
ServiceAccountSecret string `mapstructure:"service_account_secret"`
// ProviderRegistryAddr is the address of the storage registry used during
// migrations. Defaults to GatewayAddr when empty, because in the default
// OpenCloud deployment the registry is co-located with the gateway.
ProviderRegistryAddr string `mapstructure:"provider_registry_addr"`
CacheTTL int `mapstructure:"ttl"`
Events EventOptions `mapstructure:"events"`
}
// EventOptions are the configurable options for events
@@ -145,8 +152,6 @@ type EventOptions struct {
// Manager implements a share manager using a cs3 storage backend with local caching
type Manager struct {
sync.RWMutex
Cache providercache.Cache // holds all shares, sharded by provider id and space id
CreatedCache sharecache.Cache // holds the list of shares a user has created, sharded by user id
GroupReceivedCache sharecache.Cache // holds the list of shares a group has access to, sharded by group id
@@ -155,23 +160,25 @@ type Manager struct {
storage metadata.Storage
SpaceRoot *provider.ResourceId
initialized bool
ready chan struct{} // closed once initialize() has completed successfully
migrationsDone chan struct{} // closed once doMigrations() has returned on this instance
MaxConcurrency int
gatewaySelector pool.Selectable[gatewayv1beta1.GatewayAPIClient]
eventStream events.Stream
logger *zerolog.Logger
}
// NewDefault returns a new manager instance with default dependencies
func NewDefault(m map[string]interface{}) (share.Manager, error) {
func NewDefault(m map[string]interface{}, logger *zerolog.Logger) (share.Manager, error) {
c := &config{}
if err := mapstructure.Decode(m, c); err != nil {
err = errors.Wrap(err, "error creating a new manager")
return nil, err
}
s, err := metadata.NewCS3Storage(c.ProviderAddr, c.ProviderAddr, c.ServiceUserID, c.ServiceUserIdp, c.MachineAuthAPIKey)
s, err := metadata.NewCS3Storage(c.ProviderAddr, c.ProviderAddr, c.SystemUserID, c.SystemUserIdp, c.MachineAuthAPIKey)
if err != nil {
return nil, err
}
@@ -189,11 +196,34 @@ func NewDefault(m map[string]interface{}) (share.Manager, error) {
}
}
return New(s, gatewaySelector, c.CacheTTL, es, c.MaxConcurrency)
mgr, err := New(s, logger, gatewaySelector, c.CacheTTL, es, c.MaxConcurrency)
if err != nil {
return nil, err
}
providerRegistryAddr := c.ProviderRegistryAddr
if providerRegistryAddr == "" {
providerRegistryAddr = c.GatewayAddr
}
mgr.RunMigrations(migration.MigrationConfig{
ServiceAccountID: c.ServiceAccountID,
ServiceAccountSecret: c.ServiceAccountSecret,
ProviderRegistryAddr: providerRegistryAddr,
})
return mgr, nil
}
// New returns a new manager instance.
func New(s metadata.Storage, gatewaySelector pool.Selectable[gatewayv1beta1.GatewayAPIClient], ttlSeconds int, es events.Stream, maxconcurrency int) (*Manager, error) {
func New(s metadata.Storage,
logger *zerolog.Logger,
gatewaySelector pool.Selectable[gatewayv1beta1.GatewayAPIClient],
ttlSeconds int,
es events.Stream,
maxconcurrency int,
) (*Manager, error) {
if logger == nil {
nop := zerolog.Nop()
logger = &nop
}
ttl := time.Duration(ttlSeconds) * time.Second
m := &Manager{
@@ -205,13 +235,38 @@ func New(s metadata.Storage, gatewaySelector pool.Selectable[gatewayv1beta1.Gate
gatewaySelector: gatewaySelector,
eventStream: es,
MaxConcurrency: maxconcurrency,
logger: logger,
ready: make(chan struct{}),
// migrationsDone is open (blocking) by default. It is closed by
// doMigrations when all migrations complete, or by SkipMigrations for
// callers (e.g. tests) that do not run migrations at all.
migrationsDone: make(chan struct{}),
}
// Initialize the metadata storage connection in the background, retrying
// with exponential backoff if the backend is not yet available.
go func() {
backoff := time.Second
for {
if err := m.initialize(context.Background()); err != nil {
logger.Info().Err(err).Dur("backoff", backoff).Msg("share manager: metadata storage initialization failed, retrying")
time.Sleep(backoff)
if backoff < 30*time.Second {
backoff *= 2
}
continue
}
logger.Debug().Msg("share manager: initialization succeeded")
close(m.ready)
return
}
}()
// listen for events
if m.eventStream != nil {
ch, err := events.Consume(m.eventStream, "jsoncs3sharemanager", _registeredEvents...)
if err != nil {
appctx.GetLogger(context.Background()).Error().Err(err).Msg("error consuming events")
logger.Error().Err(err).Msg("error consuming events")
}
go m.ProcessEvents(ch)
}
@@ -219,23 +274,13 @@ func New(s metadata.Storage, gatewaySelector pool.Selectable[gatewayv1beta1.Gate
return m, nil
}
// initialize connects to the metadata storage backend and ensures the required
// directory structure exists. It is called once at startup from a background
// goroutine (see New) and must not be called concurrently.
func (m *Manager) initialize(ctx context.Context) error {
_, span := appctx.GetTracerProvider(ctx).Tracer(tracerName).Start(ctx, "initialize")
defer span.End()
if m.initialized {
span.SetStatus(codes.Ok, "already initialized")
return nil
}
m.Lock()
defer m.Unlock()
if m.initialized { // check if initialization happened while grabbing the lock
span.SetStatus(codes.Ok, "initialized while grabbing lock")
return nil
}
ctx = context.Background()
err := m.storage.Init(ctx, "jsoncs3-share-manager-metadata")
if err != nil {
span.RecordError(err)
@@ -261,21 +306,85 @@ func (m *Manager) initialize(ctx context.Context) error {
span.SetStatus(codes.Error, err.Error())
return err
}
err = m.storage.MakeDirIfNotExist(ctx, "migrations")
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
m.initialized = true
span.SetStatus(codes.Ok, "initialized")
return nil
}
// waitForInit blocks until the background initialization goroutine has
// successfully completed, or until ctx is cancelled.
func (m *Manager) waitForInit(ctx context.Context) error {
select {
case <-m.ready:
return nil
case <-ctx.Done():
return errors.Wrap(ctx.Err(), "share manager not yet initialized")
}
}
// waitForMigrations blocks until both storage initialization and all data
// migrations have completed on this instance, or until ctx is cancelled.
// It is a strict superset of waitForInit and should be used by write operations
// to ensure no writes race with an in-progress migration.
func (m *Manager) waitForMigrations(ctx context.Context) error {
select {
case <-m.ready:
case <-ctx.Done():
return errors.Wrap(ctx.Err(), "share manager not yet initialized")
}
select {
case <-m.migrationsDone:
return nil
case <-ctx.Done():
return errors.Wrap(ctx.Err(), "share manager migrations not yet complete")
}
}
// RunMigrations starts data migrations in a background goroutine. It should be
// called once after New() in production server startup. Callers that do not
// need migrations should call SkipMigrations instead to unblock write operations.
func (m *Manager) RunMigrations(cfg migration.MigrationConfig) {
go m.doMigrations(cfg)
}
// SkipMigrations unblocks write operations on this instance without running
// any migrations. It must be called when RunMigrations will not be called,
// for example in tests.
func (m *Manager) SkipMigrations() {
close(m.migrationsDone)
}
func (m *Manager) doMigrations(cfg migration.MigrationConfig) {
// Always close migrationsDone when this goroutine exits, whether migrations
// ran, were skipped, or failed. This unblocks write operations on this
// instance. Non-winning instances are held here by acquireLock until the
// winning instance finishes, so the close happens only after the storage
// state is fully migrated.
defer close(m.migrationsDone)
if err := m.waitForInit(context.Background()); err != nil {
m.logger.Error().Err(err).Msg("share manager: aborting migrations, manager did not initialize")
return
}
m.logger.Debug().Msg("migrations start")
migrations := migration.New(*m.logger, m.gatewaySelector, m.storage, cfg, m, m)
migrations.RunMigrations()
}
func (m *Manager) ProcessEvents(ch <-chan events.Event) {
log := logger.New()
log := m.logger
ctx := context.Background()
if err := m.waitForInit(ctx); err != nil {
log.Error().Err(err).Msg("share manager: error waiting for initialization")
return
}
for event := range ch {
ctx := context.Background()
if err := m.initialize(ctx); err != nil {
log.Error().Err(err).Msg("error initializing manager")
}
if ev, ok := event.Event.(events.SpaceDeleted); ok {
log.Debug().Msgf("space deleted event: %v", ev)
go func() { m.purgeSpace(ctx, ev.ID) }()
@@ -287,7 +396,7 @@ func (m *Manager) ProcessEvents(ch <-chan events.Event) {
func (m *Manager) Share(ctx context.Context, md *provider.ResourceInfo, g *collaboration.ShareGrant) (*collaboration.Share, error) {
ctx, span := appctx.GetTracerProvider(ctx).Tracer(tracerName).Start(ctx, "Share")
defer span.End()
if err := m.initialize(ctx); err != nil {
if err := m.waitForMigrations(ctx); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
@@ -436,7 +545,7 @@ func (m *Manager) GetShare(ctx context.Context, ref *collaboration.ShareReferenc
ctx, span := appctx.GetTracerProvider(ctx).Tracer(tracerName).Start(ctx, "GetShare")
defer span.End()
sublog := appctx.GetLogger(ctx).With().Str("id", ref.GetId().GetOpaqueId()).Str("key", ref.GetKey().String()).Str("driver", "jsoncs3").Str("handler", "GetShare").Logger()
if err := m.initialize(ctx); err != nil {
if err := m.waitForInit(ctx); err != nil {
return nil, err
}
@@ -494,7 +603,7 @@ func (m *Manager) Unshare(ctx context.Context, ref *collaboration.ShareReference
ctx, span := appctx.GetTracerProvider(ctx).Tracer(tracerName).Start(ctx, "Unshare")
defer span.End()
if err := m.initialize(ctx); err != nil {
if err := m.waitForMigrations(ctx); err != nil {
return err
}
@@ -511,7 +620,7 @@ func (m *Manager) UpdateShare(ctx context.Context, ref *collaboration.ShareRefer
ctx, span := appctx.GetTracerProvider(ctx).Tracer(tracerName).Start(ctx, "UpdateShare")
defer span.End()
if err := m.initialize(ctx); err != nil {
if err := m.waitForMigrations(ctx); err != nil {
return nil, err
}
@@ -599,7 +708,7 @@ func (m *Manager) ListShares(ctx context.Context, filters []*collaboration.Filte
ctx, span := appctx.GetTracerProvider(ctx).Tracer(tracerName).Start(ctx, "ListShares")
defer span.End()
if err := m.initialize(ctx); err != nil {
if err := m.waitForInit(ctx); err != nil {
return nil, err
}
@@ -816,7 +925,7 @@ func (m *Manager) ListReceivedShares(ctx context.Context, filters []*collaborati
defer span.End()
sublog := appctx.GetLogger(ctx).With().Str("driver", "jsoncs3").Str("handler", "ListReceivedShares").Logger()
if err := m.initialize(ctx); err != nil {
if err := m.waitForInit(ctx); err != nil {
return nil, err
}
@@ -1012,7 +1121,7 @@ func (m *Manager) convert(ctx context.Context, userID string, s *collaboration.S
// GetReceivedShare returns the information for a received share.
func (m *Manager) GetReceivedShare(ctx context.Context, ref *collaboration.ShareReference) (*collaboration.ReceivedShare, error) {
if err := m.initialize(ctx); err != nil {
if err := m.waitForInit(ctx); err != nil {
return nil, err
}
@@ -1056,7 +1165,7 @@ func (m *Manager) UpdateReceivedShare(ctx context.Context, receivedShare *collab
ctx, span := appctx.GetTracerProvider(ctx).Tracer(tracerName).Start(ctx, "UpdateReceivedShare")
defer span.End()
if err := m.initialize(ctx); err != nil {
if err := m.waitForMigrations(ctx); err != nil {
return nil, err
}
@@ -1103,8 +1212,8 @@ func updateShareID(share *collaboration.Share) {
// Load imports shares and received shares from channels (e.g. during migration)
func (m *Manager) Load(ctx context.Context, shareChan <-chan *collaboration.Share, receivedShareChan <-chan share.ReceivedShareWithUser) error {
log := appctx.GetLogger(ctx)
if err := m.initialize(ctx); err != nil {
l := m.logger
if err := m.waitForInit(ctx); err != nil {
return err
}
@@ -1119,14 +1228,14 @@ func (m *Manager) Load(ctx context.Context, shareChan <-chan *collaboration.Shar
updateShareID(s)
}
if err := m.Cache.Add(context.Background(), s.GetResourceId().GetStorageId(), s.GetResourceId().GetSpaceId(), s.Id.OpaqueId, s); err != nil {
log.Error().Err(err).Interface("share", s).Msg("error persisting share")
l.Error().Err(err).Interface("share", s).Msg("error persisting share")
} else {
log.Debug().Str("storageid", s.GetResourceId().GetStorageId()).Str("spaceid", s.GetResourceId().GetSpaceId()).Str("shareid", s.Id.OpaqueId).Msg("imported share")
l.Debug().Str("storageid", s.GetResourceId().GetStorageId()).Str("spaceid", s.GetResourceId().GetSpaceId()).Str("shareid", s.Id.OpaqueId).Msg("imported share")
}
if err := m.CreatedCache.Add(ctx, s.GetCreator().GetOpaqueId(), s.Id.OpaqueId); err != nil {
log.Error().Err(err).Interface("share", s).Msg("error persisting created cache")
l.Error().Err(err).Interface("share", s).Msg("error persisting created cache")
} else {
log.Debug().Str("creatorid", s.GetCreator().GetOpaqueId()).Str("shareid", s.Id.OpaqueId).Msg("updated created cache")
l.Debug().Str("creatorid", s.GetCreator().GetOpaqueId()).Str("shareid", s.Id.OpaqueId).Msg("updated created cache")
}
}
wg.Done()
@@ -1137,18 +1246,19 @@ func (m *Manager) Load(ctx context.Context, shareChan <-chan *collaboration.Shar
if !shareIsRoutable(s.ReceivedShare.GetShare()) {
updateShareID(s.ReceivedShare.GetShare())
}
switch s.ReceivedShare.Share.Grantee.Type {
case provider.GranteeType_GRANTEE_TYPE_USER:
if err := m.UserReceivedStates.Add(context.Background(), s.ReceivedShare.GetShare().GetGrantee().GetUserId().GetOpaqueId(), s.ReceivedShare.GetShare().GetResourceId().GetSpaceId(), s.ReceivedShare); err != nil {
log.Error().Err(err).Interface("received share", s).Msg("error persisting received share for user")
if s.UserID != nil {
spaceid := s.ReceivedShare.GetShare().GetResourceId().GetStorageId() + shareid.IDDelimiter + s.ReceivedShare.GetShare().GetResourceId().GetSpaceId()
if err := m.UserReceivedStates.Add(context.Background(), s.UserID.GetOpaqueId(), spaceid, s.ReceivedShare); err != nil {
l.Error().Err(err).Interface("received share", s).Msg("error persisting received share for user")
} else {
log.Debug().Str("userid", s.ReceivedShare.GetShare().GetGrantee().GetUserId().GetOpaqueId()).Str("spaceid", s.ReceivedShare.GetShare().GetResourceId().GetSpaceId()).Str("shareid", s.ReceivedShare.GetShare().Id.OpaqueId).Msg("updated received share userdata")
l.Debug().Str("userid", s.UserID.GetOpaqueId()).Str("spaceid", spaceid).Str("shareid", s.ReceivedShare.GetShare().Id.OpaqueId).Msg("updated received share userdata")
}
case provider.GranteeType_GRANTEE_TYPE_GROUP:
}
if s.ReceivedShare.Share.Grantee.Type == provider.GranteeType_GRANTEE_TYPE_GROUP && s.UserID == nil {
if err := m.GroupReceivedCache.Add(context.Background(), s.ReceivedShare.GetShare().GetGrantee().GetGroupId().GetOpaqueId(), s.ReceivedShare.GetShare().GetId().GetOpaqueId()); err != nil {
log.Error().Err(err).Interface("received share", s).Msg("error persisting received share to group cache")
l.Error().Err(err).Interface("received share", s).Msg("error persisting received share to group cache")
} else {
log.Debug().Str("groupid", s.ReceivedShare.GetShare().GetGrantee().GetGroupId().GetOpaqueId()).Str("shareid", s.ReceivedShare.GetShare().Id.OpaqueId).Msg("updated received share group cache")
l.Debug().Str("groupid", s.ReceivedShare.GetShare().GetGrantee().GetGroupId().GetOpaqueId()).Str("shareid", s.ReceivedShare.GetShare().Id.OpaqueId).Msg("updated received share group cache")
}
}
}
@@ -1220,7 +1330,7 @@ func (m *Manager) removeShare(ctx context.Context, s *collaboration.Share, skipS
func (m *Manager) CleanupStaleShares(ctx context.Context) {
log := appctx.GetLogger(ctx)
if err := m.initialize(ctx); err != nil {
if err := m.waitForMigrations(ctx); err != nil {
return
}
@@ -0,0 +1,435 @@
// Copyright 2026 OpenCloud GmbH
//
// 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.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package migration
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/cenkalti/backoff"
grouppb "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
registry "github.com/cs3org/go-cs3apis/cs3/storage/registry/v1beta1"
typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/google/uuid"
ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/share"
"github.com/opencloud-eu/reva/v2/pkg/share/manager/jsoncs3/shareid"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/rs/zerolog"
"google.golang.org/grpc"
)
// storageProvider is the narrow subset of provider.ProviderAPIClient that the
// migration actually uses. Keeping it narrow makes test stubs trivial to write.
type storageProvider interface {
ListGrants(ctx context.Context, in *provider.ListGrantsRequest, opts ...grpc.CallOption) (*provider.ListGrantsResponse, error)
}
type ImportSpaceMembersMigration struct {
cfg config
sharesChan chan *collaboration.Share
receivedChan chan share.ReceivedShareWithUser
userCache map[string]*userpb.UserId
groupCache map[string]*grouppb.GroupId
providerResolver func(context.Context, *provider.StorageSpace) (storageProvider, error)
}
func init() {
registerMigration(&ImportSpaceMembersMigration{})
}
func (m *ImportSpaceMembersMigration) Initialize(cfg config) {
m.cfg = cfg
m.sharesChan = make(chan *collaboration.Share)
m.receivedChan = make(chan share.ReceivedShareWithUser)
m.userCache = make(map[string]*userpb.UserId)
m.groupCache = make(map[string]*grouppb.GroupId)
m.providerResolver = func(ctx context.Context, space *provider.StorageSpace) (storageProvider, error) {
return m.storageProviderForSpace(ctx, space)
}
}
func (m *ImportSpaceMembersMigration) Name() string {
return "import_space_members"
}
func (m *ImportSpaceMembersMigration) Version() int {
return 1
}
func (m *ImportSpaceMembersMigration) Migrate() error {
gwc, err := m.cfg.gatewaySelector.Next()
if err != nil {
return err
}
svcCtx, err := utils.GetServiceUserContextWithContext(context.Background(), gwc, m.cfg.serviceAccountID, m.cfg.serviceAccountSecret)
if err != nil {
m.cfg.logger.Error().Err(err).Msg("failed to get service user context for migration")
return err
}
// List all project spaces.
listRes, err := gwc.ListStorageSpaces(svcCtx, &provider.ListStorageSpacesRequest{
Opaque: utils.AppendPlainToOpaque(nil, "unrestricted", "true"),
Filters: []*provider.ListStorageSpacesRequest_Filter{
{
Type: provider.ListStorageSpacesRequest_Filter_TYPE_SPACE_TYPE,
Term: &provider.ListStorageSpacesRequest_Filter_SpaceType{SpaceType: "project"},
},
},
})
if err != nil {
m.cfg.logger.Error().Err(err).Msg("space-membership migration: failed to list storage spaces")
return err
}
if listRes.GetStatus().GetCode() != rpc.Code_CODE_OK {
m.cfg.logger.Error().Str("status", listRes.GetStatus().GetMessage()).Msg("space-membership migration: ListStorageSpaces returned non-OK status")
return errtypes.InternalError("ListStorageSpaces")
}
spaces := listRes.GetStorageSpaces()
m.cfg.logger.Info().Int("spaces", len(spaces)).Msg("Starting migration")
// loadCtx is cancelled when the producer finishes (or fails) so that the
// Load goroutine — which blocks reading from the channels — is not left
// waiting forever if we return early from an error.
loadCtx, cancelLoad := context.WithCancel(svcCtx)
defer cancelLoad()
var wg sync.WaitGroup
var loaderError error
wg.Go(func() {
loaderError = m.cfg.loader.Load(loadCtx, m.sharesChan, m.receivedChan)
})
migrated := 0
for _, space := range spaces {
sharesCreated, err := m.migrateSpace(loadCtx, space)
if err != nil {
m.cfg.logger.Error().Err(err).Str("space", space.GetId().GetOpaqueId()).Msg("failed to migrate space; continuing with remaining spaces")
continue
}
migrated++
m.cfg.logger.Debug().
Str("space", space.GetId().GetOpaqueId()).
Int("shares_created", sharesCreated).
Msg("space migrated")
if migrated%10 == 0 {
m.cfg.logger.Info().
Int("migrated", migrated).
Int("total", len(spaces)).
Msg("migration progress")
}
}
close(m.receivedChan)
close(m.sharesChan)
wg.Wait()
m.cfg.logger.Info().Err(loaderError).Int("migrated", migrated).Int("total", len(spaces)).Msg("Migration finished")
return loaderError
}
func (m *ImportSpaceMembersMigration) migrateSpace(ctx context.Context, space *provider.StorageSpace) (int, error) {
spClient, err := m.providerResolver(ctx, space)
if err != nil {
return 0, err
}
ref := &provider.Reference{ResourceId: space.GetRoot()}
grantsRes, err := spClient.ListGrants(ctx, &provider.ListGrantsRequest{Ref: ref})
if err != nil {
return 0, err
}
if grantsRes.GetStatus().GetCode() != rpc.Code_CODE_OK {
return 0, errtypes.NewErrtypeFromStatus(grantsRes.GetStatus())
}
sharesCreated := 0
for _, grant := range grantsRes.GetGrants() {
share, receivedShares, err := m.spaceGrantToShares(ctx, grant, space)
if err != nil {
m.cfg.logger.Error().Err(err).
Interface("grant", grant).
Msg("Failed to convert grant to shares")
continue
}
if share == nil {
// share already existed; nothing to import for this grant
continue
}
select {
case m.sharesChan <- share:
case <-ctx.Done():
return sharesCreated, ctx.Err()
}
for _, rs := range receivedShares {
select {
case m.receivedChan <- rs:
case <-ctx.Done():
return sharesCreated, ctx.Err()
}
}
sharesCreated++
}
return sharesCreated, nil
}
// resolveRetries is the maximum number of times resolveUserID / resolveGroupID
// will retry after receiving an errtypes.Unavailable response (LDAP down).
const resolveRetries = 10
// retryOnUnavailable calls op, retrying with exponential backoff whenever op
// returns errtypes.Unavailable. Any other error (including context
// cancellation) stops the loop immediately and is returned as-is.
// Retries are capped at resolveRetries attempts and respect ctx cancellation.
func retryOnUnavailable(ctx context.Context, log zerolog.Logger, op func() error) error {
b := backoff.WithContext(
backoff.WithMaxRetries(backoff.NewExponentialBackOff(), resolveRetries),
ctx,
)
notify := func(err error, d time.Duration) {
log.Warn().Err(err).Dur("retry_in", d).Msg("identity provider temporarily unavailable, retrying")
}
return backoff.RetryNotify(func() error {
err := op()
if err == nil {
return nil
}
if _, ok := err.(errtypes.Unavailable); ok {
return err // transient — keep retrying
}
return backoff.Permanent(err) // permanent — stop immediately
}, b, notify)
}
func (m *ImportSpaceMembersMigration) resolveUserID(ctx context.Context, opaqueID string) (*userpb.UserId, error) {
if id, ok := m.userCache[opaqueID]; ok {
return id, nil
}
var id *userpb.UserId
err := retryOnUnavailable(ctx, m.cfg.logger, func() error {
gwc, err := m.cfg.gatewaySelector.Next()
if err != nil {
return err
}
res, err := gwc.GetUser(ctx, &userpb.GetUserRequest{
UserId: &userpb.UserId{OpaqueId: opaqueID},
SkipFetchingUserGroups: true,
})
if err != nil {
return err
}
if res.GetStatus().GetCode() != rpc.Code_CODE_OK {
// errtypes.NewErrtypeFromStatus maps CODE_UNAVAILABLE → errtypes.Unavailable,
// which retryOnUnavailable will retry; all other codes are treated as permanent.
return errtypes.NewErrtypeFromStatus(res.GetStatus())
}
id = res.GetUser().GetId()
return nil
})
if err != nil {
return nil, err
}
m.userCache[opaqueID] = id
return id, nil
}
func (m *ImportSpaceMembersMigration) resolveGroupID(ctx context.Context, opaqueID string) (*grouppb.GroupId, error) {
if id, ok := m.groupCache[opaqueID]; ok {
return id, nil
}
var id *grouppb.GroupId
err := retryOnUnavailable(ctx, m.cfg.logger, func() error {
gwc, err := m.cfg.gatewaySelector.Next()
if err != nil {
return err
}
res, err := gwc.GetGroup(ctx, &grouppb.GetGroupRequest{
GroupId: &grouppb.GroupId{OpaqueId: opaqueID},
SkipFetchingMembers: true,
})
if err != nil {
return err
}
if res.GetStatus().GetCode() != rpc.Code_CODE_OK {
return errtypes.NewErrtypeFromStatus(res.GetStatus())
}
id = res.GetGroup().GetId()
return nil
})
if err != nil {
return nil, err
}
m.groupCache[opaqueID] = id
return id, nil
}
func (m *ImportSpaceMembersMigration) spaceGrantToShares(ctx context.Context, grant *provider.Grant, space *provider.StorageSpace) (*collaboration.Share, []share.ReceivedShareWithUser, error) {
// The grantee ids as persisted on disk do not have an IDP or type stored as
// part of the userid/groupid. Resolve them via the gateway so we get the
// full userid
switch grant.GetGrantee().GetType() {
case provider.GranteeType_GRANTEE_TYPE_GROUP:
groupID, err := m.resolveGroupID(ctx, grant.GetGrantee().GetGroupId().GetOpaqueId())
if err != nil {
return nil, nil, fmt.Errorf("resolve group %s: %w", grant.GetGrantee().GetGroupId().GetOpaqueId(), err)
}
grant.Grantee.Id = &provider.Grantee_GroupId{GroupId: groupID}
case provider.GranteeType_GRANTEE_TYPE_USER:
userID, err := m.resolveUserID(ctx, grant.GetGrantee().GetUserId().GetOpaqueId())
if err != nil {
return nil, nil, fmt.Errorf("resolve user %s: %w", grant.GetGrantee().GetUserId().GetOpaqueId(), err)
}
grant.Grantee.Id = &provider.Grantee_UserId{UserId: userID}
}
ref := &collaboration.ShareReference{
Spec: &collaboration.ShareReference_Key{
Key: &collaboration.ShareKey{
ResourceId: space.GetRoot(),
Grantee: grant.GetGrantee(),
},
},
}
ctx = ctxpkg.ContextSetUser(ctx, &userpb.User{Id: grant.Creator})
if s, err := m.cfg.manager.GetShare(ctx, ref); err == nil {
// FIXME: Verify the actual grants?
m.cfg.logger.Debug().Interface("share", s).Msg("share already exists")
return nil, nil, nil
}
ts := utils.TSNow()
shareID := shareid.Encode(space.GetRoot().GetStorageId(), space.GetRoot().GetSpaceId(), uuid.NewString())
creator := grant.GetCreator()
if creator.Type == userpb.UserType_USER_TYPE_INVALID {
creator = nil
}
newShare := &collaboration.Share{
Id: &collaboration.ShareId{OpaqueId: shareID},
ResourceId: space.GetRoot(),
Permissions: &collaboration.SharePermissions{Permissions: grant.GetPermissions()},
Grantee: grant.GetGrantee(),
Expiration: grant.GetExpiration(),
Owner: creator,
Creator: creator,
Ctime: ts,
Mtime: ts,
}
var newReceivedShares []share.ReceivedShareWithUser
switch grant.GetGrantee().GetType() {
case provider.GranteeType_GRANTEE_TYPE_GROUP:
gwc, err := m.cfg.gatewaySelector.Next()
if err != nil {
m.cfg.logger.Error().Err(err).Msg("Failed to get gateway client")
return nil, nil, err
}
gr, err := gwc.GetMembers(ctx, &grouppb.GetMembersRequest{
GroupId: grant.GetGrantee().GetGroupId(),
})
if err != nil {
m.cfg.logger.Error().Err(err).Msg("Failed to expand group membership")
return nil, nil, err
}
if gr.GetStatus().GetCode() != rpc.Code_CODE_OK {
m.cfg.logger.Error().Str("Status", gr.GetStatus().GetMessage()).Msg("Failed to expand group membership")
return nil, nil, errtypes.NewErrtypeFromStatus(gr.GetStatus())
}
for _, u := range gr.GetMembers() {
newReceivedShares = append(newReceivedShares, share.ReceivedShareWithUser{
UserID: u,
ReceivedShare: &collaboration.ReceivedShare{
Share: newShare,
State: collaboration.ShareState_SHARE_STATE_ACCEPTED,
},
})
}
// Also add a group-level entry (UserID == nil) so the group cache is populated.
newReceivedShares = append(newReceivedShares, share.ReceivedShareWithUser{
UserID: nil,
ReceivedShare: &collaboration.ReceivedShare{
Share: newShare,
State: collaboration.ShareState_SHARE_STATE_ACCEPTED,
},
})
case provider.GranteeType_GRANTEE_TYPE_USER:
newReceivedShares = append(newReceivedShares, share.ReceivedShareWithUser{
UserID: grant.GetGrantee().GetUserId(),
ReceivedShare: &collaboration.ReceivedShare{
Share: newShare,
State: collaboration.ShareState_SHARE_STATE_ACCEPTED,
},
})
}
return newShare, newReceivedShares, nil
}
// storageProviderForSpace resolves the storageprovider responsible for the
// given storage space and returns a dialled client. In the default opencloud
// deployment the storage registry is co-located with the gateway, so
// the GatewayAddr is used as the registry address.
func (m *ImportSpaceMembersMigration) storageProviderForSpace(ctx context.Context, space *provider.StorageSpace) (provider.ProviderAPIClient, error) {
srClient, err := pool.GetStorageRegistryClient(m.cfg.providerRegistryAddr)
if err != nil {
return nil, fmt.Errorf("get storage registry client: %w", err)
}
spaceJSON, err := json.Marshal(space)
if err != nil {
return nil, fmt.Errorf("marshal space: %w", err)
}
res, err := srClient.GetStorageProviders(ctx, &registry.GetStorageProvidersRequest{
Opaque: &typesv1beta1.Opaque{
Map: map[string]*typesv1beta1.OpaqueEntry{
"space": {
Decoder: "json",
Value: spaceJSON,
},
},
},
})
if err != nil {
return nil, fmt.Errorf("GetStorageProviders: %w", err)
}
if len(res.GetProviders()) == 0 {
return nil, fmt.Errorf("no storage provider found for space %s", space.GetId().GetOpaqueId())
}
c, err := pool.GetStorageProviderServiceClient(res.GetProviders()[0].GetAddress())
if err != nil {
return nil, fmt.Errorf("dial storage provider: %w", err)
}
return c, nil
}
@@ -0,0 +1,353 @@
// Copyright 2026 OpenCloud GmbH
//
// 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.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package migration
import (
"cmp"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"os"
"os/signal"
"slices"
"syscall"
"time"
gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/share"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/metadata"
"github.com/rs/zerolog"
)
const stateFile = "migrations/state.json"
const (
lockFile = "migrations/lock.json"
lockTTL = time.Minute
lockHeartbeatInterval = 20 * time.Second
)
// lockPollInterval is how long acquireLock sleeps between retries when the
// lock is held by another instance. Declared as a variable so tests can
// shorten it without rebuilding.
var lockPollInterval = 5 * time.Second
// lockData is the content written to the lock file.
type lockData struct {
Timestamp time.Time `json:"timestamp"`
InstanceID string `json:"instance_id"`
}
type migration interface {
Name() string
Version() int
Initialize(config)
Migrate() error
}
// persistedState is the on-disk representation of the migration state.
type persistedState struct {
Version int `json:"version"`
}
type state struct {
version int
}
// MigrationConfig holds all caller-supplied options for a migration run.
// It is intentionally a plain struct so that new fields can be added without
// changing function signatures throughout the call chain.
type MigrationConfig struct {
ServiceAccountID string
ServiceAccountSecret string
ProviderRegistryAddr string
}
type config struct {
logger zerolog.Logger
gatewaySelector pool.Selectable[gatewayv1beta1.GatewayAPIClient]
storage metadata.Storage
serviceAccountID string
serviceAccountSecret string
providerRegistryAddr string
manager share.Manager
loader share.LoadableManager
}
type Migrations struct {
config
state state
instanceID string
}
var migrations []migration
// registerMigration is only supposed to be call from init(), which runs sequentially
// so we don't need ot protect migrations with a lock
func registerMigration(m migration) {
migrations = append(migrations, m)
}
func New(logger zerolog.Logger,
gatewaySelector pool.Selectable[gatewayv1beta1.GatewayAPIClient],
storage metadata.Storage,
cfg MigrationConfig,
manager share.Manager,
loader share.LoadableManager,
) Migrations {
slices.SortFunc(migrations, func(a, b migration) int {
return cmp.Compare(a.Version(), b.Version())
})
b := make([]byte, 8)
_, _ = rand.Read(b)
instanceID := fmt.Sprintf("%x", b)
return Migrations{
config{
logger: logger.With().Str("jsoncs3", "migrations").Logger(),
gatewaySelector: gatewaySelector,
storage: storage,
serviceAccountID: cfg.ServiceAccountID,
serviceAccountSecret: cfg.ServiceAccountSecret,
providerRegistryAddr: cfg.ProviderRegistryAddr,
manager: manager,
loader: loader,
},
state{},
instanceID,
}
}
// acquireLock tries to atomically create the lock file, blocking until the lock
// is obtained. It returns the etag of the lock file on success. It retries
// indefinitely until ctx is cancelled. A lock whose timestamp is older than
// lockTTL is considered stale and will be taken over.
func (m *Migrations) acquireLock(ctx context.Context) (string, error) {
m.logger.Debug().Str("instance", m.instanceID).Msg("acquiring migration lock")
for {
// Fast path: create the lock file only if it does not exist yet.
data, err := json.Marshal(lockData{Timestamp: time.Now(), InstanceID: m.instanceID})
if err != nil {
return "", err
}
res, err := m.storage.Upload(ctx, metadata.UploadRequest{
Path: lockFile,
Content: data,
IfNoneMatch: []string{"*"},
})
if err == nil {
m.logger.Debug().Str("instance", m.instanceID).Msg("migration lock acquired")
return res.Etag, nil
}
// Propagate context cancellation immediately.
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
// Any error other than a conflict means something unexpected happened.
if !isConflict(err) {
return "", err
}
// Lock file already exists — read it to decide whether it is stale.
dl, err := m.storage.Download(ctx, metadata.DownloadRequest{Path: lockFile})
if err != nil {
if _, ok := err.(errtypes.IsNotFound); ok {
// Lock was released between our upload attempt and the download;
// retry acquiring it immediately.
m.logger.Debug().Str("instance", m.instanceID).Msg("migration lock vanished during read; retrying")
continue
}
return "", err
}
var existing lockData
stale := true
if err := json.Unmarshal(dl.Content, &existing); err == nil {
stale = time.Since(existing.Timestamp) > lockTTL
}
if stale {
m.logger.Debug().
Str("instance", m.instanceID).
Str("held_by", existing.InstanceID).
Time("lock_timestamp", existing.Timestamp).
Msg("migration lock is stale; attempting takeover")
// Atomically take over the stale lock using the etag we just read.
newData, err := json.Marshal(lockData{Timestamp: time.Now(), InstanceID: m.instanceID})
if err != nil {
return "", err
}
res, err := m.storage.Upload(ctx, metadata.UploadRequest{
Path: lockFile,
Content: newData,
IfMatchEtag: dl.Etag,
})
if err == nil {
m.logger.Debug().Str("instance", m.instanceID).Msg("migration lock acquired via stale takeover")
return res.Etag, nil
}
// Another instance took the stale lock before us; loop and retry.
m.logger.Debug().Str("instance", m.instanceID).Err(err).Msg("stale lock takeover lost race; retrying")
continue
}
m.logger.Debug().
Str("instance", m.instanceID).
Str("held_by", existing.InstanceID).
Time("lock_timestamp", existing.Timestamp).
Dur("poll_interval", lockPollInterval).
Msg("migration lock held by another instance; waiting")
// Lock is fresh and held by another instance; wait before retrying.
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(lockPollInterval):
}
}
}
// startHeartbeat spawns a goroutine that periodically renews the lock file so
// that it is not considered stale while a long migration is running. Call the
// returned cancel function to stop the heartbeat.
func (m *Migrations) startHeartbeat(ctx context.Context, etag string) context.CancelFunc {
hbCtx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(lockHeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-hbCtx.Done():
return
case <-ticker.C:
data, err := json.Marshal(lockData{Timestamp: time.Now(), InstanceID: m.instanceID})
if err != nil {
m.logger.Warn().Err(err).Msg("failed to marshal heartbeat data for migration lock")
return
}
res, err := m.storage.Upload(hbCtx, metadata.UploadRequest{
Path: lockFile,
Content: data,
IfMatchEtag: etag,
})
if err != nil {
m.logger.Warn().Err(err).Msg("failed to renew migration lock; another instance may take over")
return
}
etag = res.Etag
}
}
}()
return cancel
}
// releaseLock deletes the lock file unconditionally.
func (m *Migrations) releaseLock(ctx context.Context) {
if err := m.storage.Delete(ctx, lockFile); err != nil {
m.logger.Warn().Err(err).Msg("failed to release migration lock")
}
}
// isConflict returns true for errors that signal a conditional-upload conflict,
// i.e. the lock file already exists or the etag did not match.
func isConflict(err error) bool {
switch err.(type) {
case errtypes.IsAlreadyExists, errtypes.IsAborted, errtypes.IsPreconditionFailed:
return true
}
return false
}
// loadState reads the persisted migration version from storage. If no state
// file exists yet (fresh deployment) it returns version 0 without error.
func (m *Migrations) loadState(ctx context.Context) error {
data, err := m.storage.SimpleDownload(ctx, stateFile)
if err != nil {
if _, ok := err.(errtypes.IsNotFound); ok {
m.state = state{version: 0}
return nil
}
return err
}
var ps persistedState
if err := json.Unmarshal(data, &ps); err != nil {
return err
}
m.state = state{version: ps.Version}
return nil
}
// saveState writes the current migration version to storage so that already-
// applied migrations are not re-run on the next server start.
func (m *Migrations) saveState(ctx context.Context) error {
data, err := json.Marshal(persistedState{Version: m.state.version})
if err != nil {
return err
}
return m.storage.SimpleUpload(ctx, stateFile, data)
}
func (m *Migrations) RunMigrations() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
etag, err := m.acquireLock(ctx)
if err != nil {
m.logger.Error().Err(err).Msg("failed to acquire migration lock; skipping migrations")
return
}
cancelHB := m.startHeartbeat(ctx, etag)
defer cancelHB()
defer m.releaseLock(ctx)
if err := m.loadState(ctx); err != nil {
m.logger.Error().Err(err).Msg("failed to load migration state; skipping migrations")
return
}
m.logger.Info().Int("current state", m.state.version).Msg("checking migrations")
for _, mig := range migrations {
if mig.Version() > m.state.version {
m.logger.Info().Str("migration", mig.Name()).Int("version", mig.Version()).Msg("running migration")
mig.Initialize(m.config)
if err := mig.Migrate(); err != nil {
m.logger.Error().Err(err).Str("migration", mig.Name()).Msg("migration failed; stopping")
return
}
m.state.version = mig.Version()
if err := m.saveState(ctx); err != nil {
m.logger.Error().Err(err).Msg("failed to save migration state; stopping")
return
}
} else {
m.logger.Info().Str("migration", mig.Name()).Int("version", mig.Version()).Msg("skipping migration")
}
}
}
+2 -1
View File
@@ -28,6 +28,7 @@ import (
ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/share"
"github.com/rs/zerolog"
"google.golang.org/genproto/protobuf/field_mask"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
@@ -46,7 +47,7 @@ func init() {
}
// New returns a new manager.
func New(c map[string]interface{}) (share.Manager, error) {
func New(c map[string]any, _ *zerolog.Logger) (share.Manager, error) {
state := map[string]map[*collaboration.ShareId]collaboration.ShareState{}
mp := map[string]map[*collaboration.ShareId]*provider.Reference{}
return &manager{
@@ -18,11 +18,14 @@
package registry
import "github.com/opencloud-eu/reva/v2/pkg/share"
import (
"github.com/opencloud-eu/reva/v2/pkg/share"
"github.com/rs/zerolog"
)
// NewFunc is the function that share managers
// should register at init time.
type NewFunc func(map[string]interface{}) (share.Manager, error)
type NewFunc func(map[string]any, *zerolog.Logger) (share.Manager, error)
// NewFuncs is a map containing all the registered share managers.
var NewFuncs = map[string]NewFunc{}
+1 -1
View File
@@ -72,7 +72,7 @@ func NewNatsKeyValueFromJetStream(c Config, js jetstream.JetStream) (jetstream.K
if err != nil {
kvConfig := jetstream.KeyValueConfig{
Bucket: c.Database,
TTL: 0, // we don't do TTLs for this store
TTL: c.TTL,
}
if c.DisablePersistence {
kvConfig.Storage = jetstream.MemoryStorage
@@ -65,16 +65,16 @@ func (c *IDCache) DeleteByPath(ctx context.Context, path string) error {
} else {
err := c.kv.Purge(ctx, baseKey)
if err != nil && err != nats.ErrKeyNotFound {
appctx.GetLogger(ctx).Error().Err(err).Str("record", path).Str("spaceID", spaceID).Str("nodeID", nodeID).Msg("could not get spaceID and nodeID from cache")
appctx.GetLogger(ctx).Error().Err(err).Str("record", baseKey).Str("spaceID", spaceID).Str("nodeID", nodeID).Msg("could not purge from cache")
}
err = c.kv.Purge(ctx, cacheKey(spaceID, nodeID))
if err != nil && err != nats.ErrKeyNotFound {
appctx.GetLogger(ctx).Error().Err(err).Str("record", path).Str("spaceID", spaceID).Str("nodeID", nodeID).Msg("could not get spaceID and nodeID from cache")
appctx.GetLogger(ctx).Error().Err(err).Str("record", cacheKey(spaceID, nodeID)).Str("spaceID", spaceID).Str("nodeID", nodeID).Msg("could not purge from cache")
}
}
watcher, err := c.kv.Watch(ctx, baseKey+".*")
watcher, err := c.kv.Watch(ctx, baseKey+".>")
if err != nil {
return err
}
@@ -85,7 +85,6 @@ func (c *IDCache) DeleteByPath(ctx context.Context, path string) error {
break
}
key := update.Key()
spaceID, nodeID, ok := c.getByReverseCacheKey(ctx, key)
if !ok {
appctx.GetLogger(ctx).Error().Str("record", key).Msg("could not get spaceID and nodeID from cache")
@@ -94,12 +93,12 @@ func (c *IDCache) DeleteByPath(ctx context.Context, path string) error {
err := c.kv.Purge(ctx, key)
if err != nil && err != nats.ErrKeyNotFound {
appctx.GetLogger(ctx).Error().Err(err).Str("record", key).Str("spaceID", spaceID).Str("nodeID", nodeID).Msg("could not get spaceID and nodeID from cache")
appctx.GetLogger(ctx).Error().Err(err).Str("record", key).Str("spaceID", spaceID).Str("nodeID", nodeID).Msg("could not purge from cache")
}
err = c.kv.Purge(ctx, cacheKey(spaceID, nodeID))
if err != nil && err != nats.ErrKeyNotFound {
appctx.GetLogger(ctx).Error().Err(err).Str("record", key).Str("spaceID", spaceID).Str("nodeID", nodeID).Msg("could not get spaceID and nodeID from cache")
appctx.GetLogger(ctx).Error().Err(err).Str("record", cacheKey(spaceID, nodeID)).Str("spaceID", spaceID).Str("nodeID", nodeID).Msg("could not purge from cache")
}
}
return nil
+11 -1
View File
@@ -24,6 +24,7 @@ import (
"os"
"path/filepath"
"syscall"
"time"
"github.com/rs/zerolog"
tusd "github.com/tus/tusd/v2/pkg/handler"
@@ -58,7 +59,8 @@ func init() {
type posixFS struct {
storage.FS
um usermapper.Mapper
tree *tree.Tree
um usermapper.Mapper
}
// New returns an implementation to of the storage.FS interface that talk to
@@ -70,6 +72,7 @@ func NewDefault(m map[string]interface{}, stream events.Stream, log *zerolog.Log
}
o.IDCache.Database += "_v2" // Use a versioned bucket name to avoid conflicts with previous implementations
o.IDCache.TTL = 0 // Disable TTL for the ID cache, as the posix driver relies on it for caching file IDs and we don't want them to expire
kv, err := cache.NewNatsKeyValue(o.IDCache)
if err != nil {
return nil, errors.Wrap(err, "could not create nats key value store")
@@ -80,6 +83,7 @@ func NewDefault(m map[string]interface{}, stream events.Stream, log *zerolog.Log
}
o.IDCache.Database += "_history" // Use a versioned bucket name to avoid conflicts with previous implementations
o.IDCache.TTL = 24 * 60 * time.Minute
historyKv, err := cache.NewNatsKeyValue(o.IDCache)
if err != nil {
return nil, errors.Wrap(err, "could not create nats key value store")
@@ -215,11 +219,17 @@ func New(o *options.Options, stream events.Stream, cache, historyCache *idcache.
mw := middleware.NewFS(dfs, hooks...)
fs.FS = mw
fs.tree = tp
fs.um = um
return fs, nil
}
// WarmupIDCache allows triggering a posix fs scan and id cache warmup manually.
func (fs *posixFS) WarmupIDCache(root string, assimilate, onlyDirty bool) error {
return fs.tree.WarmupIDCache(root, assimilate, onlyDirty)
}
// ListUploadSessions returns the upload sessions matching the given filter
func (fs *posixFS) ListUploadSessions(ctx context.Context, filter storage.UploadSessionFilter) ([]storage.UploadSession, error) {
return fs.FS.(storage.UploadSessionLister).ListUploadSessions(ctx, filter)
@@ -37,10 +37,8 @@ import (
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/reva/v2/pkg/appctx"
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/storage/fs/posix/blobstore"
@@ -590,11 +588,6 @@ func (t *Tree) Delete(ctx context.Context, n *node.Node) error {
}
}()
if appctx.DeletingSharedResourceFromContext(ctx) {
src := filepath.Join(n.ParentPath(), n.Name)
return os.RemoveAll(src)
}
var sizeDiff int64
if n.IsDir(ctx) {
treesize, err := n.GetTreeSize(ctx)
@@ -819,11 +812,3 @@ func isLockFile(path string) bool {
func isTrash(path string) bool {
return strings.HasSuffix(path, ".trashinfo") || strings.HasSuffix(path, ".trashitem") || strings.Contains(path, ".Trash")
}
func (t *Tree) AddLabel(ctx context.Context, ref *provider.Reference, userID *user.UserId, label string) error {
return errtypes.NotSupported("AddLabel not implemented")
}
func (t *Tree) RemoveLabel(ctx context.Context, ref *provider.Reference, userID *user.UserId, label string) error {
return errtypes.NotSupported("RemoveLabel not implemented")
}
@@ -101,6 +101,7 @@ func ServiceAccountPermissions() *provider.ResourcePermissions {
Delete: true, // for cli restore command with replace option
CreateContainer: true, // for space provisioning
AddGrant: true, // for initial project space member assignment
ListGrants: true, // for initial project space member assignment
}
}
@@ -429,11 +429,6 @@ func (t *Tree) Delete(ctx context.Context, n *node.Node) (err error) {
// remove entry from cache immediately to avoid inconsistencies
defer func() { _ = t.idCache.Delete(path) }()
if appctx.DeletingSharedResourceFromContext(ctx) {
src := filepath.Join(n.ParentPath(), n.Name)
return os.Remove(src)
}
// get the original path
origin, err := t.lookup.Path(ctx, n, node.NoCheck)
if err != nil {
@@ -445,11 +445,6 @@ func (t *Tree) Delete(ctx context.Context, n *node.Node) (err error) {
// remove entry from cache immediately to avoid inconsistencies
defer func() { _ = t.idCache.Delete(path) }()
if appctx.DeletingSharedResourceFromContext(ctx) {
src := filepath.Join(n.ParentPath(), n.Name)
return os.Remove(src)
}
// get the original path
origin, err := t.lookup.Path(ctx, n, node.NoCheck)
if err != nil {
+38 -1
View File
@@ -93,6 +93,40 @@ func (disk *Disk) SimpleUpload(ctx context.Context, uploadpath string, content [
// Upload stores a file on disk
func (disk *Disk) Upload(_ context.Context, req UploadRequest) (*UploadResponse, error) {
p := disk.targetPath(req.Path)
// IfNoneMatch: ["*"] means create the file only if it does not already
// exist. Use O_EXCL so the check and the create are atomic on the local
// filesystem.
for _, tag := range req.IfNoneMatch {
if tag != "*" {
continue
}
f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
if errors.Is(err, os.ErrExist) {
return nil, errtypes.AlreadyExists(p)
}
return nil, err
}
if _, err := f.Write(req.Content); err != nil {
_ = f.Close()
return nil, err
}
if err := f.Close(); err != nil {
return nil, err
}
info, err := os.Stat(p)
if err != nil {
return nil, err
}
res := &UploadResponse{}
res.Etag, err = calcEtag(info.ModTime(), info.Size())
if err != nil {
return nil, err
}
return res, nil
}
if req.IfMatchEtag != "" {
info, err := os.Stat(p)
if err != nil && !errors.Is(err, os.ErrNotExist) {
@@ -170,7 +204,10 @@ func (disk *Disk) Download(_ context.Context, req DownloadRequest) (*DownloadRes
// SimpleDownload reads a file from disk
func (disk *Disk) SimpleDownload(ctx context.Context, downloadpath string) ([]byte, error) {
res, err := disk.Download(ctx, DownloadRequest{Path: downloadpath})
return res.Content, err
if err != nil {
return nil, err
}
return res.Content, nil
}
// Delete deletes a path
+48 -41
View File
@@ -266,15 +266,10 @@ func (i *Identity) GetLDAPUserByFilter(ctx context.Context, lc ldap.Client, filt
res, err := lc.Search(searchRequest)
if err != nil {
log.Debug().Str("backend", "ldap").Err(err).Str("userfilter", filter).Msg("Error looking up user by filter")
var errmsg string
if lerr, ok := err.(*ldap.Error); ok {
if lerr.ResultCode == ldap.LDAPResultSizeLimitExceeded {
errmsg = fmt.Sprintf("too many results searching for user '%s'", filter)
}
}
span.SetAttributes(attribute.String("ldap.error", errmsg))
span.SetStatus(codes.Error, errmsg)
return nil, errtypes.NotFound(errmsg)
classified := classifySearchError(err, fmt.Sprintf("too many results searching for user '%s'", filter))
span.SetAttributes(attribute.String("ldap.error", classified.Error()))
span.SetStatus(codes.Error, classified.Error())
return nil, classified
}
if len(res.Entries) == 0 {
return nil, errtypes.NotFound(filter)
@@ -306,9 +301,10 @@ func (i *Identity) GetLDAPUserByDN(ctx context.Context, lc ldap.Client, dn strin
res, err := lc.Search(searchRequest)
if err != nil {
log.Debug().Str("backend", "ldap").Err(err).Str("dn", dn).Msg("Error looking up user by DN")
span.SetAttributes(attribute.String("ldap.error", err.Error()))
span.SetStatus(codes.Error, "")
return nil, errtypes.NotFound(dn)
classified := classifySearchError(err, "")
span.SetAttributes(attribute.String("ldap.error", classified.Error()))
span.SetStatus(codes.Error, classified.Error())
return nil, classified
}
span.SetStatus(codes.Ok, "")
if len(res.Entries) == 0 {
@@ -337,9 +333,10 @@ func (i *Identity) GetLDAPUsers(ctx context.Context, lc ldap.Client, query, tena
sr, err := lc.Search(searchRequest)
if err != nil {
log.Debug().Str("backend", "ldap").Err(err).Str("filter", filter).Msg("Error searching users")
span.SetAttributes(attribute.String("ldap.error", err.Error()))
span.SetStatus(codes.Error, "")
return nil, errtypes.NotFound(query)
classified := classifySearchError(err, "")
span.SetAttributes(attribute.String("ldap.error", classified.Error()))
span.SetStatus(codes.Error, classified.Error())
return nil, classified
}
span.SetAttributes(attribute.Int("ldap.result_count", len(sr.Entries)))
@@ -376,7 +373,8 @@ func (i *Identity) IsLDAPUserInDisabledGroup(ctx context.Context, lc ldap.Client
sr, err := lc.Search(searchRequest)
if err != nil {
log.Error().Str("backend", "ldap").Err(err).Str("filter", filter).Msg("Error looking up error group")
// Err on the side of caution.
// Err on the side of caution: treat search failures (including network
// errors) as if the user is in the disabled group.
span.SetAttributes(attribute.String("ldap.error", err.Error()))
span.SetStatus(codes.Error, "")
return true
@@ -423,10 +421,10 @@ func (i *Identity) GetLDAPUserGroups(ctx context.Context, lc ldap.Client, userEn
// not having any groups in LDAP
return []string{}, nil
}
span.SetAttributes(attribute.String("ldap.error", err.Error()))
span.SetStatus(codes.Error, "")
return []string{}, err
classified := classifySearchError(err, "")
span.SetAttributes(attribute.String("ldap.error", classified.Error()))
span.SetStatus(codes.Error, classified.Error())
return nil, classified
}
span.SetStatus(codes.Ok, "")
span.SetAttributes(attribute.Int("ldap.result_count", len(sr.Entries)))
@@ -504,15 +502,10 @@ func (i *Identity) GetLDAPGroupByFilter(ctx context.Context, lc ldap.Client, fil
res, err := lc.Search(searchRequest)
if err != nil {
log.Debug().Str("backend", "ldap").Err(err).Str("filter", filter).Msg("Error looking up group by filter")
var errmsg string
if lerr, ok := err.(*ldap.Error); ok {
if lerr.ResultCode == ldap.LDAPResultSizeLimitExceeded {
errmsg = fmt.Sprintf("too many results searching for group '%s'", filter)
}
}
span.SetAttributes(attribute.String("ldap.error", errmsg))
span.SetStatus(codes.Error, "")
return nil, errtypes.NotFound(errmsg)
classified := classifySearchError(err, fmt.Sprintf("too many results searching for group '%s'", filter))
span.SetAttributes(attribute.String("ldap.error", classified.Error()))
span.SetStatus(codes.Error, classified.Error())
return nil, classified
}
if len(res.Entries) == 0 {
return nil, errtypes.NotFound(filter)
@@ -543,10 +536,11 @@ func (i *Identity) GetLDAPGroups(ctx context.Context, lc ldap.Client, query stri
setLDAPSearchSpanAttributes(span, searchRequest)
sr, err := lc.Search(searchRequest)
if err != nil {
span.SetAttributes(attribute.String("ldap.error", err.Error()))
span.SetStatus(codes.Error, "")
log.Debug().Str("backend", "ldap").Err(err).Str("query", query).Msg("Error search for groups")
return nil, errtypes.NotFound(query)
classified := classifySearchError(err, "")
span.SetAttributes(attribute.String("ldap.error", classified.Error()))
span.SetStatus(codes.Error, classified.Error())
return nil, classified
}
span.SetStatus(codes.Ok, "")
return sr.Entries, nil
@@ -919,15 +913,10 @@ func (i *Identity) GetLDAPTenantByFilter(ctx context.Context, lc ldap.Client, fi
res, err := lc.Search(searchRequest)
if err != nil {
log.Debug().Str("backend", "ldap").Err(err).Str("tenantfilter", filter).Msg("Error looking up tenant by filter")
var errmsg string
if lerr, ok := err.(*ldap.Error); ok {
if lerr.ResultCode == ldap.LDAPResultSizeLimitExceeded {
errmsg = fmt.Sprintf("too many results searching for tenant '%s'", filter)
}
}
span.SetAttributes(attribute.String("ldap.error", errmsg))
span.SetStatus(codes.Error, errmsg)
return nil, errtypes.NotFound(errmsg)
classified := classifySearchError(err, fmt.Sprintf("too many results searching for tenant '%s'", filter))
span.SetAttributes(attribute.String("ldap.error", classified.Error()))
span.SetStatus(codes.Error, classified.Error())
return nil, classified
}
if len(res.Entries) == 0 {
return nil, errtypes.NotFound(filter)
@@ -980,6 +969,24 @@ func (i *Identity) getTenantAttributeFilter(attribute, value string) (string, er
), nil
}
// classifySearchError maps a raw error from lc.Search to the appropriate
// errtypes value:
// - ldap.ErrorNetwork → errtypes.Unavailable (transient; caller should retry)
// - ldap.LDAPResultSizeLimitExceeded → errtypes.NotFound(sizeExceededMsg)
// - anything else → errtypes.NotFound("") (preserving prior behaviour)
//
// The sizeExceededMsg is only used for the SizeLimitExceeded case; pass an
// empty string if the caller does not need a custom message for that case.
func classifySearchError(err error, sizeExceededMsg string) error {
if ldap.IsErrorWithCode(err, ldap.ErrorNetwork) {
return errtypes.Unavailable("ldap server unreachable: " + err.Error())
}
if sizeExceededMsg != "" && ldap.IsErrorWithCode(err, ldap.LDAPResultSizeLimitExceeded) {
return errtypes.NotFound(sizeExceededMsg)
}
return errtypes.NotFound("")
}
func setLDAPSearchSpanAttributes(span trace.Span, request *ldap.SearchRequest) {
span.SetAttributes(
attribute.String("ldap.basedn", request.BaseDN),