Skip to content

Commit 72eccbf

Browse files
feat(agent/agentcontainers): support apps for dev container agents
1 parent b9ac16c commit 72eccbf

File tree

8 files changed

+459
-15
lines changed

8 files changed

+459
-15
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: 27 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+
userName 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,20 @@ func WithSubAgentEnv(env ...string) Option {
153156
}
154157
}
155158

159+
// WithWorkspaceName sets the workspace name for the sub-agent.
160+
func WithWorkspaceName(name string) Option {
161+
return func(api *API) {
162+
api.workspaceName = name
163+
}
164+
}
165+
166+
// WithUserName sets the workspace name for the sub-agent.
167+
func WithUserName(name string) Option {
168+
return func(api *API) {
169+
api.userName = name
170+
}
171+
}
172+
156173
// WithDevcontainers sets the known devcontainers for the API. This
157174
// allows the API to be aware of devcontainers defined in the workspace
158175
// agent manifest.
@@ -1131,7 +1148,14 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
11311148
codersdk.DisplayAppPortForward: true,
11321149
}
11331150

1134-
if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath); err != nil {
1151+
var apps []SubAgentApp
1152+
1153+
if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath, []string{
1154+
fmt.Sprintf("CODER_AGENT_NAME=%s", dc.Name),
1155+
fmt.Sprintf("CODER_USER_NAME=%s", api.userName),
1156+
fmt.Sprintf("CODER_WORKSPACE_NAME=%s", api.workspaceName),
1157+
fmt.Sprintf("CODER_DEPLOYMENT_URL=%s", api.subAgentURL),
1158+
}); err != nil {
11351159
api.logger.Error(ctx, "unable to read devcontainer config", slog.Error(err))
11361160
} else {
11371161
coderCustomization := config.MergedConfiguration.Customizations.Coder
@@ -1140,6 +1164,8 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
11401164
for app, enabled := range customization.DisplayApps {
11411165
displayAppsMap[app] = enabled
11421166
}
1167+
1168+
apps = append(apps, customization.Apps...)
11431169
}
11441170
}
11451171

agent/agentcontainers/api_test.go

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/coder/coder/v2/agent/agentcontainers"
2727
"github.com/coder/coder/v2/agent/agentcontainers/acmock"
2828
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
29+
"github.com/coder/coder/v2/coderd/util/ptr"
2930
"github.com/coder/coder/v2/codersdk"
3031
"github.com/coder/coder/v2/testutil"
3132
"github.com/coder/quartz"
@@ -68,7 +69,7 @@ type fakeDevcontainerCLI struct {
6869
execErrC chan func(cmd string, args ...string) error // If set, send fn to return err, nil or close to return execErr.
6970
readConfig agentcontainers.DevcontainerConfig
7071
readConfigErr error
71-
readConfigErrC chan error
72+
readConfigErrC chan func(envs []string) (agentcontainers.DevcontainerConfig, error)
7273
}
7374

7475
func (f *fakeDevcontainerCLI) Up(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) {
@@ -99,14 +100,14 @@ func (f *fakeDevcontainerCLI) Exec(ctx context.Context, _, _ string, cmd string,
99100
return f.execErr
100101
}
101102

102-
func (f *fakeDevcontainerCLI) ReadConfig(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) {
103+
func (f *fakeDevcontainerCLI) ReadConfig(ctx context.Context, _, _ string, envs []string, _ ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) {
103104
if f.readConfigErrC != nil {
104105
select {
105106
case <-ctx.Done():
106107
return agentcontainers.DevcontainerConfig{}, ctx.Err()
107-
case err, ok := <-f.readConfigErrC:
108+
case fn, ok := <-f.readConfigErrC:
108109
if ok {
109-
return f.readConfig, err
110+
return fn(envs)
110111
}
111112
}
112113
}
@@ -1249,7 +1250,8 @@ func TestAPI(t *testing.T) {
12491250
deleteErrC: make(chan error, 1),
12501251
}
12511252
fakeDCCLI = &fakeDevcontainerCLI{
1252-
execErrC: make(chan func(cmd string, args ...string) error, 1),
1253+
execErrC: make(chan func(cmd string, args ...string) error, 1),
1254+
readConfigErrC: make(chan func(envs []string) (agentcontainers.DevcontainerConfig, error), 1),
12531255
}
12541256

12551257
testContainer = codersdk.WorkspaceAgentContainer{
@@ -1289,13 +1291,16 @@ func TestAPI(t *testing.T) {
12891291
agentcontainers.WithSubAgentClient(fakeSAC),
12901292
agentcontainers.WithSubAgentURL("test-subagent-url"),
12911293
agentcontainers.WithDevcontainerCLI(fakeDCCLI),
1294+
agentcontainers.WithUserName("test-user"),
1295+
agentcontainers.WithWorkspaceName("test-workspace"),
12921296
)
12931297
apiClose := func() {
12941298
closeOnce.Do(func() {
12951299
// Close before api.Close() defer to avoid deadlock after test.
12961300
close(fakeSAC.createErrC)
12971301
close(fakeSAC.deleteErrC)
12981302
close(fakeDCCLI.execErrC)
1303+
defer close(fakeDCCLI.readConfigErrC)
12991304

13001305
_ = api.Close()
13011306
})
@@ -1309,6 +1314,13 @@ func TestAPI(t *testing.T) {
13091314
assert.Empty(t, args)
13101315
return nil
13111316
}) // Exec pwd.
1317+
testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) (agentcontainers.DevcontainerConfig, error) {
1318+
assert.Contains(t, envs, "CODER_AGENT_NAME=test-container")
1319+
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
1320+
assert.Contains(t, envs, "CODER_USER_NAME=test-user")
1321+
assert.Contains(t, envs, "CODER_DEPLOYMENT_URL=test-subagent-url")
1322+
return agentcontainers.DevcontainerConfig{}, nil
1323+
})
13121324

13131325
// Make sure the ticker function has been registered
13141326
// before advancing the clock.
@@ -1413,6 +1425,13 @@ func TestAPI(t *testing.T) {
14131425
assert.Empty(t, args)
14141426
return nil
14151427
}) // Exec pwd.
1428+
testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) (agentcontainers.DevcontainerConfig, error) {
1429+
assert.Contains(t, envs, "CODER_AGENT_NAME=test-container")
1430+
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
1431+
assert.Contains(t, envs, "CODER_USER_NAME=test-user")
1432+
assert.Contains(t, envs, "CODER_DEPLOYMENT_URL=test-subagent-url")
1433+
return agentcontainers.DevcontainerConfig{}, nil
1434+
})
14161435

14171436
// Advance the clock to run updaterLoop.
14181437
for i := range 3 {
@@ -1566,6 +1585,74 @@ func TestAPI(t *testing.T) {
15661585
assert.Contains(t, subAgent.DisplayApps, codersdk.DisplayAppPortForward)
15671586
},
15681587
},
1588+
{
1589+
name: "WithApps",
1590+
customization: []agentcontainers.CoderCustomization{
1591+
{
1592+
Apps: []agentcontainers.SubAgentApp{
1593+
{
1594+
Slug: "web-app",
1595+
DisplayName: ptr.Ref("Web Application"),
1596+
URL: ptr.Ref("http://localhost:8080"),
1597+
OpenIn: codersdk.WorkspaceAppOpenInTab,
1598+
Share: codersdk.WorkspaceAppSharingLevelOwner,
1599+
Icon: ptr.Ref("/icons/web.svg"),
1600+
Order: ptr.Ref(int32(1)),
1601+
},
1602+
{
1603+
Slug: "api-server",
1604+
DisplayName: ptr.Ref("API Server"),
1605+
URL: ptr.Ref("http://localhost:3000"),
1606+
OpenIn: codersdk.WorkspaceAppOpenInSlimWindow,
1607+
Share: codersdk.WorkspaceAppSharingLevelAuthenticated,
1608+
Icon: ptr.Ref("/icons/api.svg"),
1609+
Order: ptr.Ref(int32(2)),
1610+
Hidden: ptr.Ref(true),
1611+
},
1612+
{
1613+
Slug: "docs",
1614+
DisplayName: ptr.Ref("Documentation"),
1615+
URL: ptr.Ref("http://localhost:4000"),
1616+
OpenIn: codersdk.WorkspaceAppOpenInTab,
1617+
Share: codersdk.WorkspaceAppSharingLevelPublic,
1618+
Icon: ptr.Ref("/icons/book.svg"),
1619+
Order: ptr.Ref(int32(3)),
1620+
},
1621+
},
1622+
},
1623+
},
1624+
afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) {
1625+
require.Len(t, subAgent.Apps, 3)
1626+
1627+
// Verify first app
1628+
assert.Equal(t, "web-app", subAgent.Apps[0].Slug)
1629+
assert.Equal(t, "Web Application", *subAgent.Apps[0].DisplayName)
1630+
assert.Equal(t, "http://localhost:8080", *subAgent.Apps[0].URL)
1631+
assert.Equal(t, codersdk.WorkspaceAppOpenInTab, subAgent.Apps[0].OpenIn)
1632+
assert.Equal(t, codersdk.WorkspaceAppSharingLevelOwner, subAgent.Apps[0].Share)
1633+
assert.Equal(t, "/icons/web.svg", *subAgent.Apps[0].Icon)
1634+
assert.Equal(t, int32(1), *subAgent.Apps[0].Order)
1635+
1636+
// Verify second app
1637+
assert.Equal(t, "api-server", subAgent.Apps[1].Slug)
1638+
assert.Equal(t, "API Server", *subAgent.Apps[1].DisplayName)
1639+
assert.Equal(t, "http://localhost:3000", *subAgent.Apps[1].URL)
1640+
assert.Equal(t, codersdk.WorkspaceAppOpenInSlimWindow, subAgent.Apps[1].OpenIn)
1641+
assert.Equal(t, codersdk.WorkspaceAppSharingLevelAuthenticated, subAgent.Apps[1].Share)
1642+
assert.Equal(t, "/icons/api.svg", *subAgent.Apps[1].Icon)
1643+
assert.Equal(t, int32(2), *subAgent.Apps[1].Order)
1644+
assert.Equal(t, true, *subAgent.Apps[1].Hidden)
1645+
1646+
// Verify third app
1647+
assert.Equal(t, "docs", subAgent.Apps[2].Slug)
1648+
assert.Equal(t, "Documentation", *subAgent.Apps[2].DisplayName)
1649+
assert.Equal(t, "http://localhost:4000", *subAgent.Apps[2].URL)
1650+
assert.Equal(t, codersdk.WorkspaceAppOpenInTab, subAgent.Apps[2].OpenIn)
1651+
assert.Equal(t, codersdk.WorkspaceAppSharingLevelPublic, subAgent.Apps[2].Share)
1652+
assert.Equal(t, "/icons/book.svg", *subAgent.Apps[2].Icon)
1653+
assert.Equal(t, int32(3), *subAgent.Apps[2].Order)
1654+
},
1655+
},
15691656
}
15701657

15711658
for _, tt := range tests {

agent/agentcontainers/devcontainercli.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,14 @@ type DevcontainerCustomizations struct {
3232

3333
type CoderCustomization struct {
3434
DisplayApps map[codersdk.DisplayApp]bool `json:"displayApps,omitempty"`
35+
Apps []SubAgentApp `json:"apps,omitempty"`
3536
}
3637

3738
// DevcontainerCLI is an interface for the devcontainer CLI.
3839
type DevcontainerCLI interface {
3940
Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (id string, err error)
4041
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)
42+
ReadConfig(ctx context.Context, workspaceFolder, configPath string, env []string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error)
4243
}
4344

4445
// DevcontainerCLIUpOptions are options for the devcontainer CLI Up
@@ -113,8 +114,8 @@ type devcontainerCLIReadConfigConfig struct {
113114
stderr io.Writer
114115
}
115116

116-
// WithExecOutput sets additional stdout and stderr writers for logs
117-
// during Exec operations.
117+
// WithReadConfigOutput sets additional stdout and stderr writers for logs
118+
// during ReadConfig operations.
118119
func WithReadConfigOutput(stdout, stderr io.Writer) DevcontainerCLIReadConfigOptions {
119120
return func(o *devcontainerCLIReadConfigConfig) {
120121
o.stdout = stdout
@@ -250,7 +251,7 @@ func (d *devcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath
250251
return nil
251252
}
252253

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

@@ -263,6 +264,7 @@ func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, confi
263264
}
264265

265266
c := d.execer.CommandContext(ctx, "devcontainer", args...)
267+
c.Env = append(c.Env, env...)
266268

267269
var stdoutBuf bytes.Buffer
268270
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")

agent/agentcontainers/subagent.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,32 @@ type SubAgent struct {
2020
Directory string
2121
Architecture string
2222
OperatingSystem string
23+
Apps []SubAgentApp
2324
DisplayApps []codersdk.DisplayApp
2425
}
2526

27+
type SubAgentApp struct {
28+
Slug string `json:"slug"`
29+
Command *string `json:"command"`
30+
DisplayName *string `json:"displayName"`
31+
External *bool `json:"external"`
32+
Group *string `json:"group"`
33+
HealthCheck *SubAgentHealthCheck `json:"healthCheck"`
34+
Hidden *bool `json:"hidden"`
35+
Icon *string `json:"icon"`
36+
OpenIn codersdk.WorkspaceAppOpenIn `json:"openIn"`
37+
Order *int32 `json:"order"`
38+
Share codersdk.WorkspaceAppSharingLevel `json:"share"`
39+
Subdomain *bool `json:"subdomain"`
40+
URL *string `json:"url"`
41+
}
42+
43+
type SubAgentHealthCheck struct {
44+
Interval int32 `json:"interval"`
45+
Threshold int32 `json:"threshold"`
46+
URL string `json:"url"`
47+
}
48+
2649
// SubAgentClient is an interface for managing sub agents and allows
2750
// changing the implementation without having to deal with the
2851
// agentproto package directly.
@@ -104,12 +127,63 @@ func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (SubAgen
104127
displayApps = append(displayApps, app)
105128
}
106129

130+
apps := make([]*agentproto.CreateSubAgentRequest_App, 0, len(agent.Apps))
131+
for _, app := range agent.Apps {
132+
var healthCheck *agentproto.CreateSubAgentRequest_App_Healthcheck
133+
if app.HealthCheck != nil {
134+
healthCheck = &agentproto.CreateSubAgentRequest_App_Healthcheck{
135+
Interval: app.HealthCheck.Interval,
136+
Threshold: app.HealthCheck.Threshold,
137+
Url: app.HealthCheck.URL,
138+
}
139+
}
140+
141+
var openIn *agentproto.CreateSubAgentRequest_App_OpenIn
142+
switch app.OpenIn {
143+
case codersdk.WorkspaceAppOpenInSlimWindow:
144+
openIn = agentproto.CreateSubAgentRequest_App_SLIM_WINDOW.Enum()
145+
case codersdk.WorkspaceAppOpenInTab:
146+
openIn = agentproto.CreateSubAgentRequest_App_TAB.Enum()
147+
default:
148+
return SubAgent{}, xerrors.Errorf("unexpected codersdk.WorkspaceAppOpenIn: %#v", app.OpenIn)
149+
}
150+
151+
var share *agentproto.CreateSubAgentRequest_App_Share
152+
switch app.Share {
153+
case codersdk.WorkspaceAppSharingLevelAuthenticated:
154+
share = agentproto.CreateSubAgentRequest_App_AUTHENTICATED.Enum()
155+
case codersdk.WorkspaceAppSharingLevelOwner:
156+
share = agentproto.CreateSubAgentRequest_App_OWNER.Enum()
157+
case codersdk.WorkspaceAppSharingLevelPublic:
158+
share = agentproto.CreateSubAgentRequest_App_PUBLIC.Enum()
159+
default:
160+
return SubAgent{}, xerrors.Errorf("unexpected codersdk.WorkspaceAppSharingLevel: %#v", app.Share)
161+
}
162+
163+
apps = append(apps, &agentproto.CreateSubAgentRequest_App{
164+
Slug: app.Slug,
165+
Command: app.Command,
166+
DisplayName: app.DisplayName,
167+
External: app.External,
168+
Group: app.Group,
169+
Healthcheck: healthCheck,
170+
Hidden: app.Hidden,
171+
Icon: app.Icon,
172+
OpenIn: openIn,
173+
Order: app.Order,
174+
Share: share,
175+
Subdomain: app.Subdomain,
176+
Url: app.URL,
177+
})
178+
}
179+
107180
resp, err := a.api.CreateSubAgent(ctx, &agentproto.CreateSubAgentRequest{
108181
Name: agent.Name,
109182
Directory: agent.Directory,
110183
Architecture: agent.Architecture,
111184
OperatingSystem: agent.OperatingSystem,
112185
DisplayApps: displayApps,
186+
Apps: apps,
113187
})
114188
if err != nil {
115189
return SubAgent{}, err

0 commit comments

Comments
 (0)