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 = { pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy