mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
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
This commit is contained in:
committed by
GitHub
parent
6dd1056025
commit
25fb34cabe
1
.gitattributes
vendored
1
.gitattributes
vendored
@ -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
|
||||
|
3
.github/workflows/typos.toml
vendored
3
.github/workflows/typos.toml
vendored
@ -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/**"
|
||||
]
|
||||
|
@ -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"
|
||||
@ -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
|
||||
@ -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 {
|
||||
@ -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.
|
||||
@ -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()
|
||||
@ -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)
|
||||
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)
|
||||
}
|
||||
|
@ -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,
|
||||
|
166
agent/agentcontainers/containers_test.go
Normal file
166
agent/agentcontainers/containers_test.go
Normal file
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
@ -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."
|
||||
@ -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))
|
||||
}
|
||||
|
@ -101,12 +101,12 @@ func TestExtractAndInitializeDevcontainerScripts(t *testing.T) {
|
||||
wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
Script: "devcontainer up --workspace-folder \"workspace1\"",
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"workspace1\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
Script: "devcontainer up --workspace-folder \"workspace2\"",
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"workspace2\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
},
|
||||
@ -136,12 +136,12 @@ func TestExtractAndInitializeDevcontainerScripts(t *testing.T) {
|
||||
wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
Script: "devcontainer up --workspace-folder \"workspace1\" --config \"workspace1/config1\"",
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"workspace1\" --config \"workspace1/config1\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
Script: "devcontainer up --workspace-folder \"workspace2\" --config \"workspace2/config2\"",
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"workspace2\" --config \"workspace2/config2\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
},
|
||||
@ -174,12 +174,12 @@ func TestExtractAndInitializeDevcontainerScripts(t *testing.T) {
|
||||
wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
Script: "devcontainer up --workspace-folder \"/home/workspace1\" --config \"/home/workspace1/config1\"",
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"/home/workspace1\" --config \"/home/workspace1/config1\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
Script: "devcontainer up --workspace-folder \"/home/workspace2\" --config \"/home/workspace2/config2\"",
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"/home/workspace2\" --config \"/home/workspace2/config2\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
},
|
||||
@ -216,12 +216,12 @@ func TestExtractAndInitializeDevcontainerScripts(t *testing.T) {
|
||||
wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
Script: "devcontainer up --workspace-folder \"/home/workspace1\" --config \"/home/config1\"",
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"/home/workspace1\" --config \"/home/config1\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
Script: "devcontainer up --workspace-folder \"/home/workspace2\" --config \"/config2\"",
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"/home/workspace2\" --config \"/config2\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
},
|
||||
|
193
agent/agentcontainers/devcontainercli.go
Normal file
193
agent/agentcontainers/devcontainercli.go
Normal file
@ -0,0 +1,193 @@
|
||||
package agentcontainers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
)
|
||||
|
||||
// DevcontainerCLI is an interface for the devcontainer CLI.
|
||||
type DevcontainerCLI interface {
|
||||
Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (id string, err error)
|
||||
}
|
||||
|
||||
// DevcontainerCLIUpOptions are options for the devcontainer CLI up
|
||||
// command.
|
||||
type DevcontainerCLIUpOptions func(*devcontainerCLIUpConfig)
|
||||
|
||||
// WithRemoveExistingContainer is an option to remove the existing
|
||||
// container.
|
||||
func WithRemoveExistingContainer() DevcontainerCLIUpOptions {
|
||||
return func(o *devcontainerCLIUpConfig) {
|
||||
o.removeExistingContainer = true
|
||||
}
|
||||
}
|
||||
|
||||
type devcontainerCLIUpConfig struct {
|
||||
removeExistingContainer bool
|
||||
}
|
||||
|
||||
func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig {
|
||||
conf := devcontainerCLIUpConfig{
|
||||
removeExistingContainer: false,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(&conf)
|
||||
}
|
||||
}
|
||||
return conf
|
||||
}
|
||||
|
||||
type devcontainerCLI struct {
|
||||
logger slog.Logger
|
||||
execer agentexec.Execer
|
||||
}
|
||||
|
||||
var _ DevcontainerCLI = &devcontainerCLI{}
|
||||
|
||||
func NewDevcontainerCLI(logger slog.Logger, execer agentexec.Execer) DevcontainerCLI {
|
||||
return &devcontainerCLI{
|
||||
execer: execer,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (string, error) {
|
||||
conf := applyDevcontainerCLIUpOptions(opts)
|
||||
logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath), slog.F("recreate", conf.removeExistingContainer))
|
||||
|
||||
args := []string{
|
||||
"up",
|
||||
"--log-format", "json",
|
||||
"--workspace-folder", workspaceFolder,
|
||||
}
|
||||
if configPath != "" {
|
||||
args = append(args, "--config", configPath)
|
||||
}
|
||||
if conf.removeExistingContainer {
|
||||
args = append(args, "--remove-existing-container")
|
||||
}
|
||||
cmd := d.execer.CommandContext(ctx, "devcontainer", args...)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = io.MultiWriter(&stdout, &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))})
|
||||
cmd.Stderr = &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stderr", true))}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if _, err2 := parseDevcontainerCLILastLine(ctx, logger, stdout.Bytes()); err2 != nil {
|
||||
err = errors.Join(err, err2)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
result, err := parseDevcontainerCLILastLine(ctx, logger, stdout.Bytes())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result.ContainerID, nil
|
||||
}
|
||||
|
||||
// parseDevcontainerCLILastLine parses the last line of the devcontainer CLI output
|
||||
// which is a JSON object.
|
||||
func parseDevcontainerCLILastLine(ctx context.Context, logger slog.Logger, p []byte) (result devcontainerCLIResult, err error) {
|
||||
s := bufio.NewScanner(bytes.NewReader(p))
|
||||
var lastLine []byte
|
||||
for s.Scan() {
|
||||
b := s.Bytes()
|
||||
if len(b) == 0 || b[0] != '{' {
|
||||
continue
|
||||
}
|
||||
lastLine = b
|
||||
}
|
||||
if err = s.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if len(lastLine) == 0 || lastLine[0] != '{' {
|
||||
logger.Error(ctx, "devcontainer result is not json", slog.F("result", string(lastLine)))
|
||||
return result, xerrors.Errorf("devcontainer result is not json: %q", string(lastLine))
|
||||
}
|
||||
if err = json.Unmarshal(lastLine, &result); err != nil {
|
||||
logger.Error(ctx, "parse devcontainer result failed", slog.Error(err), slog.F("result", string(lastLine)))
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, result.Err()
|
||||
}
|
||||
|
||||
// devcontainerCLIResult is the result of the devcontainer CLI command.
|
||||
// It is parsed from the last line of the devcontainer CLI stdout which
|
||||
// is a JSON object.
|
||||
type devcontainerCLIResult struct {
|
||||
Outcome string `json:"outcome"` // "error", "success".
|
||||
|
||||
// The following fields are set if outcome is success.
|
||||
ContainerID string `json:"containerId"`
|
||||
RemoteUser string `json:"remoteUser"`
|
||||
RemoteWorkspaceFolder string `json:"remoteWorkspaceFolder"`
|
||||
|
||||
// The following fields are set if outcome is error.
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (r devcontainerCLIResult) Err() error {
|
||||
if r.Outcome == "success" {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("devcontainer up failed: %s (description: %s, message: %s)", r.Outcome, r.Description, r.Message)
|
||||
}
|
||||
|
||||
// devcontainerCLIJSONLogLine is a log line from the devcontainer CLI.
|
||||
type devcontainerCLIJSONLogLine struct {
|
||||
Type string `json:"type"` // "progress", "raw", "start", "stop", "text", etc.
|
||||
Level int `json:"level"` // 1, 2, 3.
|
||||
Timestamp int `json:"timestamp"` // Unix timestamp in milliseconds.
|
||||
Text string `json:"text"`
|
||||
|
||||
// More fields can be added here as needed.
|
||||
}
|
||||
|
||||
// devcontainerCLILogWriter splits on newlines and logs each line
|
||||
// separately.
|
||||
type devcontainerCLILogWriter struct {
|
||||
ctx context.Context
|
||||
logger slog.Logger
|
||||
}
|
||||
|
||||
func (l *devcontainerCLILogWriter) Write(p []byte) (n int, err error) {
|
||||
s := bufio.NewScanner(bytes.NewReader(p))
|
||||
for s.Scan() {
|
||||
line := s.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
if line[0] != '{' {
|
||||
l.logger.Debug(l.ctx, "@devcontainer/cli", slog.F("line", string(line)))
|
||||
continue
|
||||
}
|
||||
var logLine devcontainerCLIJSONLogLine
|
||||
if err := json.Unmarshal(line, &logLine); err != nil {
|
||||
l.logger.Error(l.ctx, "parse devcontainer json log line failed", slog.Error(err), slog.F("line", string(line)))
|
||||
continue
|
||||
}
|
||||
if logLine.Level >= 3 {
|
||||
l.logger.Info(l.ctx, "@devcontainer/cli", slog.F("line", string(line)))
|
||||
continue
|
||||
}
|
||||
l.logger.Debug(l.ctx, "@devcontainer/cli", slog.F("line", string(line)))
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
l.logger.Error(l.ctx, "devcontainer log line scan failed", slog.Error(err))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
351
agent/agentcontainers/devcontainercli_test.go
Normal file
351
agent/agentcontainers/devcontainercli_test.go
Normal file
@ -0,0 +1,351 @@
|
||||
package agentcontainers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/dockertest/v3"
|
||||
"github.com/ory/dockertest/v3/docker"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/pty"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testExePath, err := os.Executable()
|
||||
require.NoError(t, err, "get test executable path")
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
|
||||
t.Run("Up", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
logFile string
|
||||
workspace string
|
||||
config string
|
||||
opts []agentcontainers.DevcontainerCLIUpOptions
|
||||
wantArgs string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
logFile: "up.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "success with config",
|
||||
logFile: "up.log",
|
||||
workspace: "/test/workspace",
|
||||
config: "/test/config.json",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace --config /test/config.json",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "already exists",
|
||||
logFile: "up-already-exists.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "docker error",
|
||||
logFile: "up-error-docker.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "bad outcome",
|
||||
logFile: "up-error-bad-outcome.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "does not exist",
|
||||
logFile: "up-error-does-not-exist.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "with remove existing container",
|
||||
logFile: "up.log",
|
||||
workspace: "/test/workspace",
|
||||
opts: []agentcontainers.DevcontainerCLIUpOptions{
|
||||
agentcontainers.WithRemoveExistingContainer(),
|
||||
},
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace --remove-existing-container",
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
testExecer := &testDevcontainerExecer{
|
||||
testExePath: testExePath,
|
||||
wantArgs: tt.wantArgs,
|
||||
wantError: tt.wantError,
|
||||
logFile: filepath.Join("testdata", "devcontainercli", "parse", tt.logFile),
|
||||
}
|
||||
|
||||
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
|
||||
containerID, err := dccli.Up(ctx, tt.workspace, tt.config, tt.opts...)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err, "want error")
|
||||
assert.Empty(t, containerID, "expected empty container ID")
|
||||
} else {
|
||||
assert.NoError(t, err, "want no error")
|
||||
assert.NotEmpty(t, containerID, "expected non-empty container ID")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// testDevcontainerExecer implements the agentexec.Execer interface for testing.
|
||||
type testDevcontainerExecer struct {
|
||||
testExePath string
|
||||
wantArgs string
|
||||
wantError bool
|
||||
logFile string
|
||||
}
|
||||
|
||||
// CommandContext returns a test binary command that simulates devcontainer responses.
|
||||
func (e *testDevcontainerExecer) CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||||
// Only handle "devcontainer" commands.
|
||||
if name != "devcontainer" {
|
||||
// For non-devcontainer commands, use a standard execer.
|
||||
return agentexec.DefaultExecer.CommandContext(ctx, name, args...)
|
||||
}
|
||||
|
||||
// Create a command that runs the test binary with special flags
|
||||
// that tell it to simulate a devcontainer command.
|
||||
testArgs := []string{
|
||||
"-test.run=TestDevcontainerHelperProcess",
|
||||
"--",
|
||||
name,
|
||||
}
|
||||
testArgs = append(testArgs, args...)
|
||||
|
||||
//nolint:gosec // This is a test binary, so we don't need to worry about command injection.
|
||||
cmd := exec.CommandContext(ctx, e.testExePath, testArgs...)
|
||||
// Set this environment variable so the child process knows it's the helper.
|
||||
cmd.Env = append(os.Environ(),
|
||||
"TEST_DEVCONTAINER_WANT_HELPER_PROCESS=1",
|
||||
"TEST_DEVCONTAINER_WANT_ARGS="+e.wantArgs,
|
||||
"TEST_DEVCONTAINER_WANT_ERROR="+fmt.Sprintf("%v", e.wantError),
|
||||
"TEST_DEVCONTAINER_LOG_FILE="+e.logFile,
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// PTYCommandContext returns a PTY command.
|
||||
func (*testDevcontainerExecer) PTYCommandContext(_ context.Context, name string, args ...string) *pty.Cmd {
|
||||
// This method shouldn't be called for our devcontainer tests.
|
||||
panic("PTYCommandContext not expected in devcontainer tests")
|
||||
}
|
||||
|
||||
// This is a special test helper that is executed as a subprocess.
|
||||
// It simulates the behavior of the devcontainer CLI.
|
||||
//
|
||||
//nolint:revive,paralleltest // This is a test helper function.
|
||||
func TestDevcontainerHelperProcess(t *testing.T) {
|
||||
// If not called by the test as a helper process, do nothing.
|
||||
if os.Getenv("TEST_DEVCONTAINER_WANT_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
|
||||
helperArgs := flag.Args()
|
||||
if len(helperArgs) < 1 {
|
||||
fmt.Fprintf(os.Stderr, "No command\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if helperArgs[0] != "devcontainer" {
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", helperArgs[0])
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Verify arguments against expected arguments and skip
|
||||
// "devcontainer", it's not included in the input args.
|
||||
wantArgs := os.Getenv("TEST_DEVCONTAINER_WANT_ARGS")
|
||||
gotArgs := strings.Join(helperArgs[1:], " ")
|
||||
if gotArgs != wantArgs {
|
||||
fmt.Fprintf(os.Stderr, "Arguments don't match.\nWant: %q\nGot: %q\n",
|
||||
wantArgs, gotArgs)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
logFilePath := os.Getenv("TEST_DEVCONTAINER_LOG_FILE")
|
||||
output, err := os.ReadFile(logFilePath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Reading log file %s failed: %v\n", logFilePath, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
_, _ = io.Copy(os.Stdout, bytes.NewReader(output))
|
||||
if os.Getenv("TEST_DEVCONTAINER_WANT_ERROR") == "true" {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// TestDockerDevcontainerCLI tests the DevcontainerCLI component with real Docker containers.
|
||||
// This test verifies that containers can be created and recreated using the actual
|
||||
// devcontainer CLI and Docker. It is skipped by default and can be run with:
|
||||
//
|
||||
// CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestDockerDevcontainerCLI
|
||||
//
|
||||
// The test requires Docker to be installed and running.
|
||||
func TestDockerDevcontainerCLI(t *testing.T) {
|
||||
t.Parallel()
|
||||
if os.Getenv("CODER_TEST_USE_DOCKER") != "1" {
|
||||
t.Skip("skipping Docker test; set CODER_TEST_USE_DOCKER=1 to run")
|
||||
}
|
||||
|
||||
// Connect to Docker.
|
||||
pool, err := dockertest.NewPool("")
|
||||
require.NoError(t, err, "connect to Docker")
|
||||
|
||||
t.Run("ContainerLifecycle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Set up workspace directory with a devcontainer configuration.
|
||||
workspaceFolder := t.TempDir()
|
||||
configPath := setupDevcontainerWorkspace(t, workspaceFolder)
|
||||
|
||||
// Use a long timeout because container operations are slow.
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
|
||||
// Create the devcontainer CLI under test.
|
||||
dccli := agentcontainers.NewDevcontainerCLI(logger, agentexec.DefaultExecer)
|
||||
|
||||
// Create a container.
|
||||
firstID, err := dccli.Up(ctx, workspaceFolder, configPath)
|
||||
require.NoError(t, err, "create container")
|
||||
require.NotEmpty(t, firstID, "container ID should not be empty")
|
||||
defer removeDevcontainerByID(t, pool, firstID)
|
||||
|
||||
// Verify container exists.
|
||||
firstContainer, found := findDevcontainerByID(t, pool, firstID)
|
||||
require.True(t, found, "container should exist")
|
||||
|
||||
// Remember the container creation time.
|
||||
firstCreated := firstContainer.Created
|
||||
|
||||
// Recreate the container.
|
||||
secondID, err := dccli.Up(ctx, workspaceFolder, configPath, agentcontainers.WithRemoveExistingContainer())
|
||||
require.NoError(t, err, "recreate container")
|
||||
require.NotEmpty(t, secondID, "recreated container ID should not be empty")
|
||||
defer removeDevcontainerByID(t, pool, secondID)
|
||||
|
||||
// Verify the new container exists and is different.
|
||||
secondContainer, found := findDevcontainerByID(t, pool, secondID)
|
||||
require.True(t, found, "recreated container should exist")
|
||||
|
||||
// Verify it's a different container by checking creation time.
|
||||
secondCreated := secondContainer.Created
|
||||
assert.NotEqual(t, firstCreated, secondCreated, "recreated container should have different creation time")
|
||||
|
||||
// Verify the first container is removed by the recreation.
|
||||
_, found = findDevcontainerByID(t, pool, firstID)
|
||||
assert.False(t, found, "first container should be removed")
|
||||
})
|
||||
}
|
||||
|
||||
// setupDevcontainerWorkspace prepares a test environment with a minimal
|
||||
// devcontainer.json configuration and returns the path to the config file.
|
||||
func setupDevcontainerWorkspace(t *testing.T, workspaceFolder string) string {
|
||||
t.Helper()
|
||||
|
||||
// Create the devcontainer directory structure.
|
||||
devcontainerDir := filepath.Join(workspaceFolder, ".devcontainer")
|
||||
err := os.MkdirAll(devcontainerDir, 0o755)
|
||||
require.NoError(t, err, "create .devcontainer directory")
|
||||
|
||||
// Write a minimal configuration with test labels for identification.
|
||||
configPath := filepath.Join(devcontainerDir, "devcontainer.json")
|
||||
content := `{
|
||||
"image": "alpine:latest",
|
||||
"containerEnv": {
|
||||
"TEST_CONTAINER": "true"
|
||||
},
|
||||
"runArgs": ["--label", "com.coder.test=devcontainercli"]
|
||||
}`
|
||||
err = os.WriteFile(configPath, []byte(content), 0o600)
|
||||
require.NoError(t, err, "create devcontainer.json file")
|
||||
|
||||
return configPath
|
||||
}
|
||||
|
||||
// findDevcontainerByID locates a container by its ID and verifies it has our
|
||||
// test label. Returns the container and whether it was found.
|
||||
func findDevcontainerByID(t *testing.T, pool *dockertest.Pool, id string) (*docker.Container, bool) {
|
||||
t.Helper()
|
||||
|
||||
container, err := pool.Client.InspectContainer(id)
|
||||
if err != nil {
|
||||
t.Logf("Inspect container failed: %v", err)
|
||||
return nil, false
|
||||
}
|
||||
require.Equal(t, "devcontainercli", container.Config.Labels["com.coder.test"], "sanity check failed: container should have the test label")
|
||||
|
||||
return container, true
|
||||
}
|
||||
|
||||
// removeDevcontainerByID safely cleans up a test container by ID, verifying
|
||||
// it has our test label before removal to prevent accidental deletion.
|
||||
func removeDevcontainerByID(t *testing.T, pool *dockertest.Pool, id string) {
|
||||
t.Helper()
|
||||
|
||||
errNoSuchContainer := &docker.NoSuchContainer{}
|
||||
|
||||
// Check if the container has the expected label.
|
||||
container, err := pool.Client.InspectContainer(id)
|
||||
if err != nil {
|
||||
if errors.As(err, &errNoSuchContainer) {
|
||||
t.Logf("Container %s not found, skipping removal", id)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err, "inspect container")
|
||||
}
|
||||
require.Equal(t, "devcontainercli", container.Config.Labels["com.coder.test"], "sanity check failed: container should have the test label")
|
||||
|
||||
t.Logf("Removing container with ID: %s", id)
|
||||
err = pool.Client.RemoveContainer(docker.RemoveContainerOptions{
|
||||
ID: container.ID,
|
||||
Force: true,
|
||||
RemoveVolumes: true,
|
||||
})
|
||||
if err != nil && !errors.As(err, &errNoSuchContainer) {
|
||||
assert.NoError(t, err, "remove container failed")
|
||||
}
|
||||
}
|
68
agent/agentcontainers/testdata/devcontainercli/parse/up-already-exists.log
generated
vendored
Normal file
68
agent/agentcontainers/testdata/devcontainercli/parse/up-already-exists.log
generated
vendored
Normal file
@ -0,0 +1,68 @@
|
||||
{"type":"text","level":3,"timestamp":1744102135254,"text":"@devcontainers/cli 0.75.0. Node.js v23.9.0. darwin 24.4.0 arm64."}
|
||||
{"type":"start","level":2,"timestamp":1744102135254,"text":"Run: docker buildx version"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135300,"text":"Run: docker buildx version","startTimestamp":1744102135254}
|
||||
{"type":"text","level":2,"timestamp":1744102135300,"text":"github.com/docker/buildx v0.21.2 1360a9e8d25a2c3d03c2776d53ae62e6ff0a843d\r\n"}
|
||||
{"type":"text","level":2,"timestamp":1744102135300,"text":"\u001b[1m\u001b[31m\u001b[39m\u001b[22m\r\n"}
|
||||
{"type":"start","level":2,"timestamp":1744102135300,"text":"Run: docker -v"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135309,"text":"Run: docker -v","startTimestamp":1744102135300}
|
||||
{"type":"start","level":2,"timestamp":1744102135309,"text":"Resolving Remote"}
|
||||
{"type":"start","level":2,"timestamp":1744102135311,"text":"Run: git rev-parse --show-cdup"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135316,"text":"Run: git rev-parse --show-cdup","startTimestamp":1744102135311}
|
||||
{"type":"start","level":2,"timestamp":1744102135316,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135333,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json","startTimestamp":1744102135316}
|
||||
{"type":"start","level":2,"timestamp":1744102135333,"text":"Run: docker inspect --type container 4f22413fe134"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135347,"text":"Run: docker inspect --type container 4f22413fe134","startTimestamp":1744102135333}
|
||||
{"type":"start","level":2,"timestamp":1744102135348,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135364,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json","startTimestamp":1744102135348}
|
||||
{"type":"start","level":2,"timestamp":1744102135364,"text":"Run: docker inspect --type container 4f22413fe134"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135378,"text":"Run: docker inspect --type container 4f22413fe134","startTimestamp":1744102135364}
|
||||
{"type":"start","level":2,"timestamp":1744102135379,"text":"Inspecting container"}
|
||||
{"type":"start","level":2,"timestamp":1744102135379,"text":"Run: docker inspect --type container 4f22413fe13472200500a66ca057df5aafba6b45743afd499c3f26fc886eb236"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135393,"text":"Run: docker inspect --type container 4f22413fe13472200500a66ca057df5aafba6b45743afd499c3f26fc886eb236","startTimestamp":1744102135379}
|
||||
{"type":"stop","level":2,"timestamp":1744102135393,"text":"Inspecting container","startTimestamp":1744102135379}
|
||||
{"type":"start","level":2,"timestamp":1744102135393,"text":"Run in container: /bin/sh"}
|
||||
{"type":"start","level":2,"timestamp":1744102135394,"text":"Run in container: uname -m"}
|
||||
{"type":"text","level":2,"timestamp":1744102135428,"text":"aarch64\n"}
|
||||
{"type":"text","level":2,"timestamp":1744102135428,"text":""}
|
||||
{"type":"stop","level":2,"timestamp":1744102135428,"text":"Run in container: uname -m","startTimestamp":1744102135394}
|
||||
{"type":"start","level":2,"timestamp":1744102135428,"text":"Run in container: (cat /etc/os-release || cat /usr/lib/os-release) 2>/dev/null"}
|
||||
{"type":"text","level":2,"timestamp":1744102135428,"text":"PRETTY_NAME=\"Debian GNU/Linux 11 (bullseye)\"\nNAME=\"Debian GNU/Linux\"\nVERSION_ID=\"11\"\nVERSION=\"11 (bullseye)\"\nVERSION_CODENAME=bullseye\nID=debian\nHOME_URL=\"https://www.debian.org/\"\nSUPPORT_URL=\"https://www.debian.org/support\"\nBUG_REPORT_URL=\"https://bugs.debian.org/\"\n"}
|
||||
{"type":"text","level":2,"timestamp":1744102135428,"text":""}
|
||||
{"type":"stop","level":2,"timestamp":1744102135428,"text":"Run in container: (cat /etc/os-release || cat /usr/lib/os-release) 2>/dev/null","startTimestamp":1744102135428}
|
||||
{"type":"start","level":2,"timestamp":1744102135429,"text":"Run in container: (command -v getent >/dev/null 2>&1 && getent passwd 'node' || grep -E '^node|^[^:]*:[^:]*:node:' /etc/passwd || true)"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135429,"text":"Run in container: (command -v getent >/dev/null 2>&1 && getent passwd 'node' || grep -E '^node|^[^:]*:[^:]*:node:' /etc/passwd || true)","startTimestamp":1744102135429}
|
||||
{"type":"start","level":2,"timestamp":1744102135430,"text":"Run in container: test -f '/var/devcontainer/.patchEtcEnvironmentMarker'"}
|
||||
{"type":"text","level":2,"timestamp":1744102135430,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135430,"text":""}
|
||||
{"type":"stop","level":2,"timestamp":1744102135430,"text":"Run in container: test -f '/var/devcontainer/.patchEtcEnvironmentMarker'","startTimestamp":1744102135430}
|
||||
{"type":"start","level":2,"timestamp":1744102135430,"text":"Run in container: test -f '/var/devcontainer/.patchEtcProfileMarker'"}
|
||||
{"type":"text","level":2,"timestamp":1744102135430,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135430,"text":""}
|
||||
{"type":"stop","level":2,"timestamp":1744102135430,"text":"Run in container: test -f '/var/devcontainer/.patchEtcProfileMarker'","startTimestamp":1744102135430}
|
||||
{"type":"text","level":2,"timestamp":1744102135431,"text":"userEnvProbe: loginInteractiveShell (default)"}
|
||||
{"type":"text","level":1,"timestamp":1744102135431,"text":"LifecycleCommandExecutionMap: {\n \"onCreateCommand\": [],\n \"updateContentCommand\": [],\n \"postCreateCommand\": [\n {\n \"origin\": \"devcontainer.json\",\n \"command\": \"npm install -g @devcontainers/cli\"\n }\n ],\n \"postStartCommand\": [],\n \"postAttachCommand\": [],\n \"initializeCommand\": []\n}"}
|
||||
{"type":"text","level":2,"timestamp":1744102135431,"text":"userEnvProbe: not found in cache"}
|
||||
{"type":"text","level":2,"timestamp":1744102135431,"text":"userEnvProbe shell: /bin/bash"}
|
||||
{"type":"start","level":2,"timestamp":1744102135431,"text":"Run in container: /bin/bash -lic echo -n 5805f204-cd2b-4911-8a88-96de28d5deb7; cat /proc/self/environ; echo -n 5805f204-cd2b-4911-8a88-96de28d5deb7"}
|
||||
{"type":"start","level":2,"timestamp":1744102135431,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.onCreateCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-07T09:21:41.201379807Z}\" != '2025-04-07T09:21:41.201379807Z' ] && echo '2025-04-07T09:21:41.201379807Z' > '/home/node/.devcontainer/.onCreateCommandMarker'"}
|
||||
{"type":"text","level":2,"timestamp":1744102135432,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135432,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135432,"text":"Exit code 1"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135432,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.onCreateCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-07T09:21:41.201379807Z}\" != '2025-04-07T09:21:41.201379807Z' ] && echo '2025-04-07T09:21:41.201379807Z' > '/home/node/.devcontainer/.onCreateCommandMarker'","startTimestamp":1744102135431}
|
||||
{"type":"start","level":2,"timestamp":1744102135432,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.updateContentCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-07T09:21:41.201379807Z}\" != '2025-04-07T09:21:41.201379807Z' ] && echo '2025-04-07T09:21:41.201379807Z' > '/home/node/.devcontainer/.updateContentCommandMarker'"}
|
||||
{"type":"text","level":2,"timestamp":1744102135434,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135434,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135434,"text":"Exit code 1"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135434,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.updateContentCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-07T09:21:41.201379807Z}\" != '2025-04-07T09:21:41.201379807Z' ] && echo '2025-04-07T09:21:41.201379807Z' > '/home/node/.devcontainer/.updateContentCommandMarker'","startTimestamp":1744102135432}
|
||||
{"type":"start","level":2,"timestamp":1744102135434,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.postCreateCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-07T09:21:41.201379807Z}\" != '2025-04-07T09:21:41.201379807Z' ] && echo '2025-04-07T09:21:41.201379807Z' > '/home/node/.devcontainer/.postCreateCommandMarker'"}
|
||||
{"type":"text","level":2,"timestamp":1744102135435,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135435,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135435,"text":"Exit code 1"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135435,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.postCreateCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-07T09:21:41.201379807Z}\" != '2025-04-07T09:21:41.201379807Z' ] && echo '2025-04-07T09:21:41.201379807Z' > '/home/node/.devcontainer/.postCreateCommandMarker'","startTimestamp":1744102135434}
|
||||
{"type":"start","level":2,"timestamp":1744102135435,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.postStartCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-08T08:48:29.406495039Z}\" != '2025-04-08T08:48:29.406495039Z' ] && echo '2025-04-08T08:48:29.406495039Z' > '/home/node/.devcontainer/.postStartCommandMarker'"}
|
||||
{"type":"text","level":2,"timestamp":1744102135436,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135436,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135436,"text":"Exit code 1"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135436,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.postStartCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-08T08:48:29.406495039Z}\" != '2025-04-08T08:48:29.406495039Z' ] && echo '2025-04-08T08:48:29.406495039Z' > '/home/node/.devcontainer/.postStartCommandMarker'","startTimestamp":1744102135435}
|
||||
{"type":"stop","level":2,"timestamp":1744102135436,"text":"Resolving Remote","startTimestamp":1744102135309}
|
||||
{"outcome":"success","containerId":"4f22413fe13472200500a66ca057df5aafba6b45743afd499c3f26fc886eb236","remoteUser":"node","remoteWorkspaceFolder":"/workspaces/devcontainers-template-starter"}
|
1
agent/agentcontainers/testdata/devcontainercli/parse/up-error-bad-outcome.log
generated
vendored
Normal file
1
agent/agentcontainers/testdata/devcontainercli/parse/up-error-bad-outcome.log
generated
vendored
Normal file
@ -0,0 +1 @@
|
||||
bad outcome
|
13
agent/agentcontainers/testdata/devcontainercli/parse/up-error-docker.log
generated
vendored
Normal file
13
agent/agentcontainers/testdata/devcontainercli/parse/up-error-docker.log
generated
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
{"type":"text","level":3,"timestamp":1744102042893,"text":"@devcontainers/cli 0.75.0. Node.js v23.9.0. darwin 24.4.0 arm64."}
|
||||
{"type":"start","level":2,"timestamp":1744102042893,"text":"Run: docker buildx version"}
|
||||
{"type":"stop","level":2,"timestamp":1744102042941,"text":"Run: docker buildx version","startTimestamp":1744102042893}
|
||||
{"type":"text","level":2,"timestamp":1744102042941,"text":"github.com/docker/buildx v0.21.2 1360a9e8d25a2c3d03c2776d53ae62e6ff0a843d\r\n"}
|
||||
{"type":"text","level":2,"timestamp":1744102042941,"text":"\u001b[1m\u001b[31m\u001b[39m\u001b[22m\r\n"}
|
||||
{"type":"start","level":2,"timestamp":1744102042941,"text":"Run: docker -v"}
|
||||
{"type":"stop","level":2,"timestamp":1744102042950,"text":"Run: docker -v","startTimestamp":1744102042941}
|
||||
{"type":"start","level":2,"timestamp":1744102042950,"text":"Resolving Remote"}
|
||||
{"type":"start","level":2,"timestamp":1744102042952,"text":"Run: git rev-parse --show-cdup"}
|
||||
{"type":"stop","level":2,"timestamp":1744102042957,"text":"Run: git rev-parse --show-cdup","startTimestamp":1744102042952}
|
||||
{"type":"start","level":2,"timestamp":1744102042957,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json"}
|
||||
{"type":"stop","level":2,"timestamp":1744102042967,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json","startTimestamp":1744102042957}
|
||||
{"outcome":"error","message":"Command failed: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json","description":"An error occurred setting up the container."}
|
15
agent/agentcontainers/testdata/devcontainercli/parse/up-error-does-not-exist.log
generated
vendored
Normal file
15
agent/agentcontainers/testdata/devcontainercli/parse/up-error-does-not-exist.log
generated
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{"type":"text","level":3,"timestamp":1744102555495,"text":"@devcontainers/cli 0.75.0. Node.js v23.9.0. darwin 24.4.0 arm64."}
|
||||
{"type":"start","level":2,"timestamp":1744102555495,"text":"Run: docker buildx version"}
|
||||
{"type":"stop","level":2,"timestamp":1744102555539,"text":"Run: docker buildx version","startTimestamp":1744102555495}
|
||||
{"type":"text","level":2,"timestamp":1744102555539,"text":"github.com/docker/buildx v0.21.2 1360a9e8d25a2c3d03c2776d53ae62e6ff0a843d\r\n"}
|
||||
{"type":"text","level":2,"timestamp":1744102555539,"text":"\u001b[1m\u001b[31m\u001b[39m\u001b[22m\r\n"}
|
||||
{"type":"start","level":2,"timestamp":1744102555539,"text":"Run: docker -v"}
|
||||
{"type":"stop","level":2,"timestamp":1744102555548,"text":"Run: docker -v","startTimestamp":1744102555539}
|
||||
{"type":"start","level":2,"timestamp":1744102555548,"text":"Resolving Remote"}
|
||||
Error: Dev container config (/code/devcontainers-template-starter/foo/.devcontainer/devcontainer.json) not found.
|
||||
at H6 (/opt/homebrew/Cellar/devcontainer/0.75.0/libexec/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:484:3219)
|
||||
at async BC (/opt/homebrew/Cellar/devcontainer/0.75.0/libexec/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:484:4957)
|
||||
at async d7 (/opt/homebrew/Cellar/devcontainer/0.75.0/libexec/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:665:202)
|
||||
at async f7 (/opt/homebrew/Cellar/devcontainer/0.75.0/libexec/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:664:14804)
|
||||
at async /opt/homebrew/Cellar/devcontainer/0.75.0/libexec/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:484:1188
|
||||
{"outcome":"error","message":"Dev container config (/code/devcontainers-template-starter/foo/.devcontainer/devcontainer.json) not found.","description":"Dev container config (/code/devcontainers-template-starter/foo/.devcontainer/devcontainer.json) not found."}
|
212
agent/agentcontainers/testdata/devcontainercli/parse/up-remove-existing.log
generated
vendored
Normal file
212
agent/agentcontainers/testdata/devcontainercli/parse/up-remove-existing.log
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
206
agent/agentcontainers/testdata/devcontainercli/parse/up.log
generated
vendored
Normal file
206
agent/agentcontainers/testdata/devcontainercli/parse/up.log
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -38,7 +38,8 @@ func (a *agent) apiHandler() http.Handler {
|
||||
}
|
||||
ch := agentcontainers.New(agentcontainers.WithLister(a.lister))
|
||||
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)
|
||||
r.Get("/api/v0/containers", ch.ServeHTTP)
|
||||
r.Get("/api/v0/containers", ch.List)
|
||||
r.Post("/api/v0/containers/{id}/recreate", ch.Recreate)
|
||||
r.Get("/api/v0/listening-ports", lp.handler)
|
||||
r.Get("/api/v0/netcheck", a.HandleNetcheck)
|
||||
r.Post("/api/v0/list-directory", a.HandleLS)
|
||||
|
@ -429,6 +429,16 @@ type WorkspaceAgentContainer struct {
|
||||
Volumes map[string]string `json:"volumes"`
|
||||
}
|
||||
|
||||
func (c *WorkspaceAgentContainer) Match(idOrName string) bool {
|
||||
if c.ID == idOrName {
|
||||
return true
|
||||
}
|
||||
if c.FriendlyName == idOrName {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WorkspaceAgentContainerPort describes a port as exposed by a container.
|
||||
type WorkspaceAgentContainerPort struct {
|
||||
// Port is the port number *inside* the container.
|
||||
|
Reference in New Issue
Block a user