Skip to content

Commit d17dd5d

Browse files
authored
feat: add filtering by initiator to provisioner job listing in the CLI (#20137)
Relates to coder/internal#934 This PR provides a mechanism to filter provisioner jobs according to who initiated the job. This will be used to find pending prebuild jobs when prebuilds have overwhelmed the provisioner job queue. They can then be canceled. If prebuilds are overwhelming provisioners, the following steps will be taken: ```bash # pause prebuild reconciliation to limit provisioner queue pollution: coder prebuilds pause # cancel pending provisioner jobs to clear the queue coder provisioner jobs list --initiator="prebuilds" --status="pending" | jq ... | xargs -n1 -I{} coder provisioner jobs cancel {} # push a fixed template and wait for the import to complete coder templates push ... # push a fixed template # resume prebuild reconciliation coder prebuilds resume ``` This interface differs somewhat from what was specified in the issue, but still provides a mechanism that addresses the issue. The original proposal was made by myself and this simpler implementation makes sense. I might add a `--search` parameter in a follow-up if there is appetite for it. Potential follow ups: * Support for this usage: `coder provisioner jobs list --search "initiator:prebuilds status:pending"` * Adding the same parameters to `coder provisioner jobs cancel` as a convenience feature so that operators don't have to pipe through `jq` and `xargs`
1 parent c1357d4 commit d17dd5d

23 files changed

+395
-39
lines changed

cli/provisionerjobs.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@ func (r *RootCmd) provisionerJobsList() *serpent.Command {
4343
cliui.TableFormat([]provisionerJobRow{}, []string{"created at", "id", "type", "template display name", "status", "queue", "tags"}),
4444
cliui.JSONFormat(),
4545
)
46-
status []string
47-
limit int64
46+
status []string
47+
limit int64
48+
initiator string
4849
)
4950

5051
cmd := &serpent.Command{
@@ -65,9 +66,20 @@ func (r *RootCmd) provisionerJobsList() *serpent.Command {
6566
return xerrors.Errorf("current organization: %w", err)
6667
}
6768

69+
var initiatorID *uuid.UUID
70+
71+
if initiator != "" {
72+
user, err := client.User(ctx, initiator)
73+
if err != nil {
74+
return xerrors.Errorf("initiator not found: %s", initiator)
75+
}
76+
initiatorID = &user.ID
77+
}
78+
6879
jobs, err := client.OrganizationProvisionerJobs(ctx, org.ID, &codersdk.OrganizationProvisionerJobsOptions{
69-
Status: slice.StringEnums[codersdk.ProvisionerJobStatus](status),
70-
Limit: int(limit),
80+
Status: slice.StringEnums[codersdk.ProvisionerJobStatus](status),
81+
Limit: int(limit),
82+
InitiatorID: initiatorID,
7183
})
7284
if err != nil {
7385
return xerrors.Errorf("list provisioner jobs: %w", err)
@@ -122,6 +134,13 @@ func (r *RootCmd) provisionerJobsList() *serpent.Command {
122134
Default: "50",
123135
Value: serpent.Int64Of(&limit),
124136
},
137+
{
138+
Flag: "initiator",
139+
FlagShorthand: "i",
140+
Env: "CODER_PROVISIONER_JOB_LIST_INITIATOR",
141+
Description: "Filter by initiator (user ID or username).",
142+
Value: serpent.StringOf(&initiator),
143+
},
125144
}...)
126145

127146
orgContext.AttachOptions(cmd)

cli/provisionerjobs_test.go

Lines changed: 168 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"database/sql"
66
"encoding/json"
77
"fmt"
8+
"strings"
89
"testing"
910
"time"
1011

@@ -26,33 +27,32 @@ import (
2627
func TestProvisionerJobs(t *testing.T) {
2728
t.Parallel()
2829

29-
db, ps := dbtestutil.NewDB(t)
30-
client, _, coderdAPI := coderdtest.NewWithAPI(t, &coderdtest.Options{
31-
IncludeProvisionerDaemon: false,
32-
Database: db,
33-
Pubsub: ps,
34-
})
35-
owner := coderdtest.CreateFirstUser(t, client)
36-
templateAdminClient, templateAdmin := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID))
37-
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
38-
39-
// These CLI tests are related to provisioner job CRUD operations and as such
40-
// do not require the overhead of starting a provisioner. Other provisioner job
41-
// functionalities (acquisition etc.) are tested elsewhere.
42-
template := dbgen.Template(t, db, database.Template{
43-
OrganizationID: owner.OrganizationID,
44-
CreatedBy: owner.UserID,
45-
AllowUserCancelWorkspaceJobs: true,
46-
})
47-
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
48-
OrganizationID: owner.OrganizationID,
49-
CreatedBy: owner.UserID,
50-
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
51-
})
52-
5330
t.Run("Cancel", func(t *testing.T) {
5431
t.Parallel()
5532

33+
db, ps := dbtestutil.NewDB(t)
34+
client, _, coderdAPI := coderdtest.NewWithAPI(t, &coderdtest.Options{
35+
IncludeProvisionerDaemon: false,
36+
Database: db,
37+
Pubsub: ps,
38+
})
39+
owner := coderdtest.CreateFirstUser(t, client)
40+
templateAdminClient, templateAdmin := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID))
41+
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
42+
43+
// These CLI tests are related to provisioner job CRUD operations and as such
44+
// do not require the overhead of starting a provisioner. Other provisioner job
45+
// functionalities (acquisition etc.) are tested elsewhere.
46+
template := dbgen.Template(t, db, database.Template{
47+
OrganizationID: owner.OrganizationID,
48+
CreatedBy: owner.UserID,
49+
AllowUserCancelWorkspaceJobs: true,
50+
})
51+
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
52+
OrganizationID: owner.OrganizationID,
53+
CreatedBy: owner.UserID,
54+
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
55+
})
5656
// Test helper to create a provisioner job of a given type with a given input.
5757
prepareJob := func(t *testing.T, jobType database.ProvisionerJobType, input json.RawMessage) database.ProvisionerJob {
5858
t.Helper()
@@ -178,4 +178,148 @@ func TestProvisionerJobs(t *testing.T) {
178178
})
179179
}
180180
})
181+
182+
t.Run("List", func(t *testing.T) {
183+
t.Parallel()
184+
185+
db, ps := dbtestutil.NewDB(t)
186+
client, _, coderdAPI := coderdtest.NewWithAPI(t, &coderdtest.Options{
187+
IncludeProvisionerDaemon: false,
188+
Database: db,
189+
Pubsub: ps,
190+
})
191+
owner := coderdtest.CreateFirstUser(t, client)
192+
_, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
193+
194+
// These CLI tests are related to provisioner job CRUD operations and as such
195+
// do not require the overhead of starting a provisioner. Other provisioner job
196+
// functionalities (acquisition etc.) are tested elsewhere.
197+
template := dbgen.Template(t, db, database.Template{
198+
OrganizationID: owner.OrganizationID,
199+
CreatedBy: owner.UserID,
200+
AllowUserCancelWorkspaceJobs: true,
201+
})
202+
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
203+
OrganizationID: owner.OrganizationID,
204+
CreatedBy: owner.UserID,
205+
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
206+
})
207+
// Create some test jobs
208+
job1 := dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{
209+
OrganizationID: owner.OrganizationID,
210+
InitiatorID: owner.UserID,
211+
Type: database.ProvisionerJobTypeTemplateVersionImport,
212+
Input: []byte(`{"template_version_id":"` + version.ID.String() + `"}`),
213+
Tags: database.StringMap{provisionersdk.TagScope: provisionersdk.ScopeOrganization},
214+
})
215+
216+
job2 := dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{
217+
OrganizationID: owner.OrganizationID,
218+
InitiatorID: member.ID,
219+
Type: database.ProvisionerJobTypeWorkspaceBuild,
220+
Input: []byte(`{"workspace_build_id":"` + uuid.New().String() + `"}`),
221+
Tags: database.StringMap{provisionersdk.TagScope: provisionersdk.ScopeOrganization},
222+
})
223+
// Test basic list command
224+
t.Run("Basic", func(t *testing.T) {
225+
t.Parallel()
226+
227+
inv, root := clitest.New(t, "provisioner", "jobs", "list")
228+
clitest.SetupConfig(t, client, root)
229+
var buf bytes.Buffer
230+
inv.Stdout = &buf
231+
err := inv.Run()
232+
require.NoError(t, err)
233+
234+
// Should contain both jobs
235+
output := buf.String()
236+
assert.Contains(t, output, job1.ID.String())
237+
assert.Contains(t, output, job2.ID.String())
238+
})
239+
240+
// Test list with JSON output
241+
t.Run("JSON", func(t *testing.T) {
242+
t.Parallel()
243+
244+
inv, root := clitest.New(t, "provisioner", "jobs", "list", "--output", "json")
245+
clitest.SetupConfig(t, client, root)
246+
var buf bytes.Buffer
247+
inv.Stdout = &buf
248+
err := inv.Run()
249+
require.NoError(t, err)
250+
251+
// Parse JSON output
252+
var jobs []codersdk.ProvisionerJob
253+
err = json.Unmarshal(buf.Bytes(), &jobs)
254+
require.NoError(t, err)
255+
256+
// Should contain both jobs
257+
jobIDs := make([]uuid.UUID, len(jobs))
258+
for i, job := range jobs {
259+
jobIDs[i] = job.ID
260+
}
261+
assert.Contains(t, jobIDs, job1.ID)
262+
assert.Contains(t, jobIDs, job2.ID)
263+
})
264+
265+
// Test list with limit
266+
t.Run("Limit", func(t *testing.T) {
267+
t.Parallel()
268+
269+
inv, root := clitest.New(t, "provisioner", "jobs", "list", "--limit", "1")
270+
clitest.SetupConfig(t, client, root)
271+
var buf bytes.Buffer
272+
inv.Stdout = &buf
273+
err := inv.Run()
274+
require.NoError(t, err)
275+
276+
// Should contain at most 1 job
277+
output := buf.String()
278+
jobCount := 0
279+
if strings.Contains(output, job1.ID.String()) {
280+
jobCount++
281+
}
282+
if strings.Contains(output, job2.ID.String()) {
283+
jobCount++
284+
}
285+
assert.LessOrEqual(t, jobCount, 1)
286+
})
287+
288+
// Test list with initiator filter
289+
t.Run("InitiatorFilter", func(t *testing.T) {
290+
t.Parallel()
291+
292+
// Get owner user details to access username
293+
ctx := testutil.Context(t, testutil.WaitShort)
294+
ownerUser, err := client.User(ctx, owner.UserID.String())
295+
require.NoError(t, err)
296+
297+
// Test filtering by initiator (using username)
298+
inv, root := clitest.New(t, "provisioner", "jobs", "list", "--initiator", ownerUser.Username)
299+
clitest.SetupConfig(t, client, root)
300+
var buf bytes.Buffer
301+
inv.Stdout = &buf
302+
err = inv.Run()
303+
require.NoError(t, err)
304+
305+
// Should only contain job1 (initiated by owner)
306+
output := buf.String()
307+
assert.Contains(t, output, job1.ID.String())
308+
assert.NotContains(t, output, job2.ID.String())
309+
})
310+
311+
// Test list with invalid user
312+
t.Run("InvalidUser", func(t *testing.T) {
313+
t.Parallel()
314+
315+
// Test with non-existent user
316+
inv, root := clitest.New(t, "provisioner", "jobs", "list", "--initiator", "nonexistent-user")
317+
clitest.SetupConfig(t, client, root)
318+
var buf bytes.Buffer
319+
inv.Stdout = &buf
320+
err := inv.Run()
321+
require.Error(t, err)
322+
assert.Contains(t, err.Error(), "initiator not found: nonexistent-user")
323+
})
324+
})
181325
}

cli/testdata/coder_list_--output_json.golden

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"queue_position": 0,
4646
"queue_size": 0,
4747
"organization_id": "===========[first org ID]===========",
48+
"initiator_id": "==========[first user ID]===========",
4849
"input": {
4950
"workspace_build_id": "========[workspace build ID]========"
5051
},

cli/testdata/coder_provisioner_jobs_list_--help.golden

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ OPTIONS:
1111
-O, --org string, $CODER_ORGANIZATION
1212
Select which organization (uuid or name) to use.
1313

14-
-c, --column [id|created at|started at|completed at|canceled at|error|error code|status|worker id|worker name|file id|tags|queue position|queue size|organization id|template version id|workspace build id|type|available workers|template version name|template id|template name|template display name|template icon|workspace id|workspace name|logs overflowed|organization|queue] (default: created at,id,type,template display name,status,queue,tags)
14+
-c, --column [id|created at|started at|completed at|canceled at|error|error code|status|worker id|worker name|file id|tags|queue position|queue size|organization id|initiator id|template version id|workspace build id|type|available workers|template version name|template id|template name|template display name|template icon|workspace id|workspace name|logs overflowed|organization|queue] (default: created at,id,type,template display name,status,queue,tags)
1515
Columns to display in table output.
1616

17+
-i, --initiator string, $CODER_PROVISIONER_JOB_LIST_INITIATOR
18+
Filter by initiator (user ID or username).
19+
1720
-l, --limit int, $CODER_PROVISIONER_JOB_LIST_LIMIT (default: 50)
1821
Limit the number of jobs returned.
1922

cli/testdata/coder_provisioner_jobs_list_--output_json.golden

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"queue_position": 0,
1616
"queue_size": 0,
1717
"organization_id": "===========[first org ID]===========",
18+
"initiator_id": "==========[first user ID]===========",
1819
"input": {
1920
"template_version_id": "============[version ID]============"
2021
},
@@ -45,6 +46,7 @@
4546
"queue_position": 0,
4647
"queue_size": 0,
4748
"organization_id": "===========[first org ID]===========",
49+
"initiator_id": "==========[first user ID]===========",
4850
"input": {
4951
"workspace_build_id": "========[workspace build ID]========"
5052
},

coderd/apidoc/docs.go

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dbauthz/dbauthz_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2484,10 +2484,12 @@ func (s *MethodTestSuite) TestExtraMethods() {
24842484

24852485
ds, err := db.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(context.Background(), database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams{
24862486
OrganizationID: org.ID,
2487+
InitiatorID: uuid.Nil,
24872488
})
24882489
s.NoError(err, "get provisioner jobs by org")
24892490
check.Args(database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams{
24902491
OrganizationID: org.ID,
2492+
InitiatorID: uuid.Nil,
24912493
}).Asserts(j1, policy.ActionRead, j2, policy.ActionRead).Returns(ds)
24922494
}))
24932495
}

coderd/database/queries.sql.go

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)