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]", "deadline": "[timestamp]",
"max_deadline": null, "max_deadline": null,
"status": "running", "status": "running",
"daily_cost": 0 "daily_cost": 0,
"matched_provisioners": {
"count": 0,
"available": 0,
"most_recently_seen": null
}
}, },
"outdated": false, "outdated": false,
"name": "test-workspace", "name": "test-workspace",
+4
View File
@@ -1568,6 +1568,10 @@ func (q *querier) GetDeploymentWorkspaceStats(ctx context.Context) (database.Get
return q.db.GetDeploymentWorkspaceStats(ctx) 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) { 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) 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") s.NoError(err, "get provisioner daemon by org")
check.Args(database.GetProvisionerDaemonsByOrganizationParams{OrganizationID: org.ID}).Asserts(d, policy.ActionRead).Returns(ds) 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) { s.Run("DeleteOldProvisionerDaemons", s.Subtest(func(db database.Store, check *expects) {
_, err := db.UpsertProvisionerDaemon(context.Background(), database.UpsertProvisionerDaemonParams{ _, err := db.UpsertProvisionerDaemon(context.Background(), database.UpsertProvisionerDaemonParams{
Tags: database.StringMap(map[string]string{ 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 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 // 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. // 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 { 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 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 { func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error {
return xerrors.New("AcquireLock must only be called within a transaction") 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 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) { func (q *FakeQuerier) GetExternalAuthLink(_ context.Context, arg database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) {
if err := validateDatabaseType(arg); err != nil { if err := validateDatabaseType(arg); err != nil {
return database.ExternalAuthLink{}, err 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) { func (q *FakeQuerier) UpsertProvisionerDaemon(_ context.Context, arg database.UpsertProvisionerDaemonParams) (database.ProvisionerDaemon, error) {
err := validateDatabaseType(arg) if err := validateDatabaseType(arg); err != nil {
if err != nil {
return database.ProvisionerDaemon{}, err return database.ProvisionerDaemon{}, err
} }
q.mutex.Lock() q.mutex.Lock()
defer q.mutex.Unlock() defer q.mutex.Unlock()
for _, d := range q.provisionerDaemons {
if d.Name == arg.Name { // Look for existing daemon using the same composite key as SQL
if d.Tags[provisionersdk.TagScope] == provisionersdk.ScopeOrganization && arg.Tags[provisionersdk.TagOwner] != "" { for i, d := range q.provisionerDaemons {
continue if d.OrganizationID == arg.OrganizationID &&
} d.Name == arg.Name &&
if d.Tags[provisionersdk.TagScope] == provisionersdk.ScopeUser && arg.Tags[provisionersdk.TagOwner] != d.Tags[provisionersdk.TagOwner] { getOwnerFromTags(d.Tags) == getOwnerFromTags(arg.Tags) {
continue
}
d.Provisioners = arg.Provisioners d.Provisioners = arg.Provisioners
d.Tags = maps.Clone(arg.Tags) d.Tags = maps.Clone(arg.Tags)
d.Version = arg.Version
d.LastSeenAt = arg.LastSeenAt 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 return d, nil
} }
} }
@@ -10372,7 +10438,6 @@ func (q *FakeQuerier) UpsertProvisionerDaemon(_ context.Context, arg database.Up
Name: arg.Name, Name: arg.Name,
Provisioners: arg.Provisioners, Provisioners: arg.Provisioners,
Tags: maps.Clone(arg.Tags), Tags: maps.Clone(arg.Tags),
ReplicaID: uuid.NullUUID{},
LastSeenAt: arg.LastSeenAt, LastSeenAt: arg.LastSeenAt,
Version: arg.Version, Version: arg.Version,
APIVersion: arg.APIVersion, APIVersion: arg.APIVersion,
@@ -637,6 +637,13 @@ func (m queryMetricsStore) GetDeploymentWorkspaceStats(ctx context.Context) (dat
return row, err 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) { func (m queryMetricsStore) GetExternalAuthLink(ctx context.Context, arg database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) {
start := time.Now() start := time.Now()
link, err := m.s.GetExternalAuthLink(ctx, arg) 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) 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. // GetExternalAuthLink mocks base method.
func (m *MockStore) GetExternalAuthLink(arg0 context.Context, arg1 database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) { func (m *MockStore) GetExternalAuthLink(arg0 context.Context, arg1 database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
+4
View File
@@ -269,6 +269,10 @@ func (p ProvisionerDaemon) RBACObject() rbac.Object {
InOrg(p.OrganizationID) InOrg(p.OrganizationID)
} }
func (p GetEligibleProvisionerDaemonsByProvisionerJobIDsRow) RBACObject() rbac.Object {
return p.ProvisionerDaemon.RBACObject()
}
func (p ProvisionerKey) RBACObject() rbac.Object { func (p ProvisionerKey) RBACObject() rbac.Object {
return rbac.ResourceProvisionerKeys. return rbac.ResourceProvisionerKeys.
WithID(p.ID). WithID(p.ID).
+1
View File
@@ -145,6 +145,7 @@ type sqlcQuerier interface {
GetDeploymentWorkspaceAgentStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentStatsRow, error) GetDeploymentWorkspaceAgentStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentStatsRow, error)
GetDeploymentWorkspaceAgentUsageStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentUsageStatsRow, error) GetDeploymentWorkspaceAgentUsageStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentUsageStatsRow, error)
GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploymentWorkspaceStatsRow, error) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploymentWorkspaceStatsRow, error)
GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.Context, provisionerJobIds []uuid.UUID) ([]GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error)
GetExternalAuthLink(ctx context.Context, arg GetExternalAuthLinkParams) (ExternalAuthLink, error) GetExternalAuthLink(ctx context.Context, arg GetExternalAuthLinkParams) (ExternalAuthLink, error)
GetExternalAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]ExternalAuthLink, error) GetExternalAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]ExternalAuthLink, error)
GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, arg GetFailedWorkspaceBuildsByTemplateIDParams) ([]GetFailedWorkspaceBuildsByTemplateIDRow, 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/httpmw"
"github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/provisionersdk"
"github.com/coder/coder/v2/testutil" "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) { func TestGetWorkspaceAgentUsageStats(t *testing.T) {
t.Parallel() t.Parallel()
+54
View File
@@ -5255,6 +5255,60 @@ func (q *sqlQuerier) DeleteOldProvisionerDaemons(ctx context.Context) error {
return err 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 const getProvisionerDaemons = `-- name: GetProvisionerDaemons :many
SELECT SELECT
id, created_at, name, provisioners, replica_id, tags, last_seen_at, version, api_version, organization_id, key_id 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: -- adding support for searching by tags:
(@want_tags :: tagset = 'null' :: tagset OR provisioner_tagset_contains(provisioner_daemons.tags::tagset, @want_tags::tagset)); (@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 -- name: DeleteOldProvisionerDaemons :exec
-- Delete provisioner daemons that have been created at least a week ago -- Delete provisioner daemons that have been created at least a week ago
-- and have not connected to coderd since a week. -- 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.scripts,
data.logSources, data.logSources,
data.templateVersions, data.templateVersions,
data.provisionerDaemons,
) )
if err != nil { if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ 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.scripts,
data.logSources, data.logSources,
data.templateVersions[0], data.templateVersions[0],
nil, data.provisionerDaemons,
) )
if err != nil { if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ 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( apiBuild, err := api.convertWorkspaceBuild(
*workspaceBuild, *workspaceBuild,
workspace, workspace,
@@ -413,7 +410,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
[]database.WorkspaceAgentScript{}, []database.WorkspaceAgentScript{},
[]database.WorkspaceAgentLogSource{}, []database.WorkspaceAgentLogSource{},
database.TemplateVersion{}, database.TemplateVersion{},
&matchedProvisioners, provisionerDaemons,
) )
if err != nil { if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ 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 { type workspaceBuildsData struct {
jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow
templateVersions []database.TemplateVersion templateVersions []database.TemplateVersion
resources []database.WorkspaceResource resources []database.WorkspaceResource
metadata []database.WorkspaceResourceMetadatum metadata []database.WorkspaceResourceMetadatum
agents []database.WorkspaceAgent agents []database.WorkspaceAgent
apps []database.WorkspaceApp apps []database.WorkspaceApp
scripts []database.WorkspaceAgentScript scripts []database.WorkspaceAgentScript
logSources []database.WorkspaceAgentLogSource logSources []database.WorkspaceAgentLogSource
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
} }
func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []database.WorkspaceBuild) (workspaceBuildsData, error) { 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) { if err != nil && !errors.Is(err, sql.ErrNoRows) {
return workspaceBuildsData{}, xerrors.Errorf("get provisioner jobs: %w", err) 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)) templateVersionIDs := make([]uuid.UUID, 0, len(workspaceBuilds))
for _, build := range workspaceBuilds { for _, build := range workspaceBuilds {
@@ -687,8 +696,9 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab
if len(resources) == 0 { if len(resources) == 0 {
return workspaceBuildsData{ return workspaceBuildsData{
jobs: jobs, jobs: jobs,
templateVersions: templateVersions, templateVersions: templateVersions,
provisionerDaemons: pendingJobProvisioners,
}, nil }, nil
} }
@@ -711,10 +721,11 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab
if len(resources) == 0 { if len(resources) == 0 {
return workspaceBuildsData{ return workspaceBuildsData{
jobs: jobs, jobs: jobs,
templateVersions: templateVersions, templateVersions: templateVersions,
resources: resources, resources: resources,
metadata: metadata, metadata: metadata,
provisionerDaemons: pendingJobProvisioners,
}, nil }, nil
} }
@@ -751,14 +762,15 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab
} }
return workspaceBuildsData{ return workspaceBuildsData{
jobs: jobs, jobs: jobs,
templateVersions: templateVersions, templateVersions: templateVersions,
resources: resources, resources: resources,
metadata: metadata, metadata: metadata,
agents: agents, agents: agents,
apps: apps, apps: apps,
scripts: scripts, scripts: scripts,
logSources: logSources, logSources: logSources,
provisionerDaemons: pendingJobProvisioners,
}, nil }, nil
} }
@@ -773,6 +785,7 @@ func (api *API) convertWorkspaceBuilds(
agentScripts []database.WorkspaceAgentScript, agentScripts []database.WorkspaceAgentScript,
agentLogSources []database.WorkspaceAgentLogSource, agentLogSources []database.WorkspaceAgentLogSource,
templateVersions []database.TemplateVersion, templateVersions []database.TemplateVersion,
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow,
) ([]codersdk.WorkspaceBuild, error) { ) ([]codersdk.WorkspaceBuild, error) {
workspaceByID := map[uuid.UUID]database.Workspace{} workspaceByID := map[uuid.UUID]database.Workspace{}
for _, workspace := range workspaces { for _, workspace := range workspaces {
@@ -814,7 +827,7 @@ func (api *API) convertWorkspaceBuilds(
agentScripts, agentScripts,
agentLogSources, agentLogSources,
templateVersion, templateVersion,
nil, provisionerDaemons,
) )
if err != nil { if err != nil {
return nil, xerrors.Errorf("converting workspace build: %w", err) return nil, xerrors.Errorf("converting workspace build: %w", err)
@@ -837,7 +850,7 @@ func (api *API) convertWorkspaceBuild(
agentScripts []database.WorkspaceAgentScript, agentScripts []database.WorkspaceAgentScript,
agentLogSources []database.WorkspaceAgentLogSource, agentLogSources []database.WorkspaceAgentLogSource,
templateVersion database.TemplateVersion, templateVersion database.TemplateVersion,
matchedProvisioners *codersdk.MatchedProvisioners, provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow,
) (codersdk.WorkspaceBuild, error) { ) (codersdk.WorkspaceBuild, error) {
resourcesByJobID := map[uuid.UUID][]database.WorkspaceResource{} resourcesByJobID := map[uuid.UUID][]database.WorkspaceResource{}
for _, resource := range workspaceResources { for _, resource := range workspaceResources {
@@ -863,6 +876,14 @@ func (api *API) convertWorkspaceBuild(
for _, logSource := range agentLogSources { for _, logSource := range agentLogSources {
logSourcesByAgentID[logSource.WorkspaceAgentID] = append(logSourcesByAgentID[logSource.WorkspaceAgentID], logSource) 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] resources := resourcesByJobID[job.ProvisionerJob.ID]
apiResources := make([]codersdk.WorkspaceResource, 0) apiResources := make([]codersdk.WorkspaceResource, 0)
@@ -930,7 +951,7 @@ func (api *API) convertWorkspaceBuild(
Resources: apiResources, Resources: apiResources,
Status: codersdk.ConvertWorkspaceStatus(apiJob.Status, transition), Status: codersdk.ConvertWorkspaceStatus(apiJob.Status, transition),
DailyCost: build.DailyCost, DailyCost: build.DailyCost,
MatchedProvisioners: matchedProvisioners, MatchedProvisioners: &matchedProvisioners,
}, nil }, nil
} }
+5 -9
View File
@@ -27,7 +27,6 @@ import (
"github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/notifications" "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"
"github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/schedule"
@@ -612,10 +611,9 @@ func createWorkspace(
} }
var ( var (
provisionerJob *database.ProvisionerJob provisionerJob *database.ProvisionerJob
workspaceBuild *database.WorkspaceBuild workspaceBuild *database.WorkspaceBuild
provisionerDaemons []database.ProvisionerDaemon provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
matchedProvisioners codersdk.MatchedProvisioners
) )
err = api.Database.InTx(func(db database.Store) error { err = api.Database.InTx(func(db database.Store) error {
now := dbtime.Now() now := dbtime.Now()
@@ -688,9 +686,6 @@ func createWorkspace(
// Client probably doesn't care about this error, so just log it. // 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)) 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() auditReq.New = workspace.WorkspaceTable()
@@ -713,7 +708,7 @@ func createWorkspace(
[]database.WorkspaceAgentScript{}, []database.WorkspaceAgentScript{},
[]database.WorkspaceAgentLogSource{}, []database.WorkspaceAgentLogSource{},
database.TemplateVersion{}, database.TemplateVersion{},
&matchedProvisioners, provisionerDaemons,
) )
if err != nil { if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ 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.scripts,
data.logSources, data.logSources,
data.templateVersions, data.templateVersions,
data.provisionerDaemons,
) )
if err != nil { if err != nil {
return workspaceData{}, xerrors.Errorf("convert workspace builds: %w", err) 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, authFunc func(action policy.Action, object rbac.Objecter) bool,
auditBaggage audit.WorkspaceBuildBaggage, auditBaggage audit.WorkspaceBuildBaggage,
) ( ) (
*database.WorkspaceBuild, *database.ProvisionerJob, []database.ProvisionerDaemon, error, *database.WorkspaceBuild, *database.ProvisionerJob, []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error,
) { ) {
var err error var err error
b.ctx, err = audit.BaggageToContext(ctx, auditBaggage) b.ctx, err = audit.BaggageToContext(ctx, auditBaggage)
@@ -228,7 +228,7 @@ func (b *Builder) Build(
// later reads are consistent with earlier ones. // later reads are consistent with earlier ones.
var workspaceBuild *database.WorkspaceBuild var workspaceBuild *database.WorkspaceBuild
var provisionerJob *database.ProvisionerJob var provisionerJob *database.ProvisionerJob
var provisionerDaemons []database.ProvisionerDaemon var provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
err = database.ReadModifyUpdate(store, func(tx database.Store) error { err = database.ReadModifyUpdate(store, func(tx database.Store) error {
var err error var err error
b.store = tx 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. // 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) ( 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 { if authFunc != nil {
err := b.authorize(authFunc) 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 // 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 // provisioner daemons for this job to show in the UI if there is no
// matching provisioner daemon. // matching provisioner daemon.
provisionerDaemons, err := b.store.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(b.ctx), database.GetProvisionerDaemonsByOrganizationParams{ provisionerDaemons, err := b.store.GetEligibleProvisionerDaemonsByProvisionerJobIDs(dbauthz.AsSystemReadProvisionerDaemons(b.ctx), []uuid.UUID{provisionerJob.ID})
OrganizationID: template.OrganizationID,
WantTags: provisionerJob.Tags,
})
if err != nil { if err != nil {
// NOTE: we do **not** want to fail a workspace build if we fail to // 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 // retrieve provisioner daemons. This is just to show in the UI if there
// is no matching provisioner daemon for the job. // is no matching provisioner daemon for the job.
provisionerDaemons = []database.ProvisionerDaemon{} provisionerDaemons = []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}
} }
templateVersionID, err := b.getTemplateVersionID() templateVersionID, err := b.getTemplateVersionID()
+13 -13
View File
@@ -61,7 +61,7 @@ func TestBuilder_NoOptions(t *testing.T) {
withRichParameters(nil), withRichParameters(nil),
withParameterSchemas(inactiveJobID, nil), withParameterSchemas(inactiveJobID, nil),
withWorkspaceTags(inactiveVersionID, nil), withWorkspaceTags(inactiveVersionID, nil),
withProvisionerDaemons([]database.ProvisionerDaemon{}), withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}),
// Outputs // Outputs
expectProvisionerJob(func(job database.InsertProvisionerJobParams) { expectProvisionerJob(func(job database.InsertProvisionerJobParams) {
@@ -116,7 +116,7 @@ func TestBuilder_Initiator(t *testing.T) {
withRichParameters(nil), withRichParameters(nil),
withParameterSchemas(inactiveJobID, nil), withParameterSchemas(inactiveJobID, nil),
withWorkspaceTags(inactiveVersionID, nil), withWorkspaceTags(inactiveVersionID, nil),
withProvisionerDaemons([]database.ProvisionerDaemon{}), withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}),
// Outputs // Outputs
expectProvisionerJob(func(job database.InsertProvisionerJobParams) { expectProvisionerJob(func(job database.InsertProvisionerJobParams) {
@@ -161,7 +161,7 @@ func TestBuilder_Baggage(t *testing.T) {
withRichParameters(nil), withRichParameters(nil),
withParameterSchemas(inactiveJobID, nil), withParameterSchemas(inactiveJobID, nil),
withWorkspaceTags(inactiveVersionID, nil), withWorkspaceTags(inactiveVersionID, nil),
withProvisionerDaemons([]database.ProvisionerDaemon{}), withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}),
// Outputs // Outputs
expectProvisionerJob(func(job database.InsertProvisionerJobParams) { expectProvisionerJob(func(job database.InsertProvisionerJobParams) {
@@ -198,7 +198,7 @@ func TestBuilder_Reason(t *testing.T) {
withRichParameters(nil), withRichParameters(nil),
withParameterSchemas(inactiveJobID, nil), withParameterSchemas(inactiveJobID, nil),
withWorkspaceTags(inactiveVersionID, nil), withWorkspaceTags(inactiveVersionID, nil),
withProvisionerDaemons([]database.ProvisionerDaemon{}), withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}),
// Outputs // Outputs
expectProvisionerJob(func(_ database.InsertProvisionerJobParams) { expectProvisionerJob(func(_ database.InsertProvisionerJobParams) {
@@ -234,7 +234,7 @@ func TestBuilder_ActiveVersion(t *testing.T) {
withLastBuildNotFound, withLastBuildNotFound,
withParameterSchemas(activeJobID, nil), withParameterSchemas(activeJobID, nil),
withWorkspaceTags(activeVersionID, nil), withWorkspaceTags(activeVersionID, nil),
withProvisionerDaemons([]database.ProvisionerDaemon{}), withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}),
// previous rich parameters are not queried because there is no previous build. // previous rich parameters are not queried because there is no previous build.
// Outputs // Outputs
@@ -324,7 +324,7 @@ func TestWorkspaceBuildWithTags(t *testing.T) {
withRichParameters(nil), withRichParameters(nil),
withParameterSchemas(inactiveJobID, nil), withParameterSchemas(inactiveJobID, nil),
withWorkspaceTags(inactiveVersionID, workspaceTags), withWorkspaceTags(inactiveVersionID, workspaceTags),
withProvisionerDaemons([]database.ProvisionerDaemon{}), withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}),
// Outputs // Outputs
expectProvisionerJob(func(job database.InsertProvisionerJobParams) { expectProvisionerJob(func(job database.InsertProvisionerJobParams) {
@@ -416,7 +416,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) {
withRichParameters(initialBuildParameters), withRichParameters(initialBuildParameters),
withParameterSchemas(inactiveJobID, nil), withParameterSchemas(inactiveJobID, nil),
withWorkspaceTags(inactiveVersionID, nil), withWorkspaceTags(inactiveVersionID, nil),
withProvisionerDaemons([]database.ProvisionerDaemon{}), withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}),
// Outputs // Outputs
expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}),
@@ -462,7 +462,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) {
withRichParameters(initialBuildParameters), withRichParameters(initialBuildParameters),
withParameterSchemas(inactiveJobID, nil), withParameterSchemas(inactiveJobID, nil),
withWorkspaceTags(inactiveVersionID, nil), withWorkspaceTags(inactiveVersionID, nil),
withProvisionerDaemons([]database.ProvisionerDaemon{}), withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}),
// Outputs // Outputs
expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}),
@@ -596,7 +596,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) {
withRichParameters(initialBuildParameters), withRichParameters(initialBuildParameters),
withParameterSchemas(activeJobID, nil), withParameterSchemas(activeJobID, nil),
withWorkspaceTags(activeVersionID, nil), withWorkspaceTags(activeVersionID, nil),
withProvisionerDaemons([]database.ProvisionerDaemon{}), withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}),
// Outputs // Outputs
expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}),
@@ -658,7 +658,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) {
withRichParameters(initialBuildParameters), withRichParameters(initialBuildParameters),
withParameterSchemas(activeJobID, nil), withParameterSchemas(activeJobID, nil),
withWorkspaceTags(activeVersionID, nil), withWorkspaceTags(activeVersionID, nil),
withProvisionerDaemons([]database.ProvisionerDaemon{}), withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}),
// Outputs // Outputs
expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}),
@@ -718,7 +718,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) {
withRichParameters(initialBuildParameters), withRichParameters(initialBuildParameters),
withParameterSchemas(activeJobID, nil), withParameterSchemas(activeJobID, nil),
withWorkspaceTags(activeVersionID, nil), withWorkspaceTags(activeVersionID, nil),
withProvisionerDaemons([]database.ProvisionerDaemon{}), withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}),
// Outputs // Outputs
expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), 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) { 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/dbfake"
"github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime" "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/notifications"
"github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac"
agplschedule "github.com/coder/coder/v2/coderd/schedule" 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/license"
"github.com/coder/coder/v2/enterprise/coderd/schedule" "github.com/coder/coder/v2/enterprise/coderd/schedule"
"github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk"
"github.com/coder/coder/v2/testutil" "github.com/coder/coder/v2/testutil"
"github.com/coder/quartz" "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") 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 { func must[T any](value T, err error) T {
if err != nil { if err != nil {
panic(err) panic(err)
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { chromatic } from "testHelpers/chromatic"; import { chromatic } from "testHelpers/chromatic";
import { ProvisionerAlert } from "./ProvisionerAlert"; import { AlertVariant, ProvisionerAlert } from "./ProvisionerAlert";
const meta: Meta<typeof ProvisionerAlert> = { const meta: Meta<typeof ProvisionerAlert> = {
title: "modules/provisioners/ProvisionerAlert", title: "modules/provisioners/ProvisionerAlert",
@@ -21,6 +21,26 @@ export default meta;
type Story = StoryObj<typeof ProvisionerAlert>; type Story = StoryObj<typeof ProvisionerAlert>;
export const Info: Story = {}; 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 = { export const NullTags: Story = {
args: { args: {
tags: undefined, tags: undefined,
@@ -1,34 +1,54 @@
import type { Theme } from "@emotion/react";
import AlertTitle from "@mui/material/AlertTitle"; import AlertTitle from "@mui/material/AlertTitle";
import { Alert, type AlertColor } from "components/Alert/Alert"; import { Alert, type AlertColor } from "components/Alert/Alert";
import { AlertDetail } from "components/Alert/Alert"; import { AlertDetail } from "components/Alert/Alert";
import { Stack } from "components/Stack/Stack"; import { Stack } from "components/Stack/Stack";
import { ProvisionerTag } from "modules/provisioners/ProvisionerTag"; import { ProvisionerTag } from "modules/provisioners/ProvisionerTag";
import type { FC } from "react"; 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 { interface ProvisionerAlertProps {
title: string; title: string;
detail: string; detail: string;
severity: AlertColor; severity: AlertColor;
tags: Record<string, string>; 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> = ({ export const ProvisionerAlert: FC<ProvisionerAlertProps> = ({
title, title,
detail, detail,
severity, severity,
tags, tags,
variant = AlertVariant.Standalone,
}) => { }) => {
return ( return (
<Alert <Alert severity={severity} {...getAlertStyles(variant, severity)}>
severity={severity}
css={(theme) => {
return {
borderRadius: 0,
border: 0,
borderBottom: `1px solid ${theme.palette.divider}`,
borderLeft: `2px solid ${theme.palette[severity].main}`,
};
}}
>
<AlertTitle>{title}</AlertTitle> <AlertTitle>{title}</AlertTitle>
<AlertDetail> <AlertDetail>
<div>{detail}</div> <div>{detail}</div>
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { chromatic } from "testHelpers/chromatic"; import { chromatic } from "testHelpers/chromatic";
import { MockTemplateVersion } from "testHelpers/entities"; import { MockTemplateVersion } from "testHelpers/entities";
import { AlertVariant } from "./ProvisionerAlert";
import { ProvisionerStatusAlert } from "./ProvisionerStatusAlert"; import { ProvisionerStatusAlert } from "./ProvisionerStatusAlert";
const meta: Meta<typeof 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 = { export const NoAvailableProvisioners: Story = {
args: { args: {
matchingProvisioners: 1, matchingProvisioners: 1,
availableProvisioners: 0, 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 { AlertColor } from "components/Alert/Alert";
import type { FC } from "react"; import type { FC } from "react";
import { ProvisionerAlert } from "./ProvisionerAlert"; import { AlertVariant, ProvisionerAlert } from "./ProvisionerAlert";
interface ProvisionerStatusAlertProps { interface ProvisionerStatusAlertProps {
matchingProvisioners: number | undefined; matchingProvisioners: number | undefined;
availableProvisioners: number | undefined; availableProvisioners: number | undefined;
tags: Record<string, string>; tags: Record<string, string>;
variant?: AlertVariant;
} }
export const ProvisionerStatusAlert: FC<ProvisionerStatusAlertProps> = ({ export const ProvisionerStatusAlert: FC<ProvisionerStatusAlertProps> = ({
matchingProvisioners, matchingProvisioners,
availableProvisioners, availableProvisioners,
tags, tags,
variant = AlertVariant.Standalone,
}) => { }) => {
let title: string; let title: string;
let detail: string; let detail: string;
@@ -42,6 +44,7 @@ export const ProvisionerStatusAlert: FC<ProvisionerStatusAlertProps> = ({
detail={detail} detail={detail}
severity={severity} severity={severity}
tags={tags} tags={tags}
variant={variant}
/> />
); );
}; };
@@ -8,6 +8,7 @@ import { visuallyHidden } from "@mui/utils";
import { JobError } from "api/queries/templates"; import { JobError } from "api/queries/templates";
import type { TemplateVersion } from "api/typesGenerated"; import type { TemplateVersion } from "api/typesGenerated";
import { Loader } from "components/Loader/Loader"; import { Loader } from "components/Loader/Loader";
import { AlertVariant } from "modules/provisioners/ProvisionerAlert";
import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert"; import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert";
import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs"; import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs";
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
@@ -94,6 +95,7 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
matchingProvisioners={matchingProvisioners} matchingProvisioners={matchingProvisioners}
availableProvisioners={availableProvisioners} availableProvisioners={availableProvisioners}
tags={templateVersion?.job.tags ?? {}} tags={templateVersion?.job.tags ?? {}}
variant={AlertVariant.Inline}
/> />
<Loader /> <Loader />
</> </>
@@ -29,6 +29,7 @@ import {
import { Loader } from "components/Loader/Loader"; import { Loader } from "components/Loader/Loader";
import { linkToTemplate, useLinks } from "modules/navigation"; import { linkToTemplate, useLinks } from "modules/navigation";
import { ProvisionerAlert } from "modules/provisioners/ProvisionerAlert"; import { ProvisionerAlert } from "modules/provisioners/ProvisionerAlert";
import { AlertVariant } from "modules/provisioners/ProvisionerAlert";
import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert"; import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert";
import { TemplateFileTree } from "modules/templates/TemplateFiles/TemplateFileTree"; import { TemplateFileTree } from "modules/templates/TemplateFiles/TemplateFileTree";
import { isBinaryData } from "modules/templates/TemplateFiles/isBinaryData"; import { isBinaryData } from "modules/templates/TemplateFiles/isBinaryData";
@@ -593,6 +594,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
detail={templateVersion.job.error} detail={templateVersion.job.error}
severity="error" severity="error"
tags={templateVersion.job.tags} tags={templateVersion.job.tags}
variant={AlertVariant.Inline}
/> />
</div> </div>
) : ( ) : (
@@ -602,6 +604,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
matchingProvisioners={matchingProvisioners} matchingProvisioners={matchingProvisioners}
availableProvisioners={availableProvisioners} availableProvisioners={availableProvisioners}
tags={templateVersion.job.tags} tags={templateVersion.job.tags}
variant={AlertVariant.Inline}
/> />
<Loader css={{ height: "100%" }} /> <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 = { export const Starting: Story = {
args: { args: {
...Running.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 { Alert, AlertDetail } from "components/Alert/Alert";
import { SidebarIconButton } from "components/FullPageLayout/Sidebar"; import { SidebarIconButton } from "components/FullPageLayout/Sidebar";
import { useSearchParamsKey } from "hooks/useSearchParamsKey"; import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert";
import { AgentRow } from "modules/resources/AgentRow"; import { AgentRow } from "modules/resources/AgentRow";
import { WorkspaceTimings } from "modules/workspaces/WorkspaceTiming/WorkspaceTimings"; import { WorkspaceTimings } from "modules/workspaces/WorkspaceTiming/WorkspaceTimings";
import type { FC } from "react"; import type { FC } from "react";
@@ -14,6 +15,7 @@ import { useNavigate } from "react-router-dom";
import { HistorySidebar } from "./HistorySidebar"; import { HistorySidebar } from "./HistorySidebar";
import { ResourceMetadata } from "./ResourceMetadata"; import { ResourceMetadata } from "./ResourceMetadata";
import { ResourcesSidebar } from "./ResourcesSidebar"; import { ResourcesSidebar } from "./ResourcesSidebar";
import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection";
import { import {
ActiveTransition, ActiveTransition,
WorkspaceBuildProgress, WorkspaceBuildProgress,
@@ -46,7 +48,7 @@ export interface WorkspaceProps {
canDebugMode: boolean; canDebugMode: boolean;
handleRetry: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; handleRetry: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
handleDebug: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; handleDebug: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
buildLogs?: React.ReactNode; buildLogs?: TypesGen.ProvisionerJobLog[];
latestVersion?: TypesGen.TemplateVersion; latestVersion?: TypesGen.TemplateVersion;
permissions: WorkspacePermissions; permissions: WorkspacePermissions;
isOwner: boolean; isOwner: boolean;
@@ -108,6 +110,14 @@ export const Workspace: FC<WorkspaceProps> = ({
(r) => resourceOptionValue(r) === resourcesNav.value, (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 ( return (
<div <div
css={{ 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 && ( {workspace.latest_build.job.error && (
<Alert severity="error"> <Alert severity="error">
<AlertTitle>Workspace build failed</AlertTitle> <AlertTitle>Workspace build failed</AlertTitle>
@@ -222,7 +244,9 @@ export const Workspace: FC<WorkspaceProps> = ({
/> />
)} )}
{buildLogs} {shouldDisplayBuildLogs && (
<WorkspaceBuildLogsSection logs={buildLogs} />
)}
{selectedResource && ( {selectedResource && (
<section <section
@@ -35,7 +35,6 @@ import { pageTitle } from "utils/page";
import { ChangeVersionDialog } from "./ChangeVersionDialog"; import { ChangeVersionDialog } from "./ChangeVersionDialog";
import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog"; import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog";
import { Workspace } from "./Workspace"; import { Workspace } from "./Workspace";
import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection";
import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog"; import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog";
import type { WorkspacePermissions } from "./permissions"; import type { WorkspacePermissions } from "./permissions";
@@ -71,10 +70,10 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
}); });
// Build logs // Build logs
const shouldDisplayBuildLogs = workspace.latest_build.status !== "running"; const shouldStreamBuildLogs = workspace.latest_build.status !== "running";
const buildLogs = useWorkspaceBuildLogs( const buildLogs = useWorkspaceBuildLogs(
workspace.latest_build.id, workspace.latest_build.id,
shouldDisplayBuildLogs, shouldStreamBuildLogs,
); );
// Restart // Restart
@@ -278,11 +277,7 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
buildInfo={buildInfoQuery.data} buildInfo={buildInfoQuery.data}
sshPrefix={sshPrefixQuery.data?.hostname_prefix} sshPrefix={sshPrefixQuery.data?.hostname_prefix}
template={template} template={template}
buildLogs={ buildLogs={buildLogs}
shouldDisplayBuildLogs && (
<WorkspaceBuildLogsSection logs={buildLogs} />
)
}
isOwner={isOwner} isOwner={isOwner}
timings={timingsQuery.data} timings={timingsQuery.data}
/> />
+4
View File
@@ -1199,6 +1199,10 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = {
resources: [MockWorkspaceResource], resources: [MockWorkspaceResource],
status: "running", status: "running",
daily_cost: 20, daily_cost: 20,
matched_provisioners: {
count: 1,
available: 1,
},
}; };
export const MockWorkspaceBuildAutostart: TypesGen.WorkspaceBuild = { export const MockWorkspaceBuildAutostart: TypesGen.WorkspaceBuild = {