Skip to content

Commit 25fb34c

Browse files
authored
feat(agent): implement recreate for devcontainers (#17308)
This change implements an interface for running `@devcontainers/cli up` and an API endpoint on the agent for triggering recreate for a running devcontainer. A couple of limitations: 1. Synchronous HTTP request, meaning the browser might choose to time it out before it's done => no result/error (and devcontainer cli command probably gets killed via ctx cancel). 2. Logs are only written to agent logs via slog, not as a "script" in the UI. Both 1 and 2 will be improved in future refactors. Fixes coder/internal#481 Fixes coder/internal#482
1 parent 6dd1056 commit 25fb34c

17 files changed

+1338
-22
lines changed

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Generated files
22
agent/agentcontainers/acmock/acmock.go linguist-generated=true
33
agent/agentcontainers/dcspec/dcspec_gen.go linguist-generated=true
4+
agent/agentcontainers/testdata/devcontainercli/*/*.log linguist-generated=true
45
coderd/apidoc/docs.go linguist-generated=true
56
docs/reference/api/*.md linguist-generated=true
67
docs/reference/cli/*.md linguist-generated=true

.github/workflows/typos.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,6 @@ extend-exclude = [
4242
"site/src/pages/SetupPage/countries.tsx",
4343
"provisioner/terraform/testdata/**",
4444
# notifications' golden files confuse the detector because of quoted-printable encoding
45-
"coderd/notifications/testdata/**"
45+
"coderd/notifications/testdata/**",
46+
"agent/agentcontainers/testdata/devcontainercli/**"
4647
]

agent/agentcontainers/containers.go

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99

1010
"golang.org/x/xerrors"
1111

12+
"github.com/go-chi/chi/v5"
13+
1214
"github.com/coder/coder/v2/coderd/httpapi"
1315
"github.com/coder/coder/v2/codersdk"
1416
"github.com/coder/quartz"
@@ -20,9 +22,10 @@ const (
2022
getContainersTimeout = 5 * time.Second
2123
)
2224

23-
type devcontainersHandler struct {
25+
type Handler struct {
2426
cacheDuration time.Duration
2527
cl Lister
28+
dccli DevcontainerCLI
2629
clock quartz.Clock
2730

2831
// lockCh protects the below fields. We use a channel instead of a mutex so we
@@ -32,20 +35,26 @@ type devcontainersHandler struct {
3235
mtime time.Time
3336
}
3437

35-
// Option is a functional option for devcontainersHandler.
36-
type Option func(*devcontainersHandler)
38+
// Option is a functional option for Handler.
39+
type Option func(*Handler)
3740

3841
// WithLister sets the agentcontainers.Lister implementation to use.
3942
// The default implementation uses the Docker CLI to list containers.
4043
func WithLister(cl Lister) Option {
41-
return func(ch *devcontainersHandler) {
44+
return func(ch *Handler) {
4245
ch.cl = cl
4346
}
4447
}
4548

46-
// New returns a new devcontainersHandler with the given options applied.
47-
func New(options ...Option) http.Handler {
48-
ch := &devcontainersHandler{
49+
func WithDevcontainerCLI(dccli DevcontainerCLI) Option {
50+
return func(ch *Handler) {
51+
ch.dccli = dccli
52+
}
53+
}
54+
55+
// New returns a new Handler with the given options applied.
56+
func New(options ...Option) *Handler {
57+
ch := &Handler{
4958
lockCh: make(chan struct{}, 1),
5059
}
5160
for _, opt := range options {
@@ -54,7 +63,7 @@ func New(options ...Option) http.Handler {
5463
return ch
5564
}
5665

57-
func (ch *devcontainersHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
66+
func (ch *Handler) List(rw http.ResponseWriter, r *http.Request) {
5867
select {
5968
case <-r.Context().Done():
6069
// Client went away.
@@ -80,7 +89,7 @@ func (ch *devcontainersHandler) ServeHTTP(rw http.ResponseWriter, r *http.Reques
8089
}
8190
}
8291

83-
func (ch *devcontainersHandler) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
92+
func (ch *Handler) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
8493
select {
8594
case <-ctx.Done():
8695
return codersdk.WorkspaceAgentListContainersResponse{}, ctx.Err()
@@ -149,3 +158,61 @@ var _ Lister = NoopLister{}
149158
func (NoopLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
150159
return codersdk.WorkspaceAgentListContainersResponse{}, nil
151160
}
161+
162+
func (ch *Handler) Recreate(w http.ResponseWriter, r *http.Request) {
163+
ctx := r.Context()
164+
id := chi.URLParam(r, "id")
165+
166+
if id == "" {
167+
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
168+
Message: "Missing container ID or name",
169+
Detail: "Container ID or name is required to recreate a devcontainer.",
170+
})
171+
return
172+
}
173+
174+
containers, err := ch.cl.List(ctx)
175+
if err != nil {
176+
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
177+
Message: "Could not list containers",
178+
Detail: err.Error(),
179+
})
180+
return
181+
}
182+
183+
containerIdx := slices.IndexFunc(containers.Containers, func(c codersdk.WorkspaceAgentContainer) bool {
184+
return c.Match(id)
185+
})
186+
if containerIdx == -1 {
187+
httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{
188+
Message: "Container not found",
189+
Detail: "Container ID or name not found in the list of containers.",
190+
})
191+
return
192+
}
193+
194+
container := containers.Containers[containerIdx]
195+
workspaceFolder := container.Labels[DevcontainerLocalFolderLabel]
196+
configPath := container.Labels[DevcontainerConfigFileLabel]
197+
198+
// Workspace folder is required to recreate a container, we don't verify
199+
// the config path here because it's optional.
200+
if workspaceFolder == "" {
201+
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
202+
Message: "Missing workspace folder label",
203+
Detail: "The workspace folder label is required to recreate a devcontainer.",
204+
})
205+
return
206+
}
207+
208+
_, err = ch.dccli.Up(ctx, workspaceFolder, configPath, WithRemoveExistingContainer())
209+
if err != nil {
210+
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
211+
Message: "Could not recreate devcontainer",
212+
Detail: err.Error(),
213+
})
214+
return
215+
}
216+
217+
w.WriteHeader(http.StatusNoContent)
218+
}

agent/agentcontainers/containers_internal_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ func TestContainersHandler(t *testing.T) {
277277
ctrl = gomock.NewController(t)
278278
mockLister = acmock.NewMockLister(ctrl)
279279
now = time.Now().UTC()
280-
ch = devcontainersHandler{
280+
ch = Handler{
281281
cacheDuration: tc.cacheDur,
282282
cl: mockLister,
283283
clock: clk,
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package agentcontainers_test
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/go-chi/chi/v5"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
"golang.org/x/xerrors"
13+
14+
"github.com/coder/coder/v2/agent/agentcontainers"
15+
"github.com/coder/coder/v2/codersdk"
16+
)
17+
18+
// fakeLister implements the agentcontainers.Lister interface for
19+
// testing.
20+
type fakeLister struct {
21+
containers codersdk.WorkspaceAgentListContainersResponse
22+
err error
23+
}
24+
25+
func (f *fakeLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
26+
return f.containers, f.err
27+
}
28+
29+
// fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI
30+
// interface for testing.
31+
type fakeDevcontainerCLI struct {
32+
id string
33+
err error
34+
}
35+
36+
func (f *fakeDevcontainerCLI) Up(_ context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) {
37+
return f.id, f.err
38+
}
39+
40+
func TestHandler(t *testing.T) {
41+
t.Parallel()
42+
43+
t.Run("Recreate", func(t *testing.T) {
44+
t.Parallel()
45+
46+
validContainer := codersdk.WorkspaceAgentContainer{
47+
ID: "container-id",
48+
FriendlyName: "container-name",
49+
Labels: map[string]string{
50+
agentcontainers.DevcontainerLocalFolderLabel: "/workspace",
51+
agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json",
52+
},
53+
}
54+
55+
missingFolderContainer := codersdk.WorkspaceAgentContainer{
56+
ID: "missing-folder-container",
57+
FriendlyName: "missing-folder-container",
58+
Labels: map[string]string{},
59+
}
60+
61+
tests := []struct {
62+
name string
63+
containerID string
64+
lister *fakeLister
65+
devcontainerCLI *fakeDevcontainerCLI
66+
wantStatus int
67+
wantBody string
68+
}{
69+
{
70+
name: "Missing ID",
71+
containerID: "",
72+
lister: &fakeLister{},
73+
devcontainerCLI: &fakeDevcontainerCLI{},
74+
wantStatus: http.StatusBadRequest,
75+
wantBody: "Missing container ID or name",
76+
},
77+
{
78+
name: "List error",
79+
containerID: "container-id",
80+
lister: &fakeLister{
81+
err: xerrors.New("list error"),
82+
},
83+
devcontainerCLI: &fakeDevcontainerCLI{},
84+
wantStatus: http.StatusInternalServerError,
85+
wantBody: "Could not list containers",
86+
},
87+
{
88+
name: "Container not found",
89+
containerID: "nonexistent-container",
90+
lister: &fakeLister{
91+
containers: codersdk.WorkspaceAgentListContainersResponse{
92+
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
93+
},
94+
},
95+
devcontainerCLI: &fakeDevcontainerCLI{},
96+
wantStatus: http.StatusNotFound,
97+
wantBody: "Container not found",
98+
},
99+
{
100+
name: "Missing workspace folder label",
101+
containerID: "missing-folder-container",
102+
lister: &fakeLister{
103+
containers: codersdk.WorkspaceAgentListContainersResponse{
104+
Containers: []codersdk.WorkspaceAgentContainer{missingFolderContainer},
105+
},
106+
},
107+
devcontainerCLI: &fakeDevcontainerCLI{},
108+
wantStatus: http.StatusBadRequest,
109+
wantBody: "Missing workspace folder label",
110+
},
111+
{
112+
name: "Devcontainer CLI error",
113+
containerID: "container-id",
114+
lister: &fakeLister{
115+
containers: codersdk.WorkspaceAgentListContainersResponse{
116+
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
117+
},
118+
},
119+
devcontainerCLI: &fakeDevcontainerCLI{
120+
err: xerrors.New("devcontainer CLI error"),
121+
},
122+
wantStatus: http.StatusInternalServerError,
123+
wantBody: "Could not recreate devcontainer",
124+
},
125+
{
126+
name: "OK",
127+
containerID: "container-id",
128+
lister: &fakeLister{
129+
containers: codersdk.WorkspaceAgentListContainersResponse{
130+
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
131+
},
132+
},
133+
devcontainerCLI: &fakeDevcontainerCLI{},
134+
wantStatus: http.StatusNoContent,
135+
wantBody: "",
136+
},
137+
}
138+
139+
for _, tt := range tests {
140+
t.Run(tt.name, func(t *testing.T) {
141+
t.Parallel()
142+
143+
// Setup router with the handler under test.
144+
r := chi.NewRouter()
145+
handler := agentcontainers.New(
146+
agentcontainers.WithLister(tt.lister),
147+
agentcontainers.WithDevcontainerCLI(tt.devcontainerCLI),
148+
)
149+
r.Post("/containers/{id}/recreate", handler.Recreate)
150+
151+
// Simulate HTTP request to the recreate endpoint.
152+
req := httptest.NewRequest(http.MethodPost, "/containers/"+tt.containerID+"/recreate", nil)
153+
rec := httptest.NewRecorder()
154+
r.ServeHTTP(rec, req)
155+
156+
// Check the response status code and body.
157+
require.Equal(t, tt.wantStatus, rec.Code, "status code mismatch")
158+
if tt.wantBody != "" {
159+
assert.Contains(t, rec.Body.String(), tt.wantBody, "response body mismatch")
160+
} else if tt.wantStatus == http.StatusNoContent {
161+
assert.Empty(t, rec.Body.String(), "expected empty response body")
162+
}
163+
})
164+
}
165+
})
166+
}

agent/agentcontainers/devcontainer.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ import (
1212
"github.com/coder/coder/v2/codersdk"
1313
)
1414

15+
const (
16+
// DevcontainerLocalFolderLabel is the label that contains the path to
17+
// the local workspace folder for a devcontainer.
18+
DevcontainerLocalFolderLabel = "devcontainer.local_folder"
19+
// DevcontainerConfigFileLabel is the label that contains the path to
20+
// the devcontainer.json configuration file.
21+
DevcontainerConfigFileLabel = "devcontainer.config_file"
22+
)
23+
1524
const devcontainerUpScriptTemplate = `
1625
if ! which devcontainer > /dev/null 2>&1; then
1726
echo "ERROR: Unable to start devcontainer, @devcontainers/cli is not installed."
@@ -52,8 +61,10 @@ ScriptLoop:
5261
}
5362

5463
func devcontainerStartupScript(dc codersdk.WorkspaceAgentDevcontainer, script codersdk.WorkspaceAgentScript) codersdk.WorkspaceAgentScript {
55-
var args []string
56-
args = append(args, fmt.Sprintf("--workspace-folder %q", dc.WorkspaceFolder))
64+
args := []string{
65+
"--log-format json",
66+
fmt.Sprintf("--workspace-folder %q", dc.WorkspaceFolder),
67+
}
5768
if dc.ConfigPath != "" {
5869
args = append(args, fmt.Sprintf("--config %q", dc.ConfigPath))
5970
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy