From f528eb33526bef34ca0a469a5ecfc6fe48dd0dfa Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 16 Jul 2025 18:50:25 +0000 Subject: [PATCH 01/13] feat(agent/agentcontainers): auto detect dev containers --- agent/agent.go | 2 +- agent/agentcontainers/api.go | 98 ++++++++++++++++++- agent/agentcontainers/api_test.go | 4 +- .../resources/AgentDevcontainerCard.tsx | 7 +- 4 files changed, 102 insertions(+), 9 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 75117769d8e2d..f9278b91a84ae 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -1168,7 +1168,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, // return existing devcontainers but actual container detection // and creation will be deferred. a.containerAPI.Init( - agentcontainers.WithManifestInfo(manifest.OwnerName, manifest.WorkspaceName, manifest.AgentName), + agentcontainers.WithManifestInfo(manifest.OwnerName, manifest.WorkspaceName, manifest.AgentName, manifest.Directory), agentcontainers.WithDevcontainers(manifest.Devcontainers, manifest.Scripts), agentcontainers.WithSubAgentClient(agentcontainers.NewSubAgentClientFromAPI(a.logger, aAPI)), ) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index dc92a4d38d9a2..cc719ad779d66 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "maps" "net/http" "os" @@ -21,6 +22,7 @@ import ( "github.com/fsnotify/fsnotify" "github.com/go-chi/chi/v5" "github.com/google/uuid" + "github.com/spf13/afero" "golang.org/x/xerrors" "cdr.dev/slog" @@ -60,6 +62,7 @@ type API struct { updateInterval time.Duration // Interval for periodic container updates. logger slog.Logger watcher watcher.Watcher + fs afero.Fs execer agentexec.Execer commandEnv CommandEnv ccli ContainerCLI @@ -71,9 +74,10 @@ type API struct { subAgentURL string subAgentEnv []string - ownerName string - workspaceName string - parentAgent string + ownerName string + workspaceName string + parentAgent string + agentDirectory string mu sync.RWMutex // Protects the following fields. initDone chan struct{} // Closed by Init. @@ -192,11 +196,12 @@ func WithSubAgentEnv(env ...string) Option { // WithManifestInfo sets the owner name, and workspace name // for the sub-agent. -func WithManifestInfo(owner, workspace, parentAgent string) Option { +func WithManifestInfo(owner, workspace, parentAgent, agentDirectory string) Option { return func(api *API) { api.ownerName = owner api.workspaceName = workspace api.parentAgent = parentAgent + api.agentDirectory = agentDirectory } } @@ -261,6 +266,13 @@ func WithWatcher(w watcher.Watcher) Option { } } +// WithFileSystem sets the file system used for discovering projects. +func WithFileSystem(fs afero.Fs) Option { + return func(api *API) { + api.fs = fs + } +} + // ScriptLogger is an interface for sending devcontainer logs to the // controlplane. type ScriptLogger interface { @@ -331,6 +343,9 @@ func NewAPI(logger slog.Logger, options ...Option) *API { api.watcher = watcher.NewNoop() } } + if api.fs == nil { + api.fs = afero.NewOsFs() + } if api.subAgentClient.Load() == nil { var c SubAgentClient = noopSubAgentClient{} api.subAgentClient.Store(&c) @@ -375,10 +390,85 @@ func (api *API) Start() { api.watcherDone = make(chan struct{}) api.updaterDone = make(chan struct{}) + go func() { + if err := api.discoverDevcontainerProjects(); err != nil { + api.logger.Error(api.ctx, "discovering dev container projects", slog.Error(err)) + } + }() + go api.watcherLoop() go api.updaterLoop() } +func (api *API) discoverDevcontainerProjects() error { + isGitProject, err := afero.DirExists(api.fs, filepath.Join(api.agentDirectory, "/.git")) + if err != nil { + return xerrors.Errorf(".git dir exists: %w", err) + } + + // If the agent directory is a git project, we'll search + // the project for any `.devcontainer/devcontainer.json` + // files. + if isGitProject { + return api.discoverDevcontainersInProject(api.agentDirectory) + } + + // The agent directory is _not_ a git project, so we'll + // search the top level of the agent directory for any + // git projects, and search those. + entries, err := afero.ReadDir(api.fs, api.agentDirectory) + if err != nil { + return xerrors.Errorf("read agent directory: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + isGitProject, err = afero.DirExists(api.fs, filepath.Join(api.agentDirectory, entry.Name(), ".git")) + if err != nil { + return xerrors.Errorf(".git dir exists: %w", err) + } + + // If this directory is a git project, we'll search + // it for any `.devcontainer/devcontainer.json` files. + if isGitProject { + if err := api.discoverDevcontainersInProject(filepath.Join(api.agentDirectory, entry.Name())); err != nil { + return err + } + } + } + + return nil +} + +func (api *API) discoverDevcontainersInProject(projectPath string) error { + return afero.Walk(api.fs, projectPath, func(path string, info fs.FileInfo, err error) error { + if strings.HasSuffix(path, ".devcontainer/devcontainer.json") { + workspaceFolder := strings.TrimSuffix(path, ".devcontainer/devcontainer.json") + + api.mu.Lock() + if _, found := api.knownDevcontainers[workspaceFolder]; !found { + dc := codersdk.WorkspaceAgentDevcontainer{ + ID: uuid.New(), + Name: "", // Updated later based on container state. + WorkspaceFolder: workspaceFolder, + ConfigPath: path, + Status: "", // Updated later based on container state. + Dirty: false, // Updated later based on config file changes. + Container: nil, + } + + api.knownDevcontainers[workspaceFolder] = dc + } + api.mu.Unlock() + } + + return nil + }) +} + func (api *API) watcherLoop() { defer close(api.watcherDone) defer api.logger.Debug(api.ctx, "watcher loop stopped") diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 9451461bb3215..fc19ef71d4046 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -1678,7 +1678,7 @@ func TestAPI(t *testing.T) { agentcontainers.WithSubAgentClient(fakeSAC), agentcontainers.WithSubAgentURL("test-subagent-url"), agentcontainers.WithDevcontainerCLI(fakeDCCLI), - agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent"), + agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent", "/parent-agent"), ) api.Start() apiClose := func() { @@ -2662,7 +2662,7 @@ func TestAPI(t *testing.T) { agentcontainers.WithSubAgentClient(fSAC), agentcontainers.WithSubAgentURL("test-subagent-url"), agentcontainers.WithWatcher(watcher.NewNoop()), - agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent"), + agentcontainers.WithManifestInfo("test-user", "test-workspace", "test-parent-agent", "/parent-agent"), ) api.Start() defer api.Close() diff --git a/site/src/modules/resources/AgentDevcontainerCard.tsx b/site/src/modules/resources/AgentDevcontainerCard.tsx index bd2f05b123cad..ea48fffced247 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.tsx @@ -181,6 +181,8 @@ export const AgentDevcontainerCard: FC = ({ const appsClasses = "flex flex-wrap gap-4 empty:hidden md:justify-start"; + console.log(devcontainer); + return ( = ({ text-sm font-semibold text-content-primary md:overflow-visible" > - {subAgent?.name ?? devcontainer.name} + {subAgent?.name ?? + (devcontainer.name || devcontainer.config_path)} {devcontainer.container && ( {" "} @@ -253,7 +256,7 @@ export const AgentDevcontainerCard: FC = ({ disabled={devcontainer.status === "starting"} > - Rebuild + {devcontainer.container === undefined ? <>Start : <>Rebuild} {showDevcontainerControls && displayApps.includes("ssh_helper") && ( From c3620e2fe3d9bb0777b7b6fdb0795957047343d0 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 21 Jul 2025 15:08:38 +0000 Subject: [PATCH 02/13] chore: add tests and fix bug --- agent/agentcontainers/api.go | 17 +- agent/agentcontainers/api_test.go | 232 ++++++++++++++++++ .../AgentDevcontainerCard.stories.tsx | 11 + site/src/modules/resources/AgentRow.tsx | 11 +- 4 files changed, 268 insertions(+), 3 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index cc719ad779d66..543ceb08adaa1 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -444,12 +444,25 @@ func (api *API) discoverDevcontainerProjects() error { } func (api *API) discoverDevcontainersInProject(projectPath string) error { + devcontainerConfigPaths := []string{ + "/.devcontainer/devcontainer.json", + "/.devcontainer.json", + } + return afero.Walk(api.fs, projectPath, func(path string, info fs.FileInfo, err error) error { - if strings.HasSuffix(path, ".devcontainer/devcontainer.json") { - workspaceFolder := strings.TrimSuffix(path, ".devcontainer/devcontainer.json") + for _, relativeConfigPath := range devcontainerConfigPaths { + if !strings.HasSuffix(path, relativeConfigPath) { + continue + } + + workspaceFolder := strings.TrimSuffix(path, relativeConfigPath) + + api.logger.Debug(api.ctx, "discovered dev container project", slog.F("workspace_folder", workspaceFolder)) api.mu.Lock() if _, found := api.knownDevcontainers[workspaceFolder]; !found { + api.logger.Debug(api.ctx, "adding dev container project", slog.F("workspace_folder", workspaceFolder)) + dc := codersdk.WorkspaceAgentDevcontainer{ ID: uuid.New(), Name: "", // Updated later based on container state. diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index fc19ef71d4046..b84dc7689fdab 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -20,6 +20,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/lib/pq" + "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -3189,3 +3190,234 @@ func TestWithDevcontainersNameGeneration(t *testing.T) { assert.Equal(t, "bar-project", response.Devcontainers[0].Name, "second devcontainer should has a collision and uses the folder name with a prefix") assert.Equal(t, "baz-project", response.Devcontainers[1].Name, "third devcontainer should use the folder name with a prefix since it collides with the first two") } + +func TestDevcontainerDiscovery(t *testing.T) { + t.Parallel() + + // We discover dev container projects by searching + // for git repositories at the agent's directory, + // and then recursively walking through these git + // repositories to find any `.devcontainer/devcontainer.json` + // files. These tests are to validate that behavior. + + tests := []struct { + name string + agentDir string + fs map[string]string + expected []codersdk.WorkspaceAgentDevcontainer + }{ + { + name: "GitProjectInRootDir/SingleProject", + agentDir: "/home/coder", + fs: map[string]string{ + "/home/coder/.git/HEAD": "", + "/home/coder/.devcontainer/devcontainer.json": "", + }, + expected: []codersdk.WorkspaceAgentDevcontainer{ + { + WorkspaceFolder: "/home/coder", + ConfigPath: "/home/coder/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + }, + }, + }, + { + name: "GitProjectInRootDir/MultipleProjects", + agentDir: "/home/coder", + fs: map[string]string{ + "/home/coder/.git/HEAD": "", + "/home/coder/.devcontainer/devcontainer.json": "", + "/home/coder/site/.devcontainer/devcontainer.json": "", + }, + expected: []codersdk.WorkspaceAgentDevcontainer{ + { + WorkspaceFolder: "/home/coder", + ConfigPath: "/home/coder/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + }, + { + WorkspaceFolder: "/home/coder/site", + ConfigPath: "/home/coder/site/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + }, + }, + }, + { + name: "GitProjectInChildDir/SingleProject", + agentDir: "/home/coder", + fs: map[string]string{ + "/home/coder/coder/.git/HEAD": "", + "/home/coder/coder/.devcontainer/devcontainer.json": "", + }, + expected: []codersdk.WorkspaceAgentDevcontainer{ + { + WorkspaceFolder: "/home/coder/coder", + ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + }, + }, + }, + { + name: "GitProjectInChildDir/MultipleProjects", + agentDir: "/home/coder", + fs: map[string]string{ + "/home/coder/coder/.git/HEAD": "", + "/home/coder/coder/.devcontainer/devcontainer.json": "", + "/home/coder/coder/site/.devcontainer/devcontainer.json": "", + }, + expected: []codersdk.WorkspaceAgentDevcontainer{ + { + WorkspaceFolder: "/home/coder/coder", + ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + }, + { + WorkspaceFolder: "/home/coder/coder/site", + ConfigPath: "/home/coder/coder/site/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + }, + }, + }, + { + name: "GitProjectInMultipleChildDirs/SingleProjectEach", + agentDir: "/home/coder", + fs: map[string]string{ + "/home/coder/coder/.git/HEAD": "", + "/home/coder/coder/.devcontainer/devcontainer.json": "", + "/home/coder/envbuilder/.git/HEAD": "", + "/home/coder/envbuilder/.devcontainer/devcontainer.json": "", + }, + expected: []codersdk.WorkspaceAgentDevcontainer{ + { + WorkspaceFolder: "/home/coder/coder", + ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + }, + { + WorkspaceFolder: "/home/coder/envbuilder", + ConfigPath: "/home/coder/envbuilder/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + }, + }, + }, + { + name: "GitProjectInMultipleChildDirs/MultipleProjectEach", + agentDir: "/home/coder", + fs: map[string]string{ + "/home/coder/coder/.git/HEAD": "", + "/home/coder/coder/.devcontainer/devcontainer.json": "", + "/home/coder/coder/site/.devcontainer/devcontainer.json": "", + "/home/coder/envbuilder/.git/HEAD": "", + "/home/coder/envbuilder/.devcontainer/devcontainer.json": "", + "/home/coder/envbuilder/x/.devcontainer/devcontainer.json": "", + }, + expected: []codersdk.WorkspaceAgentDevcontainer{ + { + WorkspaceFolder: "/home/coder/coder", + ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + }, + { + WorkspaceFolder: "/home/coder/coder/site", + ConfigPath: "/home/coder/coder/site/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + }, + { + WorkspaceFolder: "/home/coder/envbuilder", + ConfigPath: "/home/coder/envbuilder/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + }, + { + WorkspaceFolder: "/home/coder/envbuilder/x", + ConfigPath: "/home/coder/envbuilder/x/.devcontainer/devcontainer.json", + Status: codersdk.WorkspaceAgentDevcontainerStatusStopped, + }, + }, + }, + } + + initFS := func(t *testing.T, files map[string]string) afero.Fs { + t.Helper() + + fs := afero.NewMemMapFs() + for name, content := range files { + err := afero.WriteFile(fs, name, []byte(content+"\n"), 0o600) + require.NoError(t, err) + } + return fs + } + + 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) + tickerTrap = mClock.Trap().TickerFunc("updaterLoop") + + r = chi.NewRouter() + ) + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithWatcher(watcher.NewNoop()), + agentcontainers.WithFileSystem(initFS(t, tt.fs)), + agentcontainers.WithManifestInfo("owner", "workspace", "parent-agent", tt.agentDir), + agentcontainers.WithContainerCLI(&fakeContainerCLI{}), + agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}), + ) + api.Start() + defer api.Close() + r.Mount("/", api.Routes()) + + tickerTrap.MustWait(ctx).MustRelease(ctx) + tickerTrap.Close() + + // Wait until all projects have been discovered + 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) == len(tt.expected) + }, testutil.WaitShort, testutil.IntervalFast, "dev containers never found") + + // Now projects have been discovered, we'll allow the updater loop + // to set the appropriate status for these containers. + _, aw := mClock.AdvanceNext() + aw.MustWait(ctx) + + // Now we'll fetch the list of dev containers + 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) + + // We will set the IDs of each dev container to uuid.Nil to simplify + // this check. + for idx := range got.Devcontainers { + got.Devcontainers[idx].ID = uuid.Nil + } + + // Sort the expected dev containers and got dev containers by their workspace folder. + // This helps ensure a deterministic test. + slices.SortFunc(tt.expected, func(a, b codersdk.WorkspaceAgentDevcontainer) int { + return strings.Compare(a.WorkspaceFolder, b.WorkspaceFolder) + }) + slices.SortFunc(got.Devcontainers, func(a, b codersdk.WorkspaceAgentDevcontainer) int { + return strings.Compare(a.WorkspaceFolder, b.WorkspaceFolder) + }) + + require.Equal(t, tt.expected, got.Devcontainers) + }) + } +} diff --git a/site/src/modules/resources/AgentDevcontainerCard.stories.tsx b/site/src/modules/resources/AgentDevcontainerCard.stories.tsx index 75c53d8b65c62..1ae50c303cd3e 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.stories.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.stories.tsx @@ -91,6 +91,17 @@ export const Recreating: Story = { }, }; +export const NoContainerOrSubAgent: Story = { + args: { + devcontainer: { + ...MockWorkspaceAgentDevcontainer, + container: undefined, + agent: undefined, + }, + subAgents: [], + }, +}; + export const NoSubAgent: Story = { args: { devcontainer: { diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 0b5d8a5dc15c3..20c551fc73065 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -137,7 +137,16 @@ export const AgentRow: FC = ({ const [showParentApps, setShowParentApps] = useState(false); let shouldDisplayAppsSection = shouldDisplayAgentApps; - if (devcontainers && devcontainers.length > 0 && !showParentApps) { + if ( + devcontainers && + devcontainers.find( + // We only want to hide the parent apps by default when there are dev + // containers that are either starting or running. If they are all in + // the stopped state, it doesn't make sense to hide the parent apps. + (dc) => dc.status === "running" || dc.status === "starting", + ) !== undefined && + !showParentApps + ) { shouldDisplayAppsSection = false; } From 5d352c913bdc873ce7310d2bfa6351827608eeb8 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 21 Jul 2025 15:19:02 +0000 Subject: [PATCH 03/13] fix: some issues i left in --- agent/agentcontainers/api.go | 10 +++++++--- site/src/modules/resources/AgentDevcontainerCard.tsx | 2 -- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 543ceb08adaa1..03203505443c7 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -267,9 +267,9 @@ func WithWatcher(w watcher.Watcher) Option { } // WithFileSystem sets the file system used for discovering projects. -func WithFileSystem(fs afero.Fs) Option { +func WithFileSystem(fileSystem afero.Fs) Option { return func(api *API) { - api.fs = fs + api.fs = fileSystem } } @@ -449,7 +449,11 @@ func (api *API) discoverDevcontainersInProject(projectPath string) error { "/.devcontainer.json", } - return afero.Walk(api.fs, projectPath, func(path string, info fs.FileInfo, err error) error { + return afero.Walk(api.fs, projectPath, func(path string, info fs.FileInfo, _ error) error { + if info.IsDir() { + return nil + } + for _, relativeConfigPath := range devcontainerConfigPaths { if !strings.HasSuffix(path, relativeConfigPath) { continue diff --git a/site/src/modules/resources/AgentDevcontainerCard.tsx b/site/src/modules/resources/AgentDevcontainerCard.tsx index ea48fffced247..36afcd19c33e0 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.tsx @@ -181,8 +181,6 @@ export const AgentDevcontainerCard: FC = ({ const appsClasses = "flex flex-wrap gap-4 empty:hidden md:justify-start"; - console.log(devcontainer); - return ( Date: Tue, 22 Jul 2025 09:00:25 +0000 Subject: [PATCH 04/13] fix: gate project discovery behind a flag --- agent/agent.go | 1 + agent/agentcontainers/api.go | 36 +++++++++++++++++++++++++------ agent/agentcontainers/api_test.go | 1 + 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index f9278b91a84ae..6e07d6f7dcd5f 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -339,6 +339,7 @@ func (a *agent) init() { containerAPIOpts := []agentcontainers.Option{ agentcontainers.WithExecer(a.execer), agentcontainers.WithCommandEnv(a.sshServer.CommandEnv), + agentcontainers.WithProjectDiscovery(true), agentcontainers.WithScriptLogger(func(logSourceID uuid.UUID) agentcontainers.ScriptLogger { return a.logSender.GetScriptLogger(logSourceID) }), diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 03203505443c7..676c4ba6151c4 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -58,6 +58,7 @@ type API struct { cancel context.CancelFunc watcherDone chan struct{} updaterDone chan struct{} + discoverDone chan struct{} updateTrigger chan chan error // Channel to trigger manual refresh. updateInterval time.Duration // Interval for periodic container updates. logger slog.Logger @@ -74,6 +75,8 @@ type API struct { subAgentURL string subAgentEnv []string + projectDiscovery bool // If we should perform project discovery or not. + ownerName string workspaceName string parentAgent string @@ -273,6 +276,14 @@ func WithFileSystem(fileSystem afero.Fs) Option { } } +// WithProjectDiscovery sets if the API should attempt to discover +// projects on the filesystem. +func WithProjectDiscovery(projectDiscovery bool) Option { + return func(api *API) { + api.projectDiscovery = projectDiscovery + } +} + // ScriptLogger is an interface for sending devcontainer logs to the // controlplane. type ScriptLogger interface { @@ -387,19 +398,29 @@ func (api *API) Start() { return } + if api.projectDiscovery { + api.discoverDone = make(chan struct{}) + + go api.discover() + } + api.watcherDone = make(chan struct{}) api.updaterDone = make(chan struct{}) - go func() { - if err := api.discoverDevcontainerProjects(); err != nil { - api.logger.Error(api.ctx, "discovering dev container projects", slog.Error(err)) - } - }() - go api.watcherLoop() go api.updaterLoop() } +func (api *API) discover() { + defer close(api.discoverDone) + defer api.logger.Debug(api.ctx, "project discovery finished") + api.logger.Debug(api.ctx, "project discovery started") + + if err := api.discoverDevcontainerProjects(); err != nil { + api.logger.Error(api.ctx, "discovering dev container projects", slog.Error(err)) + } +} + func (api *API) discoverDevcontainerProjects() error { isGitProject, err := afero.DirExists(api.fs, filepath.Join(api.agentDirectory, "/.git")) if err != nil { @@ -1915,6 +1936,9 @@ func (api *API) Close() error { if api.updaterDone != nil { <-api.updaterDone } + if api.discoverDone != nil { + <-api.discoverDone + } // Wait for all async tasks to complete. api.asyncWg.Wait() diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index b84dc7689fdab..ebf31a219d9fa 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -3367,6 +3367,7 @@ func TestDevcontainerDiscovery(t *testing.T) { agentcontainers.WithManifestInfo("owner", "workspace", "parent-agent", tt.agentDir), agentcontainers.WithContainerCLI(&fakeContainerCLI{}), agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}), + agentcontainers.WithProjectDiscovery(true), ) api.Start() defer api.Close() From 21f1accde3575bfd3955206aecb11d6f5490515a Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 22 Jul 2025 09:21:30 +0000 Subject: [PATCH 05/13] fix: disable project discovery for cli TestSSH_Container tests Project discovery doesn't work well with these tests do not set everything up requried for this feature to work. --- cli/ssh_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 7a91cfa3ce365..d11748a51f8b8 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -2031,6 +2031,7 @@ func TestSSH_Container(t *testing.T) { _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { o.Devcontainers = true o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, + agentcontainers.WithProjectDiscovery(false), agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) }) @@ -2072,6 +2073,7 @@ func TestSSH_Container(t *testing.T) { o.Devcontainers = true o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerCLI(mLister), + agentcontainers.WithProjectDiscovery(false), agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) }) From 8c8b46b832c5b52a0eec5a0ea272f5ba814db38b Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 22 Jul 2025 09:49:06 +0000 Subject: [PATCH 06/13] fix: disable project discovery in more tests --- cli/exp_rpty_test.go | 1 + cli/open_test.go | 1 + 2 files changed, 2 insertions(+) diff --git a/cli/exp_rpty_test.go b/cli/exp_rpty_test.go index 213764bb40113..c7a0c47d18908 100644 --- a/cli/exp_rpty_test.go +++ b/cli/exp_rpty_test.go @@ -118,6 +118,7 @@ func TestExpRpty(t *testing.T) { _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { o.Devcontainers = true o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, + agentcontainers.WithProjectDiscovery(false), agentcontainers.WithContainerLabelIncludeFilter(wantLabel, "true"), ) }) diff --git a/cli/open_test.go b/cli/open_test.go index e8d4aa3e65b2e..688fc24b5e84d 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -406,6 +406,7 @@ func TestOpenVSCodeDevContainer(t *testing.T) { _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { o.Devcontainers = true o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, + agentcontainers.WithProjectDiscovery(false), agentcontainers.WithContainerCLI(fCCLI), agentcontainers.WithDevcontainerCLI(fDCCLI), agentcontainers.WithWatcher(watcher.NewNoop()), From d463ed52a459889b167b0c207158dd5f31365df6 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 22 Jul 2025 10:22:04 +0000 Subject: [PATCH 07/13] fix: disable dev container tests on windows --- agent/agentcontainers/api_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index ebf31a219d9fa..def425e97fa95 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -3194,6 +3194,10 @@ func TestWithDevcontainersNameGeneration(t *testing.T) { func TestDevcontainerDiscovery(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("Dev Container tests are not supported on Windows") + } + // We discover dev container projects by searching // for git repositories at the agent's directory, // and then recursively walking through these git From 2f132148a2ca4e910955dd08702f627f666e2bad Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 22 Jul 2025 11:15:04 +0000 Subject: [PATCH 08/13] fix: only run project discovery when agentDirectory is not empty We make sure to only run project discovery when the agent directory is not empty. If we attempt to do project discovery when the agent directory is empty we will just get an error. --- agent/agentcontainers/api.go | 2 +- agent/agentcontainers/api_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 676c4ba6151c4..79e8b72b869c6 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -398,7 +398,7 @@ func (api *API) Start() { return } - if api.projectDiscovery { + if api.projectDiscovery && api.agentDirectory != "" { api.discoverDone = make(chan struct{}) go api.discover() diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index def425e97fa95..8e1987cbb4735 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -3425,4 +3425,29 @@ func TestDevcontainerDiscovery(t *testing.T) { require.Equal(t, tt.expected, got.Devcontainers) }) } + + t.Run("NoErrorWhenAgentDirAbsent", func(t *testing.T) { + t.Parallel() + + logger := testutil.Logger(t) + + // Given: We have an empty agent directory + agentDir := "" + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithWatcher(watcher.NewNoop()), + agentcontainers.WithManifestInfo("owner", "workspace", "parent-agent", agentDir), + agentcontainers.WithContainerCLI(&fakeContainerCLI{}), + agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}), + agentcontainers.WithProjectDiscovery(true), + ) + + // When: We start and close the API + api.Start() + api.Close() + + // Then: We expect there to have been no errors. + // This is implicitly handled by `testutil.Logger` failing when it + // detects an error has been logged. + }) } From a00033079809b950768c2941ecb3ecfdd208f9c4 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 22 Jul 2025 11:16:14 +0000 Subject: [PATCH 09/13] chore: call RefreshContainers after discovery has finished This makes the experience nicer when starting the workspace. It makes found devcontainers become visible on the website sooner and at a more consistent speed (as it no longer relies on the updaterLoop). --- agent/agentcontainers/api.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 79e8b72b869c6..b950b296a215f 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -419,6 +419,10 @@ func (api *API) discover() { if err := api.discoverDevcontainerProjects(); err != nil { api.logger.Error(api.ctx, "discovering dev container projects", slog.Error(err)) } + + if err := api.RefreshContainers(api.ctx); err != nil { + api.logger.Error(api.ctx, "refreshing containers after discovery", slog.Error(err)) + } } func (api *API) discoverDevcontainerProjects() error { From 4170809daf13b5b9a09634775fb2974d7a1c8c03 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 22 Jul 2025 15:15:31 +0000 Subject: [PATCH 10/13] chore: drop `/` from `.git` in `filepath.Join` --- agent/agentcontainers/api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index b950b296a215f..10020e4ec5c30 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -426,7 +426,7 @@ func (api *API) discover() { } func (api *API) discoverDevcontainerProjects() error { - isGitProject, err := afero.DirExists(api.fs, filepath.Join(api.agentDirectory, "/.git")) + isGitProject, err := afero.DirExists(api.fs, filepath.Join(api.agentDirectory, ".git")) if err != nil { return xerrors.Errorf(".git dir exists: %w", err) } From 92c857f55800503758652f19632e19768ba8b69a Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 22 Jul 2025 15:15:44 +0000 Subject: [PATCH 11/13] chore: replace fragment syntax with strings --- site/src/modules/resources/AgentDevcontainerCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/src/modules/resources/AgentDevcontainerCard.tsx b/site/src/modules/resources/AgentDevcontainerCard.tsx index 36afcd19c33e0..4f1f75feff539 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.tsx @@ -254,7 +254,8 @@ export const AgentDevcontainerCard: FC = ({ disabled={devcontainer.status === "starting"} > - {devcontainer.container === undefined ? <>Start : <>Rebuild} + + {devcontainer.container === undefined ? "Start" : "Rebuild"} {showDevcontainerControls && displayApps.includes("ssh_helper") && ( From 225fe38080cb86560e45ff2203718fa9403c30ae Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 22 Jul 2025 15:42:21 +0000 Subject: [PATCH 12/13] test: add another storybook to cover config folder used as name --- .../resources/AgentDevcontainerCard.stories.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/site/src/modules/resources/AgentDevcontainerCard.stories.tsx b/site/src/modules/resources/AgentDevcontainerCard.stories.tsx index 1ae50c303cd3e..33f9f0e49594d 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.stories.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.stories.tsx @@ -102,6 +102,18 @@ export const NoContainerOrSubAgent: Story = { }, }; +export const NoContainerOrAgentOrName: Story = { + args: { + devcontainer: { + ...MockWorkspaceAgentDevcontainer, + container: undefined, + agent: undefined, + name: "", + }, + subAgents: [], + }, +}; + export const NoSubAgent: Story = { args: { devcontainer: { From a5ec3c47b85a937d30d9fbb5acbf996e063c03c0 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 22 Jul 2025 16:03:12 +0000 Subject: [PATCH 13/13] feat: add agent env flag to enable/disable project discovery --- agent/agent.go | 1 - cli/agent.go | 41 ++++++++++++++++---------- cli/testdata/coder_agent_--help.golden | 3 ++ 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 6e07d6f7dcd5f..f9278b91a84ae 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -339,7 +339,6 @@ func (a *agent) init() { containerAPIOpts := []agentcontainers.Option{ agentcontainers.WithExecer(a.execer), agentcontainers.WithCommandEnv(a.sshServer.CommandEnv), - agentcontainers.WithProjectDiscovery(true), agentcontainers.WithScriptLogger(func(logSourceID uuid.UUID) agentcontainers.ScriptLogger { return a.logSender.GetScriptLogger(logSourceID) }), diff --git a/cli/agent.go b/cli/agent.go index 2285d44fc3584..4f50fbfe88942 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -40,22 +40,23 @@ import ( func (r *RootCmd) workspaceAgent() *serpent.Command { var ( - auth string - logDir string - scriptDataDir string - pprofAddress string - noReap bool - sshMaxTimeout time.Duration - tailnetListenPort int64 - prometheusAddress string - debugAddress string - slogHumanPath string - slogJSONPath string - slogStackdriverPath string - blockFileTransfer bool - agentHeaderCommand string - agentHeader []string - devcontainers bool + auth string + logDir string + scriptDataDir string + pprofAddress string + noReap bool + sshMaxTimeout time.Duration + tailnetListenPort int64 + prometheusAddress string + debugAddress string + slogHumanPath string + slogJSONPath string + slogStackdriverPath string + blockFileTransfer bool + agentHeaderCommand string + agentHeader []string + devcontainers bool + devcontainerProjectDiscovery bool ) cmd := &serpent.Command{ Use: "agent", @@ -364,6 +365,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { Devcontainers: devcontainers, DevcontainerAPIOptions: []agentcontainers.Option{ agentcontainers.WithSubAgentURL(r.agentURL.String()), + agentcontainers.WithProjectDiscovery(devcontainerProjectDiscovery), }, }) @@ -510,6 +512,13 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { Description: "Allow the agent to automatically detect running devcontainers.", Value: serpent.BoolOf(&devcontainers), }, + { + Flag: "devcontainers-project-discovery-enable", + Default: "true", + Env: "CODER_AGENT_DEVCONTAINERS_PROJECT_DISCOVERY_ENABLE", + Description: "Allow the agent to search the filesystem for devcontainer projects.", + Value: serpent.BoolOf(&devcontainerProjectDiscovery), + }, } return cmd diff --git a/cli/testdata/coder_agent_--help.golden b/cli/testdata/coder_agent_--help.golden index 3dcbb343149d3..0627016855e08 100644 --- a/cli/testdata/coder_agent_--help.golden +++ b/cli/testdata/coder_agent_--help.golden @@ -36,6 +36,9 @@ OPTIONS: --devcontainers-enable bool, $CODER_AGENT_DEVCONTAINERS_ENABLE (default: true) Allow the agent to automatically detect running devcontainers. + --devcontainers-project-discovery-enable bool, $CODER_AGENT_DEVCONTAINERS_PROJECT_DISCOVERY_ENABLE (default: true) + Allow the agent to search the filesystem for devcontainer projects. + --log-dir string, $CODER_AGENT_LOG_DIR (default: /tmp) Specify the location for the agent log files. 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