mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
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`
This commit is contained in:
8
agent/agentcontainers/acmock/acmock.go
generated
8
agent/agentcontainers/acmock/acmock.go
generated
@ -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...)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1254,6 +1254,7 @@ func TestAPI(t *testing.T) {
|
||||
}
|
||||
fakeDCCLI = &fakeDevcontainerCLI{
|
||||
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 {
|
||||
|
@ -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))}}
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -49,12 +49,18 @@ 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 {
|
||||
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.
|
||||
containerAPIOpts = append(containerAPIOpts, a.containerAPIOptions...)
|
||||
|
Reference in New Issue
Block a user