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..ce252fe2909ab 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.MergedConfiguration.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..d8e696e151db2 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{ + MergedConfiguration: 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..2fad8c6560067 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 { + MergedConfiguration DevcontainerConfiguration `json:"mergedConfiguration"` +} + +type DevcontainerConfiguration struct { + Customizations DevcontainerCustomizations `json:"customizations,omitempty"` +} + +type DevcontainerCustomizations struct { + Coder *CoderCustomization `json:"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,13 +196,14 @@ 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 { + _, err2 := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes()) + if err2 != nil { err = errors.Join(err, err2) } return "", err } - result, err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes()) + result, err := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes()) if err != nil { return "", err } @@ -200,9 +250,49 @@ 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", "--include-merged-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) + } + + config, err := parseDevcontainerCLILastLine[DevcontainerConfig](ctx, logger, stdoutBuf.Bytes()) + if 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) (T, error) { + var result T + s := bufio.NewScanner(bytes.NewReader(p)) var lastLine []byte for s.Scan() { @@ -212,19 +302,19 @@ func parseDevcontainerCLILastLine(ctx context.Context, logger slog.Logger, p []b } lastLine = b } - if err = s.Err(); err != nil { + 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 { + 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() + return result, nil } // devcontainerCLIResult is the result of the devcontainer CLI command. @@ -243,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 b8b4120d2e8ab..dfe390ff7e6df 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 --include-merged-configuration --workspace-folder /test/workspace", + wantError: false, + wantConfig: agentcontainers.DevcontainerConfig{ + MergedConfiguration: 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 --include-merged-configuration --workspace-folder /test/workspace --config /test/config.json", + wantError: false, + wantConfig: agentcontainers.DevcontainerConfig{ + MergedConfiguration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: nil, + }, + }, + }, + }, + { + name: "FileNotFound", + logFile: "read-config-error-not-found.log", + workspaceFolder: "/nonexistent/workspace", + configPath: "", + wantArgs: "read-configuration --include-merged-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 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..4b805d7549fce --- /dev/null +++ b/agent/agentcontainers/subagent_test.go @@ -0,0 +1,105 @@ +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" + 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" +) + +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 *agentproto.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..fd052c50662e9 --- /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} +{"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 new file mode 100644 index 0000000000000..98fc180cdd642 --- /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} +{"mergedConfiguration":{"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,
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: