Skip to content

Commit fd49a18

Browse files
authored
feat: Add "state" command to pull and push workspace state (#1264)
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 fd49a18

11 files changed

+331
-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: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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+
Args: cobra.MinimumNArgs(1),
27+
RunE: func(cmd *cobra.Command, args []string) error {
28+
client, err := createClient(cmd)
29+
if err != nil {
30+
return err
31+
}
32+
organization, err := currentOrganization(cmd, client)
33+
if err != nil {
34+
return err
35+
}
36+
37+
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
38+
if err != nil {
39+
return err
40+
}
41+
var build codersdk.WorkspaceBuild
42+
if buildName == "latest" {
43+
build = workspace.LatestBuild
44+
} else {
45+
build, err = client.WorkspaceBuildByName(cmd.Context(), workspace.ID, buildName)
46+
if err != nil {
47+
return err
48+
}
49+
}
50+
51+
state, err := client.WorkspaceBuildState(cmd.Context(), build.ID)
52+
if err != nil {
53+
return err
54+
}
55+
56+
if len(args) < 2 {
57+
cmd.Println(string(state))
58+
return nil
59+
}
60+
61+
return os.WriteFile(args[1], state, 0600)
62+
},
63+
}
64+
cmd.Flags().StringVarP(&buildName, "build", "b", "latest", "Specify a workspace build to target by name.")
65+
return cmd
66+
}
67+
68+
func statePush() *cobra.Command {
69+
var buildName string
70+
cmd := &cobra.Command{
71+
Use: "push <workspace> <file>",
72+
Args: cobra.ExactArgs(2),
73+
RunE: func(cmd *cobra.Command, args []string) error {
74+
client, err := createClient(cmd)
75+
if err != nil {
76+
return err
77+
}
78+
organization, err := currentOrganization(cmd, client)
79+
if err != nil {
80+
return err
81+
}
82+
83+
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
84+
if err != nil {
85+
return err
86+
}
87+
var build codersdk.WorkspaceBuild
88+
if buildName == "latest" {
89+
build = workspace.LatestBuild
90+
} else {
91+
build, err = client.WorkspaceBuildByName(cmd.Context(), workspace.ID, buildName)
92+
if err != nil {
93+
return err
94+
}
95+
}
96+
97+
var state []byte
98+
if args[1] == "-" {
99+
state, err = io.ReadAll(cmd.InOrStdin())
100+
} else {
101+
state, err = os.ReadFile(args[1])
102+
}
103+
if err != nil {
104+
return err
105+
}
106+
107+
before := time.Now()
108+
build, err = client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
109+
TemplateVersionID: build.TemplateVersionID,
110+
Transition: build.Transition,
111+
ProvisionerState: state,
112+
})
113+
if err != nil {
114+
return err
115+
}
116+
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStderr(), client, build.ID, before)
117+
},
118+
}
119+
cmd.Flags().StringVarP(&buildName, "build", "b", "latest", "Specify a workspace build to target by name.")
120+
return cmd
121+
}

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)