From 3b44a89c2a39e7a96312a4a21894b17e3201790c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 12 Jun 2025 09:43:14 +0000 Subject: [PATCH 1/4] feat(agent/agentcontainers): support displayApps from devcontainer config --- agent/agentcontainers/acmock/acmock.go | 20 +++ agent/agentcontainers/api.go | 12 ++ agent/agentcontainers/api_test.go | 153 +++++++++++++++++- agent/agentcontainers/devcontainercli.go | 114 +++++++++++-- agent/agentcontainers/devcontainercli_test.go | 119 ++++++++++++++ agent/agentcontainers/subagent.go | 25 +++ agent/agentcontainers/subagent_test.go | 106 ++++++++++++ .../read-config-error-not-found.log | 2 + .../read-config-with-coder-customization.log | 8 + ...ead-config-without-coder-customization.log | 8 + agent/agenttest/client.go | 47 ++++-- 11 files changed, 588 insertions(+), 26 deletions(-) create mode 100644 agent/agentcontainers/subagent_test.go create mode 100644 agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log create mode 100644 agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log create mode 100644 agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log diff --git a/agent/agentcontainers/acmock/acmock.go b/agent/agentcontainers/acmock/acmock.go index f9723e8a15758..990a243a33ddf 100644 --- a/agent/agentcontainers/acmock/acmock.go +++ b/agent/agentcontainers/acmock/acmock.go @@ -149,6 +149,26 @@ func (mr *MockDevcontainerCLIMockRecorder) Exec(ctx, workspaceFolder, configPath return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockDevcontainerCLI)(nil).Exec), varargs...) } +// ReadConfig mocks base method. +func (m *MockDevcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configPath string, opts ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, workspaceFolder, configPath} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ReadConfig", varargs...) + ret0, _ := ret[0].(agentcontainers.DevcontainerConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadConfig indicates an expected call of ReadConfig. +func (mr *MockDevcontainerCLIMockRecorder) ReadConfig(ctx, workspaceFolder, configPath any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, workspaceFolder, configPath}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadConfig", reflect.TypeOf((*MockDevcontainerCLI)(nil).ReadConfig), varargs...) +} + // Up mocks base method. func (m *MockDevcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { m.ctrl.T.Helper() diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 56c5df6710297..6e893f2a53441 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -1099,6 +1099,17 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders directory = DevcontainerDefaultContainerWorkspaceFolder } + var displayApps []codersdk.DisplayApp + + if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath); err != nil { + api.logger.Error(ctx, "unable to read devcontainer config", slog.Error(err)) + } else { + coderCustomization := config.Configuration.Customizations.Coder + if coderCustomization != nil { + displayApps = coderCustomization.DisplayApps + } + } + // The preparation of the subagent is done, now we can create the // subagent record in the database to receive the auth token. createdAgent, err := api.subAgentClient.Create(ctx, SubAgent{ @@ -1106,6 +1117,7 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders Directory: directory, OperatingSystem: "linux", // Assuming Linux for dev containers. Architecture: arch, + DisplayApps: displayApps, }) if err != nil { return xerrors.Errorf("create agent: %w", err) diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 91cebcf2e5d25..154a916e92845 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -60,11 +60,14 @@ func (f *fakeContainerCLI) ExecAs(ctx context.Context, name, user string, args . // fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI // interface for testing. type fakeDevcontainerCLI struct { - upID string - upErr error - upErrC chan error // If set, send to return err, close to return upErr. - execErr error - execErrC chan func(cmd string, args ...string) error // If set, send fn to return err, nil or close to return execErr. + upID string + upErr error + upErrC chan error // If set, send to return err, close to return upErr. + execErr error + execErrC chan func(cmd string, args ...string) error // If set, send fn to return err, nil or close to return execErr. + readConfig agentcontainers.DevcontainerConfig + readConfigErr error + readConfigErrC chan error } func (f *fakeDevcontainerCLI) Up(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { @@ -95,6 +98,20 @@ func (f *fakeDevcontainerCLI) Exec(ctx context.Context, _, _ string, cmd string, return f.execErr } +func (f *fakeDevcontainerCLI) ReadConfig(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) { + if f.readConfigErrC != nil { + select { + case <-ctx.Done(): + return agentcontainers.DevcontainerConfig{}, ctx.Err() + case err, ok := <-f.readConfigErrC: + if ok { + return f.readConfig, err + } + } + } + return f.readConfig, f.readConfigErr +} + // fakeWatcher implements the watcher.Watcher interface for testing. // It allows controlling what events are sent and when. type fakeWatcher struct { @@ -1132,10 +1149,12 @@ func TestAPI(t *testing.T) { Containers: []codersdk.WorkspaceAgentContainer{container}, }, } + fDCCLI := &fakeDevcontainerCLI{} logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) api := agentcontainers.NewAPI( logger, + agentcontainers.WithDevcontainerCLI(fDCCLI), agentcontainers.WithContainerCLI(fLister), agentcontainers.WithWatcher(fWatcher), agentcontainers.WithClock(mClock), @@ -1421,6 +1440,130 @@ func TestAPI(t *testing.T) { assert.Contains(t, fakeSAC.deleted, existingAgentID) assert.Empty(t, fakeSAC.agents) }) + + t.Run("Create", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)") + } + + tests := []struct { + name string + customization *agentcontainers.CoderCustomization + afterCreate func(t *testing.T, subAgent agentcontainers.SubAgent) + }{ + { + name: "WithoutCustomization", + customization: nil, + }, + { + name: "WithDisplayApps", + customization: &agentcontainers.CoderCustomization{ + DisplayApps: []codersdk.DisplayApp{ + codersdk.DisplayAppSSH, + codersdk.DisplayAppWebTerminal, + codersdk.DisplayAppVSCodeInsiders, + }, + }, + afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) { + require.Len(t, subAgent.DisplayApps, 3) + assert.Equal(t, codersdk.DisplayAppSSH, subAgent.DisplayApps[0]) + assert.Equal(t, codersdk.DisplayAppWebTerminal, subAgent.DisplayApps[1]) + assert.Equal(t, codersdk.DisplayAppVSCodeInsiders, subAgent.DisplayApps[2]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitMedium) + logger = testutil.Logger(t) + mClock = quartz.NewMock(t) + mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t)) + fSAC = &fakeSubAgentClient{createErrC: make(chan error, 1)} + fDCCLI = &fakeDevcontainerCLI{ + readConfig: agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: tt.customization, + }, + }, + }, + execErrC: make(chan func(cmd string, args ...string) error, 1), + } + + testContainer = codersdk.WorkspaceAgentContainer{ + ID: "test-container-id", + FriendlyName: "test-container", + Image: "test-image", + Running: true, + CreatedAt: time.Now(), + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/workspaces", + agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json", + }, + } + ) + + coderBin, err := os.Executable() + require.NoError(t, err) + + // Mock the `List` function to always return out test container. + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{testContainer}, + }, nil).AnyTimes() + + // Mock the steps used for injecting the coder agent. + gomock.InOrder( + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return(runtime.GOARCH, nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil), + mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, coderBin, "/.coder-agent/coder").Return(nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil), + ) + + mClock.Set(time.Now()).MustWait(ctx) + tickerTrap := mClock.Trap().TickerFunc("updaterLoop") + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithContainerCLI(mCCLI), + agentcontainers.WithDevcontainerCLI(fDCCLI), + agentcontainers.WithSubAgentClient(fSAC), + agentcontainers.WithSubAgentURL("test-subagent-url"), + agentcontainers.WithWatcher(watcher.NewNoop()), + ) + defer api.Close() + + // Close before api.Close() defer to avoid deadlock after test. + defer close(fSAC.createErrC) + defer close(fDCCLI.execErrC) + + // Given: We allow agent creation and injection to succeed. + testutil.RequireSend(ctx, t, fSAC.createErrC, nil) + testutil.RequireSend(ctx, t, fDCCLI.execErrC, func(cmd string, args ...string) error { + assert.Equal(t, "pwd", cmd) + assert.Empty(t, args) + return nil + }) + + // Wait until the ticker has been registered. + tickerTrap.MustWait(ctx).MustRelease(ctx) + tickerTrap.Close() + + // Then: We expected it to succeed + require.Len(t, fSAC.created, 1) + assert.Equal(t, testContainer.FriendlyName, fSAC.created[0].Name) + + if tt.afterCreate != nil { + tt.afterCreate(t, fSAC.created[0]) + } + }) + } + }) } // mustFindDevcontainerByPath returns the devcontainer with the given workspace diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index 4e1ad93a715dc..7460f9b8baa36 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -12,12 +12,33 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/codersdk" ) +// DevcontainerConfig is a wrapper around the output from `read-configuration`. +// Unfortunately we cannot make use of `dcspec` as the output doesn't appear to +// match. +type DevcontainerConfig struct { + Configuration DevcontainerConfiguration `json:"configuration"` +} + +type DevcontainerConfiguration struct { + Customizations DevcontainerCustomizations `json:"customizations,omitempty"` +} + +type DevcontainerCustomizations struct { + Coder *CoderCustomization `json:"com.coder,omitempty"` +} + +type CoderCustomization struct { + DisplayApps []codersdk.DisplayApp `json:"displayApps,omitempty"` +} + // DevcontainerCLI is an interface for the devcontainer CLI. type DevcontainerCLI interface { Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (id string, err error) Exec(ctx context.Context, workspaceFolder, configPath string, cmd string, cmdArgs []string, opts ...DevcontainerCLIExecOptions) error + ReadConfig(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error) } // DevcontainerCLIUpOptions are options for the devcontainer CLI Up @@ -83,6 +104,24 @@ func WithRemoteEnv(env ...string) DevcontainerCLIExecOptions { } } +// DevcontainerCLIExecOptions are options for the devcontainer CLI ReadConfig +// command. +type DevcontainerCLIReadConfigOptions func(*devcontainerCLIReadConfigConfig) + +type devcontainerCLIReadConfigConfig struct { + stdout io.Writer + stderr io.Writer +} + +// WithExecOutput sets additional stdout and stderr writers for logs +// during Exec operations. +func WithReadConfigOutput(stdout, stderr io.Writer) DevcontainerCLIReadConfigOptions { + return func(o *devcontainerCLIReadConfigConfig) { + o.stdout = stdout + o.stderr = stderr + } +} + func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig { conf := devcontainerCLIUpConfig{} for _, opt := range opts { @@ -103,6 +142,16 @@ func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) devconta return conf } +func applyDevcontainerCLIReadConfigOptions(opts []DevcontainerCLIReadConfigOptions) devcontainerCLIReadConfigConfig { + conf := devcontainerCLIReadConfigConfig{} + for _, opt := range opts { + if opt != nil { + opt(&conf) + } + } + return conf +} + type devcontainerCLI struct { logger slog.Logger execer agentexec.Execer @@ -147,14 +196,21 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st cmd.Stderr = io.MultiWriter(stderrWriters...) if err := cmd.Run(); err != nil { - if _, err2 := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes()); err2 != nil { + var result devcontainerCLIResult + if err2 := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes(), &result); err2 != nil { + err = errors.Join(err, err2) + } + if err2 := result.Err(); err2 != nil { err = errors.Join(err, err2) } return "", err } - result, err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes()) - if err != nil { + var result devcontainerCLIResult + if err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes(), &result); err != nil { + return "", err + } + if err := result.Err(); err != nil { return "", err } @@ -200,9 +256,47 @@ func (d *devcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath return nil } +func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error) { + conf := applyDevcontainerCLIReadConfigOptions(opts) + logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath)) + + args := []string{"read-configuration"} + if workspaceFolder != "" { + args = append(args, "--workspace-folder", workspaceFolder) + } + if configPath != "" { + args = append(args, "--config", configPath) + } + + c := d.execer.CommandContext(ctx, "devcontainer", args...) + + var stdoutBuf bytes.Buffer + stdoutWriters := []io.Writer{&stdoutBuf, &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}} + if conf.stdout != nil { + stdoutWriters = append(stdoutWriters, conf.stdout) + } + c.Stdout = io.MultiWriter(stdoutWriters...) + stderrWriters := []io.Writer{&devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stderr", true))}} + if conf.stderr != nil { + stderrWriters = append(stderrWriters, conf.stderr) + } + c.Stderr = io.MultiWriter(stderrWriters...) + + if err := c.Run(); err != nil { + return DevcontainerConfig{}, xerrors.Errorf("devcontainer read-configuration failed: %w", err) + } + + var config DevcontainerConfig + if err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes(), &config); err != nil { + return DevcontainerConfig{}, err + } + + return config, 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) { +func parseDevcontainerCLILastLine[T any](ctx context.Context, logger slog.Logger, p []byte, result T) error { s := bufio.NewScanner(bytes.NewReader(p)) var lastLine []byte for s.Scan() { @@ -212,19 +306,19 @@ func parseDevcontainerCLILastLine(ctx context.Context, logger slog.Logger, p []b } lastLine = b } - if err = s.Err(); err != nil { - return result, err + if err := s.Err(); err != nil { + return 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)) + return xerrors.Errorf("devcontainer result is not json: %q", string(lastLine)) } - if err = json.Unmarshal(lastLine, &result); err != nil { + 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 err } - return result, result.Err() + return nil } // devcontainerCLIResult is the result of the devcontainer CLI command. diff --git a/agent/agentcontainers/devcontainercli_test.go b/agent/agentcontainers/devcontainercli_test.go index b8b4120d2e8ab..9744f736d302f 100644 --- a/agent/agentcontainers/devcontainercli_test.go +++ b/agent/agentcontainers/devcontainercli_test.go @@ -22,6 +22,7 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty" "github.com/coder/coder/v2/testutil" ) @@ -233,6 +234,91 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) { }) } }) + + t.Run("ReadConfig", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + logFile string + workspaceFolder string + configPath string + opts []agentcontainers.DevcontainerCLIReadConfigOptions + wantArgs string + wantError bool + wantConfig agentcontainers.DevcontainerConfig + }{ + { + name: "WithCoderCustomization", + logFile: "read-config-with-coder-customization.log", + workspaceFolder: "/test/workspace", + configPath: "", + wantArgs: "read-configuration --workspace-folder /test/workspace", + wantError: false, + wantConfig: agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: &agentcontainers.CoderCustomization{ + DisplayApps: []codersdk.DisplayApp{ + codersdk.DisplayAppVSCodeDesktop, + codersdk.DisplayAppWebTerminal, + }, + }, + }, + }, + }, + }, + { + name: "WithoutCoderCustomization", + logFile: "read-config-without-coder-customization.log", + workspaceFolder: "/test/workspace", + configPath: "/test/config.json", + wantArgs: "read-configuration --workspace-folder /test/workspace --config /test/config.json", + wantError: false, + wantConfig: agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: nil, + }, + }, + }, + }, + { + name: "FileNotFound", + logFile: "read-config-error-not-found.log", + workspaceFolder: "/nonexistent/workspace", + configPath: "", + wantArgs: "read-configuration --workspace-folder /nonexistent/workspace", + wantError: true, + wantConfig: agentcontainers.DevcontainerConfig{}, + }, + } + + 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", "readconfig", tt.logFile), + } + + dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) + config, err := dccli.ReadConfig(ctx, tt.workspaceFolder, tt.configPath, tt.opts...) + if tt.wantError { + assert.Error(t, err, "want error") + assert.Equal(t, agentcontainers.DevcontainerConfig{}, config, "expected empty config on error") + } else { + assert.NoError(t, err, "want no error") + assert.Equal(t, tt.wantConfig, config, "expected config to match") + } + }) + } + }) } // TestDevcontainerCLI_WithOutput tests that WithUpOutput and WithExecOutput capture CLI @@ -314,6 +400,39 @@ func TestDevcontainerCLI_WithOutput(t *testing.T) { assert.NotEmpty(t, outBuf.String(), "stdout buffer should not be empty for exec with log file") assert.Empty(t, errBuf.String(), "stderr buffer should be empty") }) + + t.Run("ReadConfig", func(t *testing.T) { + t.Parallel() + + // Buffers to capture stdout and stderr. + outBuf := &bytes.Buffer{} + errBuf := &bytes.Buffer{} + + // Simulate CLI execution with a read-config-success.log file. + wantArgs := "read-configuration --workspace-folder /test/workspace --config /test/config.json" + testExecer := &testDevcontainerExecer{ + testExePath: testExePath, + wantArgs: wantArgs, + wantError: false, + logFile: filepath.Join("testdata", "devcontainercli", "readconfig", "read-config-success.log"), + } + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) + + // Call ReadConfig with WithReadConfigOutput to capture CLI logs. + ctx := testutil.Context(t, testutil.WaitMedium) + config, err := dccli.ReadConfig(ctx, "/test/workspace", "/test/config.json", agentcontainers.WithReadConfigOutput(outBuf, errBuf)) + require.NoError(t, err, "ReadConfig should succeed") + require.NotEmpty(t, config.Configuration.Customizations, "expected non-empty customizations") + + // Read expected log content. + expLog, err := os.ReadFile(filepath.Join("testdata", "devcontainercli", "readconfig", "read-config-success.log")) + require.NoError(t, err, "reading expected log file") + + // Verify stdout buffer contains the CLI logs and stderr is empty. + assert.Equal(t, string(expLog), outBuf.String(), "stdout buffer should match CLI logs") + assert.Empty(t, errBuf.String(), "stderr buffer should be empty on success") + }) } // testDevcontainerExecer implements the agentexec.Execer interface for testing. diff --git a/agent/agentcontainers/subagent.go b/agent/agentcontainers/subagent.go index 70899fb96f70d..5848e5747e099 100644 --- a/agent/agentcontainers/subagent.go +++ b/agent/agentcontainers/subagent.go @@ -9,6 +9,7 @@ import ( "cdr.dev/slog" agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/codersdk" ) // SubAgent represents an agent running in a dev container. @@ -19,6 +20,7 @@ type SubAgent struct { Directory string Architecture string OperatingSystem string + DisplayApps []codersdk.DisplayApp } // SubAgentClient is an interface for managing sub agents and allows @@ -80,11 +82,34 @@ func (a *subAgentAPIClient) List(ctx context.Context) ([]SubAgent, error) { func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (SubAgent, error) { a.logger.Debug(ctx, "creating sub agent", slog.F("name", agent.Name), slog.F("directory", agent.Directory)) + + displayApps := make([]agentproto.CreateSubAgentRequest_DisplayApp, 0, len(agent.DisplayApps)) + for _, displayApp := range agent.DisplayApps { + var app agentproto.CreateSubAgentRequest_DisplayApp + switch displayApp { + case codersdk.DisplayAppPortForward: + app = agentproto.CreateSubAgentRequest_PORT_FORWARDING_HELPER + case codersdk.DisplayAppSSH: + app = agentproto.CreateSubAgentRequest_SSH_HELPER + case codersdk.DisplayAppVSCodeDesktop: + app = agentproto.CreateSubAgentRequest_VSCODE + case codersdk.DisplayAppVSCodeInsiders: + app = agentproto.CreateSubAgentRequest_VSCODE_INSIDERS + case codersdk.DisplayAppWebTerminal: + app = agentproto.CreateSubAgentRequest_WEB_TERMINAL + default: + return SubAgent{}, xerrors.Errorf("unexpected codersdk.DisplayApp: %#v", displayApp) + } + + displayApps = append(displayApps, app) + } + resp, err := a.api.CreateSubAgent(ctx, &agentproto.CreateSubAgentRequest{ Name: agent.Name, Directory: agent.Directory, Architecture: agent.Architecture, OperatingSystem: agent.OperatingSystem, + DisplayApps: displayApps, }) if err != nil { return SubAgent{}, err diff --git a/agent/agentcontainers/subagent_test.go b/agent/agentcontainers/subagent_test.go new file mode 100644 index 0000000000000..30e90fdee76af --- /dev/null +++ b/agent/agentcontainers/subagent_test.go @@ -0,0 +1,106 @@ +package agentcontainers_test + +import ( + "testing" + + "github.com/coder/coder/v2/agent/agentcontainers" + "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/agent/proto" + agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/testutil" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) { + t.Parallel() + + t.Run("CreateWithDisplayApps", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + displayApps []codersdk.DisplayApp + expectedApps []agentproto.CreateSubAgentRequest_DisplayApp + }{ + { + name: "single display app", + displayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop}, + expectedApps: []agentproto.CreateSubAgentRequest_DisplayApp{ + agentproto.CreateSubAgentRequest_VSCODE, + }, + }, + { + name: "multiple display apps", + displayApps: []codersdk.DisplayApp{ + codersdk.DisplayAppVSCodeDesktop, + codersdk.DisplayAppSSH, + codersdk.DisplayAppPortForward, + }, + expectedApps: []agentproto.CreateSubAgentRequest_DisplayApp{ + agentproto.CreateSubAgentRequest_VSCODE, + agentproto.CreateSubAgentRequest_SSH_HELPER, + agentproto.CreateSubAgentRequest_PORT_FORWARDING_HELPER, + }, + }, + { + name: "all display apps", + displayApps: []codersdk.DisplayApp{ + codersdk.DisplayAppPortForward, + codersdk.DisplayAppSSH, + codersdk.DisplayAppVSCodeDesktop, + codersdk.DisplayAppVSCodeInsiders, + codersdk.DisplayAppWebTerminal, + }, + expectedApps: []agentproto.CreateSubAgentRequest_DisplayApp{ + agentproto.CreateSubAgentRequest_PORT_FORWARDING_HELPER, + agentproto.CreateSubAgentRequest_SSH_HELPER, + agentproto.CreateSubAgentRequest_VSCODE, + agentproto.CreateSubAgentRequest_VSCODE_INSIDERS, + agentproto.CreateSubAgentRequest_WEB_TERMINAL, + }, + }, + { + name: "no display apps", + displayApps: []codersdk.DisplayApp{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := testutil.Logger(t) + statsCh := make(chan *proto.Stats) + + agentAPI := agenttest.NewClient(t, logger, uuid.New(), agentsdk.Manifest{}, statsCh, tailnet.NewCoordinator(logger)) + + agentClient, _, err := agentAPI.ConnectRPC26(ctx) + require.NoError(t, err) + + subAgentClient := agentcontainers.NewSubAgentClientFromAPI(logger, agentClient) + + // When: We create a sub agent with display apps. + subAgent, err := subAgentClient.Create(ctx, agentcontainers.SubAgent{ + Name: "sub-agent-" + tt.name, + Directory: "/workspaces/coder", + Architecture: "amd64", + OperatingSystem: "linux", + DisplayApps: tt.displayApps, + }) + require.NoError(t, err) + + displayApps, err := agentAPI.GetSubAgentDisplayApps(subAgent.ID) + require.NoError(t, err) + + // Then: We expect the apps to be created. + require.Equal(t, tt.expectedApps, displayApps) + }) + } + }) + +} diff --git a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log new file mode 100644 index 0000000000000..45d66957a3ba1 --- /dev/null +++ b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log @@ -0,0 +1,2 @@ +{"type":"text","level":3,"timestamp":1749557935646,"text":"@devcontainers/cli 0.75.0. Node.js v20.16.0. linux 6.8.0-60-generic x64."} +{"type":"text","level":2,"timestamp":1749557935646,"text":"Error: Dev container config (/home/coder/.devcontainer/devcontainer.json) not found.\n at v7 (/usr/local/nvm/versions/node/v20.16.0/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:668:6918)\n at async /usr/local/nvm/versions/node/v20.16.0/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:484:1188"} diff --git a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log new file mode 100644 index 0000000000000..4543fa3727542 --- /dev/null +++ b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log @@ -0,0 +1,8 @@ +{"type":"text","level":3,"timestamp":1749557820014,"text":"@devcontainers/cli 0.75.0. Node.js v20.16.0. linux 6.8.0-60-generic x64."} +{"type":"start","level":2,"timestamp":1749557820014,"text":"Run: git rev-parse --show-cdup"} +{"type":"stop","level":2,"timestamp":1749557820023,"text":"Run: git rev-parse --show-cdup","startTimestamp":1749557820014} +{"type":"start","level":2,"timestamp":1749557820023,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json"} +{"type":"stop","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json","startTimestamp":1749557820023} +{"type":"start","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder"} +{"type":"stop","level":2,"timestamp":1749557820054,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder","startTimestamp":1749557820039} +{"configuration":{"customizations":{"com.coder":{"displayApps":["vscode", "web_terminal"]}}}} diff --git a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log new file mode 100644 index 0000000000000..99d682e541ea3 --- /dev/null +++ b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log @@ -0,0 +1,8 @@ +{"type":"text","level":3,"timestamp":1749557820014,"text":"@devcontainers/cli 0.75.0. Node.js v20.16.0. linux 6.8.0-60-generic x64."} +{"type":"start","level":2,"timestamp":1749557820014,"text":"Run: git rev-parse --show-cdup"} +{"type":"stop","level":2,"timestamp":1749557820023,"text":"Run: git rev-parse --show-cdup","startTimestamp":1749557820014} +{"type":"start","level":2,"timestamp":1749557820023,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json"} +{"type":"stop","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json","startTimestamp":1749557820023} +{"type":"start","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder"} +{"type":"stop","level":2,"timestamp":1749557820054,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder","startTimestamp":1749557820039} +{"configuration":{"customizations":{}}} diff --git a/agent/agenttest/client.go b/agent/agenttest/client.go index 0a2df141ff3d4..0fc8a38af80b6 100644 --- a/agent/agenttest/client.go +++ b/agent/agenttest/client.go @@ -171,22 +171,27 @@ func (c *Client) GetSubAgentDirectory(id uuid.UUID) (string, error) { return c.fakeAgentAPI.GetSubAgentDirectory(id) } +func (c *Client) GetSubAgentDisplayApps(id uuid.UUID) ([]agentproto.CreateSubAgentRequest_DisplayApp, error) { + return c.fakeAgentAPI.GetSubAgentDisplayApps(id) +} + type FakeAgentAPI struct { sync.Mutex t testing.TB logger slog.Logger - manifest *agentproto.Manifest - startupCh chan *agentproto.Startup - statsCh chan *agentproto.Stats - appHealthCh chan *agentproto.BatchUpdateAppHealthRequest - logsCh chan<- *agentproto.BatchCreateLogsRequest - lifecycleStates []codersdk.WorkspaceAgentLifecycle - metadata map[string]agentsdk.Metadata - timings []*agentproto.Timing - connectionReports []*agentproto.ReportConnectionRequest - subAgents map[uuid.UUID]*agentproto.SubAgent - subAgentDirs map[uuid.UUID]string + manifest *agentproto.Manifest + startupCh chan *agentproto.Startup + statsCh chan *agentproto.Stats + appHealthCh chan *agentproto.BatchUpdateAppHealthRequest + logsCh chan<- *agentproto.BatchCreateLogsRequest + lifecycleStates []codersdk.WorkspaceAgentLifecycle + metadata map[string]agentsdk.Metadata + timings []*agentproto.Timing + connectionReports []*agentproto.ReportConnectionRequest + subAgents map[uuid.UUID]*agentproto.SubAgent + subAgentDirs map[uuid.UUID]string + subAgentDisplayApps map[uuid.UUID][]agentproto.CreateSubAgentRequest_DisplayApp getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error) getResourcesMonitoringConfigurationFunc func() (*agentproto.GetResourcesMonitoringConfigurationResponse, error) @@ -401,6 +406,10 @@ func (f *FakeAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Creat f.subAgentDirs = make(map[uuid.UUID]string) } f.subAgentDirs[subAgentID] = req.GetDirectory() + if f.subAgentDisplayApps == nil { + f.subAgentDisplayApps = make(map[uuid.UUID][]agentproto.CreateSubAgentRequest_DisplayApp) + } + f.subAgentDisplayApps[subAgentID] = req.GetDisplayApps() // For a fake implementation, we don't create workspace apps. // Real implementations would handle req.Apps here. @@ -477,6 +486,22 @@ func (f *FakeAgentAPI) GetSubAgentDirectory(id uuid.UUID) (string, error) { return dir, nil } +func (f *FakeAgentAPI) GetSubAgentDisplayApps(id uuid.UUID) ([]agentproto.CreateSubAgentRequest_DisplayApp, error) { + f.Lock() + defer f.Unlock() + + if f.subAgentDisplayApps == nil { + return nil, xerrors.New("no sub-agent display apps available") + } + + displayApps, ok := f.subAgentDisplayApps[id] + if !ok { + return nil, xerrors.New("sub-agent display apps not found") + } + + return displayApps, nil +} + func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest, statsCh chan *agentproto.Stats) *FakeAgentAPI { return &FakeAgentAPI{ t: t, From e12ccc73478d4d2c0b8e5ed91beef8a260a9b777 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 12 Jun 2025 10:19:11 +0000 Subject: [PATCH 2/4] chore: appease linter and remove duplicated test --- agent/agentcontainers/devcontainercli_test.go | 33 ------------------- agent/agentcontainers/subagent_test.go | 9 +++-- 2 files changed, 4 insertions(+), 38 deletions(-) diff --git a/agent/agentcontainers/devcontainercli_test.go b/agent/agentcontainers/devcontainercli_test.go index 9744f736d302f..0f4b967db6ce9 100644 --- a/agent/agentcontainers/devcontainercli_test.go +++ b/agent/agentcontainers/devcontainercli_test.go @@ -400,39 +400,6 @@ func TestDevcontainerCLI_WithOutput(t *testing.T) { assert.NotEmpty(t, outBuf.String(), "stdout buffer should not be empty for exec with log file") assert.Empty(t, errBuf.String(), "stderr buffer should be empty") }) - - t.Run("ReadConfig", func(t *testing.T) { - t.Parallel() - - // Buffers to capture stdout and stderr. - outBuf := &bytes.Buffer{} - errBuf := &bytes.Buffer{} - - // Simulate CLI execution with a read-config-success.log file. - wantArgs := "read-configuration --workspace-folder /test/workspace --config /test/config.json" - testExecer := &testDevcontainerExecer{ - testExePath: testExePath, - wantArgs: wantArgs, - wantError: false, - logFile: filepath.Join("testdata", "devcontainercli", "readconfig", "read-config-success.log"), - } - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) - dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) - - // Call ReadConfig with WithReadConfigOutput to capture CLI logs. - ctx := testutil.Context(t, testutil.WaitMedium) - config, err := dccli.ReadConfig(ctx, "/test/workspace", "/test/config.json", agentcontainers.WithReadConfigOutput(outBuf, errBuf)) - require.NoError(t, err, "ReadConfig should succeed") - require.NotEmpty(t, config.Configuration.Customizations, "expected non-empty customizations") - - // Read expected log content. - expLog, err := os.ReadFile(filepath.Join("testdata", "devcontainercli", "readconfig", "read-config-success.log")) - require.NoError(t, err, "reading expected log file") - - // Verify stdout buffer contains the CLI logs and stderr is empty. - assert.Equal(t, string(expLog), outBuf.String(), "stdout buffer should match CLI logs") - assert.Empty(t, errBuf.String(), "stderr buffer should be empty on success") - }) } // testDevcontainerExecer implements the agentexec.Execer interface for testing. diff --git a/agent/agentcontainers/subagent_test.go b/agent/agentcontainers/subagent_test.go index 30e90fdee76af..4b805d7549fce 100644 --- a/agent/agentcontainers/subagent_test.go +++ b/agent/agentcontainers/subagent_test.go @@ -3,16 +3,16 @@ package agentcontainers_test import ( "testing" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agenttest" - "github.com/coder/coder/v2/agent/proto" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/tailnet" "github.com/coder/coder/v2/testutil" - "github.com/google/uuid" - "github.com/stretchr/testify/require" ) func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) { @@ -75,7 +75,7 @@ func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) logger := testutil.Logger(t) - statsCh := make(chan *proto.Stats) + statsCh := make(chan *agentproto.Stats) agentAPI := agenttest.NewClient(t, logger, uuid.New(), agentsdk.Manifest{}, statsCh, tailnet.NewCoordinator(logger)) @@ -102,5 +102,4 @@ func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) { }) } }) - } From 4b6209e17c368ae9efb018b5f65a88963a465fc6 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 12 Jun 2025 12:02:14 +0000 Subject: [PATCH 3/4] chore: listen to feedback --- agent/agentcontainers/devcontainercli.go | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index 7460f9b8baa36..baa3223949300 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -196,8 +196,8 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st cmd.Stderr = io.MultiWriter(stderrWriters...) if err := cmd.Run(); err != nil { - var result devcontainerCLIResult - if err2 := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes(), &result); err2 != nil { + result, err2 := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes()) + if err2 != nil { err = errors.Join(err, err2) } if err2 := result.Err(); err2 != nil { @@ -206,8 +206,8 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st return "", err } - var result devcontainerCLIResult - if err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes(), &result); err != nil { + result, err := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes()) + if err != nil { return "", err } if err := result.Err(); err != nil { @@ -286,8 +286,8 @@ func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, confi return DevcontainerConfig{}, xerrors.Errorf("devcontainer read-configuration failed: %w", err) } - var config DevcontainerConfig - if err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes(), &config); err != nil { + config, err := parseDevcontainerCLILastLine[DevcontainerConfig](ctx, logger, stdoutBuf.Bytes()) + if err != nil { return DevcontainerConfig{}, err } @@ -296,7 +296,9 @@ func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, confi // parseDevcontainerCLILastLine parses the last line of the devcontainer CLI output // which is a JSON object. -func parseDevcontainerCLILastLine[T any](ctx context.Context, logger slog.Logger, p []byte, result T) error { +func parseDevcontainerCLILastLine[T any](ctx context.Context, logger slog.Logger, p []byte) (T, error) { + var result T + s := bufio.NewScanner(bytes.NewReader(p)) var lastLine []byte for s.Scan() { @@ -307,18 +309,18 @@ func parseDevcontainerCLILastLine[T any](ctx context.Context, logger slog.Logger lastLine = b } if err := s.Err(); err != nil { - return err + return result, err } if len(lastLine) == 0 || lastLine[0] != '{' { logger.Error(ctx, "devcontainer result is not json", slog.F("result", string(lastLine))) - return xerrors.Errorf("devcontainer result is not json: %q", 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 err + return result, err } - return nil + return result, nil } // devcontainerCLIResult is the result of the devcontainer CLI command. From aa77e9ee82f48b45870138d0278ab2c2a1447e78 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 12 Jun 2025 19:09:50 +0000 Subject: [PATCH 4/4] chore: include merged, com.coder to coder, UnmarshallJSON --- agent/agentcontainers/api.go | 2 +- agent/agentcontainers/api_test.go | 2 +- agent/agentcontainers/devcontainercli.go | 26 ++++++++++++------- agent/agentcontainers/devcontainercli_test.go | 10 +++---- .../read-config-with-coder-customization.log | 2 +- ...ead-config-without-coder-customization.log | 2 +- 6 files changed, 25 insertions(+), 19 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 6e893f2a53441..ce252fe2909ab 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -1104,7 +1104,7 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath); err != nil { api.logger.Error(ctx, "unable to read devcontainer config", slog.Error(err)) } else { - coderCustomization := config.Configuration.Customizations.Coder + coderCustomization := config.MergedConfiguration.Customizations.Coder if coderCustomization != nil { displayApps = coderCustomization.DisplayApps } diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 154a916e92845..d8e696e151db2 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -1487,7 +1487,7 @@ func TestAPI(t *testing.T) { fSAC = &fakeSubAgentClient{createErrC: make(chan error, 1)} fDCCLI = &fakeDevcontainerCLI{ readConfig: agentcontainers.DevcontainerConfig{ - Configuration: agentcontainers.DevcontainerConfiguration{ + MergedConfiguration: agentcontainers.DevcontainerConfiguration{ Customizations: agentcontainers.DevcontainerCustomizations{ Coder: tt.customization, }, diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index baa3223949300..2fad8c6560067 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -19,7 +19,7 @@ import ( // Unfortunately we cannot make use of `dcspec` as the output doesn't appear to // match. type DevcontainerConfig struct { - Configuration DevcontainerConfiguration `json:"configuration"` + MergedConfiguration DevcontainerConfiguration `json:"mergedConfiguration"` } type DevcontainerConfiguration struct { @@ -27,7 +27,7 @@ type DevcontainerConfiguration struct { } type DevcontainerCustomizations struct { - Coder *CoderCustomization `json:"com.coder,omitempty"` + Coder *CoderCustomization `json:"coder,omitempty"` } type CoderCustomization struct { @@ -196,13 +196,10 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st cmd.Stderr = io.MultiWriter(stderrWriters...) if err := cmd.Run(); err != nil { - result, err2 := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes()) + _, err2 := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes()) if err2 != nil { err = errors.Join(err, err2) } - if err2 := result.Err(); err2 != nil { - err = errors.Join(err, err2) - } return "", err } @@ -210,9 +207,6 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st if err != nil { return "", err } - if err := result.Err(); err != nil { - return "", err - } return result.ContainerID, nil } @@ -260,7 +254,7 @@ func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, confi conf := applyDevcontainerCLIReadConfigOptions(opts) logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath)) - args := []string{"read-configuration"} + args := []string{"read-configuration", "--include-merged-configuration"} if workspaceFolder != "" { args = append(args, "--workspace-folder", workspaceFolder) } @@ -339,6 +333,18 @@ type devcontainerCLIResult struct { Description string `json:"description"` } +func (r *devcontainerCLIResult) UnmarshalJSON(data []byte) error { + type wrapperResult devcontainerCLIResult + + var wrappedResult wrapperResult + if err := json.Unmarshal(data, &wrappedResult); err != nil { + return err + } + + *r = devcontainerCLIResult(wrappedResult) + return r.Err() +} + func (r devcontainerCLIResult) Err() error { if r.Outcome == "success" { return nil diff --git a/agent/agentcontainers/devcontainercli_test.go b/agent/agentcontainers/devcontainercli_test.go index 0f4b967db6ce9..dfe390ff7e6df 100644 --- a/agent/agentcontainers/devcontainercli_test.go +++ b/agent/agentcontainers/devcontainercli_test.go @@ -253,10 +253,10 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) { logFile: "read-config-with-coder-customization.log", workspaceFolder: "/test/workspace", configPath: "", - wantArgs: "read-configuration --workspace-folder /test/workspace", + wantArgs: "read-configuration --include-merged-configuration --workspace-folder /test/workspace", wantError: false, wantConfig: agentcontainers.DevcontainerConfig{ - Configuration: agentcontainers.DevcontainerConfiguration{ + MergedConfiguration: agentcontainers.DevcontainerConfiguration{ Customizations: agentcontainers.DevcontainerCustomizations{ Coder: &agentcontainers.CoderCustomization{ DisplayApps: []codersdk.DisplayApp{ @@ -273,10 +273,10 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) { logFile: "read-config-without-coder-customization.log", workspaceFolder: "/test/workspace", configPath: "/test/config.json", - wantArgs: "read-configuration --workspace-folder /test/workspace --config /test/config.json", + wantArgs: "read-configuration --include-merged-configuration --workspace-folder /test/workspace --config /test/config.json", wantError: false, wantConfig: agentcontainers.DevcontainerConfig{ - Configuration: agentcontainers.DevcontainerConfiguration{ + MergedConfiguration: agentcontainers.DevcontainerConfiguration{ Customizations: agentcontainers.DevcontainerCustomizations{ Coder: nil, }, @@ -288,7 +288,7 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) { logFile: "read-config-error-not-found.log", workspaceFolder: "/nonexistent/workspace", configPath: "", - wantArgs: "read-configuration --workspace-folder /nonexistent/workspace", + wantArgs: "read-configuration --include-merged-configuration --workspace-folder /nonexistent/workspace", wantError: true, wantConfig: agentcontainers.DevcontainerConfig{}, }, diff --git a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log index 4543fa3727542..fd052c50662e9 100644 --- a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log +++ b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log @@ -5,4 +5,4 @@ {"type":"stop","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json","startTimestamp":1749557820023} {"type":"start","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder"} {"type":"stop","level":2,"timestamp":1749557820054,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder","startTimestamp":1749557820039} -{"configuration":{"customizations":{"com.coder":{"displayApps":["vscode", "web_terminal"]}}}} +{"mergedConfiguration":{"customizations":{"coder":{"displayApps":["vscode", "web_terminal"]}}}} diff --git a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log index 99d682e541ea3..98fc180cdd642 100644 --- a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log +++ b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log @@ -5,4 +5,4 @@ {"type":"stop","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json","startTimestamp":1749557820023} {"type":"start","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder"} {"type":"stop","level":2,"timestamp":1749557820054,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder","startTimestamp":1749557820039} -{"configuration":{"customizations":{}}} +{"mergedConfiguration":{"customizations":{}}}