Skip to content

Commit 529fb50

Browse files
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`
1 parent 5e3a225 commit 529fb50

File tree

9 files changed

+526
-20
lines changed

9 files changed

+526
-20
lines changed

agent/agentcontainers/acmock/acmock.go

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

agent/agentcontainers/api.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ type API struct {
6464
subAgentURL string
6565
subAgentEnv []string
6666

67+
ownerName string
68+
workspaceName string
69+
6770
mu sync.RWMutex
6871
closed bool
6972
containers codersdk.WorkspaceAgentListContainersResponse // Output from the last list operation.
@@ -153,6 +156,15 @@ func WithSubAgentEnv(env ...string) Option {
153156
}
154157
}
155158

159+
// WithManifestInfo sets the owner name, and workspace name
160+
// for the sub-agent.
161+
func WithManifestInfo(owner, workspace string) Option {
162+
return func(api *API) {
163+
api.ownerName = owner
164+
api.workspaceName = workspace
165+
}
166+
}
167+
156168
// WithDevcontainers sets the known devcontainers for the API. This
157169
// allows the API to be aware of devcontainers defined in the workspace
158170
// agent manifest.
@@ -1127,7 +1139,16 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
11271139
codersdk.DisplayAppPortForward: true,
11281140
}
11291141

1130-
if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath); err != nil {
1142+
var appsWithPossibleDuplicates []SubAgentApp
1143+
1144+
if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath,
1145+
[]string{
1146+
fmt.Sprintf("CODER_WORKSPACE_AGENT_NAME=%s", dc.Name),
1147+
fmt.Sprintf("CODER_WORKSPACE_OWNER_NAME=%s", api.ownerName),
1148+
fmt.Sprintf("CODER_WORKSPACE_NAME=%s", api.workspaceName),
1149+
fmt.Sprintf("CODER_URL=%s", api.subAgentURL),
1150+
},
1151+
); err != nil {
11311152
api.logger.Error(ctx, "unable to read devcontainer config", slog.Error(err))
11321153
} else {
11331154
coderCustomization := config.MergedConfiguration.Customizations.Coder
@@ -1143,6 +1164,8 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
11431164
}
11441165
displayAppsMap[app] = enabled
11451166
}
1167+
1168+
appsWithPossibleDuplicates = append(appsWithPossibleDuplicates, customization.Apps...)
11461169
}
11471170
}
11481171

@@ -1154,7 +1177,27 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
11541177
}
11551178
slices.Sort(displayApps)
11561179

1180+
appSlugs := make(map[string]struct{})
1181+
apps := make([]SubAgentApp, 0, len(appsWithPossibleDuplicates))
1182+
1183+
// We want to deduplicate the apps based on their slugs here.
1184+
// As we want to prioritize later apps, we will walk through this
1185+
// backwards.
1186+
for _, app := range slices.Backward(appsWithPossibleDuplicates) {
1187+
if _, slugAlreadyExists := appSlugs[app.Slug]; slugAlreadyExists {
1188+
continue
1189+
}
1190+
1191+
appSlugs[app.Slug] = struct{}{}
1192+
apps = append(apps, app)
1193+
}
1194+
1195+
// Apps is currently in reverse order here, so by reversing it we restore
1196+
// it to the original order.
1197+
slices.Reverse(apps)
1198+
11571199
subAgentConfig.DisplayApps = displayApps
1200+
subAgentConfig.Apps = apps
11581201
}
11591202

11601203
deleteSubAgent := proc.agent.ID != uuid.Nil && maybeRecreateSubAgent && !proc.agent.EqualConfig(subAgentConfig)

agent/agentcontainers/api_test.go

Lines changed: 132 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ type fakeDevcontainerCLI struct {
6868
execErrC chan func(cmd string, args ...string) error // If set, send fn to return err, nil or close to return execErr.
6969
readConfig agentcontainers.DevcontainerConfig
7070
readConfigErr error
71-
readConfigErrC chan error
71+
readConfigErrC chan func(envs []string) error
7272
}
7373

7474
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,
9999
return f.execErr
100100
}
101101

102-
func (f *fakeDevcontainerCLI) ReadConfig(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) {
102+
func (f *fakeDevcontainerCLI) ReadConfig(ctx context.Context, _, _ string, envs []string, _ ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) {
103103
if f.readConfigErrC != nil {
104104
select {
105105
case <-ctx.Done():
106106
return agentcontainers.DevcontainerConfig{}, ctx.Err()
107-
case err, ok := <-f.readConfigErrC:
107+
case fn, ok := <-f.readConfigErrC:
108108
if ok {
109-
return f.readConfig, err
109+
return f.readConfig, fn(envs)
110110
}
111111
}
112112
}
@@ -1253,7 +1253,8 @@ func TestAPI(t *testing.T) {
12531253
deleteErrC: make(chan error, 1),
12541254
}
12551255
fakeDCCLI = &fakeDevcontainerCLI{
1256-
execErrC: make(chan func(cmd string, args ...string) error, 1),
1256+
execErrC: make(chan func(cmd string, args ...string) error, 1),
1257+
readConfigErrC: make(chan func(envs []string) error, 1),
12571258
}
12581259

12591260
testContainer = codersdk.WorkspaceAgentContainer{
@@ -1293,13 +1294,15 @@ func TestAPI(t *testing.T) {
12931294
agentcontainers.WithSubAgentClient(fakeSAC),
12941295
agentcontainers.WithSubAgentURL("test-subagent-url"),
12951296
agentcontainers.WithDevcontainerCLI(fakeDCCLI),
1297+
agentcontainers.WithManifestInfo("test-user", "test-workspace"),
12961298
)
12971299
apiClose := func() {
12981300
closeOnce.Do(func() {
12991301
// Close before api.Close() defer to avoid deadlock after test.
13001302
close(fakeSAC.createErrC)
13011303
close(fakeSAC.deleteErrC)
13021304
close(fakeDCCLI.execErrC)
1305+
close(fakeDCCLI.readConfigErrC)
13031306

13041307
_ = api.Close()
13051308
})
@@ -1313,6 +1316,13 @@ func TestAPI(t *testing.T) {
13131316
assert.Empty(t, args)
13141317
return nil
13151318
}) // Exec pwd.
1319+
testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error {
1320+
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=test-container")
1321+
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
1322+
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
1323+
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
1324+
return nil
1325+
})
13161326

13171327
// Make sure the ticker function has been registered
13181328
// before advancing the clock.
@@ -1453,6 +1463,13 @@ func TestAPI(t *testing.T) {
14531463
assert.Empty(t, args)
14541464
return nil
14551465
}) // Exec pwd.
1466+
testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error {
1467+
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=test-container")
1468+
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
1469+
assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user")
1470+
assert.Contains(t, envs, "CODER_URL=test-subagent-url")
1471+
return nil
1472+
})
14561473

14571474
err = api.RefreshContainers(ctx)
14581475
require.NoError(t, err, "refresh containers should not fail")
@@ -1603,6 +1620,116 @@ func TestAPI(t *testing.T) {
16031620
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppPortForward)
16041621
},
16051622
},
1623+
{
1624+
name: "WithApps",
1625+
customization: []agentcontainers.CoderCustomization{
1626+
{
1627+
Apps: []agentcontainers.SubAgentApp{
1628+
{
1629+
Slug: "web-app",
1630+
DisplayName: "Web Application",
1631+
URL: "http://localhost:8080",
1632+
OpenIn: codersdk.WorkspaceAppOpenInTab,
1633+
Share: codersdk.WorkspaceAppSharingLevelOwner,
1634+
Icon: "/icons/web.svg",
1635+
Order: int32(1),
1636+
},
1637+
{
1638+
Slug: "api-server",
1639+
DisplayName: "API Server",
1640+
URL: "http://localhost:3000",
1641+
OpenIn: codersdk.WorkspaceAppOpenInSlimWindow,
1642+
Share: codersdk.WorkspaceAppSharingLevelAuthenticated,
1643+
Icon: "/icons/api.svg",
1644+
Order: int32(2),
1645+
Hidden: true,
1646+
},
1647+
{
1648+
Slug: "docs",
1649+
DisplayName: "Documentation",
1650+
URL: "http://localhost:4000",
1651+
OpenIn: codersdk.WorkspaceAppOpenInTab,
1652+
Share: codersdk.WorkspaceAppSharingLevelPublic,
1653+
Icon: "/icons/book.svg",
1654+
Order: int32(3),
1655+
},
1656+
},
1657+
},
1658+
},
1659+
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
1660+
require.Len(t, subAgent.Apps, 3)
1661+
1662+
// Verify first app
1663+
assert.Equal(t, "web-app", subAgent.Apps[0].Slug)
1664+
assert.Equal(t, "Web Application", subAgent.Apps[0].DisplayName)
1665+
assert.Equal(t, "http://localhost:8080", subAgent.Apps[0].URL)
1666+
assert.Equal(t, codersdk.WorkspaceAppOpenInTab, subAgent.Apps[0].OpenIn)
1667+
assert.Equal(t, codersdk.WorkspaceAppSharingLevelOwner, subAgent.Apps[0].Share)
1668+
assert.Equal(t, "/icons/web.svg", subAgent.Apps[0].Icon)
1669+
assert.Equal(t, int32(1), subAgent.Apps[0].Order)
1670+
1671+
// Verify second app
1672+
assert.Equal(t, "api-server", subAgent.Apps[1].Slug)
1673+
assert.Equal(t, "API Server", subAgent.Apps[1].DisplayName)
1674+
assert.Equal(t, "http://localhost:3000", subAgent.Apps[1].URL)
1675+
assert.Equal(t, codersdk.WorkspaceAppOpenInSlimWindow, subAgent.Apps[1].OpenIn)
1676+
assert.Equal(t, codersdk.WorkspaceAppSharingLevelAuthenticated, subAgent.Apps[1].Share)
1677+
assert.Equal(t, "/icons/api.svg", subAgent.Apps[1].Icon)
1678+
assert.Equal(t, int32(2), subAgent.Apps[1].Order)
1679+
assert.Equal(t, true, subAgent.Apps[1].Hidden)
1680+
1681+
// Verify third app
1682+
assert.Equal(t, "docs", subAgent.Apps[2].Slug)
1683+
assert.Equal(t, "Documentation", subAgent.Apps[2].DisplayName)
1684+
assert.Equal(t, "http://localhost:4000", subAgent.Apps[2].URL)
1685+
assert.Equal(t, codersdk.WorkspaceAppOpenInTab, subAgent.Apps[2].OpenIn)
1686+
assert.Equal(t, codersdk.WorkspaceAppSharingLevelPublic, subAgent.Apps[2].Share)
1687+
assert.Equal(t, "/icons/book.svg", subAgent.Apps[2].Icon)
1688+
assert.Equal(t, int32(3), subAgent.Apps[2].Order)
1689+
},
1690+
},
1691+
{
1692+
name: "AppDeduplication",
1693+
customization: []agentcontainers.CoderCustomization{
1694+
{
1695+
Apps: []agentcontainers.SubAgentApp{
1696+
{
1697+
Slug: "foo-app",
1698+
Hidden: true,
1699+
Order: 1,
1700+
},
1701+
{
1702+
Slug: "bar-app",
1703+
},
1704+
},
1705+
},
1706+
{
1707+
Apps: []agentcontainers.SubAgentApp{
1708+
{
1709+
Slug: "foo-app",
1710+
Order: 2,
1711+
},
1712+
{
1713+
Slug: "baz-app",
1714+
},
1715+
},
1716+
},
1717+
},
1718+
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
1719+
require.Len(t, subAgent.Apps, 3)
1720+
1721+
// As the original "foo-app" gets overridden by the later "foo-app",
1722+
// we expect "bar-app" to be first in the order.
1723+
assert.Equal(t, "bar-app", subAgent.Apps[0].Slug)
1724+
assert.Equal(t, "foo-app", subAgent.Apps[1].Slug)
1725+
assert.Equal(t, "baz-app", subAgent.Apps[2].Slug)
1726+
1727+
// We do not expect the properties from the original "foo-app" to be
1728+
// carried over.
1729+
assert.Equal(t, false, subAgent.Apps[1].Hidden)
1730+
assert.Equal(t, int32(2), subAgent.Apps[1].Order)
1731+
},
1732+
},
16061733
}
16071734

16081735
for _, tt := range tests {

agent/agentcontainers/devcontainercli.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/json"
88
"errors"
99
"io"
10+
"os"
1011

1112
"golang.org/x/xerrors"
1213

@@ -32,13 +33,14 @@ type DevcontainerCustomizations struct {
3233

3334
type CoderCustomization struct {
3435
DisplayApps map[codersdk.DisplayApp]bool `json:"displayApps,omitempty"`
36+
Apps []SubAgentApp `json:"apps,omitempty"`
3537
}
3638

3739
// DevcontainerCLI is an interface for the devcontainer CLI.
3840
type DevcontainerCLI interface {
3941
Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (id string, err error)
4042
Exec(ctx context.Context, workspaceFolder, configPath string, cmd string, cmdArgs []string, opts ...DevcontainerCLIExecOptions) error
41-
ReadConfig(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error)
43+
ReadConfig(ctx context.Context, workspaceFolder, configPath string, env []string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error)
4244
}
4345

4446
// DevcontainerCLIUpOptions are options for the devcontainer CLI Up
@@ -113,8 +115,8 @@ type devcontainerCLIReadConfigConfig struct {
113115
stderr io.Writer
114116
}
115117

116-
// WithExecOutput sets additional stdout and stderr writers for logs
117-
// during Exec operations.
118+
// WithReadConfigOutput sets additional stdout and stderr writers for logs
119+
// during ReadConfig operations.
118120
func WithReadConfigOutput(stdout, stderr io.Writer) DevcontainerCLIReadConfigOptions {
119121
return func(o *devcontainerCLIReadConfigConfig) {
120122
o.stdout = stdout
@@ -250,7 +252,7 @@ func (d *devcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath
250252
return nil
251253
}
252254

253-
func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error) {
255+
func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configPath string, env []string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error) {
254256
conf := applyDevcontainerCLIReadConfigOptions(opts)
255257
logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath))
256258

@@ -263,6 +265,8 @@ func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, confi
263265
}
264266

265267
c := d.execer.CommandContext(ctx, "devcontainer", args...)
268+
c.Env = append(c.Env, "PATH="+os.Getenv("PATH"))
269+
c.Env = append(c.Env, env...)
266270

267271
var stdoutBuf bytes.Buffer
268272
stdoutWriters := []io.Writer{&stdoutBuf, &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}}

agent/agentcontainers/devcontainercli_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) {
316316
}
317317

318318
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
319-
config, err := dccli.ReadConfig(ctx, tt.workspaceFolder, tt.configPath, tt.opts...)
319+
config, err := dccli.ReadConfig(ctx, tt.workspaceFolder, tt.configPath, []string{}, tt.opts...)
320320
if tt.wantError {
321321
assert.Error(t, err, "want error")
322322
assert.Equal(t, agentcontainers.DevcontainerConfig{}, config, "expected empty config on error")

0 commit comments

Comments
 (0)