Skip to content

Commit 75c899f

Browse files
authored
feat(cli): add provisioner job cancel command (#16252)
Fixes #16117 Updates #15084
1 parent 84a54c1 commit 75c899f

19 files changed

+568
-21
lines changed

cli/provisionerjobs.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import (
44
"fmt"
55
"slices"
66

7+
"github.com/google/uuid"
78
"golang.org/x/xerrors"
89

910
"github.com/coder/coder/v2/cli/cliui"
11+
"github.com/coder/coder/v2/coderd/util/ptr"
1012
"github.com/coder/coder/v2/coderd/util/slice"
1113
"github.com/coder/coder/v2/codersdk"
1214
"github.com/coder/serpent"
@@ -21,6 +23,7 @@ func (r *RootCmd) provisionerJobs() *serpent.Command {
2123
},
2224
Aliases: []string{"job"},
2325
Children: []*serpent.Command{
26+
r.provisionerJobsCancel(),
2427
r.provisionerJobsList(),
2528
},
2629
}
@@ -124,3 +127,58 @@ func (r *RootCmd) provisionerJobsList() *serpent.Command {
124127

125128
return cmd
126129
}
130+
131+
func (r *RootCmd) provisionerJobsCancel() *serpent.Command {
132+
var (
133+
client = new(codersdk.Client)
134+
orgContext = NewOrganizationContext()
135+
)
136+
cmd := &serpent.Command{
137+
Use: "cancel <job_id>",
138+
Short: "Cancel a provisioner job",
139+
Middleware: serpent.Chain(
140+
serpent.RequireNArgs(1),
141+
r.InitClient(client),
142+
),
143+
Handler: func(inv *serpent.Invocation) error {
144+
ctx := inv.Context()
145+
org, err := orgContext.Selected(inv, client)
146+
if err != nil {
147+
return xerrors.Errorf("current organization: %w", err)
148+
}
149+
150+
jobID, err := uuid.Parse(inv.Args[0])
151+
if err != nil {
152+
return xerrors.Errorf("invalid job ID: %w", err)
153+
}
154+
155+
job, err := client.OrganizationProvisionerJob(ctx, org.ID, jobID)
156+
if err != nil {
157+
return xerrors.Errorf("get provisioner job: %w", err)
158+
}
159+
160+
switch job.Type {
161+
case codersdk.ProvisionerJobTypeTemplateVersionDryRun:
162+
_, _ = fmt.Fprintf(inv.Stdout, "Canceling template version dry run job %s...\n", job.ID)
163+
err = client.CancelTemplateVersionDryRun(ctx, ptr.NilToEmpty(job.Input.TemplateVersionID), job.ID)
164+
case codersdk.ProvisionerJobTypeTemplateVersionImport:
165+
_, _ = fmt.Fprintf(inv.Stdout, "Canceling template version import job %s...\n", job.ID)
166+
err = client.CancelTemplateVersion(ctx, ptr.NilToEmpty(job.Input.TemplateVersionID))
167+
case codersdk.ProvisionerJobTypeWorkspaceBuild:
168+
_, _ = fmt.Fprintf(inv.Stdout, "Canceling workspace build job %s...\n", job.ID)
169+
err = client.CancelWorkspaceBuild(ctx, ptr.NilToEmpty(job.Input.WorkspaceBuildID))
170+
}
171+
if err != nil {
172+
return xerrors.Errorf("cancel provisioner job: %w", err)
173+
}
174+
175+
_, _ = fmt.Fprintln(inv.Stdout, "Job canceled")
176+
177+
return nil
178+
},
179+
}
180+
181+
orgContext.AttachOptions(cmd)
182+
183+
return cmd
184+
}

cli/provisionerjobs_test.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package cli_test
2+
3+
import (
4+
"bytes"
5+
"database/sql"
6+
"encoding/json"
7+
"fmt"
8+
"testing"
9+
"time"
10+
11+
"github.com/aws/smithy-go/ptr"
12+
"github.com/google/uuid"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
16+
"github.com/coder/coder/v2/cli/clitest"
17+
"github.com/coder/coder/v2/coderd/coderdtest"
18+
"github.com/coder/coder/v2/coderd/database"
19+
"github.com/coder/coder/v2/coderd/database/dbgen"
20+
"github.com/coder/coder/v2/coderd/database/dbtestutil"
21+
"github.com/coder/coder/v2/coderd/rbac"
22+
"github.com/coder/coder/v2/codersdk"
23+
"github.com/coder/coder/v2/testutil"
24+
)
25+
26+
func TestProvisionerJobs(t *testing.T) {
27+
t.Parallel()
28+
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+
// Create initial resources with a running provisioner.
40+
firstProvisioner := coderdtest.NewTaggedProvisionerDaemon(t, coderdAPI, "default-provisioner", map[string]string{"owner": "", "scope": "organization"})
41+
t.Cleanup(func() { _ = firstProvisioner.Close() })
42+
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent())
43+
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
44+
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(req *codersdk.CreateTemplateRequest) {
45+
req.AllowUserCancelWorkspaceJobs = ptr.Bool(true)
46+
})
47+
48+
// Stop the provisioner so it doesn't grab any more jobs.
49+
firstProvisioner.Close()
50+
51+
t.Run("Cancel", func(t *testing.T) {
52+
t.Parallel()
53+
54+
// Set up test helpers.
55+
type jobInput struct {
56+
WorkspaceBuildID string `json:"workspace_build_id,omitempty"`
57+
TemplateVersionID string `json:"template_version_id,omitempty"`
58+
DryRun bool `json:"dry_run,omitempty"`
59+
}
60+
prepareJob := func(t *testing.T, input jobInput) database.ProvisionerJob {
61+
t.Helper()
62+
63+
inputBytes, err := json.Marshal(input)
64+
require.NoError(t, err)
65+
66+
var typ database.ProvisionerJobType
67+
switch {
68+
case input.WorkspaceBuildID != "":
69+
typ = database.ProvisionerJobTypeWorkspaceBuild
70+
case input.TemplateVersionID != "":
71+
if input.DryRun {
72+
typ = database.ProvisionerJobTypeTemplateVersionDryRun
73+
} else {
74+
typ = database.ProvisionerJobTypeTemplateVersionImport
75+
}
76+
default:
77+
t.Fatal("invalid input")
78+
}
79+
80+
var (
81+
tags = database.StringMap{"owner": "", "scope": "organization", "foo": uuid.New().String()}
82+
_ = dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{Tags: tags})
83+
job = dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{
84+
InitiatorID: member.ID,
85+
Input: json.RawMessage(inputBytes),
86+
Type: typ,
87+
Tags: tags,
88+
StartedAt: sql.NullTime{Time: coderdAPI.Clock.Now().Add(-time.Minute), Valid: true},
89+
})
90+
)
91+
return job
92+
}
93+
94+
prepareWorkspaceBuildJob := func(t *testing.T) database.ProvisionerJob {
95+
t.Helper()
96+
var (
97+
wbID = uuid.New()
98+
job = prepareJob(t, jobInput{WorkspaceBuildID: wbID.String()})
99+
w = dbgen.Workspace(t, db, database.WorkspaceTable{
100+
OrganizationID: owner.OrganizationID,
101+
OwnerID: member.ID,
102+
TemplateID: template.ID,
103+
})
104+
_ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
105+
ID: wbID,
106+
InitiatorID: member.ID,
107+
WorkspaceID: w.ID,
108+
TemplateVersionID: version.ID,
109+
JobID: job.ID,
110+
})
111+
)
112+
return job
113+
}
114+
115+
prepareTemplateVersionImportJobBuilder := func(t *testing.T, dryRun bool) database.ProvisionerJob {
116+
t.Helper()
117+
var (
118+
tvID = uuid.New()
119+
job = prepareJob(t, jobInput{TemplateVersionID: tvID.String(), DryRun: dryRun})
120+
_ = dbgen.TemplateVersion(t, db, database.TemplateVersion{
121+
OrganizationID: owner.OrganizationID,
122+
CreatedBy: templateAdmin.ID,
123+
ID: tvID,
124+
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
125+
JobID: job.ID,
126+
})
127+
)
128+
return job
129+
}
130+
prepareTemplateVersionImportJob := func(t *testing.T) database.ProvisionerJob {
131+
return prepareTemplateVersionImportJobBuilder(t, false)
132+
}
133+
prepareTemplateVersionImportJobDryRun := func(t *testing.T) database.ProvisionerJob {
134+
return prepareTemplateVersionImportJobBuilder(t, true)
135+
}
136+
137+
// Run the cancellation test suite.
138+
for _, tt := range []struct {
139+
role string
140+
client *codersdk.Client
141+
name string
142+
prepare func(*testing.T) database.ProvisionerJob
143+
wantCancelled bool
144+
}{
145+
{"Owner", client, "WorkspaceBuild", prepareWorkspaceBuildJob, true},
146+
{"Owner", client, "TemplateVersionImport", prepareTemplateVersionImportJob, true},
147+
{"Owner", client, "TemplateVersionImportDryRun", prepareTemplateVersionImportJobDryRun, true},
148+
{"TemplateAdmin", templateAdminClient, "WorkspaceBuild", prepareWorkspaceBuildJob, false},
149+
{"TemplateAdmin", templateAdminClient, "TemplateVersionImport", prepareTemplateVersionImportJob, true},
150+
{"TemplateAdmin", templateAdminClient, "TemplateVersionImportDryRun", prepareTemplateVersionImportJobDryRun, false},
151+
{"Member", memberClient, "WorkspaceBuild", prepareWorkspaceBuildJob, false},
152+
{"Member", memberClient, "TemplateVersionImport", prepareTemplateVersionImportJob, false},
153+
{"Member", memberClient, "TemplateVersionImportDryRun", prepareTemplateVersionImportJobDryRun, false},
154+
} {
155+
tt := tt
156+
wantMsg := "OK"
157+
if !tt.wantCancelled {
158+
wantMsg = "FAIL"
159+
}
160+
t.Run(fmt.Sprintf("%s/%s/%v", tt.role, tt.name, wantMsg), func(t *testing.T) {
161+
t.Parallel()
162+
163+
job := tt.prepare(t)
164+
require.False(t, job.CanceledAt.Valid, "job.CanceledAt.Valid")
165+
166+
inv, root := clitest.New(t, "provisioner", "jobs", "cancel", job.ID.String())
167+
clitest.SetupConfig(t, tt.client, root)
168+
var buf bytes.Buffer
169+
inv.Stdout = &buf
170+
err := inv.Run()
171+
if tt.wantCancelled {
172+
assert.NoError(t, err)
173+
} else {
174+
assert.Error(t, err)
175+
}
176+
177+
job, err = db.GetProvisionerJobByID(testutil.Context(t, testutil.WaitShort), job.ID)
178+
require.NoError(t, err)
179+
assert.Equal(t, tt.wantCancelled, job.CanceledAt.Valid, "job.CanceledAt.Valid")
180+
assert.Equal(t, tt.wantCancelled, job.CanceledAt.Time.After(job.StartedAt.Time), "job.CanceledAt.Time")
181+
if tt.wantCancelled {
182+
assert.Contains(t, buf.String(), "Job canceled")
183+
} else {
184+
assert.NotContains(t, buf.String(), "Job canceled")
185+
}
186+
})
187+
}
188+
})
189+
}

cli/testdata/coder_provisioner_jobs_--help.golden

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ USAGE:
88
Aliases: job
99

1010
SUBCOMMANDS:
11-
list List provisioner jobs
11+
cancel Cancel a provisioner job
12+
list List provisioner jobs
1213

1314
———
1415
Run `coder --help` for a list of global options.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
coder v0.0.0-devel
2+
3+
USAGE:
4+
coder provisioner jobs cancel [flags] <job_id>
5+
6+
Cancel a provisioner job
7+
8+
OPTIONS:
9+
-O, --org string, $CODER_ORGANIZATION
10+
Select which organization (uuid or name) to use.
11+
12+
———
13+
Run `coder --help` for a list of global options.

coderd/apidoc/docs.go

Lines changed: 43 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: 39 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,7 @@ func New(options *Options) *API {
10111011
r.Get("/", api.provisionerDaemons)
10121012
})
10131013
r.Route("/provisionerjobs", func(r chi.Router) {
1014+
r.Get("/{job}", api.provisionerJob)
10141015
r.Get("/", api.provisionerJobs)
10151016
})
10161017
})

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy