Skip to content

Commit eba7329

Browse files
committed
Add orphanage to CLI
1 parent 60059ba commit eba7329

File tree

5 files changed

+175
-55
lines changed

5 files changed

+175
-55
lines changed

cli/delete.go

Lines changed: 26 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,29 @@ func deleteWorkspace() *cobra.Command {
3637
if err != nil {
3738
return err
3839
}
40+
41+
var state []byte
42+
if orphan {
43+
cliui.Warn(cmd.ErrOrStderr(), "Orphaning workspace",
44+
"Template edit permission is required to orphan workspaces.",
45+
)
46+
47+
state, err = client.WorkspaceBuildState(cmd.Context(), workspace.LatestBuild.ID)
48+
if err != nil {
49+
return err
50+
}
51+
52+
state, err = codersdk.OrphanTerraformState(state)
53+
if err != nil {
54+
return err
55+
}
56+
fmt.Printf("new state: %s\n", state)
57+
}
58+
3959
before := time.Now()
4060
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
41-
Transition: codersdk.WorkspaceTransitionDelete,
61+
Transition: codersdk.WorkspaceTransitionDelete,
62+
ProvisionerState: state,
4263
})
4364
if err != nil {
4465
return err
@@ -53,6 +74,10 @@ func deleteWorkspace() *cobra.Command {
5374
return nil
5475
},
5576
}
77+
cmd.Flags().BoolVar(&orphan, "orphan", false,
78+
`Delete workspace without deleting its resources. This can delete a
79+
workspace in a broken state, but may also lead to unaccounted cloud resources.`,
80+
)
5681
cliui.AllowSkipPrompt(cmd)
5782
return cmd
5883
}

cli/delete_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,34 @@ 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{IncludeProvisionerD: 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+
go func() {
63+
defer close(doneChan)
64+
err := cmd.Execute()
65+
// When running with the race detector on, we sometimes get an EOF.
66+
if err != nil {
67+
assert.ErrorIs(t, err, io.EOF)
68+
}
69+
}()
70+
pty.ExpectMatch("Cleaning Up")
71+
<-doneChan
72+
})
73+
4674
t.Run("DifferentUser", func(t *testing.T) {
4775
t.Parallel()
4876
adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})

coderd/workspacebuilds_test.go

Lines changed: 60 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -165,60 +165,6 @@ func TestWorkspaceBuilds(t *testing.T) {
165165
require.NoError(t, err)
166166
})
167167

168-
t.Run("OrphanNotOwner", func(t *testing.T) {
169-
t.Parallel()
170-
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
171-
first := coderdtest.CreateFirstUser(t, client)
172-
173-
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
174-
defer cancel()
175-
176-
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil)
177-
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
178-
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
179-
180-
regularUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
181-
182-
workspace := coderdtest.CreateWorkspace(t, regularUser, first.OrganizationID, template.ID)
183-
coderdtest.AwaitWorkspaceBuildJob(t, regularUser, workspace.LatestBuild.ID)
184-
185-
_, err := regularUser.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
186-
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
187-
Transition: workspace.LatestBuild.Transition,
188-
ProvisionerState: []byte(" "),
189-
})
190-
require.Error(t, err)
191-
192-
var cerr *codersdk.Error
193-
require.True(t, errors.As(err, &cerr))
194-
195-
code := cerr.StatusCode()
196-
require.Equal(t, http.StatusForbidden, code, "unexpected status %s", http.StatusText(code))
197-
})
198-
199-
t.Run("Orphan", func(t *testing.T) {
200-
t.Parallel()
201-
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
202-
first := coderdtest.CreateFirstUser(t, client)
203-
204-
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
205-
defer cancel()
206-
207-
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil)
208-
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
209-
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
210-
211-
workspace := coderdtest.CreateWorkspace(t, client, first.OrganizationID, template.ID)
212-
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
213-
214-
_, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
215-
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
216-
Transition: workspace.LatestBuild.Transition,
217-
ProvisionerState: []byte(" "),
218-
})
219-
require.Nil(t, err)
220-
})
221-
222168
t.Run("PaginateNonExistentRow", func(t *testing.T) {
223169
t.Parallel()
224170
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
@@ -285,6 +231,66 @@ func TestWorkspaceBuilds(t *testing.T) {
285231
})
286232
}
287233

234+
func TestWorkspaceBuilds_CustomState(t *testing.T) {
235+
t.Parallel()
236+
237+
t.Run("Forbidden", func(t *testing.T) {
238+
t.Parallel()
239+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: 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+
regularUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
250+
251+
workspace := coderdtest.CreateWorkspace(t, regularUser, first.OrganizationID, template.ID)
252+
coderdtest.AwaitWorkspaceBuildJob(t, regularUser, workspace.LatestBuild.ID)
253+
254+
_, err := regularUser.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
255+
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
256+
Transition: workspace.LatestBuild.Transition,
257+
ProvisionerState: []byte(" "),
258+
})
259+
require.Error(t, err)
260+
261+
var cerr *codersdk.Error
262+
require.True(t, errors.As(err, &cerr))
263+
264+
code := cerr.StatusCode()
265+
require.Equal(t, http.StatusForbidden, code, "unexpected status %s", http.StatusText(code))
266+
})
267+
268+
t.Run("Success", func(t *testing.T) {
269+
t.Parallel()
270+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
271+
first := coderdtest.CreateFirstUser(t, client)
272+
273+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
274+
defer cancel()
275+
276+
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil)
277+
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
278+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
279+
280+
workspace := coderdtest.CreateWorkspace(t, client, first.OrganizationID, template.ID)
281+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
282+
283+
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
284+
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
285+
Transition: codersdk.WorkspaceTransitionDelete,
286+
ProvisionerState: []byte(" "),
287+
})
288+
require.Nil(t, err)
289+
290+
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
291+
})
292+
}
293+
288294
func TestPatchCancelWorkspaceBuild(t *testing.T) {
289295
t.Parallel()
290296
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})

codersdk/workspaces.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,26 @@ type Workspace struct {
3333
LastUsedAt time.Time `json:"last_used_at"`
3434
}
3535

36+
// OrphanTerraformState removes all the resources from the provided Terraform
37+
// state. When the new state is used, Terraform will operate as if none
38+
// of the resources in the original state exist.
39+
func OrphanTerraformState(state []byte) ([]byte, error) {
40+
stateMap := make(map[string]interface{})
41+
err := json.Unmarshal(state, &stateMap)
42+
if err != nil {
43+
return nil, err
44+
}
45+
46+
_, ok := stateMap["resources"]
47+
if !ok {
48+
return nil, xerrors.Errorf("no resources detected, is this terraform state?")
49+
}
50+
51+
stateMap["resources"] = []int{}
52+
53+
return json.Marshal(stateMap)
54+
}
55+
3656
// CreateWorkspaceBuildRequest provides options to update the latest workspace build.
3757
type CreateWorkspaceBuildRequest struct {
3858
TemplateVersionID uuid.UUID `json:"template_version_id,omitempty"`

codersdk/workspaces_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package codersdk_test
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
"github.com/coder/coder/codersdk"
8+
)
9+
10+
func TestOrphanTerraformState(t *testing.T) {
11+
t.Parallel()
12+
13+
type args struct {
14+
state []byte
15+
}
16+
tests := []struct {
17+
name string
18+
args args
19+
want []byte
20+
wantErr bool
21+
}{
22+
{"invalid json", args{[]byte("---")}, nil, true},
23+
{"no resources", args{[]byte(`{"a":4}`)}, nil, true},
24+
{"some resources", args{[]byte(`{"a":4, "resources":[1, 2, 3]}`)}, []byte(`{"a":4,"resources":[]}`), false},
25+
}
26+
for _, tt := range tests {
27+
tt := tt
28+
t.Run(tt.name, func(t *testing.T) {
29+
t.Parallel()
30+
31+
got, err := codersdk.OrphanTerraformState(tt.args.state)
32+
if (err != nil) != tt.wantErr {
33+
t.Errorf("OrphanTerraformState() error = %v, wantErr %v", err, tt.wantErr)
34+
return
35+
}
36+
if !reflect.DeepEqual(got, tt.want) {
37+
t.Errorf("OrphanTerraformState() = %s, want %s", got, tt.want)
38+
}
39+
})
40+
}
41+
}

0 commit comments

Comments
 (0)