Skip to content

Commit 9fe7a08

Browse files
committed
feat: Add "state" command to pull and push workspace state
It's possible for a workspace to become in an invalid state. This is something we'll detect for jobs, and allow monitoring of. These commands will allow admins to manually reconcile state.
1 parent 43c6bff commit 9fe7a08

File tree

11 files changed

+333
-2
lines changed

11 files changed

+333
-2
lines changed

cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ func Root() *cobra.Command {
8080
server(),
8181
show(),
8282
start(),
83+
state(),
8384
stop(),
8485
ssh(),
8586
templates(),

cli/state.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package cli
2+
3+
import (
4+
"io"
5+
"os"
6+
"time"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/coder/coder/cli/cliui"
11+
"github.com/coder/coder/codersdk"
12+
)
13+
14+
func state() *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "state",
17+
}
18+
cmd.AddCommand(statePull(), statePush())
19+
return cmd
20+
}
21+
22+
func statePull() *cobra.Command {
23+
var buildName string
24+
cmd := &cobra.Command{
25+
Use: "pull <workspace> [file]",
26+
RunE: func(cmd *cobra.Command, args []string) error {
27+
client, err := createClient(cmd)
28+
if err != nil {
29+
return err
30+
}
31+
organization, err := currentOrganization(cmd, client)
32+
if err != nil {
33+
return err
34+
}
35+
36+
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
37+
if err != nil {
38+
return err
39+
}
40+
var build codersdk.WorkspaceBuild
41+
if buildName == "latest" {
42+
build = workspace.LatestBuild
43+
} else {
44+
build, err = client.WorkspaceBuildByName(cmd.Context(), workspace.ID, buildName)
45+
if err != nil {
46+
return err
47+
}
48+
}
49+
50+
state, err := client.WorkspaceBuildState(cmd.Context(), build.ID)
51+
if err != nil {
52+
return err
53+
}
54+
55+
if len(args) < 2 {
56+
cmd.Println(string(state))
57+
return nil
58+
}
59+
60+
return os.WriteFile(args[1], state, 0600)
61+
},
62+
}
63+
cmd.Flags().StringVarP(&buildName, "build", "b", "latest", "Specify a workspace build to target by name.")
64+
return cmd
65+
}
66+
67+
func statePush() *cobra.Command {
68+
var buildName string
69+
cmd := &cobra.Command{
70+
Use: "push <workspace> <file>",
71+
Args: cobra.ExactArgs(2),
72+
RunE: func(cmd *cobra.Command, args []string) error {
73+
client, err := createClient(cmd)
74+
if err != nil {
75+
return err
76+
}
77+
organization, err := currentOrganization(cmd, client)
78+
if err != nil {
79+
return err
80+
}
81+
82+
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
83+
if err != nil {
84+
return err
85+
}
86+
var build codersdk.WorkspaceBuild
87+
if buildName == "latest" {
88+
build = workspace.LatestBuild
89+
} else {
90+
build, err = client.WorkspaceBuildByName(cmd.Context(), workspace.ID, buildName)
91+
if err != nil {
92+
return err
93+
}
94+
}
95+
96+
var state []byte
97+
if args[1] == "-" {
98+
state, err = io.ReadAll(cmd.InOrStdin())
99+
} else {
100+
state, err = os.ReadFile(args[1])
101+
}
102+
if err != nil {
103+
return err
104+
}
105+
106+
before := time.Now()
107+
build, err = client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
108+
TemplateVersionID: build.TemplateVersionID,
109+
Transition: build.Transition,
110+
ProvisionerState: state,
111+
})
112+
if err != nil {
113+
return err
114+
}
115+
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStderr(), client, build.ID, before)
116+
},
117+
}
118+
cmd.Flags().StringVarP(&buildName, "build", "b", "latest", "Specify a workspace build to target by name.")
119+
return cmd
120+
}
121+
122+
// Resources in a started / stopped state should be generalized anyways.
123+
// SSHing into a stopped instance should provide a

cli/state_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package cli_test
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/coder/coder/cli/clitest"
14+
"github.com/coder/coder/coderd/coderdtest"
15+
"github.com/coder/coder/provisioner/echo"
16+
"github.com/coder/coder/provisionersdk/proto"
17+
)
18+
19+
func TestStatePull(t *testing.T) {
20+
t.Parallel()
21+
t.Run("File", func(t *testing.T) {
22+
t.Parallel()
23+
client := coderdtest.New(t, nil)
24+
user := coderdtest.CreateFirstUser(t, client)
25+
coderdtest.NewProvisionerDaemon(t, client)
26+
wantState := []byte("some state")
27+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
28+
Parse: echo.ParseComplete,
29+
Provision: []*proto.Provision_Response{{
30+
Type: &proto.Provision_Response_Complete{
31+
Complete: &proto.Provision_Complete{
32+
State: wantState,
33+
},
34+
},
35+
}},
36+
})
37+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
38+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
39+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
40+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
41+
statefilePath := filepath.Join(t.TempDir(), "state")
42+
cmd, root := clitest.New(t, "state", "pull", workspace.Name, statefilePath)
43+
clitest.SetupConfig(t, client, root)
44+
err := cmd.Execute()
45+
require.NoError(t, err)
46+
gotState, err := os.ReadFile(statefilePath)
47+
require.NoError(t, err)
48+
require.Equal(t, wantState, gotState)
49+
})
50+
t.Run("Stdout", func(t *testing.T) {
51+
t.Parallel()
52+
client := coderdtest.New(t, nil)
53+
user := coderdtest.CreateFirstUser(t, client)
54+
coderdtest.NewProvisionerDaemon(t, client)
55+
wantState := []byte("some state")
56+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
57+
Parse: echo.ParseComplete,
58+
Provision: []*proto.Provision_Response{{
59+
Type: &proto.Provision_Response_Complete{
60+
Complete: &proto.Provision_Complete{
61+
State: wantState,
62+
},
63+
},
64+
}},
65+
})
66+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
67+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
68+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
69+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
70+
cmd, root := clitest.New(t, "state", "pull", workspace.Name)
71+
var gotState bytes.Buffer
72+
cmd.SetOut(&gotState)
73+
clitest.SetupConfig(t, client, root)
74+
err := cmd.Execute()
75+
require.NoError(t, err)
76+
require.Equal(t, wantState, bytes.TrimSpace(gotState.Bytes()))
77+
})
78+
}
79+
80+
func TestStatePush(t *testing.T) {
81+
t.Parallel()
82+
t.Run("File", func(t *testing.T) {
83+
t.Parallel()
84+
client := coderdtest.New(t, nil)
85+
user := coderdtest.CreateFirstUser(t, client)
86+
coderdtest.NewProvisionerDaemon(t, client)
87+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
88+
Parse: echo.ParseComplete,
89+
Provision: echo.ProvisionComplete,
90+
})
91+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
92+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
93+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
94+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
95+
stateFile, err := os.CreateTemp(t.TempDir(), "")
96+
require.NoError(t, err)
97+
wantState := []byte("some magic state")
98+
_, err = stateFile.Write(wantState)
99+
require.NoError(t, err)
100+
err = stateFile.Close()
101+
require.NoError(t, err)
102+
cmd, root := clitest.New(t, "state", "push", workspace.Name, stateFile.Name())
103+
cmd.SetErr(io.Discard)
104+
cmd.SetOut(io.Discard)
105+
clitest.SetupConfig(t, client, root)
106+
err = cmd.Execute()
107+
require.NoError(t, err)
108+
})
109+
110+
t.Run("Stdin", func(t *testing.T) {
111+
t.Parallel()
112+
client := coderdtest.New(t, nil)
113+
user := coderdtest.CreateFirstUser(t, client)
114+
coderdtest.NewProvisionerDaemon(t, client)
115+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
116+
Parse: echo.ParseComplete,
117+
Provision: echo.ProvisionComplete,
118+
})
119+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
120+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
121+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
122+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
123+
cmd, root := clitest.New(t, "state", "push", "--build", workspace.LatestBuild.Name, workspace.Name, "-")
124+
clitest.SetupConfig(t, client, root)
125+
cmd.SetIn(strings.NewReader("some magic state"))
126+
err := cmd.Execute()
127+
require.NoError(t, err)
128+
})
129+
}

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ func New(options *Options) (http.Handler, func()) {
268268
r.Patch("/cancel", api.patchCancelWorkspaceBuild)
269269
r.Get("/logs", api.workspaceBuildLogs)
270270
r.Get("/resources", api.workspaceBuildResources)
271+
r.Get("/state", api.workspaceBuildState)
271272
})
272273
})
273274
r.NotFound(site.DefaultHandler().ServeHTTP)

coderd/provisionerdaemons.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func (api *api) provisionerDaemonsListen(rw http.ResponseWriter, r *http.Request
9595
},
9696
})
9797
err = server.Serve(r.Context(), session)
98-
if err != nil {
98+
if err != nil && !xerrors.Is(err, io.EOF) {
9999
api.Logger.Debug(r.Context(), "provisioner daemon disconnected", slog.Error(err))
100100
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("serve: %s", err))
101101
return

coderd/workspacebuilds.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ func (api *api) workspaceBuildLogs(rw http.ResponseWriter, r *http.Request) {
8787
api.provisionerJobLogs(rw, r, job)
8888
}
8989

90+
func (*api) workspaceBuildState(rw http.ResponseWriter, r *http.Request) {
91+
workspaceBuild := httpmw.WorkspaceBuildParam(r)
92+
93+
rw.Header().Set("Content-Type", "application/json")
94+
rw.WriteHeader(http.StatusOK)
95+
_, _ = rw.Write(workspaceBuild.ProvisionerState)
96+
}
97+
9098
func convertWorkspaceBuild(workspaceBuild database.WorkspaceBuild, job codersdk.ProvisionerJob) codersdk.WorkspaceBuild {
9199
//nolint:unconvert
92100
return codersdk.WorkspaceBuild{

coderd/workspacebuilds_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,29 @@ func TestWorkspaceBuildLogs(t *testing.T) {
168168
}
169169
require.Fail(t, "example message never happened")
170170
}
171+
172+
func TestWorkspaceBuildState(t *testing.T) {
173+
t.Parallel()
174+
client := coderdtest.New(t, nil)
175+
user := coderdtest.CreateFirstUser(t, client)
176+
coderdtest.NewProvisionerDaemon(t, client)
177+
wantState := []byte("some kinda state")
178+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
179+
Parse: echo.ParseComplete,
180+
ProvisionDryRun: echo.ProvisionComplete,
181+
Provision: []*proto.Provision_Response{{
182+
Type: &proto.Provision_Response_Complete{
183+
Complete: &proto.Provision_Complete{
184+
State: wantState,
185+
},
186+
},
187+
}},
188+
})
189+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
190+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
191+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
192+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
193+
gotState, err := client.WorkspaceBuildState(context.Background(), workspace.LatestBuild.ID)
194+
require.NoError(t, err)
195+
require.Equal(t, wantState, gotState)
196+
}

coderd/workspaces.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,11 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
212212
if err != nil {
213213
return xerrors.Errorf("insert provisioner job: %w", err)
214214
}
215+
state := createBuild.ProvisionerState
216+
if state == nil || len(state) == 0 {
217+
state = priorHistory.ProvisionerState
218+
}
219+
215220
workspaceBuild, err = db.InsertWorkspaceBuild(r.Context(), database.InsertWorkspaceBuildParams{
216221
ID: workspaceBuildID,
217222
CreatedAt: database.Now(),
@@ -220,7 +225,7 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
220225
TemplateVersionID: templateVersion.ID,
221226
BeforeID: priorHistoryID,
222227
Name: namesgenerator.GetRandomName(1),
223-
ProvisionerState: priorHistory.ProvisionerState,
228+
ProvisionerState: state,
224229
InitiatorID: apiKey.UserID,
225230
Transition: createBuild.Transition,
226231
JobID: provisionerJob.ID,

coderd/workspaces_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,29 @@ func TestPostWorkspaceBuild(t *testing.T) {
130130
require.Equal(t, build.ID.String(), firstBuild.AfterID.String())
131131
})
132132

133+
t.Run("WithState", func(t *testing.T) {
134+
t.Parallel()
135+
client := coderdtest.New(t, nil)
136+
user := coderdtest.CreateFirstUser(t, client)
137+
closeDaemon := coderdtest.NewProvisionerDaemon(t, client)
138+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
139+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
140+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
141+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
142+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
143+
_ = closeDaemon.Close()
144+
wantState := []byte("something")
145+
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
146+
TemplateVersionID: template.ActiveVersionID,
147+
Transition: database.WorkspaceTransitionStart,
148+
ProvisionerState: wantState,
149+
})
150+
require.NoError(t, err)
151+
gotState, err := client.WorkspaceBuildState(context.Background(), build.ID)
152+
require.NoError(t, err)
153+
require.Equal(t, wantState, gotState)
154+
})
155+
133156
t.Run("Delete", func(t *testing.T) {
134157
t.Parallel()
135158
client := coderdtest.New(t, nil)

codersdk/workspacebuilds.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"io"
78
"net/http"
89
"time"
910

@@ -79,3 +80,16 @@ func (c *Client) WorkspaceBuildLogsBefore(ctx context.Context, build uuid.UUID,
7980
func (c *Client) WorkspaceBuildLogsAfter(ctx context.Context, build uuid.UUID, after time.Time) (<-chan ProvisionerJobLog, error) {
8081
return c.provisionerJobLogsAfter(ctx, fmt.Sprintf("/api/v2/workspacebuilds/%s/logs", build), after)
8182
}
83+
84+
// WorkspaceBuildState returns the provisioner state of the build.
85+
func (c *Client) WorkspaceBuildState(ctx context.Context, build uuid.UUID) ([]byte, error) {
86+
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/state", build), nil)
87+
if err != nil {
88+
return nil, err
89+
}
90+
defer res.Body.Close()
91+
if res.StatusCode != http.StatusOK {
92+
return nil, readBodyAsError(res)
93+
}
94+
return io.ReadAll(res.Body)
95+
}

0 commit comments

Comments
 (0)