mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
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:
20
agent/agentcontainers/acmock/acmock.go
generated
20
agent/agentcontainers/acmock/acmock.go
generated
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -65,6 +65,9 @@ type fakeDevcontainerCLI struct {
|
||||
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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
105
agent/agentcontainers/subagent_test.go
Normal file
105
agent/agentcontainers/subagent_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
2
agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log
generated
vendored
Normal file
2
agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log
generated
vendored
Normal 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"}
|
8
agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log
generated
vendored
Normal file
8
agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log
generated
vendored
Normal 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"]}}}}
|
8
agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log
generated
vendored
Normal file
8
agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log
generated
vendored
Normal 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":{}}}
|
@ -171,6 +171,10 @@ 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
|
||||
@ -187,6 +191,7 @@ type FakeAgentAPI struct {
|
||||
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,
|
||||
|
Reference in New Issue
Block a user