feat(agent/agentcontainers): support apps for dev container agents (#18346)

Add apps to the sub agent based on the dev container customization.

The implementation also provides the following env variables for use in
the devcontainer json

- `CODER_WORKSPACE_AGENT_NAME`
- `CODER_WORKSPACE_USER_NAME`
- `CODER_WORKSPACE_NAME`
- `CODER_DEPLOYMENT_URL`
This commit is contained in:
Danielle Maywood
2025-06-18 14:55:27 +01:00
committed by GitHub
parent 5e3a225e3b
commit 529fb5083c
9 changed files with 526 additions and 20 deletions

View File

@ -150,9 +150,9 @@ func (mr *MockDevcontainerCLIMockRecorder) Exec(ctx, workspaceFolder, configPath
}
// ReadConfig mocks base method.
func (m *MockDevcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configPath string, opts ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) {
func (m *MockDevcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configPath string, env []string, opts ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) {
m.ctrl.T.Helper()
varargs := []any{ctx, workspaceFolder, configPath}
varargs := []any{ctx, workspaceFolder, configPath, env}
for _, a := range opts {
varargs = append(varargs, a)
}
@ -163,9 +163,9 @@ func (m *MockDevcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, c
}
// ReadConfig indicates an expected call of ReadConfig.
func (mr *MockDevcontainerCLIMockRecorder) ReadConfig(ctx, workspaceFolder, configPath any, opts ...any) *gomock.Call {
func (mr *MockDevcontainerCLIMockRecorder) ReadConfig(ctx, workspaceFolder, configPath, env any, opts ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx, workspaceFolder, configPath}, opts...)
varargs := append([]any{ctx, workspaceFolder, configPath, env}, opts...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadConfig", reflect.TypeOf((*MockDevcontainerCLI)(nil).ReadConfig), varargs...)
}

View File

@ -64,6 +64,9 @@ type API struct {
subAgentURL string
subAgentEnv []string
ownerName string
workspaceName string
mu sync.RWMutex
closed bool
containers codersdk.WorkspaceAgentListContainersResponse // Output from the last list operation.
@ -153,6 +156,15 @@ func WithSubAgentEnv(env ...string) Option {
}
}
// WithManifestInfo sets the owner name, and workspace name
// for the sub-agent.
func WithManifestInfo(owner, workspace string) Option {
return func(api *API) {
api.ownerName = owner
api.workspaceName = workspace
}
}
// WithDevcontainers sets the known devcontainers for the API. This
// allows the API to be aware of devcontainers defined in the workspace
// agent manifest.
@ -1127,7 +1139,16 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
codersdk.DisplayAppPortForward: true,
}
if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath); err != nil {
var appsWithPossibleDuplicates []SubAgentApp
if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath,
[]string{
fmt.Sprintf("CODER_WORKSPACE_AGENT_NAME=%s", dc.Name),
fmt.Sprintf("CODER_WORKSPACE_OWNER_NAME=%s", api.ownerName),
fmt.Sprintf("CODER_WORKSPACE_NAME=%s", api.workspaceName),
fmt.Sprintf("CODER_URL=%s", api.subAgentURL),
},
); err != nil {
api.logger.Error(ctx, "unable to read devcontainer config", slog.Error(err))
} else {
coderCustomization := config.MergedConfiguration.Customizations.Coder
@ -1143,6 +1164,8 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
}
displayAppsMap[app] = enabled
}
appsWithPossibleDuplicates = append(appsWithPossibleDuplicates, customization.Apps...)
}
}
@ -1154,7 +1177,27 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
}
slices.Sort(displayApps)
appSlugs := make(map[string]struct{})
apps := make([]SubAgentApp, 0, len(appsWithPossibleDuplicates))
// We want to deduplicate the apps based on their slugs here.
// As we want to prioritize later apps, we will walk through this
// backwards.
for _, app := range slices.Backward(appsWithPossibleDuplicates) {
if _, slugAlreadyExists := appSlugs[app.Slug]; slugAlreadyExists {
continue
}
appSlugs[app.Slug] = struct{}{}
apps = append(apps, app)
}
// Apps is currently in reverse order here, so by reversing it we restore
// it to the original order.
slices.Reverse(apps)
subAgentConfig.DisplayApps = displayApps
subAgentConfig.Apps = apps
}
deleteSubAgent := proc.agent.ID != uuid.Nil && maybeRecreateSubAgent && !proc.agent.EqualConfig(subAgentConfig)

View File

@ -68,7 +68,7 @@ type fakeDevcontainerCLI struct {
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
readConfigErrC chan func(envs []string) error
}
func (f *fakeDevcontainerCLI) Up(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) {
@ -99,14 +99,14 @@ 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) {
func (f *fakeDevcontainerCLI) ReadConfig(ctx context.Context, _, _ string, envs []string, _ ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) {
if f.readConfigErrC != nil {
select {
case <-ctx.Done():
return agentcontainers.DevcontainerConfig{}, ctx.Err()
case err, ok := <-f.readConfigErrC:
case fn, ok := <-f.readConfigErrC:
if ok {
return f.readConfig, err
return f.readConfig, fn(envs)
}
}
}
@ -1254,6 +1254,7 @@ func TestAPI(t *testing.T) {
}
fakeDCCLI = &fakeDevcontainerCLI{
execErrC: make(chan func(cmd string, args ...string) error, 1),
readConfigErrC: make(chan func(envs []string) error, 1),
}
testContainer = codersdk.WorkspaceAgentContainer{
@ -1293,6 +1294,7 @@ func TestAPI(t *testing.T) {
agentcontainers.WithSubAgentClient(fakeSAC),
agentcontainers.WithSubAgentURL("test-subagent-url"),
agentcontainers.WithDevcontainerCLI(fakeDCCLI),
agentcontainers.WithManifestInfo("test-user", "test-workspace"),
)
apiClose := func() {
closeOnce.Do(func() {
@ -1300,6 +1302,7 @@ func TestAPI(t *testing.T) {
close(fakeSAC.createErrC)
close(fakeSAC.deleteErrC)
close(fakeDCCLI.execErrC)
close(fakeDCCLI.readConfigErrC)
_ = api.Close()
})
@ -1313,6 +1316,13 @@ func TestAPI(t *testing.T) {
assert.Empty(t, args)
return nil
}) // Exec pwd.
testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error {
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=test-container")
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
return nil
})
// Make sure the ticker function has been registered
// before advancing the clock.
@ -1453,6 +1463,13 @@ func TestAPI(t *testing.T) {
assert.Empty(t, args)
return nil
}) // Exec pwd.
testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error {
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=test-container")
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
return nil
})
err = api.RefreshContainers(ctx)
require.NoError(t, err, "refresh containers should not fail")
@ -1603,6 +1620,116 @@ func TestAPI(t *testing.T) {
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppPortForward)
},
},
{
name: "WithApps",
customization: []agentcontainers.CoderCustomization{
{
Apps: []agentcontainers.SubAgentApp{
{
Slug: "web-app",
DisplayName: "Web Application",
URL: "http://localhost:8080",
OpenIn: codersdk.WorkspaceAppOpenInTab,
Share: codersdk.WorkspaceAppSharingLevelOwner,
Icon: "/icons/web.svg",
Order: int32(1),
},
{
Slug: "api-server",
DisplayName: "API Server",
URL: "http://localhost:3000",
OpenIn: codersdk.WorkspaceAppOpenInSlimWindow,
Share: codersdk.WorkspaceAppSharingLevelAuthenticated,
Icon: "/icons/api.svg",
Order: int32(2),
Hidden: true,
},
{
Slug: "docs",
DisplayName: "Documentation",
URL: "http://localhost:4000",
OpenIn: codersdk.WorkspaceAppOpenInTab,
Share: codersdk.WorkspaceAppSharingLevelPublic,
Icon: "/icons/book.svg",
Order: int32(3),
},
},
},
},
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
require.Len(t, subAgent.Apps, 3)
// Verify first app
assert.Equal(t, "web-app", subAgent.Apps[0].Slug)
assert.Equal(t, "Web Application", subAgent.Apps[0].DisplayName)
assert.Equal(t, "http://localhost:8080", subAgent.Apps[0].URL)
assert.Equal(t, codersdk.WorkspaceAppOpenInTab, subAgent.Apps[0].OpenIn)
assert.Equal(t, codersdk.WorkspaceAppSharingLevelOwner, subAgent.Apps[0].Share)
assert.Equal(t, "/icons/web.svg", subAgent.Apps[0].Icon)
assert.Equal(t, int32(1), subAgent.Apps[0].Order)
// Verify second app
assert.Equal(t, "api-server", subAgent.Apps[1].Slug)
assert.Equal(t, "API Server", subAgent.Apps[1].DisplayName)
assert.Equal(t, "http://localhost:3000", subAgent.Apps[1].URL)
assert.Equal(t, codersdk.WorkspaceAppOpenInSlimWindow, subAgent.Apps[1].OpenIn)
assert.Equal(t, codersdk.WorkspaceAppSharingLevelAuthenticated, subAgent.Apps[1].Share)
assert.Equal(t, "/icons/api.svg", subAgent.Apps[1].Icon)
assert.Equal(t, int32(2), subAgent.Apps[1].Order)
assert.Equal(t, true, subAgent.Apps[1].Hidden)
// Verify third app
assert.Equal(t, "docs", subAgent.Apps[2].Slug)
assert.Equal(t, "Documentation", subAgent.Apps[2].DisplayName)
assert.Equal(t, "http://localhost:4000", subAgent.Apps[2].URL)
assert.Equal(t, codersdk.WorkspaceAppOpenInTab, subAgent.Apps[2].OpenIn)
assert.Equal(t, codersdk.WorkspaceAppSharingLevelPublic, subAgent.Apps[2].Share)
assert.Equal(t, "/icons/book.svg", subAgent.Apps[2].Icon)
assert.Equal(t, int32(3), subAgent.Apps[2].Order)
},
},
{
name: "AppDeduplication",
customization: []agentcontainers.CoderCustomization{
{
Apps: []agentcontainers.SubAgentApp{
{
Slug: "foo-app",
Hidden: true,
Order: 1,
},
{
Slug: "bar-app",
},
},
},
{
Apps: []agentcontainers.SubAgentApp{
{
Slug: "foo-app",
Order: 2,
},
{
Slug: "baz-app",
},
},
},
},
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
require.Len(t, subAgent.Apps, 3)
// As the original "foo-app" gets overridden by the later "foo-app",
// we expect "bar-app" to be first in the order.
assert.Equal(t, "bar-app", subAgent.Apps[0].Slug)
assert.Equal(t, "foo-app", subAgent.Apps[1].Slug)
assert.Equal(t, "baz-app", subAgent.Apps[2].Slug)
// We do not expect the properties from the original "foo-app" to be
// carried over.
assert.Equal(t, false, subAgent.Apps[1].Hidden)
assert.Equal(t, int32(2), subAgent.Apps[1].Order)
},
},
}
for _, tt := range tests {

View File

@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"io"
"os"
"golang.org/x/xerrors"
@ -32,13 +33,14 @@ type DevcontainerCustomizations struct {
type CoderCustomization struct {
DisplayApps map[codersdk.DisplayApp]bool `json:"displayApps,omitempty"`
Apps []SubAgentApp `json:"apps,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)
ReadConfig(ctx context.Context, workspaceFolder, configPath string, env []string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error)
}
// DevcontainerCLIUpOptions are options for the devcontainer CLI Up
@ -113,8 +115,8 @@ type devcontainerCLIReadConfigConfig struct {
stderr io.Writer
}
// WithExecOutput sets additional stdout and stderr writers for logs
// during Exec operations.
// WithReadConfigOutput sets additional stdout and stderr writers for logs
// during ReadConfig operations.
func WithReadConfigOutput(stdout, stderr io.Writer) DevcontainerCLIReadConfigOptions {
return func(o *devcontainerCLIReadConfigConfig) {
o.stdout = stdout
@ -250,7 +252,7 @@ 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) {
func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configPath string, env []string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error) {
conf := applyDevcontainerCLIReadConfigOptions(opts)
logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath))
@ -263,6 +265,8 @@ func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, confi
}
c := d.execer.CommandContext(ctx, "devcontainer", args...)
c.Env = append(c.Env, "PATH="+os.Getenv("PATH"))
c.Env = append(c.Env, env...)
var stdoutBuf bytes.Buffer
stdoutWriters := []io.Writer{&stdoutBuf, &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}}

View File

@ -316,7 +316,7 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) {
}
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
config, err := dccli.ReadConfig(ctx, tt.workspaceFolder, tt.configPath, tt.opts...)
config, err := dccli.ReadConfig(ctx, tt.workspaceFolder, tt.configPath, []string{}, tt.opts...)
if tt.wantError {
assert.Error(t, err, "want error")
assert.Equal(t, agentcontainers.DevcontainerConfig{}, config, "expected empty config on error")

View File

@ -21,6 +21,7 @@ type SubAgent struct {
Directory string
Architecture string
OperatingSystem string
Apps []SubAgentApp
DisplayApps []codersdk.DisplayApp
}
@ -33,6 +34,7 @@ func (s SubAgent) CloneConfig(dc codersdk.WorkspaceAgentDevcontainer) SubAgent {
Architecture: s.Architecture,
OperatingSystem: s.OperatingSystem,
DisplayApps: slices.Clone(s.DisplayApps),
Apps: slices.Clone(s.Apps),
}
}
@ -41,7 +43,92 @@ func (s SubAgent) EqualConfig(other SubAgent) bool {
s.Directory == other.Directory &&
s.Architecture == other.Architecture &&
s.OperatingSystem == other.OperatingSystem &&
slices.Equal(s.DisplayApps, other.DisplayApps)
slices.Equal(s.DisplayApps, other.DisplayApps) &&
slices.Equal(s.Apps, other.Apps)
}
type SubAgentApp struct {
Slug string `json:"slug"`
Command string `json:"command"`
DisplayName string `json:"displayName"`
External bool `json:"external"`
Group string `json:"group"`
HealthCheck SubAgentHealthCheck `json:"healthCheck"`
Hidden bool `json:"hidden"`
Icon string `json:"icon"`
OpenIn codersdk.WorkspaceAppOpenIn `json:"openIn"`
Order int32 `json:"order"`
Share codersdk.WorkspaceAppSharingLevel `json:"share"`
Subdomain bool `json:"subdomain"`
URL string `json:"url"`
}
func (app SubAgentApp) ToProtoApp() (*agentproto.CreateSubAgentRequest_App, error) {
proto := agentproto.CreateSubAgentRequest_App{
Slug: app.Slug,
External: &app.External,
Hidden: &app.Hidden,
Order: &app.Order,
Subdomain: &app.Subdomain,
}
if app.Command != "" {
proto.Command = &app.Command
}
if app.DisplayName != "" {
proto.DisplayName = &app.DisplayName
}
if app.Group != "" {
proto.Group = &app.Group
}
if app.Icon != "" {
proto.Icon = &app.Icon
}
if app.URL != "" {
proto.Url = &app.URL
}
if app.HealthCheck.URL != "" {
proto.Healthcheck = &agentproto.CreateSubAgentRequest_App_Healthcheck{
Interval: app.HealthCheck.Interval,
Threshold: app.HealthCheck.Threshold,
Url: app.HealthCheck.URL,
}
}
if app.OpenIn != "" {
switch app.OpenIn {
case codersdk.WorkspaceAppOpenInSlimWindow:
proto.OpenIn = agentproto.CreateSubAgentRequest_App_SLIM_WINDOW.Enum()
case codersdk.WorkspaceAppOpenInTab:
proto.OpenIn = agentproto.CreateSubAgentRequest_App_TAB.Enum()
default:
return nil, xerrors.Errorf("unexpected codersdk.WorkspaceAppOpenIn: %#v", app.OpenIn)
}
}
if app.Share != "" {
switch app.Share {
case codersdk.WorkspaceAppSharingLevelAuthenticated:
proto.Share = agentproto.CreateSubAgentRequest_App_AUTHENTICATED.Enum()
case codersdk.WorkspaceAppSharingLevelOwner:
proto.Share = agentproto.CreateSubAgentRequest_App_OWNER.Enum()
case codersdk.WorkspaceAppSharingLevelPublic:
proto.Share = agentproto.CreateSubAgentRequest_App_PUBLIC.Enum()
case codersdk.WorkspaceAppSharingLevelOrganization:
proto.Share = agentproto.CreateSubAgentRequest_App_ORGANIZATION.Enum()
default:
return nil, xerrors.Errorf("unexpected codersdk.WorkspaceAppSharingLevel: %#v", app.Share)
}
}
return &proto, nil
}
type SubAgentHealthCheck struct {
Interval int32 `json:"interval"`
Threshold int32 `json:"threshold"`
URL string `json:"url"`
}
// SubAgentClient is an interface for managing sub agents and allows
@ -125,12 +212,23 @@ func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (SubAgen
displayApps = append(displayApps, app)
}
apps := make([]*agentproto.CreateSubAgentRequest_App, 0, len(agent.Apps))
for _, app := range agent.Apps {
protoApp, err := app.ToProtoApp()
if err != nil {
return SubAgent{}, xerrors.Errorf("convert app: %w", err)
}
apps = append(apps, protoApp)
}
resp, err := a.api.CreateSubAgent(ctx, &agentproto.CreateSubAgentRequest{
Name: agent.Name,
Directory: agent.Directory,
Architecture: agent.Architecture,
OperatingSystem: agent.OperatingSystem,
DisplayApps: displayApps,
Apps: apps,
})
if err != nil {
return SubAgent{}, err

View File

@ -4,11 +4,13 @@ import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"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/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/tailnet"
@ -102,4 +104,205 @@ func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) {
})
}
})
t.Run("CreateWithApps", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
apps []agentcontainers.SubAgentApp
expectedApps []*agentproto.CreateSubAgentRequest_App
}{
{
name: "SlugOnly",
apps: []agentcontainers.SubAgentApp{
{
Slug: "code-server",
},
},
expectedApps: []*agentproto.CreateSubAgentRequest_App{
{
Slug: "code-server",
},
},
},
{
name: "AllFields",
apps: []agentcontainers.SubAgentApp{
{
Slug: "jupyter",
Command: "jupyter lab --port=8888",
DisplayName: "Jupyter Lab",
External: false,
Group: "Development",
HealthCheck: agentcontainers.SubAgentHealthCheck{
Interval: 30,
Threshold: 3,
URL: "http://localhost:8888/api",
},
Hidden: false,
Icon: "/icon/jupyter.svg",
OpenIn: codersdk.WorkspaceAppOpenInTab,
Order: int32(1),
Share: codersdk.WorkspaceAppSharingLevelAuthenticated,
Subdomain: true,
URL: "http://localhost:8888",
},
},
expectedApps: []*agentproto.CreateSubAgentRequest_App{
{
Slug: "jupyter",
Command: ptr.Ref("jupyter lab --port=8888"),
DisplayName: ptr.Ref("Jupyter Lab"),
External: ptr.Ref(false),
Group: ptr.Ref("Development"),
Healthcheck: &agentproto.CreateSubAgentRequest_App_Healthcheck{
Interval: 30,
Threshold: 3,
Url: "http://localhost:8888/api",
},
Hidden: ptr.Ref(false),
Icon: ptr.Ref("/icon/jupyter.svg"),
OpenIn: agentproto.CreateSubAgentRequest_App_TAB.Enum(),
Order: ptr.Ref(int32(1)),
Share: agentproto.CreateSubAgentRequest_App_AUTHENTICATED.Enum(),
Subdomain: ptr.Ref(true),
Url: ptr.Ref("http://localhost:8888"),
},
},
},
{
name: "AllSharingLevels",
apps: []agentcontainers.SubAgentApp{
{
Slug: "owner-app",
Share: codersdk.WorkspaceAppSharingLevelOwner,
},
{
Slug: "authenticated-app",
Share: codersdk.WorkspaceAppSharingLevelAuthenticated,
},
{
Slug: "public-app",
Share: codersdk.WorkspaceAppSharingLevelPublic,
},
{
Slug: "organization-app",
Share: codersdk.WorkspaceAppSharingLevelOrganization,
},
},
expectedApps: []*agentproto.CreateSubAgentRequest_App{
{
Slug: "owner-app",
Share: agentproto.CreateSubAgentRequest_App_OWNER.Enum(),
},
{
Slug: "authenticated-app",
Share: agentproto.CreateSubAgentRequest_App_AUTHENTICATED.Enum(),
},
{
Slug: "public-app",
Share: agentproto.CreateSubAgentRequest_App_PUBLIC.Enum(),
},
{
Slug: "organization-app",
Share: agentproto.CreateSubAgentRequest_App_ORGANIZATION.Enum(),
},
},
},
{
name: "WithHealthCheck",
apps: []agentcontainers.SubAgentApp{
{
Slug: "health-app",
HealthCheck: agentcontainers.SubAgentHealthCheck{
Interval: 60,
Threshold: 5,
URL: "http://localhost:3000/health",
},
},
},
expectedApps: []*agentproto.CreateSubAgentRequest_App{
{
Slug: "health-app",
Healthcheck: &agentproto.CreateSubAgentRequest_App_Healthcheck{
Interval: 60,
Threshold: 5,
Url: "http://localhost:3000/health",
},
},
},
},
}
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",
Apps: tt.apps,
})
require.NoError(t, err)
apps, err := agentAPI.GetSubAgentApps(subAgent.ID)
require.NoError(t, err)
// Then: We expect the apps to be created.
require.Len(t, apps, len(tt.expectedApps))
for i, expectedApp := range tt.expectedApps {
actualApp := apps[i]
assert.Equal(t, expectedApp.Slug, actualApp.Slug)
assert.Equal(t, expectedApp.Command, actualApp.Command)
assert.Equal(t, expectedApp.DisplayName, actualApp.DisplayName)
assert.Equal(t, ptr.NilToEmpty(expectedApp.External), ptr.NilToEmpty(actualApp.External))
assert.Equal(t, expectedApp.Group, actualApp.Group)
assert.Equal(t, ptr.NilToEmpty(expectedApp.Hidden), ptr.NilToEmpty(actualApp.Hidden))
assert.Equal(t, expectedApp.Icon, actualApp.Icon)
assert.Equal(t, ptr.NilToEmpty(expectedApp.Order), ptr.NilToEmpty(actualApp.Order))
assert.Equal(t, ptr.NilToEmpty(expectedApp.Subdomain), ptr.NilToEmpty(actualApp.Subdomain))
assert.Equal(t, expectedApp.Url, actualApp.Url)
if expectedApp.OpenIn != nil {
require.NotNil(t, actualApp.OpenIn)
assert.Equal(t, *expectedApp.OpenIn, *actualApp.OpenIn)
} else {
assert.Equal(t, expectedApp.OpenIn, actualApp.OpenIn)
}
if expectedApp.Share != nil {
require.NotNil(t, actualApp.Share)
assert.Equal(t, *expectedApp.Share, *actualApp.Share)
} else {
assert.Equal(t, expectedApp.Share, actualApp.Share)
}
if expectedApp.Healthcheck != nil {
require.NotNil(t, expectedApp.Healthcheck)
assert.Equal(t, expectedApp.Healthcheck.Interval, actualApp.Healthcheck.Interval)
assert.Equal(t, expectedApp.Healthcheck.Threshold, actualApp.Healthcheck.Threshold)
assert.Equal(t, expectedApp.Healthcheck.Url, actualApp.Healthcheck.Url)
} else {
assert.Equal(t, expectedApp.Healthcheck, actualApp.Healthcheck)
}
}
})
}
})
}

View File

@ -175,6 +175,10 @@ func (c *Client) GetSubAgentDisplayApps(id uuid.UUID) ([]agentproto.CreateSubAge
return c.fakeAgentAPI.GetSubAgentDisplayApps(id)
}
func (c *Client) GetSubAgentApps(id uuid.UUID) ([]*agentproto.CreateSubAgentRequest_App, error) {
return c.fakeAgentAPI.GetSubAgentApps(id)
}
type FakeAgentAPI struct {
sync.Mutex
t testing.TB
@ -192,6 +196,7 @@ type FakeAgentAPI struct {
subAgents map[uuid.UUID]*agentproto.SubAgent
subAgentDirs map[uuid.UUID]string
subAgentDisplayApps map[uuid.UUID][]agentproto.CreateSubAgentRequest_DisplayApp
subAgentApps map[uuid.UUID][]*agentproto.CreateSubAgentRequest_App
getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error)
getResourcesMonitoringConfigurationFunc func() (*agentproto.GetResourcesMonitoringConfigurationResponse, error)
@ -410,6 +415,10 @@ func (f *FakeAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Creat
f.subAgentDisplayApps = make(map[uuid.UUID][]agentproto.CreateSubAgentRequest_DisplayApp)
}
f.subAgentDisplayApps[subAgentID] = req.GetDisplayApps()
if f.subAgentApps == nil {
f.subAgentApps = make(map[uuid.UUID][]*agentproto.CreateSubAgentRequest_App)
}
f.subAgentApps[subAgentID] = req.GetApps()
// For a fake implementation, we don't create workspace apps.
// Real implementations would handle req.Apps here.
@ -502,6 +511,22 @@ func (f *FakeAgentAPI) GetSubAgentDisplayApps(id uuid.UUID) ([]agentproto.Create
return displayApps, nil
}
func (f *FakeAgentAPI) GetSubAgentApps(id uuid.UUID) ([]*agentproto.CreateSubAgentRequest_App, error) {
f.Lock()
defer f.Unlock()
if f.subAgentApps == nil {
return nil, xerrors.New("no sub-agent apps available")
}
apps, ok := f.subAgentApps[id]
if !ok {
return nil, xerrors.New("sub-agent apps not found")
}
return apps, nil
}
func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest, statsCh chan *agentproto.Stats) *FakeAgentAPI {
return &FakeAgentAPI{
t: t,

View File

@ -49,12 +49,18 @@ func (a *agent) apiHandler(aAPI proto.DRPCAgentClient26) (http.Handler, func() e
agentcontainers.WithSubAgentClient(agentcontainers.NewSubAgentClientFromAPI(a.logger, aAPI)),
}
manifest := a.manifest.Load()
if manifest != nil && len(manifest.Devcontainers) > 0 {
if manifest != nil {
containerAPIOpts = append(containerAPIOpts,
agentcontainers.WithManifestInfo(manifest.OwnerName, manifest.WorkspaceName),
)
if len(manifest.Devcontainers) > 0 {
containerAPIOpts = append(
containerAPIOpts,
agentcontainers.WithDevcontainers(manifest.Devcontainers, manifest.Scripts),
)
}
}
// Append after to allow the agent options to override the default options.
containerAPIOpts = append(containerAPIOpts, a.containerAPIOptions...)