From df5255b24249e91b8cc6b99151615caf9a832b76 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 23 Jul 2025 11:48:10 +0000 Subject: [PATCH 1/3] feat(agent/agentcontainers): allow auto starting discovered dev containers --- agent/agentcontainers/api.go | 25 ++- agent/agentcontainers/api_test.go | 246 +++++++++++++++++++++++ agent/agentcontainers/devcontainercli.go | 1 + 3 files changed, 268 insertions(+), 4 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 4f9287713fcfc..ef80b5e9146f6 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,39 @@ 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 { + go func() { + _ = 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..6ebfff03853d4 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 disbaled. + 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 { From 02e606e6c9e83bbbff36883181ca3aaaffdb3fd7 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 28 Jul 2025 09:36:39 +0000 Subject: [PATCH 2/3] chore: appease linter --- agent/agentcontainers/api.go | 1 - agent/agentcontainers/api_test.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index ef80b5e9146f6..c9b72a4738c94 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -557,7 +557,6 @@ func (api *API) discoverDevcontainersInProject(projectPath string) error { _ = api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath) }() } - } api.mu.Unlock() } diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 6ebfff03853d4..e6e25ed92558e 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -3682,7 +3682,7 @@ func TestDevcontainerDiscovery(t *testing.T) { ) gomock.InOrder( - // Given: This dev container has auto start disbaled. + // Given: This dev container has auto start disabled. mDCCLI.EXPECT().ReadConfig(gomock.Any(), "/home/coder/project", "/home/coder/project/.devcontainer.json", From d5d9edc3115ba713a92535b3b4d96da459af1b33 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 28 Jul 2025 10:39:31 +0000 Subject: [PATCH 3/3] chore: add some asyncWg logic --- agent/agentcontainers/api.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index c9b72a4738c94..c8cba67cea73f 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -553,7 +553,10 @@ func (api *API) discoverDevcontainersInProject(projectPath string) error { api.broadcastUpdatesLocked() if dc.Status == codersdk.WorkspaceAgentDevcontainerStatusStarting { + api.asyncWg.Add(1) go func() { + defer api.asyncWg.Done() + _ = api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath) }() } 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