package authconfig import ( "context" "errors" "sync" "github.com/aritmolab/thothii/tools/tht/internal/authprojection" ) const transactionLockFileName = ".auth-transaction.lock" var ( errProjectionMutation = errors.New("authentication projection mutation failed") errProjectionPublish = errors.New("authentication projection publication failed") errProjectionCleanup = errors.New("authentication projection cleanup failed") errProjectionIntegrity = errors.New("authentication projection coordination failed") ) var runtimeProjectionBegin = authprojection.BeginContext func setRuntimeProjectionBeginForTest(begin func(context.Context, authprojection.Spec, *authprojection.Snapshot, bool) (*authprojection.Transaction, error)) func() { previous := runtimeProjectionBegin runtimeProjectionBegin = begin return func() { runtimeProjectionBegin = previous } } type ProjectionSpec struct { RuntimeRoot string UID uint32 GID uint32 } type ProjectionStatus struct { State string Generation string CanonicalRevision string Equal bool } type ExternalProjectionTransaction struct { ctx context.Context canonicalRoot string spec authprojection.Spec outerLock *transactionLock projection *authprojection.Transaction before authprojection.Snapshot hasBefore bool published bool closed bool } type projectionCoordinatorHooks struct { onOuterLockContention func() onCanonicalLockContention func() beforeProjectionBegin func() verifyCommittedProjection func(authprojection.Status, authprojection.Snapshot) error } var projectionCoordinatorHookState struct { sync.Mutex hooks projectionCoordinatorHooks } func setProjectionCoordinatorHooksForTest(hooks projectionCoordinatorHooks) func() { projectionCoordinatorHookState.Lock() previous := projectionCoordinatorHookState.hooks projectionCoordinatorHookState.hooks = hooks projectionCoordinatorHookState.Unlock() return func() { projectionCoordinatorHookState.Lock() projectionCoordinatorHookState.hooks = previous projectionCoordinatorHookState.Unlock() } } func coordinatorHooks() projectionCoordinatorHooks { projectionCoordinatorHookState.Lock() defer projectionCoordinatorHookState.Unlock() return projectionCoordinatorHookState.hooks } func RunProjectedMutation(ctx context.Context, canonicalRoot string, spec ProjectionSpec, mutate func() error) (err error) { if mutate == nil { return errProjectionMutation } transaction, err := BeginExternalProjectionTransaction(ctx, canonicalRoot, spec) if err != nil { return errProjectionIntegrity } defer func() { if closeErr := transaction.Close(); closeErr != nil { if err == nil { err = errProjectionCleanup } else { err = errors.Join(err, errProjectionCleanup) } } }() if mutateErr := mutate(); mutateErr != nil { if restoreErr := transaction.restorePriorIfCanonicalUnchanged(ctx); restoreErr != nil { return errors.Join(errProjectionMutation, errProjectionCleanup) } return errProjectionMutation } if _, publishErr := transaction.publishCanonicalContext(ctx); publishErr != nil { return errProjectionPublish } return nil } func PublishProjectedCanonical(ctx context.Context, canonicalRoot string, spec ProjectionSpec) (status ProjectionStatus, err error) { transaction, err := BeginExternalProjectionTransaction(ctx, canonicalRoot, spec) if err != nil { return ProjectionStatus{}, errProjectionIntegrity } defer func() { if closeErr := transaction.Close(); closeErr != nil { if err == nil { err = errProjectionCleanup } else { err = errors.Join(err, errProjectionCleanup) } } }() return transaction.publishCanonicalContext(ctx) } func BeginExternalProjectionTransaction(ctx context.Context, canonicalRoot string, spec ProjectionSpec) (transaction *ExternalProjectionTransaction, err error) { if ctx == nil { return nil, errProjectionIntegrity } if err := requirePrivateDirectory(canonicalRoot); err != nil { return nil, errProjectionIntegrity } outerLock, err := acquireTransactionLock(ctx, canonicalRoot) if err != nil { return nil, errProjectionIntegrity } transaction = &ExternalProjectionTransaction{ ctx: ctx, canonicalRoot: canonicalRoot, spec: authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID}, outerLock: outerLock, } created := transaction defer func() { if err != nil { if closeErr := created.closeProjectionAndOuterLock(); closeErr != nil { err = errors.Join(errProjectionIntegrity, errProjectionCleanup) } else { err = errProjectionIntegrity } } }() if err = transaction.beginProjection(ctx); err != nil { return nil, err } return transaction, nil } func (transaction *ExternalProjectionTransaction) PublishCanonical() (ProjectionStatus, error) { if transaction == nil { return ProjectionStatus{}, errProjectionIntegrity } return transaction.publishCanonicalContext(transaction.ctx) } func (transaction *ExternalProjectionTransaction) publishCanonicalContext(ctx context.Context) (ProjectionStatus, error) { if transaction == nil || transaction.closed { return ProjectionStatus{}, errProjectionIntegrity } if transaction.projection == nil { if err := transaction.beginProjection(ctx); err != nil { return ProjectionStatus{}, errProjectionIntegrity } } var published authprojection.Status var expected authprojection.Snapshot committed := false err := transaction.withCanonicalLock(ctx, func() error { snapshot, err := loadSnapshotBytes(transaction.canonicalRoot) if err != nil { return err } expected = snapshot published, err = transaction.projection.Commit(snapshot) if err != nil { return err } committed = true if !equalProjection(published, snapshot) { return errProjectionIntegrity } if verify := coordinatorHooks().verifyCommittedProjection; verify != nil { return verify(published, snapshot) } return nil }) if err != nil { if committed { if blockErr := transaction.ensureBlocked(); blockErr != nil { return ProjectionStatus{}, errors.Join(errProjectionPublish, errProjectionCleanup) } } return ProjectionStatus{}, errProjectionPublish } transaction.published = true if err := transaction.releaseProjection(); err != nil { if blockErr := transaction.ensureBlocked(); blockErr != nil { return ProjectionStatus{}, errors.Join(errProjectionCleanup, errProjectionIntegrity) } return ProjectionStatus{}, errProjectionCleanup } return projectionStatus(published, expected), nil } func (transaction *ExternalProjectionTransaction) RestorePriorIfCanonicalUnchanged() error { if transaction == nil { return errProjectionIntegrity } return transaction.restorePriorIfCanonicalUnchanged(transaction.ctx) } func (transaction *ExternalProjectionTransaction) restorePriorIfCanonicalUnchanged(ctx context.Context) error { if transaction == nil || transaction.closed || transaction.projection == nil || !transaction.hasBefore { return errProjectionIntegrity } err := transaction.withCanonicalLock(ctx, func() error { current, err := loadSnapshotBytes(transaction.canonicalRoot) if err != nil { return err } if current.Generation != transaction.before.Generation { return errProjectionIntegrity } return transaction.projection.RestoreIfUnchanged(current) }) if err != nil { return errProjectionIntegrity } return transaction.releaseProjection() } func (transaction *ExternalProjectionTransaction) Close() (err error) { if transaction == nil || transaction.closed { return nil } defer func() { transaction.closed = true err = errors.Join(err, transaction.closeProjectionAndOuterLock()) }() if transaction.projection == nil && transaction.published { if beginErr := transaction.beginProjection(context.Background()); beginErr != nil { return errProjectionIntegrity } if restoreErr := transaction.restorePriorIfCanonicalUnchanged(context.Background()); restoreErr != nil { return errProjectionIntegrity } } return nil } func (transaction *ExternalProjectionTransaction) beginProjection(ctx context.Context) error { status, err := authprojection.Inspect(transaction.spec) var before *authprojection.Snapshot requireReadyMatch := false if err == nil { captured := status.Snapshot before = &captured requireReadyMatch = true } if hook := coordinatorHooks().beforeProjectionBegin; hook != nil { hook() } projection, err := runtimeProjectionBegin(ctx, transaction.spec, before, requireReadyMatch) if err != nil { return errProjectionIntegrity } transaction.projection = projection transaction.hasBefore = before != nil if before != nil { transaction.before = *before } else { transaction.before = authprojection.Snapshot{} } return nil } func (transaction *ExternalProjectionTransaction) ensureBlocked() error { if transaction.projection != nil { if err := transaction.releaseProjection(); err != nil { return err } } return transaction.beginProjection(context.Background()) } func (transaction *ExternalProjectionTransaction) releaseProjection() error { if transaction.projection == nil { return nil } projection := transaction.projection transaction.projection = nil if err := projection.Close(); err != nil { return errProjectionIntegrity } return nil } func (transaction *ExternalProjectionTransaction) closeProjectionAndOuterLock() error { var err error if closeErr := transaction.releaseProjection(); closeErr != nil { err = errors.Join(err, closeErr) } if transaction.outerLock != nil { if unlockErr := transaction.outerLock.Close(); unlockErr != nil { err = errors.Join(err, errProjectionCleanup) } transaction.outerLock = nil } return err } func (transaction *ExternalProjectionTransaction) withCanonicalLock(ctx context.Context, callback func() error) (err error) { lock, err := acquireLockContext(ctx, transaction.canonicalRoot) if err != nil { return errProjectionIntegrity } defer func() { if unlockErr := lock.Unlock(); unlockErr != nil { err = errors.Join(err, errProjectionCleanup) } }() return callback() } func equalProjection(status authprojection.Status, snapshot authprojection.Snapshot) bool { return status.Selector.State == "ready" && status.Snapshot.Generation == snapshot.Generation && status.Snapshot.CanonicalRevision == snapshot.CanonicalRevision } func projectionStatus(status authprojection.Status, expected authprojection.Snapshot) ProjectionStatus { return ProjectionStatus{ State: status.Selector.State, Generation: status.Snapshot.Generation, CanonicalRevision: status.Snapshot.CanonicalRevision, Equal: equalProjection(status, expected), } }