diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index 6fe0a72311220..969bc77f96f1e 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -50,7 +50,12 @@ "deadline": "[timestamp]", "max_deadline": null, "status": "running", - "daily_cost": 0 + "daily_cost": 0, + "matched_provisioners": { + "count": 0, + "available": 0, + "most_recently_seen": null + } }, "outdated": false, "name": "test-workspace", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 2d5e440575cab..f64dbcc166591 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1568,6 +1568,10 @@ func (q *querier) GetDeploymentWorkspaceStats(ctx context.Context) (database.Get return q.db.GetDeploymentWorkspaceStats(ctx) } +func (q *querier) GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.Context, provisionerJobIds []uuid.UUID) ([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetEligibleProvisionerDaemonsByProvisionerJobIDs)(ctx, provisionerJobIds) +} + func (q *querier) GetExternalAuthLink(ctx context.Context, arg database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) { return fetchWithAction(q.log, q.auth, policy.ActionReadPersonal, q.db.GetExternalAuthLink)(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 5c8cb7766a960..961f5d535b280 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2119,6 +2119,29 @@ func (s *MethodTestSuite) TestExtraMethods() { s.NoError(err, "get provisioner daemon by org") check.Args(database.GetProvisionerDaemonsByOrganizationParams{OrganizationID: org.ID}).Asserts(d, policy.ActionRead).Returns(ds) })) + s.Run("GetEligibleProvisionerDaemonsByProvisionerJobIDs", s.Subtest(func(db database.Store, check *expects) { + org := dbgen.Organization(s.T(), db, database.Organization{}) + tags := database.StringMap(map[string]string{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }) + j, err := db.InsertProvisionerJob(context.Background(), database.InsertProvisionerJobParams{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Tags: tags, + Provisioner: database.ProvisionerTypeEcho, + StorageMethod: database.ProvisionerStorageMethodFile, + }) + s.NoError(err, "insert provisioner job") + d, err := db.UpsertProvisionerDaemon(context.Background(), database.UpsertProvisionerDaemonParams{ + OrganizationID: org.ID, + Tags: tags, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }) + s.NoError(err, "insert provisioner daemon") + ds, err := db.GetEligibleProvisionerDaemonsByProvisionerJobIDs(context.Background(), []uuid.UUID{j.ID}) + s.NoError(err, "get provisioner daemon by org") + check.Args(uuid.UUIDs{j.ID}).Asserts(d, policy.ActionRead).Returns(ds) + })) s.Run("DeleteOldProvisionerDaemons", s.Subtest(func(db database.Store, check *expects) { _, err := db.UpsertProvisionerDaemon(context.Background(), database.UpsertProvisionerDaemonParams{ Tags: database.StringMap(map[string]string{ diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index f8ca54bb9df83..d0bf6a4ce0f50 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -503,6 +503,46 @@ func GroupMember(t testing.TB, db database.Store, member database.GroupMemberTab return groupMember } +// ProvisionerDaemon creates a provisioner daemon as far as the database is concerned. It does not run a provisioner daemon. +// If no key is provided, it will create one. +func ProvisionerDaemon(t testing.TB, db database.Store, daemon database.ProvisionerDaemon) database.ProvisionerDaemon { + t.Helper() + + if daemon.KeyID == uuid.Nil { + key, err := db.InsertProvisionerKey(genCtx, database.InsertProvisionerKeyParams{ + ID: uuid.New(), + Name: daemon.Name + "-key", + OrganizationID: daemon.OrganizationID, + HashedSecret: []byte("secret"), + CreatedAt: dbtime.Now(), + Tags: daemon.Tags, + }) + require.NoError(t, err) + daemon.KeyID = key.ID + } + + if daemon.CreatedAt.IsZero() { + daemon.CreatedAt = dbtime.Now() + } + if daemon.Name == "" { + daemon.Name = "test-daemon" + } + + d, err := db.UpsertProvisionerDaemon(genCtx, database.UpsertProvisionerDaemonParams{ + Name: daemon.Name, + OrganizationID: daemon.OrganizationID, + CreatedAt: daemon.CreatedAt, + Provisioners: daemon.Provisioners, + Tags: daemon.Tags, + KeyID: daemon.KeyID, + LastSeenAt: daemon.LastSeenAt, + Version: daemon.Version, + APIVersion: daemon.APIVersion, + }) + require.NoError(t, err) + return d +} + // ProvisionerJob is a bit more involved to get the values such as "completedAt", "startedAt", "cancelledAt" set. ps // can be set to nil if you are SURE that you don't require a provisionerdaemon to acquire the job in your test. func ProvisionerJob(t testing.TB, db database.Store, ps pubsub.Pubsub, orig database.ProvisionerJob) database.ProvisionerJob { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index c5b9f7f6cc06f..d37b1fc029aa7 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1120,6 +1120,14 @@ func (q *FakeQuerier) getWorkspaceAgentScriptsByAgentIDsNoLock(ids []uuid.UUID) return scripts, nil } +// getOwnerFromTags returns the lowercase owner from tags, matching SQL's COALESCE(tags ->> 'owner', ”) +func getOwnerFromTags(tags map[string]string) string { + if owner, ok := tags["owner"]; ok { + return strings.ToLower(owner) + } + return "" +} + func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error { return xerrors.New("AcquireLock must only be called within a transaction") } @@ -2773,6 +2781,63 @@ func (q *FakeQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (database return stat, nil } +func (q *FakeQuerier) GetEligibleProvisionerDaemonsByProvisionerJobIDs(_ context.Context, provisionerJobIds []uuid.UUID) ([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + results := make([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, 0) + seen := make(map[string]struct{}) // Track unique combinations + + for _, jobID := range provisionerJobIds { + var job database.ProvisionerJob + found := false + for _, j := range q.provisionerJobs { + if j.ID == jobID { + job = j + found = true + break + } + } + if !found { + continue + } + + for _, daemon := range q.provisionerDaemons { + if daemon.OrganizationID != job.OrganizationID { + continue + } + + if !tagsSubset(job.Tags, daemon.Tags) { + continue + } + + provisionerMatches := false + for _, p := range daemon.Provisioners { + if p == job.Provisioner { + provisionerMatches = true + break + } + } + if !provisionerMatches { + continue + } + + key := jobID.String() + "-" + daemon.ID.String() + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + + results = append(results, database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{ + JobID: jobID, + ProvisionerDaemon: daemon, + }) + } + } + + return results, nil +} + func (q *FakeQuerier) GetExternalAuthLink(_ context.Context, arg database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) { if err := validateDatabaseType(arg); err != nil { return database.ExternalAuthLink{}, err @@ -10344,25 +10409,26 @@ func (q *FakeQuerier) UpsertOAuthSigningKey(_ context.Context, value string) err } func (q *FakeQuerier) UpsertProvisionerDaemon(_ context.Context, arg database.UpsertProvisionerDaemonParams) (database.ProvisionerDaemon, error) { - err := validateDatabaseType(arg) - if err != nil { + if err := validateDatabaseType(arg); err != nil { return database.ProvisionerDaemon{}, err } q.mutex.Lock() defer q.mutex.Unlock() - for _, d := range q.provisionerDaemons { - if d.Name == arg.Name { - if d.Tags[provisionersdk.TagScope] == provisionersdk.ScopeOrganization && arg.Tags[provisionersdk.TagOwner] != "" { - continue - } - if d.Tags[provisionersdk.TagScope] == provisionersdk.ScopeUser && arg.Tags[provisionersdk.TagOwner] != d.Tags[provisionersdk.TagOwner] { - continue - } + + // Look for existing daemon using the same composite key as SQL + for i, d := range q.provisionerDaemons { + if d.OrganizationID == arg.OrganizationID && + d.Name == arg.Name && + getOwnerFromTags(d.Tags) == getOwnerFromTags(arg.Tags) { d.Provisioners = arg.Provisioners d.Tags = maps.Clone(arg.Tags) - d.Version = arg.Version d.LastSeenAt = arg.LastSeenAt + d.Version = arg.Version + d.APIVersion = arg.APIVersion + d.OrganizationID = arg.OrganizationID + d.KeyID = arg.KeyID + q.provisionerDaemons[i] = d return d, nil } } @@ -10372,7 +10438,6 @@ func (q *FakeQuerier) UpsertProvisionerDaemon(_ context.Context, arg database.Up Name: arg.Name, Provisioners: arg.Provisioners, Tags: maps.Clone(arg.Tags), - ReplicaID: uuid.NullUUID{}, LastSeenAt: arg.LastSeenAt, Version: arg.Version, APIVersion: arg.APIVersion, diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 797e3648aaff6..645357d6f095e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -637,6 +637,13 @@ func (m queryMetricsStore) GetDeploymentWorkspaceStats(ctx context.Context) (dat return row, err } +func (m queryMetricsStore) GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.Context, provisionerJobIds []uuid.UUID) ([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error) { + start := time.Now() + r0, r1 := m.s.GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx, provisionerJobIds) + m.queryLatencies.WithLabelValues("GetEligibleProvisionerDaemonsByProvisionerJobIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetExternalAuthLink(ctx context.Context, arg database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) { start := time.Now() link, err := m.s.GetExternalAuthLink(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 9d05156496580..73a0e6d60af55 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1281,6 +1281,21 @@ func (mr *MockStoreMockRecorder) GetDeploymentWorkspaceStats(arg0 any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeploymentWorkspaceStats", reflect.TypeOf((*MockStore)(nil).GetDeploymentWorkspaceStats), arg0) } +// GetEligibleProvisionerDaemonsByProvisionerJobIDs mocks base method. +func (m *MockStore) GetEligibleProvisionerDaemonsByProvisionerJobIDs(arg0 context.Context, arg1 []uuid.UUID) ([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEligibleProvisionerDaemonsByProvisionerJobIDs", arg0, arg1) + ret0, _ := ret[0].([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEligibleProvisionerDaemonsByProvisionerJobIDs indicates an expected call of GetEligibleProvisionerDaemonsByProvisionerJobIDs. +func (mr *MockStoreMockRecorder) GetEligibleProvisionerDaemonsByProvisionerJobIDs(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEligibleProvisionerDaemonsByProvisionerJobIDs", reflect.TypeOf((*MockStore)(nil).GetEligibleProvisionerDaemonsByProvisionerJobIDs), arg0, arg1) +} + // GetExternalAuthLink mocks base method. func (m *MockStore) GetExternalAuthLink(arg0 context.Context, arg1 database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 002c48a9b4f81..ca74c121bc0a6 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -269,6 +269,10 @@ func (p ProvisionerDaemon) RBACObject() rbac.Object { InOrg(p.OrganizationID) } +func (p GetEligibleProvisionerDaemonsByProvisionerJobIDsRow) RBACObject() rbac.Object { + return p.ProvisionerDaemon.RBACObject() +} + func (p ProvisionerKey) RBACObject() rbac.Object { return rbac.ResourceProvisionerKeys. WithID(p.ID). diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 371dde97034db..2128315ce6dad 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -145,6 +145,7 @@ type sqlcQuerier interface { GetDeploymentWorkspaceAgentStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentStatsRow, error) GetDeploymentWorkspaceAgentUsageStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentUsageStatsRow, error) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploymentWorkspaceStatsRow, error) + GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.Context, provisionerJobIds []uuid.UUID) ([]GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error) GetExternalAuthLink(ctx context.Context, arg GetExternalAuthLinkParams) (ExternalAuthLink, error) GetExternalAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]ExternalAuthLink, error) GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, arg GetFailedWorkspaceBuildsByTemplateIDParams) ([]GetFailedWorkspaceBuildsByTemplateIDRow, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 619e9868b612f..8fb12a5acf923 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/testutil" ) @@ -211,6 +212,145 @@ func TestGetDeploymentWorkspaceAgentUsageStats(t *testing.T) { }) } +func TestGetEligibleProvisionerDaemonsByProvisionerJobIDs(t *testing.T) { + t.Parallel() + + t.Run("NoJobsReturnsEmpty", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + daemons, err := db.GetEligibleProvisionerDaemonsByProvisionerJobIDs(context.Background(), []uuid.UUID{}) + require.NoError(t, err) + require.Empty(t, daemons) + }) + + t.Run("MatchesProvisionerType", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Provisioner: database.ProvisionerTypeEcho, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + matchingDaemon := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "matching-daemon", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "non-matching-daemon", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeTerraform}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + daemons, err := db.GetEligibleProvisionerDaemonsByProvisionerJobIDs(context.Background(), []uuid.UUID{job.ID}) + require.NoError(t, err) + require.Len(t, daemons, 1) + require.Equal(t, matchingDaemon.ID, daemons[0].ProvisionerDaemon.ID) + }) + + t.Run("MatchesOrganizationScope", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Provisioner: database.ProvisionerTypeEcho, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + provisionersdk.TagOwner: "", + }, + }) + + orgDaemon := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "org-daemon", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + provisionersdk.TagOwner: "", + }, + }) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "user-daemon", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeUser, + }, + }) + + daemons, err := db.GetEligibleProvisionerDaemonsByProvisionerJobIDs(context.Background(), []uuid.UUID{job.ID}) + require.NoError(t, err) + require.Len(t, daemons, 1) + require.Equal(t, orgDaemon.ID, daemons[0].ProvisionerDaemon.ID) + }) + + t.Run("MatchesMultipleProvisioners", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Provisioner: database.ProvisionerTypeEcho, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + daemon1 := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "daemon-1", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + daemon2 := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "daemon-2", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "daemon-3", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeTerraform}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + daemons, err := db.GetEligibleProvisionerDaemonsByProvisionerJobIDs(context.Background(), []uuid.UUID{job.ID}) + require.NoError(t, err) + require.Len(t, daemons, 2) + + daemonIDs := []uuid.UUID{daemons[0].ProvisionerDaemon.ID, daemons[1].ProvisionerDaemon.ID} + require.ElementsMatch(t, []uuid.UUID{daemon1.ID, daemon2.ID}, daemonIDs) + }) +} + func TestGetWorkspaceAgentUsageStats(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ee2b71a4a6c63..4915c61025ea4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5255,6 +5255,60 @@ func (q *sqlQuerier) DeleteOldProvisionerDaemons(ctx context.Context) error { return err } +const getEligibleProvisionerDaemonsByProvisionerJobIDs = `-- name: GetEligibleProvisionerDaemonsByProvisionerJobIDs :many +SELECT DISTINCT + provisioner_jobs.id as job_id, provisioner_daemons.id, provisioner_daemons.created_at, provisioner_daemons.name, provisioner_daemons.provisioners, provisioner_daemons.replica_id, provisioner_daemons.tags, provisioner_daemons.last_seen_at, provisioner_daemons.version, provisioner_daemons.api_version, provisioner_daemons.organization_id, provisioner_daemons.key_id +FROM + provisioner_jobs +JOIN + provisioner_daemons ON provisioner_daemons.organization_id = provisioner_jobs.organization_id + AND provisioner_tagset_contains(provisioner_daemons.tags::tagset, provisioner_jobs.tags::tagset) + AND provisioner_jobs.provisioner = ANY(provisioner_daemons.provisioners) +WHERE + provisioner_jobs.id = ANY($1 :: uuid[]) +` + +type GetEligibleProvisionerDaemonsByProvisionerJobIDsRow struct { + JobID uuid.UUID `db:"job_id" json:"job_id"` + ProvisionerDaemon ProvisionerDaemon `db:"provisioner_daemon" json:"provisioner_daemon"` +} + +func (q *sqlQuerier) GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.Context, provisionerJobIds []uuid.UUID) ([]GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error) { + rows, err := q.db.QueryContext(ctx, getEligibleProvisionerDaemonsByProvisionerJobIDs, pq.Array(provisionerJobIds)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetEligibleProvisionerDaemonsByProvisionerJobIDsRow + for rows.Next() { + var i GetEligibleProvisionerDaemonsByProvisionerJobIDsRow + if err := rows.Scan( + &i.JobID, + &i.ProvisionerDaemon.ID, + &i.ProvisionerDaemon.CreatedAt, + &i.ProvisionerDaemon.Name, + pq.Array(&i.ProvisionerDaemon.Provisioners), + &i.ProvisionerDaemon.ReplicaID, + &i.ProvisionerDaemon.Tags, + &i.ProvisionerDaemon.LastSeenAt, + &i.ProvisionerDaemon.Version, + &i.ProvisionerDaemon.APIVersion, + &i.ProvisionerDaemon.OrganizationID, + &i.ProvisionerDaemon.KeyID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getProvisionerDaemons = `-- name: GetProvisionerDaemons :many SELECT id, created_at, name, provisioners, replica_id, tags, last_seen_at, version, api_version, organization_id, key_id diff --git a/coderd/database/queries/provisionerdaemons.sql b/coderd/database/queries/provisionerdaemons.sql index a6633c91158a9..f76f71f5015bf 100644 --- a/coderd/database/queries/provisionerdaemons.sql +++ b/coderd/database/queries/provisionerdaemons.sql @@ -16,6 +16,18 @@ WHERE -- adding support for searching by tags: (@want_tags :: tagset = 'null' :: tagset OR provisioner_tagset_contains(provisioner_daemons.tags::tagset, @want_tags::tagset)); +-- name: GetEligibleProvisionerDaemonsByProvisionerJobIDs :many +SELECT DISTINCT + provisioner_jobs.id as job_id, sqlc.embed(provisioner_daemons) +FROM + provisioner_jobs +JOIN + provisioner_daemons ON provisioner_daemons.organization_id = provisioner_jobs.organization_id + AND provisioner_tagset_contains(provisioner_daemons.tags::tagset, provisioner_jobs.tags::tagset) + AND provisioner_jobs.provisioner = ANY(provisioner_daemons.provisioners) +WHERE + provisioner_jobs.id = ANY(@provisioner_job_ids :: uuid[]); + -- name: DeleteOldProvisionerDaemons :exec -- Delete provisioner daemons that have been created at least a week ago -- and have not connected to coderd since a week. diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index f041734a7d195..7eb598a7d4564 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -202,6 +202,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { data.scripts, data.logSources, data.templateVersions, + data.provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -291,7 +292,7 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ data.scripts, data.logSources, data.templateVersions[0], - nil, + data.provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -395,10 +396,6 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { } } - var matchedProvisioners codersdk.MatchedProvisioners - if provisionerJob != nil { - matchedProvisioners = db2sdk.MatchedProvisioners(provisionerDaemons, provisionerJob.CreatedAt, provisionerdserver.StaleInterval) - } apiBuild, err := api.convertWorkspaceBuild( *workspaceBuild, workspace, @@ -413,7 +410,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, - &matchedProvisioners, + provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -648,14 +645,15 @@ func (api *API) workspaceBuildTimings(rw http.ResponseWriter, r *http.Request) { } type workspaceBuildsData struct { - jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow - templateVersions []database.TemplateVersion - resources []database.WorkspaceResource - metadata []database.WorkspaceResourceMetadatum - agents []database.WorkspaceAgent - apps []database.WorkspaceApp - scripts []database.WorkspaceAgentScript - logSources []database.WorkspaceAgentLogSource + jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow + templateVersions []database.TemplateVersion + resources []database.WorkspaceResource + metadata []database.WorkspaceResourceMetadatum + agents []database.WorkspaceAgent + apps []database.WorkspaceApp + scripts []database.WorkspaceAgentScript + logSources []database.WorkspaceAgentLogSource + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow } func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []database.WorkspaceBuild) (workspaceBuildsData, error) { @@ -667,6 +665,17 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab if err != nil && !errors.Is(err, sql.ErrNoRows) { return workspaceBuildsData{}, xerrors.Errorf("get provisioner jobs: %w", err) } + pendingJobIDs := []uuid.UUID{} + for _, job := range jobs { + if job.ProvisionerJob.JobStatus == database.ProvisionerJobStatusPending { + pendingJobIDs = append(pendingJobIDs, job.ProvisionerJob.ID) + } + } + + pendingJobProvisioners, err := api.Database.GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx, pendingJobIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return workspaceBuildsData{}, xerrors.Errorf("get provisioner daemons: %w", err) + } templateVersionIDs := make([]uuid.UUID, 0, len(workspaceBuilds)) for _, build := range workspaceBuilds { @@ -687,8 +696,9 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab if len(resources) == 0 { return workspaceBuildsData{ - jobs: jobs, - templateVersions: templateVersions, + jobs: jobs, + templateVersions: templateVersions, + provisionerDaemons: pendingJobProvisioners, }, nil } @@ -711,10 +721,11 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab if len(resources) == 0 { return workspaceBuildsData{ - jobs: jobs, - templateVersions: templateVersions, - resources: resources, - metadata: metadata, + jobs: jobs, + templateVersions: templateVersions, + resources: resources, + metadata: metadata, + provisionerDaemons: pendingJobProvisioners, }, nil } @@ -751,14 +762,15 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab } return workspaceBuildsData{ - jobs: jobs, - templateVersions: templateVersions, - resources: resources, - metadata: metadata, - agents: agents, - apps: apps, - scripts: scripts, - logSources: logSources, + jobs: jobs, + templateVersions: templateVersions, + resources: resources, + metadata: metadata, + agents: agents, + apps: apps, + scripts: scripts, + logSources: logSources, + provisionerDaemons: pendingJobProvisioners, }, nil } @@ -773,6 +785,7 @@ func (api *API) convertWorkspaceBuilds( agentScripts []database.WorkspaceAgentScript, agentLogSources []database.WorkspaceAgentLogSource, templateVersions []database.TemplateVersion, + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, ) ([]codersdk.WorkspaceBuild, error) { workspaceByID := map[uuid.UUID]database.Workspace{} for _, workspace := range workspaces { @@ -814,7 +827,7 @@ func (api *API) convertWorkspaceBuilds( agentScripts, agentLogSources, templateVersion, - nil, + provisionerDaemons, ) if err != nil { return nil, xerrors.Errorf("converting workspace build: %w", err) @@ -837,7 +850,7 @@ func (api *API) convertWorkspaceBuild( agentScripts []database.WorkspaceAgentScript, agentLogSources []database.WorkspaceAgentLogSource, templateVersion database.TemplateVersion, - matchedProvisioners *codersdk.MatchedProvisioners, + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, ) (codersdk.WorkspaceBuild, error) { resourcesByJobID := map[uuid.UUID][]database.WorkspaceResource{} for _, resource := range workspaceResources { @@ -863,6 +876,14 @@ func (api *API) convertWorkspaceBuild( for _, logSource := range agentLogSources { logSourcesByAgentID[logSource.WorkspaceAgentID] = append(logSourcesByAgentID[logSource.WorkspaceAgentID], logSource) } + provisionerDaemonsForThisWorkspaceBuild := []database.ProvisionerDaemon{} + for _, provisionerDaemon := range provisionerDaemons { + if provisionerDaemon.JobID != job.ProvisionerJob.ID { + continue + } + provisionerDaemonsForThisWorkspaceBuild = append(provisionerDaemonsForThisWorkspaceBuild, provisionerDaemon.ProvisionerDaemon) + } + matchedProvisioners := db2sdk.MatchedProvisioners(provisionerDaemonsForThisWorkspaceBuild, job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval) resources := resourcesByJobID[job.ProvisionerJob.ID] apiResources := make([]codersdk.WorkspaceResource, 0) @@ -930,7 +951,7 @@ func (api *API) convertWorkspaceBuild( Resources: apiResources, Status: codersdk.ConvertWorkspaceStatus(apiJob.Status, transition), DailyCost: build.DailyCost, - MatchedProvisioners: matchedProvisioners, + MatchedProvisioners: &matchedProvisioners, }, nil } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 3554308d44523..8828ad6509d52 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -27,7 +27,6 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" - "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/schedule" @@ -612,10 +611,9 @@ func createWorkspace( } var ( - provisionerJob *database.ProvisionerJob - workspaceBuild *database.WorkspaceBuild - provisionerDaemons []database.ProvisionerDaemon - matchedProvisioners codersdk.MatchedProvisioners + provisionerJob *database.ProvisionerJob + workspaceBuild *database.WorkspaceBuild + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow ) err = api.Database.InTx(func(db database.Store) error { now := dbtime.Now() @@ -688,9 +686,6 @@ func createWorkspace( // Client probably doesn't care about this error, so just log it. api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) } - if provisionerJob != nil { - matchedProvisioners = db2sdk.MatchedProvisioners(provisionerDaemons, provisionerJob.CreatedAt, provisionerdserver.StaleInterval) - } auditReq.New = workspace.WorkspaceTable() @@ -713,7 +708,7 @@ func createWorkspace( []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, - &matchedProvisioners, + provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -1843,6 +1838,7 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa data.scripts, data.logSources, data.templateVersions, + data.provisionerDaemons, ) if err != nil { return workspaceData{}, xerrors.Errorf("convert workspace builds: %w", err) diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 46b65e70d0f80..d59af8cdc1b32 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -214,7 +214,7 @@ func (b *Builder) Build( authFunc func(action policy.Action, object rbac.Objecter) bool, auditBaggage audit.WorkspaceBuildBaggage, ) ( - *database.WorkspaceBuild, *database.ProvisionerJob, []database.ProvisionerDaemon, error, + *database.WorkspaceBuild, *database.ProvisionerJob, []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error, ) { var err error b.ctx, err = audit.BaggageToContext(ctx, auditBaggage) @@ -228,7 +228,7 @@ func (b *Builder) Build( // later reads are consistent with earlier ones. var workspaceBuild *database.WorkspaceBuild var provisionerJob *database.ProvisionerJob - var provisionerDaemons []database.ProvisionerDaemon + var provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow err = database.ReadModifyUpdate(store, func(tx database.Store) error { var err error b.store = tx @@ -248,7 +248,7 @@ func (b *Builder) Build( // // In order to utilize this cache, the functions that compute build attributes use a pointer receiver type. func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Objecter) bool) ( - *database.WorkspaceBuild, *database.ProvisionerJob, []database.ProvisionerDaemon, error, + *database.WorkspaceBuild, *database.ProvisionerJob, []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error, ) { if authFunc != nil { err := b.authorize(authFunc) @@ -338,15 +338,12 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object // to read all provisioner daemons. We need to retrieve the eligible // provisioner daemons for this job to show in the UI if there is no // matching provisioner daemon. - provisionerDaemons, err := b.store.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(b.ctx), database.GetProvisionerDaemonsByOrganizationParams{ - OrganizationID: template.OrganizationID, - WantTags: provisionerJob.Tags, - }) + provisionerDaemons, err := b.store.GetEligibleProvisionerDaemonsByProvisionerJobIDs(dbauthz.AsSystemReadProvisionerDaemons(b.ctx), []uuid.UUID{provisionerJob.ID}) if err != nil { // NOTE: we do **not** want to fail a workspace build if we fail to // retrieve provisioner daemons. This is just to show in the UI if there // is no matching provisioner daemon for the job. - provisionerDaemons = []database.ProvisionerDaemon{} + provisionerDaemons = []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{} } templateVersionID, err := b.getTemplateVersionID() diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index ad0df2816ffac..3f373efd3bfdb 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -61,7 +61,7 @@ func TestBuilder_NoOptions(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -116,7 +116,7 @@ func TestBuilder_Initiator(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -161,7 +161,7 @@ func TestBuilder_Baggage(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -198,7 +198,7 @@ func TestBuilder_Reason(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(_ database.InsertProvisionerJobParams) { @@ -234,7 +234,7 @@ func TestBuilder_ActiveVersion(t *testing.T) { withLastBuildNotFound, withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // previous rich parameters are not queried because there is no previous build. // Outputs @@ -324,7 +324,7 @@ func TestWorkspaceBuildWithTags(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, workspaceTags), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -416,7 +416,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -462,7 +462,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -596,7 +596,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -658,7 +658,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -718,7 +718,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -1009,8 +1009,8 @@ func expectBuildParameters( } } -func withProvisionerDaemons(provisionerDaemons []database.ProvisionerDaemon) func(mTx *dbmock.MockStore) { +func withProvisionerDaemons(provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow) func(mTx *dbmock.MockStore) { return func(mTx *dbmock.MockStore) { - mTx.EXPECT().GetProvisionerDaemonsByOrganization(gomock.Any(), gomock.Any()).Return(provisionerDaemons, nil) + mTx.EXPECT().GetEligibleProvisionerDaemonsByProvisionerJobIDs(gomock.Any(), gomock.Any()).Return(provisionerDaemons, nil) } } diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 2dd7eeec9719e..e5142c1a83ee8 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -23,6 +23,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/rbac" agplschedule "github.com/coder/coder/v2/coderd/schedule" @@ -35,6 +36,7 @@ import ( "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/enterprise/coderd/schedule" "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" ) @@ -1764,6 +1766,214 @@ func TestAdminViewAllWorkspaces(t *testing.T) { require.Equal(t, 0, len(memberViewWorkspaces.Workspaces), "member in other org should see 0 workspaces") } +func TestWorkspaceByOwnerAndName(t *testing.T) { + t.Parallel() + + t.Run("Matching Provisioner", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + client, db, userResponse := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, userResponse.UserID, rbac.ExpandableScope(rbac.ScopeAll)) + require.NoError(t, err) + user, err := client.User(ctx, userSubject.ID) + require.NoError(t, err) + username := user.Username + + _ = coderdenttest.NewExternalProvisionerDaemon(t, client, userResponse.OrganizationID, map[string]string{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }) + + version := coderdtest.CreateTemplateVersion(t, client, userResponse.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, userResponse.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + + // Pending builds should show matching provisioners + require.Equal(t, workspace.LatestBuild.Status, codersdk.WorkspaceStatusPending) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Count, 1) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Available, 1) + + // Completed builds should not show matching provisioners, because no provisioner daemon can + // be eligible to process a job that is already completed. + completedBuild := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + require.Equal(t, completedBuild.Status, codersdk.WorkspaceStatusRunning) + require.Equal(t, completedBuild.MatchedProvisioners.Count, 0) + require.Equal(t, completedBuild.MatchedProvisioners.Available, 0) + + ws, err := client.WorkspaceByOwnerAndName(ctx, username, workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + + // Verify the workspace details + require.Equal(t, workspace.ID, ws.ID) + require.Equal(t, workspace.Name, ws.Name) + require.Equal(t, workspace.TemplateID, ws.TemplateID) + require.Equal(t, completedBuild.Status, ws.LatestBuild.Status) + require.Equal(t, ws.LatestBuild.MatchedProvisioners.Count, 0) + require.Equal(t, ws.LatestBuild.MatchedProvisioners.Available, 0) + + // Verify that the provisioner daemon is registered in the database + //nolint:gocritic // unit testing + daemons, err := db.GetProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) + require.NoError(t, err) + require.Equal(t, 1, len(daemons)) + require.Equal(t, provisionersdk.ScopeOrganization, daemons[0].Tags[provisionersdk.TagScope]) + }) + + t.Run("No Matching Provisioner", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + client, db, userResponse := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, userResponse.UserID, rbac.ExpandableScope(rbac.ScopeAll)) + require.NoError(t, err) + user, err := client.User(ctx, userSubject.ID) + require.NoError(t, err) + username := user.Username + + closer := coderdenttest.NewExternalProvisionerDaemon(t, client, userResponse.OrganizationID, map[string]string{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }) + + version := coderdtest.CreateTemplateVersion(t, client, userResponse.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, userResponse.OrganizationID, version.ID) + + // nolint:gocritic // unit testing + daemons, err := db.GetProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) + require.NoError(t, err) + require.Equal(t, len(daemons), 1) + + // Simulate a provisioner daemon failure: + err = closer.Close() + require.NoError(t, err) + + // Simulate it's subsequent deletion from the database: + + // nolint:gocritic // unit testing + _, err = db.UpsertProvisionerDaemon(dbauthz.AsSystemRestricted(ctx), database.UpsertProvisionerDaemonParams{ + Name: daemons[0].Name, + OrganizationID: daemons[0].OrganizationID, + Tags: daemons[0].Tags, + Provisioners: daemons[0].Provisioners, + Version: daemons[0].Version, + APIVersion: daemons[0].APIVersion, + KeyID: daemons[0].KeyID, + // Simulate the passing of time such that the provisioner daemon is considered stale + // and will be deleted: + CreatedAt: time.Now().Add(-time.Hour * 24 * 8), + LastSeenAt: sql.NullTime{ + Time: time.Now().Add(-time.Hour * 24 * 8), + Valid: true, + }, + }) + require.NoError(t, err) + // nolint:gocritic // unit testing + err = db.DeleteOldProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) + require.NoError(t, err) + + // Create a workspace that will not be able to provision due to a lack of provisioner daemons: + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + + require.Equal(t, workspace.LatestBuild.Status, codersdk.WorkspaceStatusPending) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Count, 0) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Available, 0) + + // nolint:gocritic // unit testing + _, err = client.WorkspaceByOwnerAndName(dbauthz.As(ctx, userSubject), username, workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + require.Equal(t, workspace.LatestBuild.Status, codersdk.WorkspaceStatusPending) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Count, 0) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Available, 0) + }) + + t.Run("Unavailable Provisioner", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + client, db, userResponse := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, userResponse.UserID, rbac.ExpandableScope(rbac.ScopeAll)) + require.NoError(t, err) + user, err := client.User(ctx, userSubject.ID) + require.NoError(t, err) + username := user.Username + + closer := coderdenttest.NewExternalProvisionerDaemon(t, client, userResponse.OrganizationID, map[string]string{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }) + + version := coderdtest.CreateTemplateVersion(t, client, userResponse.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, userResponse.OrganizationID, version.ID) + + // nolint:gocritic // unit testing + daemons, err := db.GetProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) + require.NoError(t, err) + require.Equal(t, len(daemons), 1) + + // Simulate a provisioner daemon failure: + err = closer.Close() + require.NoError(t, err) + + // nolint:gocritic // unit testing + _, err = db.UpsertProvisionerDaemon(dbauthz.AsSystemRestricted(ctx), database.UpsertProvisionerDaemonParams{ + Name: daemons[0].Name, + OrganizationID: daemons[0].OrganizationID, + Tags: daemons[0].Tags, + Provisioners: daemons[0].Provisioners, + Version: daemons[0].Version, + APIVersion: daemons[0].APIVersion, + KeyID: daemons[0].KeyID, + // Simulate the passing of time such that the provisioner daemon, though not stale, has been + // has been inactive for a while: + CreatedAt: time.Now().Add(-time.Hour * 24 * 2), + LastSeenAt: sql.NullTime{ + Time: time.Now().Add(-time.Hour * 24 * 2), + Valid: true, + }, + }) + require.NoError(t, err) + + // Create a workspace that will not be able to provision due to a lack of provisioner daemons: + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + + require.Equal(t, workspace.LatestBuild.Status, codersdk.WorkspaceStatusPending) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Count, 1) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Available, 0) + + // nolint:gocritic // unit testing + _, err = client.WorkspaceByOwnerAndName(dbauthz.As(ctx, userSubject), username, workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + require.Equal(t, workspace.LatestBuild.Status, codersdk.WorkspaceStatusPending) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Count, 1) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Available, 0) + }) +} + func must[T any](value T, err error) T { if err != nil { panic(err) diff --git a/site/src/modules/provisioners/ProvisionerAlert.stories.tsx b/site/src/modules/provisioners/ProvisionerAlert.stories.tsx index d9ca1501d6611..496934bf2275e 100644 --- a/site/src/modules/provisioners/ProvisionerAlert.stories.tsx +++ b/site/src/modules/provisioners/ProvisionerAlert.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { chromatic } from "testHelpers/chromatic"; -import { ProvisionerAlert } from "./ProvisionerAlert"; +import { AlertVariant, ProvisionerAlert } from "./ProvisionerAlert"; const meta: Meta = { title: "modules/provisioners/ProvisionerAlert", @@ -21,6 +21,26 @@ export default meta; type Story = StoryObj; export const Info: Story = {}; + +export const InfoInline: Story = { + args: { + variant: AlertVariant.Inline, + }, +}; + +export const Warning: Story = { + args: { + severity: "warning", + }, +}; + +export const WarningInline: Story = { + args: { + severity: "warning", + variant: AlertVariant.Inline, + }, +}; + export const NullTags: Story = { args: { tags: undefined, diff --git a/site/src/modules/provisioners/ProvisionerAlert.tsx b/site/src/modules/provisioners/ProvisionerAlert.tsx index 54d9ab8473e87..86d69796cd4b9 100644 --- a/site/src/modules/provisioners/ProvisionerAlert.tsx +++ b/site/src/modules/provisioners/ProvisionerAlert.tsx @@ -1,34 +1,54 @@ +import type { Theme } from "@emotion/react"; import AlertTitle from "@mui/material/AlertTitle"; import { Alert, type AlertColor } from "components/Alert/Alert"; import { AlertDetail } from "components/Alert/Alert"; import { Stack } from "components/Stack/Stack"; import { ProvisionerTag } from "modules/provisioners/ProvisionerTag"; import type { FC } from "react"; + +export enum AlertVariant { + // Alerts are usually styled with a full rounded border and meant to use as a visually distinct element of the page. + // The Standalone variant conforms to this styling. + Standalone = "Standalone", + // We show these same alerts in environments such as log drawers where we stream the logs from builds. + // In this case the full border is incongruent with the surroundings of the component. + // The Inline variant replaces the full rounded border with a left border and a divider so that it complements the surroundings. + Inline = "Inline", +} + interface ProvisionerAlertProps { title: string; detail: string; severity: AlertColor; tags: Record; + variant?: AlertVariant; } +const getAlertStyles = (variant: AlertVariant, severity: AlertColor) => { + switch (variant) { + case AlertVariant.Inline: + return { + css: (theme: Theme) => ({ + borderRadius: 0, + border: 0, + borderBottom: `1px solid ${theme.palette.divider}`, + borderLeft: `2px solid ${theme.palette[severity].main}`, + }), + }; + default: + return {}; + } +}; + export const ProvisionerAlert: FC = ({ title, detail, severity, tags, + variant = AlertVariant.Standalone, }) => { return ( - { - return { - borderRadius: 0, - border: 0, - borderBottom: `1px solid ${theme.palette.divider}`, - borderLeft: `2px solid ${theme.palette[severity].main}`, - }; - }} - > + {title}
{detail}
diff --git a/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx b/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx index d4f746e99c417..ec3e7ed20f953 100644 --- a/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx +++ b/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { chromatic } from "testHelpers/chromatic"; import { MockTemplateVersion } from "testHelpers/entities"; +import { AlertVariant } from "./ProvisionerAlert"; import { ProvisionerStatusAlert } from "./ProvisionerStatusAlert"; const meta: Meta = { @@ -47,9 +48,24 @@ export const NoMatchingProvisioners: Story = { }, }; +export const NoMatchingProvisionersInLogs: Story = { + args: { + matchingProvisioners: 0, + variant: AlertVariant.Inline, + }, +}; + export const NoAvailableProvisioners: Story = { args: { matchingProvisioners: 1, availableProvisioners: 0, }, }; + +export const NoAvailableProvisionersInLogs: Story = { + args: { + matchingProvisioners: 1, + availableProvisioners: 0, + variant: AlertVariant.Inline, + }, +}; diff --git a/site/src/modules/provisioners/ProvisionerStatusAlert.tsx b/site/src/modules/provisioners/ProvisionerStatusAlert.tsx index 54a2b56704877..e75887f1d97a7 100644 --- a/site/src/modules/provisioners/ProvisionerStatusAlert.tsx +++ b/site/src/modules/provisioners/ProvisionerStatusAlert.tsx @@ -1,17 +1,19 @@ import type { AlertColor } from "components/Alert/Alert"; import type { FC } from "react"; -import { ProvisionerAlert } from "./ProvisionerAlert"; +import { AlertVariant, ProvisionerAlert } from "./ProvisionerAlert"; interface ProvisionerStatusAlertProps { matchingProvisioners: number | undefined; availableProvisioners: number | undefined; tags: Record; + variant?: AlertVariant; } export const ProvisionerStatusAlert: FC = ({ matchingProvisioners, availableProvisioners, tags, + variant = AlertVariant.Standalone, }) => { let title: string; let detail: string; @@ -42,6 +44,7 @@ export const ProvisionerStatusAlert: FC = ({ detail={detail} severity={severity} tags={tags} + variant={variant} /> ); }; diff --git a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx index 4eb1805b60e36..7f6b5f45aef04 100644 --- a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx +++ b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx @@ -8,6 +8,7 @@ import { visuallyHidden } from "@mui/utils"; import { JobError } from "api/queries/templates"; import type { TemplateVersion } from "api/typesGenerated"; import { Loader } from "components/Loader/Loader"; +import { AlertVariant } from "modules/provisioners/ProvisionerAlert"; import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert"; import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs"; import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; @@ -94,6 +95,7 @@ export const BuildLogsDrawer: FC = ({ matchingProvisioners={matchingProvisioners} availableProvisioners={availableProvisioners} tags={templateVersion?.job.tags ?? {}} + variant={AlertVariant.Inline} /> diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx index 858f57dd59493..bb9bbb7c72732 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx @@ -29,6 +29,7 @@ import { import { Loader } from "components/Loader/Loader"; import { linkToTemplate, useLinks } from "modules/navigation"; import { ProvisionerAlert } from "modules/provisioners/ProvisionerAlert"; +import { AlertVariant } from "modules/provisioners/ProvisionerAlert"; import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert"; import { TemplateFileTree } from "modules/templates/TemplateFiles/TemplateFileTree"; import { isBinaryData } from "modules/templates/TemplateFiles/isBinaryData"; @@ -593,6 +594,7 @@ export const TemplateVersionEditor: FC = ({ detail={templateVersion.job.error} severity="error" tags={templateVersion.job.tags} + variant={AlertVariant.Inline} /> ) : ( @@ -602,6 +604,7 @@ export const TemplateVersionEditor: FC = ({ matchingProvisioners={matchingProvisioners} availableProvisioners={availableProvisioners} tags={templateVersion.job.tags} + variant={AlertVariant.Inline} /> diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index 2e3745c2f65bf..3dd05a398cf2e 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -95,6 +95,51 @@ export const PendingInQueue: Story = { }, }; +export const PendingWithNoProvisioners: Story = { + args: { + ...Running.args, + workspace: { + ...Mocks.MockPendingWorkspace, + latest_build: { + ...Mocks.MockPendingWorkspace.latest_build, + matched_provisioners: { + count: 0, + available: 0, + }, + }, + }, + }, +}; + +export const PendingWithNoAvailableProvisioners: Story = { + args: { + ...Running.args, + workspace: { + ...Mocks.MockPendingWorkspace, + latest_build: { + ...Mocks.MockPendingWorkspace.latest_build, + matched_provisioners: { + count: 1, + available: 0, + }, + }, + }, + }, +}; + +export const PendingWithUndefinedProvisioners: Story = { + args: { + ...Running.args, + workspace: { + ...Mocks.MockPendingWorkspace, + latest_build: { + ...Mocks.MockPendingWorkspace.latest_build, + matched_provisioners: undefined, + }, + }, + }, +}; + export const Starting: Story = { args: { ...Running.args, @@ -130,7 +175,7 @@ export const FailedWithLogs: Story = { }, }, }, - buildLogs: , + buildLogs: makeFailedBuildLogs(), }, }; @@ -148,7 +193,7 @@ export const FailedWithRetry: Story = { }, }, }, - buildLogs: , + buildLogs: makeFailedBuildLogs(), }, }; diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 5b9919474a620..1d89e63d8914a 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -7,6 +7,7 @@ import type * as TypesGen from "api/typesGenerated"; import { Alert, AlertDetail } from "components/Alert/Alert"; import { SidebarIconButton } from "components/FullPageLayout/Sidebar"; import { useSearchParamsKey } from "hooks/useSearchParamsKey"; +import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert"; import { AgentRow } from "modules/resources/AgentRow"; import { WorkspaceTimings } from "modules/workspaces/WorkspaceTiming/WorkspaceTimings"; import type { FC } from "react"; @@ -14,6 +15,7 @@ import { useNavigate } from "react-router-dom"; import { HistorySidebar } from "./HistorySidebar"; import { ResourceMetadata } from "./ResourceMetadata"; import { ResourcesSidebar } from "./ResourcesSidebar"; +import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection"; import { ActiveTransition, WorkspaceBuildProgress, @@ -46,7 +48,7 @@ export interface WorkspaceProps { canDebugMode: boolean; handleRetry: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; handleDebug: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; - buildLogs?: React.ReactNode; + buildLogs?: TypesGen.ProvisionerJobLog[]; latestVersion?: TypesGen.TemplateVersion; permissions: WorkspacePermissions; isOwner: boolean; @@ -108,6 +110,14 @@ export const Workspace: FC = ({ (r) => resourceOptionValue(r) === resourcesNav.value, ); + const shouldDisplayBuildLogs = + (buildLogs ?? []).length > 0 && workspace.latest_build.status !== "running"; + + const provisionersHealthy = + (workspace.latest_build.matched_provisioners?.available ?? 0) > 0; + const shouldShowProvisionerAlert = + !provisionersHealthy && (!buildLogs || buildLogs.length === 0); + return (
= ({ /> )} + {shouldShowProvisionerAlert && ( + + )} + {workspace.latest_build.job.error && ( Workspace build failed @@ -222,7 +244,9 @@ export const Workspace: FC = ({ /> )} - {buildLogs} + {shouldDisplayBuildLogs && ( + + )} {selectedResource && (
= ({ }); // Build logs - const shouldDisplayBuildLogs = workspace.latest_build.status !== "running"; + const shouldStreamBuildLogs = workspace.latest_build.status !== "running"; const buildLogs = useWorkspaceBuildLogs( workspace.latest_build.id, - shouldDisplayBuildLogs, + shouldStreamBuildLogs, ); // Restart @@ -278,11 +277,7 @@ export const WorkspaceReadyPage: FC = ({ buildInfo={buildInfoQuery.data} sshPrefix={sshPrefixQuery.data?.hostname_prefix} template={template} - buildLogs={ - shouldDisplayBuildLogs && ( - - ) - } + buildLogs={buildLogs} isOwner={isOwner} timings={timingsQuery.data} /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index c38272e384fb7..434bd76a7da6e 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1199,6 +1199,10 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { resources: [MockWorkspaceResource], status: "running", daily_cost: 20, + matched_provisioners: { + count: 1, + available: 1, + }, }; export const MockWorkspaceBuildAutostart: TypesGen.WorkspaceBuild = {