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

Skip to content

Commit d50b500

Browse files
committed
feat(cli): add provisioner job cancel command
1 parent 84081e9 commit d50b500

18 files changed

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