Content-Length: 14247 | pFad | http://github.com/coder/coder/pull/19040.patch
thub.com
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)
}()
}
--- a PPN by Garber Painting Akron. With Image Size Reduction included!Fetched URL: http://github.com/coder/coder/pull/19040.patch
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy