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)
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