feat(agent/agentcontainers): support displayApps from devcontainer config (#18342)

Updates the agent injection routine to read the dev container's
configuration so we can add display apps to the sub agent.
This commit is contained in:
Danielle Maywood
2025-06-12 23:36:23 +01:00
committed by GitHub
parent bc74166963
commit dd150264bc
11 changed files with 558 additions and 22 deletions

View File

@ -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...) 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. // Up mocks base method.
func (m *MockDevcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { func (m *MockDevcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...agentcontainers.DevcontainerCLIUpOptions) (string, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()

View File

@ -1099,6 +1099,17 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders
directory = DevcontainerDefaultContainerWorkspaceFolder 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 // The preparation of the subagent is done, now we can create the
// subagent record in the database to receive the auth token. // subagent record in the database to receive the auth token.
createdAgent, err := api.subAgentClient.Create(ctx, SubAgent{ createdAgent, err := api.subAgentClient.Create(ctx, SubAgent{
@ -1106,6 +1117,7 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders
Directory: directory, Directory: directory,
OperatingSystem: "linux", // Assuming Linux for dev containers. OperatingSystem: "linux", // Assuming Linux for dev containers.
Architecture: arch, Architecture: arch,
DisplayApps: displayApps,
}) })
if err != nil { if err != nil {
return xerrors.Errorf("create agent: %w", err) return xerrors.Errorf("create agent: %w", err)

View File

@ -60,11 +60,14 @@ func (f *fakeContainerCLI) ExecAs(ctx context.Context, name, user string, args .
// fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI // fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI
// interface for testing. // interface for testing.
type fakeDevcontainerCLI struct { type fakeDevcontainerCLI struct {
upID string upID string
upErr error upErr error
upErrC chan error // If set, send to return err, close to return upErr. upErrC chan error // If set, send to return err, close to return upErr.
execErr error execErr error
execErrC chan func(cmd string, args ...string) error // If set, send fn to return err, nil or close to return execErr. 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) { 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 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. // fakeWatcher implements the watcher.Watcher interface for testing.
// It allows controlling what events are sent and when. // It allows controlling what events are sent and when.
type fakeWatcher struct { type fakeWatcher struct {
@ -1132,10 +1149,12 @@ func TestAPI(t *testing.T) {
Containers: []codersdk.WorkspaceAgentContainer{container}, Containers: []codersdk.WorkspaceAgentContainer{container},
}, },
} }
fDCCLI := &fakeDevcontainerCLI{}
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
api := agentcontainers.NewAPI( api := agentcontainers.NewAPI(
logger, logger,
agentcontainers.WithDevcontainerCLI(fDCCLI),
agentcontainers.WithContainerCLI(fLister), agentcontainers.WithContainerCLI(fLister),
agentcontainers.WithWatcher(fWatcher), agentcontainers.WithWatcher(fWatcher),
agentcontainers.WithClock(mClock), agentcontainers.WithClock(mClock),
@ -1421,6 +1440,130 @@ func TestAPI(t *testing.T) {
assert.Contains(t, fakeSAC.deleted, existingAgentID) assert.Contains(t, fakeSAC.deleted, existingAgentID)
assert.Empty(t, fakeSAC.agents) 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 // mustFindDevcontainerByPath returns the devcontainer with the given workspace

View File

@ -12,12 +12,33 @@ import (
"cdr.dev/slog" "cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentexec" "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. // DevcontainerCLI is an interface for the devcontainer CLI.
type DevcontainerCLI interface { type DevcontainerCLI interface {
Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (id string, err error) 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 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 // 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 { func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig {
conf := devcontainerCLIUpConfig{} conf := devcontainerCLIUpConfig{}
for _, opt := range opts { for _, opt := range opts {
@ -103,6 +142,16 @@ func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) devconta
return conf return conf
} }
func applyDevcontainerCLIReadConfigOptions(opts []DevcontainerCLIReadConfigOptions) devcontainerCLIReadConfigConfig {
conf := devcontainerCLIReadConfigConfig{}
for _, opt := range opts {
if opt != nil {
opt(&conf)
}
}
return conf
}
type devcontainerCLI struct { type devcontainerCLI struct {
logger slog.Logger logger slog.Logger
execer agentexec.Execer execer agentexec.Execer
@ -147,13 +196,14 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st
cmd.Stderr = io.MultiWriter(stderrWriters...) cmd.Stderr = io.MultiWriter(stderrWriters...)
if err := cmd.Run(); err != nil { 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) err = errors.Join(err, err2)
} }
return "", err return "", err
} }
result, err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes()) result, err := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes())
if err != nil { if err != nil {
return "", err return "", err
} }
@ -200,9 +250,49 @@ func (d *devcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath
return nil 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 // parseDevcontainerCLILastLine parses the last line of the devcontainer CLI output
// which is a JSON object. // 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)) s := bufio.NewScanner(bytes.NewReader(p))
var lastLine []byte var lastLine []byte
for s.Scan() { for s.Scan() {
@ -212,19 +302,19 @@ func parseDevcontainerCLILastLine(ctx context.Context, logger slog.Logger, p []b
} }
lastLine = b lastLine = b
} }
if err = s.Err(); err != nil { if err := s.Err(); err != nil {
return result, err return result, err
} }
if len(lastLine) == 0 || lastLine[0] != '{' { if len(lastLine) == 0 || lastLine[0] != '{' {
logger.Error(ctx, "devcontainer result is not json", slog.F("result", string(lastLine))) 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 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))) logger.Error(ctx, "parse devcontainer result failed", slog.Error(err), slog.F("result", string(lastLine)))
return result, err return result, err
} }
return result, result.Err() return result, nil
} }
// devcontainerCLIResult is the result of the devcontainer CLI command. // devcontainerCLIResult is the result of the devcontainer CLI command.
@ -243,6 +333,18 @@ type devcontainerCLIResult struct {
Description string `json:"description"` 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 { func (r devcontainerCLIResult) Err() error {
if r.Outcome == "success" { if r.Outcome == "success" {
return nil return nil

View File

@ -22,6 +22,7 @@ import (
"cdr.dev/slog/sloggers/slogtest" "cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentexec" "github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty" "github.com/coder/coder/v2/pty"
"github.com/coder/coder/v2/testutil" "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 // TestDevcontainerCLI_WithOutput tests that WithUpOutput and WithExecOutput capture CLI

View File

@ -9,6 +9,7 @@ import (
"cdr.dev/slog" "cdr.dev/slog"
agentproto "github.com/coder/coder/v2/agent/proto" agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/codersdk"
) )
// SubAgent represents an agent running in a dev container. // SubAgent represents an agent running in a dev container.
@ -19,6 +20,7 @@ type SubAgent struct {
Directory string Directory string
Architecture string Architecture string
OperatingSystem string OperatingSystem string
DisplayApps []codersdk.DisplayApp
} }
// SubAgentClient is an interface for managing sub agents and allows // 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) { 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)) 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{ resp, err := a.api.CreateSubAgent(ctx, &agentproto.CreateSubAgentRequest{
Name: agent.Name, Name: agent.Name,
Directory: agent.Directory, Directory: agent.Directory,
Architecture: agent.Architecture, Architecture: agent.Architecture,
OperatingSystem: agent.OperatingSystem, OperatingSystem: agent.OperatingSystem,
DisplayApps: displayApps,
}) })
if err != nil { if err != nil {
return SubAgent{}, err return SubAgent{}, err

View File

@ -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)
})
}
})
}

View File

@ -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"}

View File

@ -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"]}}}}

View File

@ -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":{}}}

View File

@ -171,22 +171,27 @@ func (c *Client) GetSubAgentDirectory(id uuid.UUID) (string, error) {
return c.fakeAgentAPI.GetSubAgentDirectory(id) return c.fakeAgentAPI.GetSubAgentDirectory(id)
} }
func (c *Client) GetSubAgentDisplayApps(id uuid.UUID) ([]agentproto.CreateSubAgentRequest_DisplayApp, error) {
return c.fakeAgentAPI.GetSubAgentDisplayApps(id)
}
type FakeAgentAPI struct { type FakeAgentAPI struct {
sync.Mutex sync.Mutex
t testing.TB t testing.TB
logger slog.Logger logger slog.Logger
manifest *agentproto.Manifest manifest *agentproto.Manifest
startupCh chan *agentproto.Startup startupCh chan *agentproto.Startup
statsCh chan *agentproto.Stats statsCh chan *agentproto.Stats
appHealthCh chan *agentproto.BatchUpdateAppHealthRequest appHealthCh chan *agentproto.BatchUpdateAppHealthRequest
logsCh chan<- *agentproto.BatchCreateLogsRequest logsCh chan<- *agentproto.BatchCreateLogsRequest
lifecycleStates []codersdk.WorkspaceAgentLifecycle lifecycleStates []codersdk.WorkspaceAgentLifecycle
metadata map[string]agentsdk.Metadata metadata map[string]agentsdk.Metadata
timings []*agentproto.Timing timings []*agentproto.Timing
connectionReports []*agentproto.ReportConnectionRequest connectionReports []*agentproto.ReportConnectionRequest
subAgents map[uuid.UUID]*agentproto.SubAgent subAgents map[uuid.UUID]*agentproto.SubAgent
subAgentDirs map[uuid.UUID]string subAgentDirs map[uuid.UUID]string
subAgentDisplayApps map[uuid.UUID][]agentproto.CreateSubAgentRequest_DisplayApp
getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error) getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error)
getResourcesMonitoringConfigurationFunc func() (*agentproto.GetResourcesMonitoringConfigurationResponse, 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 = make(map[uuid.UUID]string)
} }
f.subAgentDirs[subAgentID] = req.GetDirectory() 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. // For a fake implementation, we don't create workspace apps.
// Real implementations would handle req.Apps here. // Real implementations would handle req.Apps here.
@ -477,6 +486,22 @@ func (f *FakeAgentAPI) GetSubAgentDirectory(id uuid.UUID) (string, error) {
return dir, nil 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 { func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest, statsCh chan *agentproto.Stats) *FakeAgentAPI {
return &FakeAgentAPI{ return &FakeAgentAPI{
t: t, t: t,