Skip to content

feat(agent): implement recreate for devcontainers #17308

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Apr 10, 2025
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Generated files
agent/agentcontainers/acmock/acmock.go linguist-generated=true
agent/agentcontainers/dcspec/dcspec_gen.go linguist-generated=true
agent/agentcontainers/testdata/devcontainercli/*/*.log linguist-generated=true
coderd/apidoc/docs.go linguist-generated=true
docs/reference/api/*.md linguist-generated=true
docs/reference/cli/*.md linguist-generated=true
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,6 @@ extend-exclude = [
"site/src/pages/SetupPage/countries.tsx",
"provisioner/terraform/testdata/**",
# notifications' golden files confuse the detector because of quoted-printable encoding
"coderd/notifications/testdata/**"
"coderd/notifications/testdata/**",
"agent/agentcontainers/testdata/devcontainercli/**"
]
85 changes: 76 additions & 9 deletions agent/agentcontainers/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (

"golang.org/x/xerrors"

"github.com/go-chi/chi/v5"

"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/quartz"
Expand All @@ -20,9 +22,10 @@ const (
getContainersTimeout = 5 * time.Second
)

type devcontainersHandler struct {
type Handler struct {
cacheDuration time.Duration
cl Lister
dccli DevcontainerCLI
clock quartz.Clock

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

// Option is a functional option for devcontainersHandler.
type Option func(*devcontainersHandler)
// Option is a functional option for Handler.
type Option func(*Handler)

// WithLister sets the agentcontainers.Lister implementation to use.
// The default implementation uses the Docker CLI to list containers.
func WithLister(cl Lister) Option {
return func(ch *devcontainersHandler) {
return func(ch *Handler) {
ch.cl = cl
}
}

// New returns a new devcontainersHandler with the given options applied.
func New(options ...Option) http.Handler {
ch := &devcontainersHandler{
func WithDevcontainerCLI(dccli DevcontainerCLI) Option {
return func(ch *Handler) {
ch.dccli = dccli
}
}

// New returns a new Handler with the given options applied.
func New(options ...Option) *Handler {
ch := &Handler{
lockCh: make(chan struct{}, 1),
}
for _, opt := range options {
Expand All @@ -54,7 +63,7 @@ func New(options ...Option) http.Handler {
return ch
}

func (ch *devcontainersHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
func (ch *Handler) List(rw http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
// Client went away.
Expand All @@ -80,7 +89,7 @@ func (ch *devcontainersHandler) ServeHTTP(rw http.ResponseWriter, r *http.Reques
}
}

func (ch *devcontainersHandler) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
func (ch *Handler) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
select {
case <-ctx.Done():
return codersdk.WorkspaceAgentListContainersResponse{}, ctx.Err()
Expand Down Expand Up @@ -149,3 +158,61 @@ var _ Lister = NoopLister{}
func (NoopLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
return codersdk.WorkspaceAgentListContainersResponse{}, nil
}

func (ch *Handler) Recreate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := chi.URLParam(r, "id")

if id == "" {
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
Message: "Missing container ID or name",
Detail: "Container ID or name is required to recreate a devcontainer.",
})
return
}

containers, err := ch.cl.List(ctx)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential follow-up (non-blocking): I wonder if we should also add an Inspect() method to the Lister; listing the entire containers endpoint is probably wasteful.

if err != nil {
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
Message: "Could not list containers",
Detail: err.Error(),
})
return
}

containerIdx := slices.IndexFunc(containers.Containers, func(c codersdk.WorkspaceAgentContainer) bool {
return c.Match(id)
})
if containerIdx == -1 {
httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{
Message: "Container not found",
Detail: "Container ID or name not found in the list of containers.",
})
return
}

container := containers.Containers[containerIdx]
workspaceFolder := container.Labels[DevcontainerLocalFolderLabel]
configPath := container.Labels[DevcontainerConfigFileLabel]

// Workspace folder is required to recreate a container, we don't verify
// the config path here because it's optional.
if workspaceFolder == "" {
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
Message: "Missing workspace folder label",
Detail: "The workspace folder label is required to recreate a devcontainer.",
})
return
}

_, err = ch.dccli.Up(ctx, workspaceFolder, configPath, WithRemoveExistingContainer())
if err != nil {
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
Message: "Could not recreate devcontainer",
Detail: err.Error(),
})
return
}

w.WriteHeader(http.StatusNoContent)
}
2 changes: 1 addition & 1 deletion agent/agentcontainers/containers_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ func TestContainersHandler(t *testing.T) {
ctrl = gomock.NewController(t)
mockLister = acmock.NewMockLister(ctrl)
now = time.Now().UTC()
ch = devcontainersHandler{
ch = Handler{
cacheDuration: tc.cacheDur,
cl: mockLister,
clock: clk,
Expand Down
166 changes: 166 additions & 0 deletions agent/agentcontainers/containers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package agentcontainers_test

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"

"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/codersdk"
)

// fakeLister implements the agentcontainers.Lister interface for
// testing.
type fakeLister struct {
containers codersdk.WorkspaceAgentListContainersResponse
err error
}

func (f *fakeLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
return f.containers, f.err
}

// fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI
// interface for testing.
type fakeDevcontainerCLI struct {
id string
err error
}

func (f *fakeDevcontainerCLI) Up(_ context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) {
return f.id, f.err
}

func TestHandler(t *testing.T) {
t.Parallel()

t.Run("Recreate", func(t *testing.T) {
t.Parallel()

validContainer := codersdk.WorkspaceAgentContainer{
ID: "container-id",
FriendlyName: "container-name",
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: "/workspace",
agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json",
},
}

missingFolderContainer := codersdk.WorkspaceAgentContainer{
ID: "missing-folder-container",
FriendlyName: "missing-folder-container",
Labels: map[string]string{},
}

tests := []struct {
name string
containerID string
lister *fakeLister
devcontainerCLI *fakeDevcontainerCLI
wantStatus int
wantBody string
}{
{
name: "Missing ID",
containerID: "",
lister: &fakeLister{},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: http.StatusBadRequest,
wantBody: "Missing container ID or name",
},
{
name: "List error",
containerID: "container-id",
lister: &fakeLister{
err: xerrors.New("list error"),
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: http.StatusInternalServerError,
wantBody: "Could not list containers",
},
{
name: "Container not found",
containerID: "nonexistent-container",
lister: &fakeLister{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
},
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: http.StatusNotFound,
wantBody: "Container not found",
},
{
name: "Missing workspace folder label",
containerID: "missing-folder-container",
lister: &fakeLister{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{missingFolderContainer},
},
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: http.StatusBadRequest,
wantBody: "Missing workspace folder label",
},
{
name: "Devcontainer CLI error",
containerID: "container-id",
lister: &fakeLister{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
},
},
devcontainerCLI: &fakeDevcontainerCLI{
err: xerrors.New("devcontainer CLI error"),
},
wantStatus: http.StatusInternalServerError,
wantBody: "Could not recreate devcontainer",
},
{
name: "OK",
containerID: "container-id",
lister: &fakeLister{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
},
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: http.StatusNoContent,
wantBody: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

// Setup router with the handler under test.
r := chi.NewRouter()
handler := agentcontainers.New(
agentcontainers.WithLister(tt.lister),
agentcontainers.WithDevcontainerCLI(tt.devcontainerCLI),
)
r.Post("/containers/{id}/recreate", handler.Recreate)

// Simulate HTTP request to the recreate endpoint.
req := httptest.NewRequest(http.MethodPost, "/containers/"+tt.containerID+"/recreate", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)

// Check the response status code and body.
require.Equal(t, tt.wantStatus, rec.Code, "status code mismatch")
if tt.wantBody != "" {
assert.Contains(t, rec.Body.String(), tt.wantBody, "response body mismatch")
} else if tt.wantStatus == http.StatusNoContent {
assert.Empty(t, rec.Body.String(), "expected empty response body")
}
})
}
})
}
15 changes: 13 additions & 2 deletions agent/agentcontainers/devcontainer.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ import (
"github.com/coder/coder/v2/codersdk"
)

const (
// DevcontainerLocalFolderLabel is the label that contains the path to
// the local workspace folder for a devcontainer.
DevcontainerLocalFolderLabel = "devcontainer.local_folder"
// DevcontainerConfigFileLabel is the label that contains the path to
// the devcontainer.json configuration file.
DevcontainerConfigFileLabel = "devcontainer.config_file"
)

const devcontainerUpScriptTemplate = `
if ! which devcontainer > /dev/null 2>&1; then
echo "ERROR: Unable to start devcontainer, @devcontainers/cli is not installed."
Expand Down Expand Up @@ -52,8 +61,10 @@ ScriptLoop:
}

func devcontainerStartupScript(dc codersdk.WorkspaceAgentDevcontainer, script codersdk.WorkspaceAgentScript) codersdk.WorkspaceAgentScript {
var args []string
args = append(args, fmt.Sprintf("--workspace-folder %q", dc.WorkspaceFolder))
args := []string{
"--log-format json",
fmt.Sprintf("--workspace-folder %q", dc.WorkspaceFolder),
}
if dc.ConfigPath != "" {
args = append(args, fmt.Sprintf("--config %q", dc.ConfigPath))
}
Expand Down
Loading
Loading