diff --git a/agent/agentcontainers/acmock/acmock.go b/agent/agentcontainers/acmock/acmock.go index 990a243a33ddf..b6bb4a9523fb6 100644 --- a/agent/agentcontainers/acmock/acmock.go +++ b/agent/agentcontainers/acmock/acmock.go @@ -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...) } diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 785d87bf3654e..3e42a737463c4 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -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) diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 8dc1f83dc916b..526c7432c3790 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -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) } } } @@ -1253,7 +1253,8 @@ func TestAPI(t *testing.T) { deleteErrC: make(chan error, 1), } fakeDCCLI = &fakeDevcontainerCLI{ - execErrC: make(chan func(cmd string, args ...string) error, 1), + 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 { diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index 002858c70562e..335be53648c2d 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -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))}} diff --git a/agent/agentcontainers/devcontainercli_test.go b/agent/agentcontainers/devcontainercli_test.go index cffb3e12fd494..311ec440e357a 100644 --- a/agent/agentcontainers/devcontainercli_test.go +++ b/agent/agentcontainers/devcontainercli_test.go @@ -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") diff --git a/agent/agentcontainers/subagent.go b/agent/agentcontainers/subagent.go index ea527f8c46e37..b8e87707b3058 100644 --- a/agent/agentcontainers/subagent.go +++ b/agent/agentcontainers/subagent.go @@ -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 diff --git a/agent/agentcontainers/subagent_test.go b/agent/agentcontainers/subagent_test.go index 4b805d7549fce..ad3040e12bc13 100644 --- a/agent/agentcontainers/subagent_test.go +++ b/agent/agentcontainers/subagent_test.go @@ -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) + } + } + }) + } + }) } diff --git a/agent/agenttest/client.go b/agent/agenttest/client.go index 0fc8a38af80b6..5d78dfe697c93 100644 --- a/agent/agenttest/client.go +++ b/agent/agenttest/client.go @@ -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, diff --git a/agent/api.go b/agent/api.go index 1c9a707fbb338..464c5fc5dab30 100644 --- a/agent/api.go +++ b/agent/api.go @@ -49,11 +49,17 @@ 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 { - containerAPIOpts = append( - containerAPIOpts, - agentcontainers.WithDevcontainers(manifest.Devcontainers, manifest.Scripts), + 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.