Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 75c899f

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

19 files changed

+568
-21
lines changed

cli/provisionerjobs.go

+58
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

+189
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

+2-1
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.
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

+43
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+39
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

+1
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)