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 18:40:26 +02:00
committed by GitHub
parent d7809ecf3f
commit 071bb26018
31 changed files with 1106 additions and 188 deletions

61
coderd/apidoc/docs.go generated
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": {

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": {

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) {

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 {

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{})

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 {

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()

View File

@ -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)

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()

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',

View File

@ -0,0 +1 @@
DROP TYPE provisioner_daemon_status;

View File

@ -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.';

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()
}

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

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)

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()

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

View File

@ -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.

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"

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
}))
}

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)
})
}

View File

@ -39,17 +39,38 @@ const (
LogLevelError LogLevel = "error"
)
// ProvisionerDaemonStatus represents the status of a provisioner daemon.
type ProvisionerDaemonStatus string
// ProvisionerDaemonStatus enums.
const (
ProvisionerDaemonOffline ProvisionerDaemonStatus = "offline"
ProvisionerDaemonIdle ProvisionerDaemonStatus = "idle"
ProvisionerDaemonBusy ProvisionerDaemonStatus = "busy"
)
type ProvisionerDaemon struct {
ID uuid.UUID `json:"id" format:"uuid"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
KeyID uuid.UUID `json:"key_id" format:"uuid"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
LastSeenAt NullTime `json:"last_seen_at,omitempty" format:"date-time"`
Name string `json:"name"`
Version string `json:"version"`
APIVersion string `json:"api_version"`
Provisioners []ProvisionerType `json:"provisioners"`
Tags map[string]string `json:"tags"`
ID uuid.UUID `json:"id" format:"uuid" table:"id"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid" table:"organization id"`
KeyID uuid.UUID `json:"key_id" format:"uuid" table:"-"`
CreatedAt time.Time `json:"created_at" format:"date-time" table:"created at"`
LastSeenAt NullTime `json:"last_seen_at,omitempty" format:"date-time" table:"last seen at"`
Name string `json:"name" table:"name,default_sort"`
Version string `json:"version" table:"version"`
APIVersion string `json:"api_version" table:"api version"`
Provisioners []ProvisionerType `json:"provisioners" table:"-"`
Tags map[string]string `json:"tags" table:"tags"`
// Optional fields.
KeyName *string `json:"key_name" table:"key name"`
Status *ProvisionerDaemonStatus `json:"status" enums:"offline,idle,busy" table:"status"`
CurrentJob *ProvisionerDaemonJob `json:"current_job" table:"current job,recursive"`
PreviousJob *ProvisionerDaemonJob `json:"previous_job" table:"previous job,recursive"`
}
type ProvisionerDaemonJob struct {
ID uuid.UUID `json:"id" format:"uuid" table:"id"`
Status ProvisionerJobStatus `json:"status" enums:"pending,running,succeeded,canceling,canceled,failed" table:"status"`
}
// MatchedProvisioners represents the number of provisioner daemons

View File

@ -307,14 +307,24 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \
"provisioner_daemon": {
"api_version": "string",
"created_at": "2019-08-24T14:15:22Z",
"current_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
"key_name": "string",
"last_seen_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"previous_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"provisioners": [
"string"
],
"status": "offline",
"tags": {
"property1": "string",
"property2": "string"

View File

@ -1474,79 +1474,6 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get provisioner daemons
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisionerdaemons \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /organizations/{organization}/provisionerdaemons`
### Parameters
| Name | In | Type | Required | Description |
|----------------|-------|--------------|----------|------------------------------------------------------------------------------------|
| `organization` | path | string(uuid) | true | Organization ID |
| `tags` | query | object | false | Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'}) |
### Example responses
> 200 Response
```json
[
{
"api_version": "string",
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
"last_seen_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"provisioners": [
"string"
],
"tags": {
"property1": "string",
"property2": "string"
},
"version": "string"
}
]
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|-----------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.ProvisionerDaemon](schemas.md#codersdkprovisionerdaemon) |
<h3 id="get-provisioner-daemons-responseschema">Response Schema</h3>
Status Code **200**
| Name | Type | Required | Restrictions | Description |
|---------------------|-------------------|----------|--------------|-------------|
| `[array item]` | array | false | | |
| `» api_version` | string | false | | |
| `» created_at` | string(date-time) | false | | |
| `» id` | string(uuid) | false | | |
| `» key_id` | string(uuid) | false | | |
| `» last_seen_at` | string(date-time) | false | | |
| `» name` | string | false | | |
| `» organization_id` | string(uuid) | false | | |
| `» provisioners` | array | false | | |
| `» tags` | object | false | | |
| `»» [any property]` | string | false | | |
| `» version` | string | false | | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Serve provisioner daemon
### Code samples
@ -1700,14 +1627,24 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisi
{
"api_version": "string",
"created_at": "2019-08-24T14:15:22Z",
"current_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
"key_name": "string",
"last_seen_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"previous_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"provisioners": [
"string"
],
"status": "offline",
"tags": {
"property1": "string",
"property2": "string"
@ -1739,28 +1676,48 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisi
Status Code **200**
| Name | Type | Required | Restrictions | Description |
|----------------------|----------------------------------------------------------------------|----------|--------------|-------------|
| `[array item]` | array | false | | |
| `» daemons` | array | false | | |
| `»» api_version` | string | false | | |
| `»» created_at` | string(date-time) | false | | |
| `»» id` | string(uuid) | false | | |
| `»» key_id` | string(uuid) | false | | |
| `»» last_seen_at` | string(date-time) | false | | |
| `»» name` | string | false | | |
| `»» organization_id` | string(uuid) | false | | |
| `»» provisioners` | array | false | | |
| `»» tags` | object | false | | |
| `»»» [any property]` | string | false | | |
| `»» version` | string | false | | |
| `» key` | [codersdk.ProvisionerKey](schemas.md#codersdkprovisionerkey) | false | | |
| `»» created_at` | string(date-time) | false | | |
| `»» id` | string(uuid) | false | | |
| `»» name` | string | false | | |
| `»» organization` | string(uuid) | false | | |
| `»» tags` | [codersdk.ProvisionerKeyTags](schemas.md#codersdkprovisionerkeytags) | false | | |
| `»»» [any property]` | string | false | | |
| Name | Type | Required | Restrictions | Description |
|----------------------|--------------------------------------------------------------------------------|----------|--------------|------------------|
| `[array item]` | array | false | | |
| `» daemons` | array | false | | |
| `»» api_version` | string | false | | |
| `»» created_at` | string(date-time) | false | | |
| `»» current_job` | [codersdk.ProvisionerDaemonJob](schemas.md#codersdkprovisionerdaemonjob) | false | | |
| `»»» id` | string(uuid) | false | | |
| `»»» status` | [codersdk.ProvisionerJobStatus](schemas.md#codersdkprovisionerjobstatus) | false | | |
| `»» id` | string(uuid) | false | | |
| `»» key_id` | string(uuid) | false | | |
| `»» key_name` | string | false | | Optional fields. |
| `»» last_seen_at` | string(date-time) | false | | |
| `»» name` | string | false | | |
| `»» organization_id` | string(uuid) | false | | |
| `»» previous_job` | [codersdk.ProvisionerDaemonJob](schemas.md#codersdkprovisionerdaemonjob) | false | | |
| `»» provisioners` | array | false | | |
| `»» status` | [codersdk.ProvisionerDaemonStatus](schemas.md#codersdkprovisionerdaemonstatus) | false | | |
| `»» tags` | object | false | | |
| `»»» [any property]` | string | false | | |
| `»» version` | string | false | | |
| `» key` | [codersdk.ProvisionerKey](schemas.md#codersdkprovisionerkey) | false | | |
| `»» created_at` | string(date-time) | false | | |
| `»» id` | string(uuid) | false | | |
| `»» name` | string | false | | |
| `»» organization` | string(uuid) | false | | |
| `»» tags` | [codersdk.ProvisionerKeyTags](schemas.md#codersdkprovisionerkeytags) | false | | |
| `»»» [any property]` | string | false | | |
#### Enumerated Values
| Property | Value |
|----------|-------------|
| `status` | `pending` |
| `status` | `running` |
| `status` | `succeeded` |
| `status` | `canceling` |
| `status` | `canceled` |
| `status` | `failed` |
| `status` | `offline` |
| `status` | `idle` |
| `status` | `busy` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).

104
docs/reference/api/provisioning.md generated Normal file
View File

@ -0,0 +1,104 @@
# Provisioning
## Get provisioner daemons
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisionerdaemons \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /organizations/{organization}/provisionerdaemons`
### Parameters
| Name | In | Type | Required | Description |
|----------------|-------|--------------|----------|------------------------------------------------------------------------------------|
| `organization` | path | string(uuid) | true | Organization ID |
| `tags` | query | object | false | Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'}) |
### Example responses
> 200 Response
```json
[
{
"api_version": "string",
"created_at": "2019-08-24T14:15:22Z",
"current_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
"key_name": "string",
"last_seen_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"previous_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"provisioners": [
"string"
],
"status": "offline",
"tags": {
"property1": "string",
"property2": "string"
},
"version": "string"
}
]
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|-----------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.ProvisionerDaemon](schemas.md#codersdkprovisionerdaemon) |
<h3 id="get-provisioner-daemons-responseschema">Response Schema</h3>
Status Code **200**
| Name | Type | Required | Restrictions | Description |
|---------------------|--------------------------------------------------------------------------------|----------|--------------|------------------|
| `[array item]` | array | false | | |
| `» api_version` | string | false | | |
| `» created_at` | string(date-time) | false | | |
| `» current_job` | [codersdk.ProvisionerDaemonJob](schemas.md#codersdkprovisionerdaemonjob) | false | | |
| `»» id` | string(uuid) | false | | |
| `»» status` | [codersdk.ProvisionerJobStatus](schemas.md#codersdkprovisionerjobstatus) | false | | |
| `» id` | string(uuid) | false | | |
| `» key_id` | string(uuid) | false | | |
| `» key_name` | string | false | | Optional fields. |
| `» last_seen_at` | string(date-time) | false | | |
| `» name` | string | false | | |
| `» organization_id` | string(uuid) | false | | |
| `» previous_job` | [codersdk.ProvisionerDaemonJob](schemas.md#codersdkprovisionerdaemonjob) | false | | |
| `» provisioners` | array | false | | |
| `» status` | [codersdk.ProvisionerDaemonStatus](schemas.md#codersdkprovisionerdaemonstatus) | false | | |
| `» tags` | object | false | | |
| `»» [any property]` | string | false | | |
| `» version` | string | false | | |
#### Enumerated Values
| Property | Value |
|----------|-------------|
| `status` | `pending` |
| `status` | `running` |
| `status` | `succeeded` |
| `status` | `canceling` |
| `status` | `canceled` |
| `status` | `failed` |
| `status` | `offline` |
| `status` | `idle` |
| `status` | `busy` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).

View File

@ -4345,14 +4345,24 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
{
"api_version": "string",
"created_at": "2019-08-24T14:15:22Z",
"current_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
"key_name": "string",
"last_seen_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"previous_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"provisioners": [
"string"
],
"status": "offline",
"tags": {
"property1": "string",
"property2": "string"
@ -4363,19 +4373,74 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------------|-----------------|----------|--------------|-------------|
| `api_version` | string | false | | |
| `created_at` | string | false | | |
| `id` | string | false | | |
| `key_id` | string | false | | |
| `last_seen_at` | string | false | | |
| `name` | string | false | | |
| `organization_id` | string | false | | |
| `provisioners` | array of string | false | | |
| `tags` | object | false | | |
| » `[any property]` | string | false | | |
| `version` | string | false | | |
| Name | Type | Required | Restrictions | Description |
|--------------------|----------------------------------------------------------------------|----------|--------------|------------------|
| `api_version` | string | false | | |
| `created_at` | string | false | | |
| `current_job` | [codersdk.ProvisionerDaemonJob](#codersdkprovisionerdaemonjob) | false | | |
| `id` | string | false | | |
| `key_id` | string | false | | |
| `key_name` | string | false | | Optional fields. |
| `last_seen_at` | string | false | | |
| `name` | string | false | | |
| `organization_id` | string | false | | |
| `previous_job` | [codersdk.ProvisionerDaemonJob](#codersdkprovisionerdaemonjob) | false | | |
| `provisioners` | array of string | false | | |
| `status` | [codersdk.ProvisionerDaemonStatus](#codersdkprovisionerdaemonstatus) | false | | |
| `tags` | object | false | | |
| » `[any property]` | string | false | | |
| `version` | string | false | | |
#### Enumerated Values
| Property | Value |
|----------|-----------|
| `status` | `offline` |
| `status` | `idle` |
| `status` | `busy` |
## codersdk.ProvisionerDaemonJob
```json
{
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|----------|----------------------------------------------------------------|----------|--------------|-------------|
| `id` | string | false | | |
| `status` | [codersdk.ProvisionerJobStatus](#codersdkprovisionerjobstatus) | false | | |
#### Enumerated Values
| Property | Value |
|----------|-------------|
| `status` | `pending` |
| `status` | `running` |
| `status` | `succeeded` |
| `status` | `canceling` |
| `status` | `canceled` |
| `status` | `failed` |
## codersdk.ProvisionerDaemonStatus
```json
"offline"
```
### Properties
#### Enumerated Values
| Value |
|-----------|
| `offline` |
| `idle` |
| `busy` |
## codersdk.ProvisionerJob
@ -4518,14 +4583,24 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
{
"api_version": "string",
"created_at": "2019-08-24T14:15:22Z",
"current_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
"key_name": "string",
"last_seen_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"previous_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"provisioners": [
"string"
],
"status": "offline",
"tags": {
"property1": "string",
"property2": "string"
@ -9363,14 +9438,24 @@ Zero means unspecified. There might be a limit, but the client need not try to r
"provisioner_daemon": {
"api_version": "string",
"created_at": "2019-08-24T14:15:22Z",
"current_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
"key_name": "string",
"last_seen_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"previous_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"provisioners": [
"string"
],
"status": "offline",
"tags": {
"property1": "string",
"property2": "string"
@ -9489,14 +9574,24 @@ Zero means unspecified. There might be a limit, but the client need not try to r
"provisioner_daemon": {
"api_version": "string",
"created_at": "2019-08-24T14:15:22Z",
"current_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
"key_name": "string",
"last_seen_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"previous_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"provisioners": [
"string"
],
"status": "offline",
"tags": {
"property1": "string",
"property2": "string"
@ -9546,14 +9641,24 @@ Zero means unspecified. There might be a limit, but the client need not try to r
"provisioner_daemon": {
"api_version": "string",
"created_at": "2019-08-24T14:15:22Z",
"current_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
"key_name": "string",
"last_seen_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"previous_job": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"status": "pending"
},
"provisioners": [
"string"
],
"status": "offline",
"tags": {
"property1": "string",
"property2": "string"

View File

@ -440,7 +440,6 @@ func TestProvisionerDaemon_ProvisionerKey(t *testing.T) {
clitest.Start(t, inv)
pty.ExpectNoMatchBefore(ctx, "check entitlement", "starting provisioner daemon")
pty.ExpectMatchContext(ctx, "matt-daemon")
var daemons []codersdk.ProvisionerDaemon
require.Eventually(t, func() bool {
daemons, err = client.OrganizationProvisionerDaemons(ctx, anotherOrg.ID, nil)

View File

@ -379,7 +379,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
//
// We may in future decide to scope provisioner daemons to organizations, so we'll keep the API
// route as is.
r.Route("/organizations/{organization}/provisionerdaemons", func(r chi.Router) {
r.Route("/organizations/{organization}/provisionerdaemons/serve", func(r chi.Router) {
r.Use(
api.provisionerDaemonsEnabledMW,
apiKeyMiddlewareOptional,
@ -393,8 +393,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
httpmw.RequireAPIKeyOrProvisionerDaemonAuth(),
httpmw.ExtractOrganizationParam(api.Database),
)
r.With(apiKeyMiddleware).Get("/", api.provisionerDaemons)
r.With(apiKeyMiddlewareOptional).Get("/serve", api.provisionerDaemonServe)
r.Get("/", api.provisionerDaemonServe)
})
r.Route("/templates/{template}/acl", func(r chi.Router) {
r.Use(

View File

@ -20,7 +20,6 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
@ -49,50 +48,6 @@ func (api *API) provisionerDaemonsEnabledMW(next http.Handler) http.Handler {
})
}
// @Summary Get provisioner daemons
// @ID get-provisioner-daemons
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @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.GetProvisionerDaemonsByOrganization(
ctx,
database.GetProvisionerDaemonsByOrganizationParams{
OrganizationID: org.ID,
WantTags: 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, db2sdk.ProvisionerDaemon))
}
type provisiionerDaemonAuthResponse struct {
keyID uuid.UUID
orgID uuid.UUID

View File

@ -1532,6 +1532,16 @@ export interface ProvisionerDaemon {
readonly api_version: string;
readonly provisioners: readonly ProvisionerType[];
readonly tags: Record<string, string>;
readonly key_name: string | null;
readonly status: ProvisionerDaemonStatus | null;
readonly current_job: ProvisionerDaemonJob | null;
readonly previous_job: ProvisionerDaemonJob | null;
}
// From codersdk/provisionerdaemons.go
export interface ProvisionerDaemonJob {
readonly id: string;
readonly status: ProvisionerJobStatus;
}
// From codersdk/client.go
@ -1540,6 +1550,15 @@ export const ProvisionerDaemonKey = "Coder-Provisioner-Daemon-Key";
// From codersdk/client.go
export const ProvisionerDaemonPSK = "Coder-Provisioner-Daemon-PSK";
// From codersdk/provisionerdaemons.go
export type ProvisionerDaemonStatus = "busy" | "idle" | "offline";
export const ProvisionerDaemonStatuses: ProvisionerDaemonStatus[] = [
"busy",
"idle",
"offline",
];
// From healthsdk/healthsdk.go
export interface ProvisionerDaemonsReport extends BaseReport {
readonly items: readonly ProvisionerDaemonsReportItem[];

View File

@ -580,6 +580,10 @@ export const MockProvisioner: TypesGen.ProvisionerDaemon = {
version: MockBuildInfo.version,
api_version: MockBuildInfo.provisioner_api_version,
last_seen_at: new Date().toISOString(),
key_name: "test-provisioner",
status: "idle",
current_job: null,
previous_job: null,
};
export const MockUserAuthProvisioner: TypesGen.ProvisionerDaemon = {
@ -594,6 +598,7 @@ export const MockPskProvisioner: TypesGen.ProvisionerDaemon = {
...MockProvisioner,
id: "test-psk-provisioner",
key_id: MockProvisionerPskKey.id,
key_name: MockProvisionerPskKey.name,
name: "Test psk provisioner",
};
@ -601,6 +606,7 @@ export const MockKeyProvisioner: TypesGen.ProvisionerDaemon = {
...MockProvisioner,
id: "test-key-provisioner",
key_id: MockProvisionerKey.id,
key_name: MockProvisionerKey.name,
organization_id: MockProvisionerKey.organization,
name: "Test key provisioner",
tags: MockProvisionerKey.tags,
@ -611,6 +617,7 @@ export const MockProvisioner2: TypesGen.ProvisionerDaemon = {
id: "test-provisioner-2",
name: "Test Provisioner 2",
key_id: MockProvisionerKey.id,
key_name: MockProvisionerKey.name,
};
export const MockUserProvisioner: TypesGen.ProvisionerDaemon = {
@ -3741,6 +3748,10 @@ export const MockHealth: TypesGen.HealthcheckReport = {
tag_1: "1",
tag_yes: "yes",
},
key_name: MockProvisionerKey.name,
current_job: null,
previous_job: null,
status: "idle",
},
warnings: [],
},
@ -3763,6 +3774,10 @@ export const MockHealth: TypesGen.HealthcheckReport = {
tag_1: "1",
tag_YES: "YES",
},
key_name: MockProvisionerKey.name,
current_job: null,
previous_job: null,
status: "idle",
},
warnings: [],
},
@ -3785,6 +3800,10 @@ export const MockHealth: TypesGen.HealthcheckReport = {
tag_0: "0",
tag_no: "no",
},
key_name: MockProvisionerKey.name,
current_job: null,
previous_job: null,
status: "idle",
},
warnings: [
{
@ -3938,6 +3957,10 @@ export const DeploymentHealthUnhealthy: TypesGen.HealthcheckReport = {
owner: "",
scope: "organization",
},
key_name: MockProvisionerKey.name,
current_job: null,
previous_job: null,
status: "idle",
},
warnings: [
{