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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy