feat(coderd): add endpoint to list provisioner daemons (#16028)

Updates #15190
Updates #15084
Supersedes #15940
This commit is contained in:
Mathias Fredriksson
2025-01-14 16:40:26 +00:00
committed by GitHub
parent d7809ecf3f
commit 071bb26018
31 changed files with 1106 additions and 188 deletions
+60 -1
View File
@@ -2963,7 +2963,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"Enterprise"
"Provisioning"
],
"summary": "Get provisioner daemons",
"operationId": "get-provisioner-daemons",
@@ -12463,6 +12463,9 @@ const docTemplate = `{
"type": "string",
"format": "date-time"
},
"current_job": {
"$ref": "#/definitions/codersdk.ProvisionerDaemonJob"
},
"id": {
"type": "string",
"format": "uuid"
@@ -12471,6 +12474,10 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"key_name": {
"description": "Optional fields.",
"type": "string"
},
"last_seen_at": {
"type": "string",
"format": "date-time"
@@ -12482,12 +12489,27 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"previous_job": {
"$ref": "#/definitions/codersdk.ProvisionerDaemonJob"
},
"provisioners": {
"type": "array",
"items": {
"type": "string"
}
},
"status": {
"enum": [
"offline",
"idle",
"busy"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.ProvisionerDaemonStatus"
}
]
},
"tags": {
"type": "object",
"additionalProperties": {
@@ -12499,6 +12521,43 @@ const docTemplate = `{
}
}
},
"codersdk.ProvisionerDaemonJob": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"status": {
"enum": [
"pending",
"running",
"succeeded",
"canceling",
"canceled",
"failed"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.ProvisionerJobStatus"
}
]
}
}
},
"codersdk.ProvisionerDaemonStatus": {
"type": "string",
"enum": [
"offline",
"idle",
"busy"
],
"x-enum-varnames": [
"ProvisionerDaemonOffline",
"ProvisionerDaemonIdle",
"ProvisionerDaemonBusy"
]
},
"codersdk.ProvisionerJob": {
"type": "object",
"properties": {
+52 -1
View File
@@ -2598,7 +2598,7 @@
}
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"tags": ["Provisioning"],
"summary": "Get provisioner daemons",
"operationId": "get-provisioner-daemons",
"parameters": [
@@ -11244,6 +11244,9 @@
"type": "string",
"format": "date-time"
},
"current_job": {
"$ref": "#/definitions/codersdk.ProvisionerDaemonJob"
},
"id": {
"type": "string",
"format": "uuid"
@@ -11252,6 +11255,10 @@
"type": "string",
"format": "uuid"
},
"key_name": {
"description": "Optional fields.",
"type": "string"
},
"last_seen_at": {
"type": "string",
"format": "date-time"
@@ -11263,12 +11270,23 @@
"type": "string",
"format": "uuid"
},
"previous_job": {
"$ref": "#/definitions/codersdk.ProvisionerDaemonJob"
},
"provisioners": {
"type": "array",
"items": {
"type": "string"
}
},
"status": {
"enum": ["offline", "idle", "busy"],
"allOf": [
{
"$ref": "#/definitions/codersdk.ProvisionerDaemonStatus"
}
]
},
"tags": {
"type": "object",
"additionalProperties": {
@@ -11280,6 +11298,39 @@
}
}
},
"codersdk.ProvisionerDaemonJob": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"status": {
"enum": [
"pending",
"running",
"succeeded",
"canceling",
"canceled",
"failed"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.ProvisionerJobStatus"
}
]
}
}
},
"codersdk.ProvisionerDaemonStatus": {
"type": "string",
"enum": ["offline", "idle", "busy"],
"x-enum-varnames": [
"ProvisionerDaemonOffline",
"ProvisionerDaemonIdle",
"ProvisionerDaemonBusy"
]
},
"codersdk.ProvisionerJob": {
"type": "object",
"properties": {
+3
View File
@@ -1007,6 +1007,9 @@ func New(options *Options) *API {
})
})
})
r.Route("/provisionerdaemons", func(r chi.Router) {
r.Get("/", api.provisionerDaemons)
})
})
})
r.Route("/templates", func(r chi.Router) {
+4
View File
@@ -1936,6 +1936,10 @@ func (q *querier) GetProvisionerDaemonsByOrganization(ctx context.Context, organ
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetProvisionerDaemonsByOrganization)(ctx, organizationID)
}
func (q *querier) GetProvisionerDaemonsWithStatusByOrganization(ctx context.Context, arg database.GetProvisionerDaemonsWithStatusByOrganizationParams) ([]database.GetProvisionerDaemonsWithStatusByOrganizationRow, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetProvisionerDaemonsWithStatusByOrganization)(ctx, arg)
}
func (q *querier) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (database.ProvisionerJob, error) {
job, err := q.db.GetProvisionerJobByID(ctx, id)
if err != nil {
+18
View File
@@ -3189,6 +3189,24 @@ 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("GetProvisionerDaemonsWithStatusByOrganization", s.Subtest(func(db database.Store, check *expects) {
org := dbgen.Organization(s.T(), db, database.Organization{})
d := dbgen.ProvisionerDaemon(s.T(), db, database.ProvisionerDaemon{
OrganizationID: org.ID,
Tags: map[string]string{
provisionersdk.TagScope: provisionersdk.ScopeOrganization,
},
})
ds, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{
OrganizationID: org.ID,
StaleIntervalMS: 24 * time.Hour.Milliseconds(),
})
s.NoError(err, "get provisioner daemon with status by org")
check.Args(database.GetProvisionerDaemonsWithStatusByOrganizationParams{
OrganizationID: org.ID,
StaleIntervalMS: 24 * time.Hour.Milliseconds(),
}).Asserts(d, policy.ActionRead).Returns(ds)
}))
s.Run("GetEligibleProvisionerDaemonsByProvisionerJobIDs", s.Subtest(func(db database.Store, check *expects) {
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
org := dbgen.Organization(s.T(), db, database.Organization{})
+26 -19
View File
@@ -505,9 +505,27 @@ func GroupMember(t testing.TB, db database.Store, member database.GroupMemberTab
// 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 {
func ProvisionerDaemon(t testing.TB, db database.Store, orig database.ProvisionerDaemon) database.ProvisionerDaemon {
t.Helper()
var defOrgID uuid.UUID
if orig.OrganizationID == uuid.Nil {
defOrg, _ := db.GetDefaultOrganization(genCtx)
defOrgID = defOrg.ID
}
daemon := database.UpsertProvisionerDaemonParams{
Name: takeFirst(orig.Name, testutil.GetRandomName(t)),
OrganizationID: takeFirst(orig.OrganizationID, defOrgID, uuid.New()),
CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()),
Provisioners: takeFirstSlice(orig.Provisioners, []database.ProvisionerType{database.ProvisionerTypeEcho}),
Tags: takeFirstMap(orig.Tags, database.StringMap{}),
KeyID: takeFirst(orig.KeyID, uuid.Nil),
LastSeenAt: takeFirst(orig.LastSeenAt, sql.NullTime{Time: dbtime.Now(), Valid: true}),
Version: takeFirst(orig.Version, "v0.0.0"),
APIVersion: takeFirst(orig.APIVersion, "1.1"),
}
if daemon.KeyID == uuid.Nil {
key, err := db.InsertProvisionerKey(genCtx, database.InsertProvisionerKeyParams{
ID: uuid.New(),
@@ -521,24 +539,7 @@ func ProvisionerDaemon(t testing.TB, db database.Store, daemon database.Provisio
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,
})
d, err := db.UpsertProvisionerDaemon(genCtx, daemon)
require.NoError(t, err)
return d
}
@@ -1109,6 +1110,12 @@ func takeFirstSlice[T any](values ...[]T) []T {
})
}
func takeFirstMap[T, E comparable](values ...map[T]E) map[T]E {
return takeFirstF(values, func(v map[T]E) bool {
return v != nil
})
}
// takeFirstF takes the first value that returns true
func takeFirstF[Value any](values []Value, take func(v Value) bool) Value {
for _, v := range values {
+94
View File
@@ -3756,6 +3756,100 @@ func (q *FakeQuerier) GetProvisionerDaemonsByOrganization(_ context.Context, arg
return daemons, nil
}
func (q *FakeQuerier) GetProvisionerDaemonsWithStatusByOrganization(_ context.Context, arg database.GetProvisionerDaemonsWithStatusByOrganizationParams) ([]database.GetProvisionerDaemonsWithStatusByOrganizationRow, error) {
err := validateDatabaseType(arg)
if err != nil {
return nil, err
}
q.mutex.RLock()
defer q.mutex.RUnlock()
var rows []database.GetProvisionerDaemonsWithStatusByOrganizationRow
for _, daemon := range q.provisionerDaemons {
if daemon.OrganizationID != arg.OrganizationID {
continue
}
if len(arg.IDs) > 0 && !slices.Contains(arg.IDs, daemon.ID) {
continue
}
if len(arg.Tags) > 0 {
// Special case for untagged provisioners: only match untagged jobs.
// Ref: coderd/database/queries/provisionerjobs.sql:24-30
// CASE WHEN nested.tags :: jsonb = '{"scope": "organization", "owner": ""}' :: jsonb
// THEN nested.tags :: jsonb = @tags :: jsonb
if tagsEqual(arg.Tags, tagsUntagged) && !tagsEqual(arg.Tags, daemon.Tags) {
continue
}
// ELSE nested.tags :: jsonb <@ @tags :: jsonb
if !tagsSubset(arg.Tags, daemon.Tags) {
continue
}
}
var status database.ProvisionerDaemonStatus
var currentJob database.ProvisionerJob
if !daemon.LastSeenAt.Valid || daemon.LastSeenAt.Time.Before(time.Now().Add(-time.Duration(arg.StaleIntervalMS)*time.Millisecond)) {
status = database.ProvisionerDaemonStatusOffline
} else {
for _, job := range q.provisionerJobs {
if job.WorkerID.Valid && job.WorkerID.UUID == daemon.ID && !job.CompletedAt.Valid && !job.Error.Valid {
currentJob = job
break
}
}
if currentJob.ID != uuid.Nil {
status = database.ProvisionerDaemonStatusBusy
} else {
status = database.ProvisionerDaemonStatusIdle
}
}
var previousJob database.ProvisionerJob
for _, job := range q.provisionerJobs {
if !job.WorkerID.Valid || job.WorkerID.UUID != daemon.ID {
continue
}
if job.StartedAt.Valid ||
job.CanceledAt.Valid ||
job.CompletedAt.Valid ||
job.Error.Valid {
if job.CompletedAt.Time.After(previousJob.CompletedAt.Time) {
previousJob = job
}
}
}
// Get the provisioner key name
var keyName string
for _, key := range q.provisionerKeys {
if key.ID == daemon.KeyID {
keyName = key.Name
break
}
}
rows = append(rows, database.GetProvisionerDaemonsWithStatusByOrganizationRow{
ProvisionerDaemon: daemon,
Status: status,
KeyName: keyName,
CurrentJobID: uuid.NullUUID{UUID: currentJob.ID, Valid: currentJob.ID != uuid.Nil},
CurrentJobStatus: database.NullProvisionerJobStatus{ProvisionerJobStatus: currentJob.JobStatus, Valid: currentJob.ID != uuid.Nil},
PreviousJobID: uuid.NullUUID{UUID: previousJob.ID, Valid: previousJob.ID != uuid.Nil},
PreviousJobStatus: database.NullProvisionerJobStatus{ProvisionerJobStatus: previousJob.JobStatus, Valid: previousJob.ID != uuid.Nil},
})
}
slices.SortFunc(rows, func(a, b database.GetProvisionerDaemonsWithStatusByOrganizationRow) int {
return a.ProvisionerDaemon.CreatedAt.Compare(b.ProvisionerDaemon.CreatedAt)
})
return rows, nil
}
func (q *FakeQuerier) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (database.ProvisionerJob, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@@ -987,6 +987,13 @@ func (m queryMetricsStore) GetProvisionerDaemonsByOrganization(ctx context.Conte
return r0, r1
}
func (m queryMetricsStore) GetProvisionerDaemonsWithStatusByOrganization(ctx context.Context, arg database.GetProvisionerDaemonsWithStatusByOrganizationParams) ([]database.GetProvisionerDaemonsWithStatusByOrganizationRow, error) {
start := time.Now()
r0, r1 := m.s.GetProvisionerDaemonsWithStatusByOrganization(ctx, arg)
m.queryLatencies.WithLabelValues("GetProvisionerDaemonsWithStatusByOrganization").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (database.ProvisionerJob, error) {
start := time.Now()
job, err := m.s.GetProvisionerJobByID(ctx, id)
+15
View File
@@ -2030,6 +2030,21 @@ func (mr *MockStoreMockRecorder) GetProvisionerDaemonsByOrganization(arg0, arg1
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProvisionerDaemonsByOrganization", reflect.TypeOf((*MockStore)(nil).GetProvisionerDaemonsByOrganization), arg0, arg1)
}
// GetProvisionerDaemonsWithStatusByOrganization mocks base method.
func (m *MockStore) GetProvisionerDaemonsWithStatusByOrganization(arg0 context.Context, arg1 database.GetProvisionerDaemonsWithStatusByOrganizationParams) ([]database.GetProvisionerDaemonsWithStatusByOrganizationRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetProvisionerDaemonsWithStatusByOrganization", arg0, arg1)
ret0, _ := ret[0].([]database.GetProvisionerDaemonsWithStatusByOrganizationRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetProvisionerDaemonsWithStatusByOrganization indicates an expected call of GetProvisionerDaemonsWithStatusByOrganization.
func (mr *MockStoreMockRecorder) GetProvisionerDaemonsWithStatusByOrganization(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProvisionerDaemonsWithStatusByOrganization", reflect.TypeOf((*MockStore)(nil).GetProvisionerDaemonsWithStatusByOrganization), arg0, arg1)
}
// GetProvisionerJobByID mocks base method.
func (m *MockStore) GetProvisionerJobByID(arg0 context.Context, arg1 uuid.UUID) (database.ProvisionerJob, error) {
m.ctrl.T.Helper()
+8
View File
@@ -137,6 +137,14 @@ CREATE TYPE port_share_protocol AS ENUM (
'https'
);
CREATE TYPE provisioner_daemon_status AS ENUM (
'offline',
'idle',
'busy'
);
COMMENT ON TYPE provisioner_daemon_status IS 'The status of a provisioner daemon.';
CREATE TYPE provisioner_job_status AS ENUM (
'pending',
'running',
@@ -0,0 +1 @@
DROP TYPE provisioner_daemon_status;
@@ -0,0 +1,3 @@
CREATE TYPE provisioner_daemon_status AS ENUM ('offline', 'idle', 'busy');
COMMENT ON TYPE provisioner_daemon_status IS 'The status of a provisioner daemon.';
+4
View File
@@ -269,6 +269,10 @@ func (p ProvisionerDaemon) RBACObject() rbac.Object {
InOrg(p.OrganizationID)
}
func (p GetProvisionerDaemonsWithStatusByOrganizationRow) RBACObject() rbac.Object {
return p.ProvisionerDaemon.RBACObject()
}
func (p GetEligibleProvisionerDaemonsByProvisionerJobIDsRow) RBACObject() rbac.Object {
return p.ProvisionerDaemon.RBACObject()
}
+62
View File
@@ -1209,6 +1209,68 @@ func AllPortShareProtocolValues() []PortShareProtocol {
}
}
// The status of a provisioner daemon.
type ProvisionerDaemonStatus string
const (
ProvisionerDaemonStatusOffline ProvisionerDaemonStatus = "offline"
ProvisionerDaemonStatusIdle ProvisionerDaemonStatus = "idle"
ProvisionerDaemonStatusBusy ProvisionerDaemonStatus = "busy"
)
func (e *ProvisionerDaemonStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = ProvisionerDaemonStatus(s)
case string:
*e = ProvisionerDaemonStatus(s)
default:
return fmt.Errorf("unsupported scan type for ProvisionerDaemonStatus: %T", src)
}
return nil
}
type NullProvisionerDaemonStatus struct {
ProvisionerDaemonStatus ProvisionerDaemonStatus `json:"provisioner_daemon_status"`
Valid bool `json:"valid"` // Valid is true if ProvisionerDaemonStatus is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullProvisionerDaemonStatus) Scan(value interface{}) error {
if value == nil {
ns.ProvisionerDaemonStatus, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.ProvisionerDaemonStatus.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullProvisionerDaemonStatus) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.ProvisionerDaemonStatus), nil
}
func (e ProvisionerDaemonStatus) Valid() bool {
switch e {
case ProvisionerDaemonStatusOffline,
ProvisionerDaemonStatusIdle,
ProvisionerDaemonStatusBusy:
return true
}
return false
}
func AllProvisionerDaemonStatusValues() []ProvisionerDaemonStatus {
return []ProvisionerDaemonStatus{
ProvisionerDaemonStatusOffline,
ProvisionerDaemonStatusIdle,
ProvisionerDaemonStatusBusy,
}
}
// Computed status of a provisioner job. Jobs could be stuck in a hung state, these states do not guarantee any transition to another state.
type ProvisionerJobStatus string
+1
View File
@@ -203,6 +203,7 @@ type sqlcQuerier interface {
GetPreviousTemplateVersion(ctx context.Context, arg GetPreviousTemplateVersionParams) (TemplateVersion, error)
GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, error)
GetProvisionerDaemonsByOrganization(ctx context.Context, arg GetProvisionerDaemonsByOrganizationParams) ([]ProvisionerDaemon, error)
GetProvisionerDaemonsWithStatusByOrganization(ctx context.Context, arg GetProvisionerDaemonsWithStatusByOrganizationParams) ([]GetProvisionerDaemonsWithStatusByOrganizationRow, error)
GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (ProvisionerJob, error)
GetProvisionerJobTimingsByJobID(ctx context.Context, jobID uuid.UUID) ([]ProvisionerJobTiming, error)
GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error)
+120
View File
@@ -353,6 +353,126 @@ func TestGetEligibleProvisionerDaemonsByProvisionerJobIDs(t *testing.T) {
})
}
func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) {
t.Parallel()
t.Run("NoDaemonsInOrgReturnsEmpty", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
otherOrg := dbgen.Organization(t, db, database.Organization{})
dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
Name: "non-matching-daemon",
OrganizationID: otherOrg.ID,
})
daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{
OrganizationID: org.ID,
})
require.NoError(t, err)
require.Empty(t, daemons)
})
t.Run("MatchesProvisionerIDs", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
matchingDaemon0 := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
Name: "matching-daemon0",
OrganizationID: org.ID,
})
matchingDaemon1 := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
Name: "matching-daemon1",
OrganizationID: org.ID,
})
dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
Name: "non-matching-daemon",
OrganizationID: org.ID,
})
daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{
OrganizationID: org.ID,
IDs: []uuid.UUID{matchingDaemon0.ID, matchingDaemon1.ID},
})
require.NoError(t, err)
require.Len(t, daemons, 2)
if daemons[0].ProvisionerDaemon.ID != matchingDaemon0.ID {
daemons[0], daemons[1] = daemons[1], daemons[0]
}
require.Equal(t, matchingDaemon0.ID, daemons[0].ProvisionerDaemon.ID)
require.Equal(t, matchingDaemon1.ID, daemons[1].ProvisionerDaemon.ID)
})
t.Run("MatchesTags", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
fooDaemon := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
Name: "foo-daemon",
OrganizationID: org.ID,
Tags: database.StringMap{
"foo": "bar",
},
})
dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
Name: "baz-daemon",
OrganizationID: org.ID,
Tags: database.StringMap{
"baz": "qux",
},
})
daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{
OrganizationID: org.ID,
Tags: database.StringMap{"foo": "bar"},
})
require.NoError(t, err)
require.Len(t, daemons, 1)
require.Equal(t, fooDaemon.ID, daemons[0].ProvisionerDaemon.ID)
})
t.Run("UsesStaleInterval", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
daemon1 := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
Name: "stale-daemon",
OrganizationID: org.ID,
CreatedAt: dbtime.Now().Add(-time.Hour),
LastSeenAt: sql.NullTime{
Valid: true,
Time: dbtime.Now().Add(-time.Hour),
},
})
daemon2 := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{
Name: "idle-daemon",
OrganizationID: org.ID,
CreatedAt: dbtime.Now().Add(-(30 * time.Minute)),
LastSeenAt: sql.NullTime{
Valid: true,
Time: dbtime.Now().Add(-(30 * time.Minute)),
},
})
daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{
OrganizationID: org.ID,
StaleIntervalMS: 45 * time.Minute.Milliseconds(),
})
require.NoError(t, err)
require.Len(t, daemons, 2)
if daemons[0].ProvisionerDaemon.ID != daemon1.ID {
daemons[0], daemons[1] = daemons[1], daemons[0]
}
require.Equal(t, daemon1.ID, daemons[0].ProvisionerDaemon.ID)
require.Equal(t, daemon2.ID, daemons[1].ProvisionerDaemon.ID)
require.Equal(t, database.ProvisionerDaemonStatusOffline, daemons[0].Status)
require.Equal(t, database.ProvisionerDaemonStatusIdle, daemons[1].Status)
})
}
func TestGetWorkspaceAgentUsageStats(t *testing.T) {
t.Parallel()
+112
View File
@@ -5572,6 +5572,118 @@ func (q *sqlQuerier) GetProvisionerDaemonsByOrganization(ctx context.Context, ar
return items, nil
}
const getProvisionerDaemonsWithStatusByOrganization = `-- name: GetProvisionerDaemonsWithStatusByOrganization :many
SELECT
pd.id, pd.created_at, pd.name, pd.provisioners, pd.replica_id, pd.tags, pd.last_seen_at, pd.version, pd.api_version, pd.organization_id, pd.key_id,
CASE
WHEN pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - ($1::bigint || ' ms')::interval)
THEN 'offline'
ELSE CASE
WHEN current_job.id IS NOT NULL THEN 'busy'
ELSE 'idle'
END
END::provisioner_daemon_status AS status,
pk.name AS key_name,
-- NOTE(mafredri): sqlc.embed doesn't support nullable tables nor renaming them.
current_job.id AS current_job_id,
current_job.job_status AS current_job_status,
previous_job.id AS previous_job_id,
previous_job.job_status AS previous_job_status
FROM
provisioner_daemons pd
JOIN
provisioner_keys pk ON pk.id = pd.key_id
LEFT JOIN
provisioner_jobs current_job ON (
current_job.worker_id = pd.id
AND current_job.completed_at IS NULL
)
LEFT JOIN
provisioner_jobs previous_job ON (
previous_job.id = (
SELECT
id
FROM
provisioner_jobs
WHERE
worker_id = pd.id
AND completed_at IS NOT NULL
ORDER BY
completed_at DESC
LIMIT 1
)
)
WHERE
pd.organization_id = $2::uuid
AND (COALESCE(array_length($3::uuid[], 1), 0) = 0 OR pd.id = ANY($3::uuid[]))
AND ($4::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, $4::tagset))
ORDER BY
pd.created_at ASC
`
type GetProvisionerDaemonsWithStatusByOrganizationParams struct {
StaleIntervalMS int64 `db:"stale_interval_ms" json:"stale_interval_ms"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
IDs []uuid.UUID `db:"ids" json:"ids"`
Tags StringMap `db:"tags" json:"tags"`
}
type GetProvisionerDaemonsWithStatusByOrganizationRow struct {
ProvisionerDaemon ProvisionerDaemon `db:"provisioner_daemon" json:"provisioner_daemon"`
Status ProvisionerDaemonStatus `db:"status" json:"status"`
KeyName string `db:"key_name" json:"key_name"`
CurrentJobID uuid.NullUUID `db:"current_job_id" json:"current_job_id"`
CurrentJobStatus NullProvisionerJobStatus `db:"current_job_status" json:"current_job_status"`
PreviousJobID uuid.NullUUID `db:"previous_job_id" json:"previous_job_id"`
PreviousJobStatus NullProvisionerJobStatus `db:"previous_job_status" json:"previous_job_status"`
}
func (q *sqlQuerier) GetProvisionerDaemonsWithStatusByOrganization(ctx context.Context, arg GetProvisionerDaemonsWithStatusByOrganizationParams) ([]GetProvisionerDaemonsWithStatusByOrganizationRow, error) {
rows, err := q.db.QueryContext(ctx, getProvisionerDaemonsWithStatusByOrganization,
arg.StaleIntervalMS,
arg.OrganizationID,
pq.Array(arg.IDs),
arg.Tags,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetProvisionerDaemonsWithStatusByOrganizationRow
for rows.Next() {
var i GetProvisionerDaemonsWithStatusByOrganizationRow
if err := rows.Scan(
&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,
&i.Status,
&i.KeyName,
&i.CurrentJobID,
&i.CurrentJobStatus,
&i.PreviousJobID,
&i.PreviousJobStatus,
); 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 updateProvisionerDaemonLastSeenAt = `-- name: UpdateProvisionerDaemonLastSeenAt :exec
UPDATE provisioner_daemons
SET
@@ -28,6 +28,54 @@ JOIN
WHERE
provisioner_jobs.id = ANY(@provisioner_job_ids :: uuid[]);
-- name: GetProvisionerDaemonsWithStatusByOrganization :many
SELECT
sqlc.embed(pd),
CASE
WHEN pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)
THEN 'offline'
ELSE CASE
WHEN current_job.id IS NOT NULL THEN 'busy'
ELSE 'idle'
END
END::provisioner_daemon_status AS status,
pk.name AS key_name,
-- NOTE(mafredri): sqlc.embed doesn't support nullable tables nor renaming them.
current_job.id AS current_job_id,
current_job.job_status AS current_job_status,
previous_job.id AS previous_job_id,
previous_job.job_status AS previous_job_status
FROM
provisioner_daemons pd
JOIN
provisioner_keys pk ON pk.id = pd.key_id
LEFT JOIN
provisioner_jobs current_job ON (
current_job.worker_id = pd.id
AND current_job.completed_at IS NULL
)
LEFT JOIN
provisioner_jobs previous_job ON (
previous_job.id = (
SELECT
id
FROM
provisioner_jobs
WHERE
worker_id = pd.id
AND completed_at IS NOT NULL
ORDER BY
completed_at DESC
LIMIT 1
)
)
WHERE
pd.organization_id = @organization_id::uuid
AND (COALESCE(array_length(@ids::uuid[], 1), 0) = 0 OR pd.id = ANY(@ids::uuid[]))
AND (@tags::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, @tags::tagset))
ORDER BY
pd.created_at ASC;
-- name: DeleteOldProvisionerDaemons :exec
-- Delete provisioner daemons that have been created at least a week ago
-- and have not connected to coderd since a week.
+1
View File
@@ -146,6 +146,7 @@ sql:
login_type_oauth2_provider_app: LoginTypeOAuth2ProviderApp
crypto_key_feature_workspace_apps_api_key: CryptoKeyFeatureWorkspaceAppsAPIKey
crypto_key_feature_oidc_convert: CryptoKeyFeatureOIDCConvert
stale_interval_ms: StaleIntervalMS
rules:
- name: do-not-use-public-schema-in-queries
message: "do not use public schema in queries"
+81
View File
@@ -0,0 +1,81 @@
package coderd
import (
"net/http"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/provisionerdserver"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
)
// @Summary Get provisioner daemons
// @ID get-provisioner-daemons
// @Security CoderSessionToken
// @Produce json
// @Tags Provisioning
// @Param organization path string true "Organization ID" format(uuid)
// @Param tags query object false "Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'})"
// @Success 200 {array} codersdk.ProvisionerDaemon
// @Router /organizations/{organization}/provisionerdaemons [get]
func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
org = httpmw.OrganizationParam(r)
tagParam = r.URL.Query().Get("tags")
tags = database.StringMap{}
err = tags.Scan([]byte(tagParam))
)
if tagParam != "" && err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid tags query parameter",
Detail: err.Error(),
})
return
}
daemons, err := api.Database.GetProvisionerDaemonsWithStatusByOrganization(
ctx,
database.GetProvisionerDaemonsWithStatusByOrganizationParams{
OrganizationID: org.ID,
StaleIntervalMS: provisionerdserver.StaleInterval.Milliseconds(),
Tags: tags,
},
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner daemons.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(daemons, func(dbDaemon database.GetProvisionerDaemonsWithStatusByOrganizationRow) codersdk.ProvisionerDaemon {
pd := db2sdk.ProvisionerDaemon(dbDaemon.ProvisionerDaemon)
var currentJob, previousJob *codersdk.ProvisionerDaemonJob
if dbDaemon.CurrentJobID.Valid {
currentJob = &codersdk.ProvisionerDaemonJob{
ID: dbDaemon.CurrentJobID.UUID,
Status: codersdk.ProvisionerJobStatus(dbDaemon.CurrentJobStatus.ProvisionerJobStatus),
}
}
if dbDaemon.PreviousJobID.Valid {
previousJob = &codersdk.ProvisionerDaemonJob{
ID: dbDaemon.PreviousJobID.UUID,
Status: codersdk.ProvisionerJobStatus(dbDaemon.PreviousJobStatus.ProvisionerJobStatus),
}
}
// Add optional fields.
pd.KeyName = &dbDaemon.KeyName
pd.Status = ptr.Ref(codersdk.ProvisionerDaemonStatus(dbDaemon.Status))
pd.CurrentJob = currentJob
pd.PreviousJob = previousJob
return pd
}))
}
+27
View File
@@ -0,0 +1,27 @@
package coderd_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/testutil"
)
func TestGetProvisionerDaemons(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitMedium)
daemons, err := memberClient.ProvisionerDaemons(ctx)
require.NoError(t, err)
require.Len(t, daemons, 1)
})
}