feat(site): add a provisioner warning to workspace builds (#15686)

This PR adds warnings about provisioner health to workspace build pages.
It closes https://github.com/coder/coder/issues/15048


![image](https://github.com/user-attachments/assets/fa54d0e8-c51f-427a-8f66-7e5dbbc9baca)

![image](https://github.com/user-attachments/assets/b5169669-ab05-43d5-8553-315a3099b4fd)
This commit is contained in:
Sas Swart
2024-12-11 13:38:13 +02:00
committed by GitHub
parent 104898ae87
commit b39becba66
27 changed files with 825 additions and 99 deletions
+6 -1
View File
@@ -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",
+4
View File
@@ -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)
}
+23
View File
@@ -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{
+40
View File
@@ -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 {
+77 -12
View File
@@ -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,
@@ -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)
+15
View File
@@ -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()
+4
View File
@@ -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).
+1
View File
@@ -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)
+140
View File
@@ -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()
+54
View File
@@ -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
@@ -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.
+52 -31
View File
@@ -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
}
+5 -9
View File
@@ -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)
+5 -8
View File
@@ -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()
+13 -13
View File
@@ -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)
}
}
+210
View File
@@ -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)
@@ -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<typeof ProvisionerAlert> = {
title: "modules/provisioners/ProvisionerAlert",
@@ -21,6 +21,26 @@ export default meta;
type Story = StoryObj<typeof ProvisionerAlert>;
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,
@@ -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<string, string>;
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<ProvisionerAlertProps> = ({
title,
detail,
severity,
tags,
variant = AlertVariant.Standalone,
}) => {
return (
<Alert
severity={severity}
css={(theme) => {
return {
borderRadius: 0,
border: 0,
borderBottom: `1px solid ${theme.palette.divider}`,
borderLeft: `2px solid ${theme.palette[severity].main}`,
};
}}
>
<Alert severity={severity} {...getAlertStyles(variant, severity)}>
<AlertTitle>{title}</AlertTitle>
<AlertDetail>
<div>{detail}</div>
@@ -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<typeof ProvisionerStatusAlert> = {
@@ -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,
},
};
@@ -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<string, string>;
variant?: AlertVariant;
}
export const ProvisionerStatusAlert: FC<ProvisionerStatusAlertProps> = ({
matchingProvisioners,
availableProvisioners,
tags,
variant = AlertVariant.Standalone,
}) => {
let title: string;
let detail: string;
@@ -42,6 +44,7 @@ export const ProvisionerStatusAlert: FC<ProvisionerStatusAlertProps> = ({
detail={detail}
severity={severity}
tags={tags}
variant={variant}
/>
);
};
@@ -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<BuildLogsDrawerProps> = ({
matchingProvisioners={matchingProvisioners}
availableProvisioners={availableProvisioners}
tags={templateVersion?.job.tags ?? {}}
variant={AlertVariant.Inline}
/>
<Loader />
</>
@@ -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<TemplateVersionEditorProps> = ({
detail={templateVersion.job.error}
severity="error"
tags={templateVersion.job.tags}
variant={AlertVariant.Inline}
/>
</div>
) : (
@@ -602,6 +604,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
matchingProvisioners={matchingProvisioners}
availableProvisioners={availableProvisioners}
tags={templateVersion.job.tags}
variant={AlertVariant.Inline}
/>
<Loader css={{ height: "100%" }} />
</>
@@ -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: <WorkspaceBuildLogsSection logs={makeFailedBuildLogs()} />,
buildLogs: makeFailedBuildLogs(),
},
};
@@ -148,7 +193,7 @@ export const FailedWithRetry: Story = {
},
},
},
buildLogs: <WorkspaceBuildLogsSection logs={makeFailedBuildLogs()} />,
buildLogs: makeFailedBuildLogs(),
},
};
+26 -2
View File
@@ -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<WorkspaceProps> = ({
(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 (
<div
css={{
@@ -208,6 +218,18 @@ export const Workspace: FC<WorkspaceProps> = ({
/>
)}
{shouldShowProvisionerAlert && (
<ProvisionerStatusAlert
matchingProvisioners={
workspace.latest_build.matched_provisioners?.count
}
availableProvisioners={
workspace.latest_build.matched_provisioners?.available ?? 0
}
tags={workspace.latest_build.job.tags}
/>
)}
{workspace.latest_build.job.error && (
<Alert severity="error">
<AlertTitle>Workspace build failed</AlertTitle>
@@ -222,7 +244,9 @@ export const Workspace: FC<WorkspaceProps> = ({
/>
)}
{buildLogs}
{shouldDisplayBuildLogs && (
<WorkspaceBuildLogsSection logs={buildLogs} />
)}
{selectedResource && (
<section
@@ -35,7 +35,6 @@ import { pageTitle } from "utils/page";
import { ChangeVersionDialog } from "./ChangeVersionDialog";
import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog";
import { Workspace } from "./Workspace";
import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection";
import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog";
import type { WorkspacePermissions } from "./permissions";
@@ -71,10 +70,10 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
});
// 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<WorkspaceReadyPageProps> = ({
buildInfo={buildInfoQuery.data}
sshPrefix={sshPrefixQuery.data?.hostname_prefix}
template={template}
buildLogs={
shouldDisplayBuildLogs && (
<WorkspaceBuildLogsSection logs={buildLogs} />
)
}
buildLogs={buildLogs}
isOwner={isOwner}
timings={timingsQuery.data}
/>
+4
View File
@@ -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 = {