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