Files

131 lines
4.5 KiB
Go

package ldapsync
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/italypaleale/francis/actor"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
)
// The LdapSync singleton actor decides when the recurring LDAP synchronization runs.
// SyncActorType is the actor type for the LDAP sync actor
const SyncActorType = "LdapSync"
const (
// alarmSync is the name of the repeating alarm that runs the synchronization
alarmSync = "sync"
// syncInterval is how often the synchronization runs, as the ISO8601 duration the alarm repetition expects
// There's no jitter: the alarm is cluster-wide, so there are no replicas to spread apart
syncInterval = "PT1H"
// Delay the initial sync by 5s
initialSyncDelay = 5 * time.Second
// alarmTimeout bounds the alarm operations performed by the actor
alarmTimeout = 10 * time.Second
)
// syncActor is the cluster-wide singleton that triggers the recurring LDAP synchronization
type syncActor struct {
log *slog.Logger
service *Service
appConfig appconfig.AppConfigResolver
// scheduleDisabled removes the alarm instead of arming it, for environments that drive syncs explicitly
scheduleDisabled bool
client actor.Client[struct{}]
}
// NewSyncActor returns the factory that allocates the LDAP sync actor
func NewSyncActor(service *Service, appConfig appconfig.AppConfigResolver, scheduleDisabled bool) actor.Factory {
return func(actorID string, actorService *actor.Service) actor.Actor {
return &syncActor{
log: slog.With(
slog.String("scope", "actor"),
slog.String("actorType", SyncActorType),
),
service: service,
appConfig: appConfig,
scheduleDisabled: scheduleDisabled,
// The actor keeps no state of its own: the client is only used to manage the alarm
client: actor.NewActorClient[struct{}](SyncActorType, actorID, actorService),
}
}
}
// Bootstrap implements actor.ActorBootstrapper
// The host drives it on every startup, routed to the single owning host, so it must stay idempotent
func (a *syncActor) Bootstrap(parentCtx context.Context, _ actor.Envelope) error {
ctx, cancel := context.WithTimeout(parentCtx, alarmTimeout)
defer cancel()
// The schedule may have been enabled in a previous run, so make sure a leftover alarm doesn't keep firing
if a.scheduleDisabled {
err := a.client.DeleteAlarm(ctx, alarmSync)
if err != nil && !errors.Is(err, actor.ErrAlarmNotFound) {
return fmt.Errorf("error deleting the LDAP sync alarm: %w", err)
}
return nil
}
// Setting the alarm replaces whatever is registered, which both restores an alarm that was lost and picks up a change to the interval
// It's due right away (with a small delay) so the directory is synchronized as soon as the cluster starts, matching what the pre-actor scheduled job did
err := a.client.SetAlarm(ctx, alarmSync, actor.AlarmProperties{
DueTime: time.Now().Add(initialSyncDelay),
Interval: syncInterval,
})
if err != nil {
return fmt.Errorf("error setting the LDAP sync alarm: %w", err)
}
a.log.DebugContext(parentCtx, "Registered the recurring LDAP sync alarm", slog.String("interval", syncInterval))
return nil
}
// Alarm implements actor.ActorAlarm
func (a *syncActor) Alarm(ctx context.Context, name string, _ actor.Envelope) error {
if name != alarmSync {
return fmt.Errorf("unsupported alarm '%s' for the %s actor", name, SyncActorType)
}
a.sync(ctx)
// A failed sync never surfaces as an error: the framework would retry the occurrence and then delete the alarm once the attempts run out, which would stop the synchronization altogether
// The next occurrence comes around on its own, exactly like the pre-actor scheduled job
return nil
}
// sync runs one synchronization, unless LDAP is disabled
// It logs failures rather than returning them, since the alarm has nowhere useful to send the error
func (a *syncActor) sync(ctx context.Context) {
dbConfig, err := a.appConfig.GetConfig(ctx)
if err != nil {
a.log.ErrorContext(ctx, "Failed to load the app configuration, skipping the LDAP sync", slog.Any("error", err))
return
}
if !dbConfig.LdapEnabled.IsTrue() {
a.log.DebugContext(ctx, "LDAP is disabled, skipping the sync")
return
}
a.log.InfoContext(ctx, "Starting the LDAP sync")
start := time.Now()
err = a.service.SyncAll(ctx, dbConfig)
if err != nil {
a.log.ErrorContext(ctx, "LDAP sync failed, will try again on the next run", slog.Duration("duration", time.Since(start)), slog.Any("error", err))
return
}
a.log.InfoContext(ctx, "LDAP sync completed", slog.Duration("duration", time.Since(start)))
}