Skip to content

Commit 4f0105e

Browse files
authored
feat: add orphan support (coder#3849)
* feat: add resource orphanage * feat: deny custom state in build for regular users * Minor protoc improvements
1 parent 209e011 commit 4f0105e

16 files changed

+334
-51
lines changed

.github/workflows/coder.yaml

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -193,16 +193,6 @@ jobs:
193193
- name: Install node_modules
194194
run: ./scripts/yarn_install.sh
195195

196-
- name: Install Protoc
197-
run: |
198-
# protoc must be in lockstep with our dogfood Dockerfile
199-
# or the version in the comments will differ.
200-
set -x
201-
cd dogfood
202-
DOCKER_BUILDKIT=1 docker build . --target proto -t protoc
203-
protoc_dir=/usr/local/bin/protoc
204-
docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_dir
205-
chmod +x $protoc_dir
206196
- uses: actions/setup-go@v3
207197
with:
208198
go-version: "~1.19"
@@ -235,6 +225,18 @@ jobs:
235225
- name: Install goimports
236226
run: go install golang.org/x/tools/cmd/goimports@latest
237227

228+
- name: Install Protoc
229+
run: |
230+
# protoc must be in lockstep with our dogfood Dockerfile
231+
# or the version in the comments will differ.
232+
set -x
233+
cd dogfood
234+
DOCKER_BUILDKIT=1 docker build . --target proto -t protoc
235+
protoc_path=/usr/local/bin/protoc
236+
docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_path
237+
chmod +x $protoc_path
238+
protoc --version
239+
238240
- name: make gen
239241
run: "make --output-sync -j -B gen"
240242

cli/delete.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
// nolint
1414
func deleteWorkspace() *cobra.Command {
15+
var orphan bool
1516
cmd := &cobra.Command{
1617
Annotations: workspaceCommand,
1718
Use: "delete <workspace>",
@@ -36,9 +37,21 @@ func deleteWorkspace() *cobra.Command {
3637
if err != nil {
3738
return err
3839
}
40+
41+
var state []byte
42+
43+
if orphan {
44+
cliui.Warn(
45+
cmd.ErrOrStderr(),
46+
"Orphaning workspace requires template edit permission",
47+
)
48+
}
49+
3950
before := time.Now()
4051
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
41-
Transition: codersdk.WorkspaceTransitionDelete,
52+
Transition: codersdk.WorkspaceTransitionDelete,
53+
ProvisionerState: state,
54+
Orphan: orphan,
4255
})
4356
if err != nil {
4457
return err
@@ -53,6 +66,10 @@ func deleteWorkspace() *cobra.Command {
5366
return nil
5467
},
5568
}
69+
cmd.Flags().BoolVar(&orphan, "orphan", false,
70+
`Delete a workspace without deleting its resources. This can delete a
71+
workspace in a broken state, but may also lead to unaccounted cloud resources.`,
72+
)
5673
cliui.AllowSkipPrompt(cmd)
5774
return cmd
5875
}

cli/delete_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,35 @@ func TestDelete(t *testing.T) {
4343
<-doneChan
4444
})
4545

46+
t.Run("Orphan", func(t *testing.T) {
47+
t.Parallel()
48+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
49+
user := coderdtest.CreateFirstUser(t, client)
50+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
51+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
52+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
53+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
54+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
55+
cmd, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan")
56+
57+
clitest.SetupConfig(t, client, root)
58+
doneChan := make(chan struct{})
59+
pty := ptytest.New(t)
60+
cmd.SetIn(pty.Input())
61+
cmd.SetOut(pty.Output())
62+
cmd.SetErr(pty.Output())
63+
go func() {
64+
defer close(doneChan)
65+
err := cmd.Execute()
66+
// When running with the race detector on, we sometimes get an EOF.
67+
if err != nil {
68+
assert.ErrorIs(t, err, io.EOF)
69+
}
70+
}()
71+
pty.ExpectMatch("Cleaning Up")
72+
<-doneChan
73+
})
74+
4675
t.Run("DifferentUser", func(t *testing.T) {
4776
t.Parallel()
4877
adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})

cli/portforward.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ import (
1212
"sync"
1313
"syscall"
1414

15-
"cdr.dev/slog"
1615
"github.com/pion/udp"
1716
"github.com/spf13/cobra"
1817
"golang.org/x/xerrors"
1918

19+
"cdr.dev/slog"
20+
2021
"github.com/coder/coder/agent"
2122
"github.com/coder/coder/cli/cliui"
2223
"github.com/coder/coder/codersdk"

coderd/coderdtest/coderdtest.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"encoding/base64"
1414
"encoding/json"
1515
"encoding/pem"
16+
"errors"
1617
"fmt"
1718
"io"
1819
"math/big"
@@ -75,8 +76,10 @@ type Options struct {
7576
AutobuildStats chan<- executor.Stats
7677

7778
// IncludeProvisionerDaemon when true means to start an in-memory provisionerD
78-
IncludeProvisionerDaemon bool
79-
APIBuilder func(*coderd.Options) *coderd.API
79+
IncludeProvisionerDaemon bool
80+
APIBuilder func(*coderd.Options) *coderd.API
81+
MetricsCacheRefreshInterval time.Duration
82+
AgentStatsRefreshInterval time.Duration
8083
}
8184

8285
// New constructs a codersdk client connected to an in-memory API instance.
@@ -235,8 +238,8 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
235238
},
236239
},
237240
AutoImportTemplates: options.AutoImportTemplates,
238-
MetricsCacheRefreshInterval: time.Millisecond * 100,
239-
AgentStatsRefreshInterval: time.Millisecond * 100,
241+
MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval,
242+
AgentStatsRefreshInterval: options.AgentStatsRefreshInterval,
240243
})
241244
t.Cleanup(func() {
242245
_ = coderAPI.Close()
@@ -752,3 +755,10 @@ func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
752755
type nopcloser struct{}
753756

754757
func (nopcloser) Close() error { return nil }
758+
759+
// SDKError coerces err into an SDK error.
760+
func SDKError(t *testing.T, err error) *codersdk.Error {
761+
var cerr *codersdk.Error
762+
require.True(t, errors.As(err, &cerr))
763+
return cerr
764+
}

coderd/templates_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,9 @@ func TestTemplateDAUs(t *testing.T) {
549549
t.Parallel()
550550

551551
client := coderdtest.New(t, &coderdtest.Options{
552-
IncludeProvisionerDaemon: true,
552+
IncludeProvisionerDaemon: true,
553+
AgentStatsRefreshInterval: time.Millisecond * 100,
554+
MetricsCacheRefreshInterval: time.Millisecond * 100,
553555
})
554556

555557
user := coderdtest.CreateFirstUser(t, client)

coderd/workspacebuilds.go

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
318318
}
319319
createBuild.TemplateVersionID = latestBuild.TemplateVersionID
320320
}
321+
321322
templateVersion, err := api.Database.GetTemplateVersionByID(r.Context(), createBuild.TemplateVersionID)
322323
if errors.Is(err, sql.ErrNoRows) {
323324
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
@@ -336,6 +337,47 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
336337
})
337338
return
338339
}
340+
341+
template, err := api.Database.GetTemplateByID(r.Context(), templateVersion.TemplateID.UUID)
342+
if err != nil {
343+
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
344+
Message: "Failed to get template",
345+
Detail: err.Error(),
346+
})
347+
return
348+
}
349+
350+
var state []byte
351+
// If custom state, deny request since user could be corrupting or leaking
352+
// cloud state.
353+
if createBuild.ProvisionerState != nil || createBuild.Orphan {
354+
if !api.Authorize(r, rbac.ActionUpdate, template.RBACObject()) {
355+
httpapi.Write(rw, http.StatusForbidden, codersdk.Response{
356+
Message: "Only template managers may provide custom state",
357+
})
358+
return
359+
}
360+
state = createBuild.ProvisionerState
361+
}
362+
363+
if createBuild.Orphan {
364+
if createBuild.Transition != codersdk.WorkspaceTransitionDelete {
365+
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
366+
Message: "Orphan is only permitted when deleting a workspace.",
367+
Detail: err.Error(),
368+
})
369+
return
370+
}
371+
372+
if createBuild.ProvisionerState != nil && createBuild.Orphan {
373+
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
374+
Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.",
375+
})
376+
return
377+
}
378+
state = []byte{}
379+
}
380+
339381
templateVersionJob, err := api.Database.GetProvisionerJobByID(r.Context(), templateVersion.JobID)
340382
if err != nil {
341383
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
@@ -363,15 +405,6 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
363405
return
364406
}
365407

366-
template, err := api.Database.GetTemplateByID(r.Context(), templateVersion.TemplateID.UUID)
367-
if err != nil {
368-
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
369-
Message: "Internal error fetching template job.",
370-
Detail: err.Error(),
371-
})
372-
return
373-
}
374-
375408
// Store prior build number to compute new build number
376409
var priorBuildNum int32
377410
priorHistory, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID)
@@ -393,6 +426,10 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
393426
return
394427
}
395428

429+
if state == nil {
430+
state = priorHistory.ProvisionerState
431+
}
432+
396433
var workspaceBuild database.WorkspaceBuild
397434
var provisionerJob database.ProvisionerJob
398435
// This must happen in a transaction to ensure history can be inserted, and
@@ -457,10 +494,6 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
457494
if err != nil {
458495
return xerrors.Errorf("insert provisioner job: %w", err)
459496
}
460-
state := createBuild.ProvisionerState
461-
if len(state) == 0 {
462-
state = priorHistory.ProvisionerState
463-
}
464497

465498
workspaceBuild, err = db.InsertWorkspaceBuild(r.Context(), database.InsertWorkspaceBuildParams{
466499
ID: workspaceBuildID,

coderd/workspacebuilds_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package coderd_test
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"net/http"
78
"strconv"
@@ -230,6 +231,95 @@ func TestWorkspaceBuilds(t *testing.T) {
230231
})
231232
}
232233

234+
func TestWorkspaceBuildsProvisionerState(t *testing.T) {
235+
t.Parallel()
236+
237+
t.Run("Permissions", func(t *testing.T) {
238+
t.Parallel()
239+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
240+
first := coderdtest.CreateFirstUser(t, client)
241+
242+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
243+
defer cancel()
244+
245+
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil)
246+
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
247+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
248+
249+
workspace := coderdtest.CreateWorkspace(t, client, first.OrganizationID, template.ID)
250+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
251+
252+
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
253+
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
254+
Transition: codersdk.WorkspaceTransitionDelete,
255+
ProvisionerState: []byte(" "),
256+
})
257+
require.Nil(t, err)
258+
259+
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
260+
261+
// A regular user on the very same template must not be able to modify the
262+
// state.
263+
regularUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
264+
265+
workspace = coderdtest.CreateWorkspace(t, regularUser, first.OrganizationID, template.ID)
266+
coderdtest.AwaitWorkspaceBuildJob(t, regularUser, workspace.LatestBuild.ID)
267+
268+
_, err = regularUser.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
269+
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
270+
Transition: workspace.LatestBuild.Transition,
271+
ProvisionerState: []byte(" "),
272+
})
273+
require.Error(t, err)
274+
275+
var cerr *codersdk.Error
276+
require.True(t, errors.As(err, &cerr))
277+
278+
code := cerr.StatusCode()
279+
require.Equal(t, http.StatusForbidden, code, "unexpected status %s", http.StatusText(code))
280+
})
281+
282+
t.Run("Orphan", func(t *testing.T) {
283+
t.Parallel()
284+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
285+
first := coderdtest.CreateFirstUser(t, client)
286+
287+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
288+
defer cancel()
289+
290+
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil)
291+
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
292+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
293+
294+
workspace := coderdtest.CreateWorkspace(t, client, first.OrganizationID, template.ID)
295+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
296+
297+
// Providing both state and orphan fails.
298+
_, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
299+
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
300+
Transition: codersdk.WorkspaceTransitionDelete,
301+
ProvisionerState: []byte(" "),
302+
Orphan: true,
303+
})
304+
require.Error(t, err)
305+
cerr := coderdtest.SDKError(t, err)
306+
require.Equal(t, http.StatusBadRequest, cerr.StatusCode())
307+
308+
// Regular orphan operation succeeds.
309+
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
310+
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
311+
Transition: codersdk.WorkspaceTransitionDelete,
312+
Orphan: true,
313+
})
314+
require.NoError(t, err)
315+
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
316+
317+
_, err = client.Workspace(ctx, workspace.ID)
318+
require.Error(t, err)
319+
require.Equal(t, http.StatusGone, coderdtest.SDKError(t, err).StatusCode())
320+
})
321+
}
322+
233323
func TestPatchCancelWorkspaceBuild(t *testing.T) {
234324
t.Parallel()
235325
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})

codersdk/workspaces.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ type CreateWorkspaceBuildRequest struct {
3939
Transition WorkspaceTransition `json:"transition" validate:"oneof=create start stop delete,required"`
4040
DryRun bool `json:"dry_run,omitempty"`
4141
ProvisionerState []byte `json:"state,omitempty"`
42+
// Orphan may be set for the Destroy transition.
43+
Orphan bool `json:"orphan,omitempty"`
4244
// ParameterValues are optional. It will write params to the 'workspace' scope.
4345
// This will overwrite any existing parameters with the same name.
4446
// This will not delete old params not included in this list.

site/src/api/typesGenerated.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ export interface CreateWorkspaceBuildRequest {
170170
readonly transition: WorkspaceTransition
171171
readonly dry_run?: boolean
172172
readonly state?: string
173+
readonly orphan?: boolean
173174
readonly parameter_values?: CreateParameterRequest[]
174175
}
175176

0 commit comments

Comments
 (0)