diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 4f9287713fcfc..c8cba67cea73f 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -143,7 +143,8 @@ func WithCommandEnv(ce CommandEnv) Option { strings.HasPrefix(s, "CODER_WORKSPACE_AGENT_URL=") || strings.HasPrefix(s, "CODER_AGENT_TOKEN=") || strings.HasPrefix(s, "CODER_AGENT_AUTH=") || - strings.HasPrefix(s, "CODER_AGENT_DEVCONTAINERS_ENABLE=") + strings.HasPrefix(s, "CODER_AGENT_DEVCONTAINERS_ENABLE=") || + strings.HasPrefix(s, "CODER_AGENT_DEVCONTAINERS_PROJECT_DISCOVERY_ENABLE=") }) return shell, dir, env, nil } @@ -524,23 +525,41 @@ func (api *API) discoverDevcontainersInProject(projectPath string) error { workspaceFolder := strings.TrimSuffix(path, relativeConfigPath) - logger.Debug(api.ctx, "discovered dev container project", slog.F("workspace_folder", workspaceFolder)) + logger := logger.With(slog.F("workspace_folder", workspaceFolder)) + logger.Debug(api.ctx, "discovered dev container project") api.mu.Lock() if _, found := api.knownDevcontainers[workspaceFolder]; !found { - logger.Debug(api.ctx, "adding dev container project", slog.F("workspace_folder", workspaceFolder)) + logger.Debug(api.ctx, "adding dev container project") dc := codersdk.WorkspaceAgentDevcontainer{ ID: uuid.New(), Name: "", // Updated later based on container state. WorkspaceFolder: workspaceFolder, ConfigPath: path, - Status: "", // Updated later based on container state. + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, Dirty: false, // Updated later based on config file changes. Container: nil, } + config, err := api.dccli.ReadConfig(api.ctx, workspaceFolder, path, []string{}) + if err != nil { + logger.Error(api.ctx, "read project configuration", slog.Error(err)) + } else if config.Configuration.Customizations.Coder.AutoStart { + dc.Status = codersdk.WorkspaceAgentDevcontainerStatusStarting + } + api.knownDevcontainers[workspaceFolder] = dc + api.broadcastUpdatesLocked() + + if dc.Status == codersdk.WorkspaceAgentDevcontainerStatusStarting { + api.asyncWg.Add(1) + go func() { + defer api.asyncWg.Done() + + _ = api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath) + }() + } } api.mu.Unlock() } diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 5714027960a7b..e6e25ed92558e 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -3568,4 +3568,250 @@ func TestDevcontainerDiscovery(t *testing.T) { // This is implicitly handled by `testutil.Logger` failing when it // detects an error has been logged. }) + + t.Run("AutoStart", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + agentDir string + fs map[string]string + expectDevcontainerCount int + setupMocks func(mDCCLI *acmock.MockDevcontainerCLI) + }{ + { + name: "SingleEnabled", + agentDir: "/home/coder", + expectDevcontainerCount: 1, + fs: map[string]string{ + "/home/coder/.git/HEAD": "", + "/home/coder/.devcontainer/devcontainer.json": "", + }, + setupMocks: func(mDCCLI *acmock.MockDevcontainerCLI) { + gomock.InOrder( + // Given: This dev container has auto start enabled. + mDCCLI.EXPECT().ReadConfig(gomock.Any(), + "/home/coder", + "/home/coder/.devcontainer/devcontainer.json", + []string{}, + ).Return(agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{ + AutoStart: true, + }, + }, + }, + }, nil), + + // Then: We expect it to be started. + mDCCLI.EXPECT().Up(gomock.Any(), + "/home/coder", + "/home/coder/.devcontainer/devcontainer.json", + gomock.Any(), + ).Return("", nil), + ) + }, + }, + { + name: "SingleDisabled", + agentDir: "/home/coder", + expectDevcontainerCount: 1, + fs: map[string]string{ + "/home/coder/.git/HEAD": "", + "/home/coder/.devcontainer/devcontainer.json": "", + }, + setupMocks: func(mDCCLI *acmock.MockDevcontainerCLI) { + gomock.InOrder( + // Given: This dev container has auto start disabled. + mDCCLI.EXPECT().ReadConfig(gomock.Any(), + "/home/coder", + "/home/coder/.devcontainer/devcontainer.json", + []string{}, + ).Return(agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{ + AutoStart: false, + }, + }, + }, + }, nil), + + // Then: We expect it to _not_ be started. + mDCCLI.EXPECT().Up(gomock.Any(), + "/home/coder", + "/home/coder/.devcontainer/devcontainer.json", + gomock.Any(), + ).Return("", nil).Times(0), + ) + }, + }, + { + name: "OneEnabledOneDisabled", + agentDir: "/home/coder", + expectDevcontainerCount: 2, + fs: map[string]string{ + "/home/coder/.git/HEAD": "", + "/home/coder/.devcontainer/devcontainer.json": "", + "/home/coder/project/.devcontainer.json": "", + }, + setupMocks: func(mDCCLI *acmock.MockDevcontainerCLI) { + gomock.InOrder( + // Given: This dev container has auto start enabled. + mDCCLI.EXPECT().ReadConfig(gomock.Any(), + "/home/coder", + "/home/coder/.devcontainer/devcontainer.json", + []string{}, + ).Return(agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{ + AutoStart: true, + }, + }, + }, + }, nil), + + // Then: We expect it to be started. + mDCCLI.EXPECT().Up(gomock.Any(), + "/home/coder", + "/home/coder/.devcontainer/devcontainer.json", + gomock.Any(), + ).Return("", nil), + ) + + gomock.InOrder( + // Given: This dev container has auto start disabled. + mDCCLI.EXPECT().ReadConfig(gomock.Any(), + "/home/coder/project", + "/home/coder/project/.devcontainer.json", + []string{}, + ).Return(agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{ + AutoStart: false, + }, + }, + }, + }, nil), + + // Then: We expect it to _not_ be started. + mDCCLI.EXPECT().Up(gomock.Any(), + "/home/coder/project", + "/home/coder/project/.devcontainer.json", + gomock.Any(), + ).Return("", nil).Times(0), + ) + }, + }, + { + name: "MultipleEnabled", + agentDir: "/home/coder", + expectDevcontainerCount: 2, + fs: map[string]string{ + "/home/coder/.git/HEAD": "", + "/home/coder/.devcontainer/devcontainer.json": "", + "/home/coder/project/.devcontainer.json": "", + }, + setupMocks: func(mDCCLI *acmock.MockDevcontainerCLI) { + gomock.InOrder( + // Given: This dev container has auto start enabled. + mDCCLI.EXPECT().ReadConfig(gomock.Any(), + "/home/coder", + "/home/coder/.devcontainer/devcontainer.json", + []string{}, + ).Return(agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{ + AutoStart: true, + }, + }, + }, + }, nil), + + // Then: We expect it to be started. + mDCCLI.EXPECT().Up(gomock.Any(), + "/home/coder", + "/home/coder/.devcontainer/devcontainer.json", + gomock.Any(), + ).Return("", nil), + ) + + gomock.InOrder( + // Given: This dev container has auto start enabled. + mDCCLI.EXPECT().ReadConfig(gomock.Any(), + "/home/coder/project", + "/home/coder/project/.devcontainer.json", + []string{}, + ).Return(agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{ + AutoStart: true, + }, + }, + }, + }, nil), + + // Then: We expect it to be started. + mDCCLI.EXPECT().Up(gomock.Any(), + "/home/coder/project", + "/home/coder/project/.devcontainer.json", + gomock.Any(), + ).Return("", nil), + ) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + logger = testutil.Logger(t) + mClock = quartz.NewMock(t) + mDCCLI = acmock.NewMockDevcontainerCLI(gomock.NewController(t)) + + r = chi.NewRouter() + ) + + // Given: We setup our mocks. These mocks handle our expectations for these + // tests. If there are missing/unexpected mock calls, the test will fail. + tt.setupMocks(mDCCLI) + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithWatcher(watcher.NewNoop()), + agentcontainers.WithFileSystem(initFS(t, tt.fs)), + agentcontainers.WithManifestInfo("owner", "workspace", "parent-agent", "/home/coder"), + agentcontainers.WithContainerCLI(&fakeContainerCLI{}), + agentcontainers.WithDevcontainerCLI(mDCCLI), + agentcontainers.WithProjectDiscovery(true), + ) + api.Start() + defer api.Close() + r.Mount("/", api.Routes()) + + // When: All expected dev containers have been found. + require.Eventuallyf(t, func() bool { + req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + got := codersdk.WorkspaceAgentListContainersResponse{} + err := json.NewDecoder(rec.Body).Decode(&got) + require.NoError(t, err) + + return len(got.Devcontainers) >= tt.expectDevcontainerCount + }, testutil.WaitShort, testutil.IntervalFast, "dev containers never found") + + // Then: We expect the mock infra to not fail. + }) + } + }) } diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index d7cd25f85a7b3..2242e62f602e8 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -91,6 +91,7 @@ type CoderCustomization struct { Apps []SubAgentApp `json:"apps,omitempty"` Name string `json:"name,omitempty"` Ignore bool `json:"ignore,omitempty"` + AutoStart bool `json:"autoStart,omitempty"` } type DevcontainerWorkspace struct {
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: