Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -6921,6 +6921,19 @@ func (q *querier) LockChatByID(ctx context.Context, id uuid.UUID) (uuid.UUID, er
return q.db.LockChatByID(ctx, id)
}

func (q *querier) LockProvisionerKeyByIDForShare(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
// The lock query returns only the key ID, so fetch the key to authorize
// the read against its RBAC object.
key, err := q.db.GetProvisionerKeyByID(ctx, id)
if err != nil {
return uuid.Nil, err
}
if err := q.authorizeContext(ctx, policy.ActionRead, key); err != nil {
return uuid.Nil, err
}
return q.db.LockProvisionerKeyByIDForShare(ctx, id)
}

func (q *querier) MarkAllInboxNotificationsAsRead(ctx context.Context, arg database.MarkAllInboxNotificationsAsReadParams) error {
resource := rbac.ResourceInboxNotification.WithOwner(arg.UserID.String())

Expand Down
7 changes: 7 additions & 0 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4488,6 +4488,13 @@ func (s *MethodTestSuite) TestProvisionerKeys() {
dbm.EXPECT().GetProvisionerKeyByID(gomock.Any(), pk.ID).Return(pk, nil).AnyTimes()
check.Args(pk.ID).Asserts(pk, policy.ActionRead).Returns(pk)
}))
s.Run("LockProvisionerKeyByIDForShare", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
org := testutil.Fake(s.T(), faker, database.Organization{})
pk := testutil.Fake(s.T(), faker, database.ProvisionerKey{OrganizationID: org.ID})
dbm.EXPECT().GetProvisionerKeyByID(gomock.Any(), pk.ID).Return(pk, nil).AnyTimes()
dbm.EXPECT().LockProvisionerKeyByIDForShare(gomock.Any(), pk.ID).Return(pk.ID, nil).AnyTimes()
check.Args(pk.ID).Asserts(pk, policy.ActionRead).Returns(pk.ID)
}))
s.Run("GetProvisionerKeyByHashedSecret", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
org := testutil.Fake(s.T(), faker, database.Organization{})
pk := testutil.Fake(s.T(), faker, database.ProvisionerKey{OrganizationID: org.ID, HashedSecret: []byte("foo")})
Expand Down
8 changes: 8 additions & 0 deletions coderd/database/dbmetrics/querymetrics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions coderd/database/dbmock/dbmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions coderd/database/querier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2613,6 +2613,27 @@ func TestAcquireProvisionerJob(t *testing.T) {
})
require.ErrorIs(t, err, sql.ErrNoRows)
})

t.Run("ProvisionerKeyLock", func(t *testing.T) {
t.Parallel()
var (
db, _ = dbtestutil.NewDB(t)
ctx = testutil.Context(t, testutil.WaitMedium)
org = dbgen.Organization(t, db, database.Organization{})
key = dbgen.ProvisionerKey(t, db, database.ProvisionerKey{OrganizationID: org.ID})
)

// While the key exists, the lock returns its ID.
id, err := db.LockProvisionerKeyByIDForShare(ctx, key.ID)
require.NoError(t, err)
require.Equal(t, key.ID, id)

// Once the key is deleted, the lock reports no rows.
err = db.DeleteProvisionerKey(ctx, key.ID)
require.NoError(t, err)
_, err = db.LockProvisionerKeyByIDForShare(ctx, key.ID)
require.ErrorIs(t, err, sql.ErrNoRows)
})
}

func TestUserLastSeenFilter(t *testing.T) {
Expand Down
21 changes: 21 additions & 0 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions coderd/database/queries/provisionerkeys.sql
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ FROM
WHERE
id = $1;

-- name: LockProvisionerKeyByIDForShare :one
-- Locks the provisioner key row with FOR KEY SHARE for the remainder of the
-- current transaction. FOR KEY SHARE conflicts with DELETE, so while the lock
-- is held the key cannot be deleted, and a committed deletion is observed as
-- no rows by later calls.
SELECT
id
FROM
provisioner_keys
WHERE
id = $1
FOR KEY SHARE;

-- name: GetProvisionerKeyByHashedSecret :one
SELECT
*
Expand Down
74 changes: 58 additions & 16 deletions coderd/provisionerdserver/acquirer.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import (

"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/quartz"
)

Expand Down Expand Up @@ -61,9 +63,12 @@ func WithClock(clock quartz.Clock) AcquirerOption {
}
}

// AcquirerStore is the subset of database.Store that the Acquirer needs
// AcquirerStore is the subset of database.Store that the Acquirer needs. Job
// acquisition runs in a transaction that locks the worker's deletable
// provisioner key (LockProvisionerKeyByIDForShare) before claiming a job
// (AcquireProvisionerJob), so a claim cannot commit after the key's deletion.
type AcquirerStore interface {
AcquireProvisionerJob(context.Context, database.AcquireProvisionerJobParams) (database.ProvisionerJob, error)
InTx(func(database.Store) error, *database.TxOptions) error
}

func NewAcquirer(ctx context.Context, logger slog.Logger, store AcquirerStore, ps pubsub.Pubsub,
Expand All @@ -88,11 +93,15 @@ func NewAcquirer(ctx context.Context, logger slog.Logger, store AcquirerStore, p
// tags from the database. The call blocks until a job is acquired, the context is
// done, or the database returns an error _other_ than that no jobs are available.
// If no jobs are available, this method handles retrying as appropriate.
// When keyID is a deletable provisioner key, the claim only succeeds while
// that key row still exists. Reserved keys and the zero value are not
// checked, as they have no row to delete.
func (a *Acquirer) AcquireJob(
ctx context.Context, organization uuid.UUID, worker uuid.UUID, pt []database.ProvisionerType, tags Tags,
ctx context.Context, organization uuid.UUID, worker uuid.UUID, pt []database.ProvisionerType, tags Tags, keyID uuid.UUID,
) (
retJob database.ProvisionerJob, retErr error,
) {
deletableKey := codersdk.IsDeletableProvisionerKey(keyID)
logger := a.logger.With(
slog.F("organization_id", organization),
slog.F("worker_id", worker),
Expand Down Expand Up @@ -120,19 +129,52 @@ func (a *Acquirer) AcquireJob(
return database.ProvisionerJob{}, err
case <-clearance:
logger.Debug(ctx, "got clearance to call database")
job, err := a.store.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
OrganizationID: organization,
StartedAt: sql.NullTime{
Time: dbtime.Now(),
Valid: true,
},
WorkerID: uuid.NullUUID{
UUID: worker,
Valid: true,
},
Types: pt,
ProvisionerTags: dbTags,
})
var job database.ProvisionerJob
err := a.store.InTx(func(tx database.Store) error {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is executed in a transaction to ensure that a job is claimed iff the provisioner's associated key is still live. The transaction adds an 4 extra round trips to the database, holding the key lock for 3 of these. imo this is worthwhile - the backup acquisition polling is every 30s per domain, much larger than the milliseconds added by the chained queries. If this is a concern, an earlier version of this code did the check within AcquireProvisionerJob, but overloading that query to handle key deletion sacrificed a good bit of clarity.

if deletableKey {
// Lock the key for the rest of the transaction so the claim
// below cannot commit after the key's deletion. A missing row
// means the key was deleted.
_, err := tx.LockProvisionerKeyByIDForShare(
//nolint:gocritic // The acquire context has no actor that can
// read provisioner keys, so scope the read to this narrow subject.
dbauthz.AsSystemReadProvisionerDaemons(ctx), keyID)
if xerrors.Is(err, sql.ErrNoRows) {
return ErrProvisionerKeyDeleted
}
if err != nil {
return xerrors.Errorf("lock provisioner key: %w", err)
}
}
acquired, err := tx.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
OrganizationID: organization,
StartedAt: sql.NullTime{
Time: dbtime.Now(),
Valid: true,
},
WorkerID: uuid.NullUUID{
UUID: worker,
Valid: true,
},
Types: pt,
ProvisionerTags: dbTags,
})
if err != nil {
return err
}
job = acquired
return nil
}, nil)
if xerrors.Is(err, ErrProvisionerKeyDeleted) {
logger.Debug(ctx, "provisioner key deleted, exiting acquire")
// cancel (not done) hands an in-progress clearance to another
// acquiree in the domain, re-dispatching the wakeup this
// acquiree consumed.
if internalError := a.cancel(dk, clearance); internalError != nil {
return database.ProvisionerJob{}, internalError
}
return database.ProvisionerJob{}, ErrProvisionerKeyDeleted
}
if xerrors.Is(err, sql.ErrNoRows) {
logger.Debug(ctx, "no job available")
continue
Expand Down
Loading
Loading