Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
mafredri merged 13 commits intomainfrommafredri/feat-agent-recreate-devcontainer
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes fromall commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions.gitattributes
View file
Open in desktop
Original file line numberDiff line numberDiff 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
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionsagent/agentcontainers/containers.go
View file
Open in desktop
Original file line numberDiff line numberDiff 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
)

typedevcontainersHandler struct {
typeHandler 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 fordevcontainersHandler.
type Option func(*devcontainersHandler)
// Option is a functional option forHandler.
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 DownExpand 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 anInspect() method to theLister; listing the entire containers endpoint is probably wasteful.

mafredri reacted with thumbs up emoji
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 deletionagent/agentcontainers/containers_internal_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionsagent/agentcontainers/containers_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionsagent/agentcontainers/devcontainer.go
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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

[8]ページ先頭

©2009-2025 Movatter.jp