Skip to content

Commit f41275e

Browse files
feat(agent/agentcontainers): auto detect dev containers (#18950)
Relates to coder/internal#711 This PR implements a project discovery mechanism that searches for any dev container projects and makes them visible in the UI so that they can be started. To make the wording on the site more clear, "Rebuild" has been changed to "Start" when there is no container associated with a known dev container configuration. I've also made it so that site will show the dev container config path when there is no other name available. ### Design decisions Just want to ensure my explanation for a few design decisions are noted down: - We only search for dev container configurations inside git repositories - We only search for these git repositories if they're at the top level or a direct child of the agent directory. This limited approach is to reduce the amount of files we ultimately walk when trying to find these projects. It makes sense to limit it to only the agent directory, although I'm open to expanding how deep we search.
1 parent c6efe64 commit f41275e

File tree

11 files changed

+473
-26
lines changed

11 files changed

+473
-26
lines changed

agent/agent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1168,7 +1168,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
11681168
// return existing devcontainers but actual container detection
11691169
// and creation will be deferred.
11701170
a.containerAPI.Init(
1171-
agentcontainers.WithManifestInfo(manifest.OwnerName, manifest.WorkspaceName, manifest.AgentName),
1171+
agentcontainers.WithManifestInfo(manifest.OwnerName, manifest.WorkspaceName, manifest.AgentName, manifest.Directory),
11721172
agentcontainers.WithDevcontainers(manifest.Devcontainers, manifest.Scripts),
11731173
agentcontainers.WithSubAgentClient(agentcontainers.NewSubAgentClientFromAPI(a.logger, aAPI)),
11741174
)

agent/agentcontainers/api.go

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8+
"io/fs"
89
"maps"
910
"net/http"
1011
"os"
@@ -21,6 +22,7 @@ import (
2122
"github.com/fsnotify/fsnotify"
2223
"github.com/go-chi/chi/v5"
2324
"github.com/google/uuid"
25+
"github.com/spf13/afero"
2426
"golang.org/x/xerrors"
2527

2628
"cdr.dev/slog"
@@ -56,10 +58,12 @@ type API struct {
5658
cancel context.CancelFunc
5759
watcherDone chan struct{}
5860
updaterDone chan struct{}
61+
discoverDone chan struct{}
5962
updateTrigger chan chan error // Channel to trigger manual refresh.
6063
updateInterval time.Duration // Interval for periodic container updates.
6164
logger slog.Logger
6265
watcher watcher.Watcher
66+
fs afero.Fs
6367
execer agentexec.Execer
6468
commandEnv CommandEnv
6569
ccli ContainerCLI
@@ -71,9 +75,12 @@ type API struct {
7175
subAgentURL string
7276
subAgentEnv []string
7377

74-
ownerName string
75-
workspaceName string
76-
parentAgent string
78+
projectDiscovery bool // If we should perform project discovery or not.
79+
80+
ownerName string
81+
workspaceName string
82+
parentAgent string
83+
agentDirectory string
7784

7885
mu sync.RWMutex // Protects the following fields.
7986
initDone chan struct{} // Closed by Init.
@@ -192,11 +199,12 @@ func WithSubAgentEnv(env ...string) Option {
192199

193200
// WithManifestInfo sets the owner name, and workspace name
194201
// for the sub-agent.
195-
func WithManifestInfo(owner, workspace, parentAgent string) Option {
202+
func WithManifestInfo(owner, workspace, parentAgent, agentDirectory string) Option {
196203
return func(api *API) {
197204
api.ownerName = owner
198205
api.workspaceName = workspace
199206
api.parentAgent = parentAgent
207+
api.agentDirectory = agentDirectory
200208
}
201209
}
202210

@@ -261,6 +269,21 @@ func WithWatcher(w watcher.Watcher) Option {
261269
}
262270
}
263271

272+
// WithFileSystem sets the file system used for discovering projects.
273+
func WithFileSystem(fileSystem afero.Fs) Option {
274+
return func(api *API) {
275+
api.fs = fileSystem
276+
}
277+
}
278+
279+
// WithProjectDiscovery sets if the API should attempt to discover
280+
// projects on the filesystem.
281+
func WithProjectDiscovery(projectDiscovery bool) Option {
282+
return func(api *API) {
283+
api.projectDiscovery = projectDiscovery
284+
}
285+
}
286+
264287
// ScriptLogger is an interface for sending devcontainer logs to the
265288
// controlplane.
266289
type ScriptLogger interface {
@@ -331,6 +354,9 @@ func NewAPI(logger slog.Logger, options ...Option) *API {
331354
api.watcher = watcher.NewNoop()
332355
}
333356
}
357+
if api.fs == nil {
358+
api.fs = afero.NewOsFs()
359+
}
334360
if api.subAgentClient.Load() == nil {
335361
var c SubAgentClient = noopSubAgentClient{}
336362
api.subAgentClient.Store(&c)
@@ -372,13 +398,119 @@ func (api *API) Start() {
372398
return
373399
}
374400

401+
if api.projectDiscovery && api.agentDirectory != "" {
402+
api.discoverDone = make(chan struct{})
403+
404+
go api.discover()
405+
}
406+
375407
api.watcherDone = make(chan struct{})
376408
api.updaterDone = make(chan struct{})
377409

378410
go api.watcherLoop()
379411
go api.updaterLoop()
380412
}
381413

414+
func (api *API) discover() {
415+
defer close(api.discoverDone)
416+
defer api.logger.Debug(api.ctx, "project discovery finished")
417+
api.logger.Debug(api.ctx, "project discovery started")
418+
419+
if err := api.discoverDevcontainerProjects(); err != nil {
420+
api.logger.Error(api.ctx, "discovering dev container projects", slog.Error(err))
421+
}
422+
423+
if err := api.RefreshContainers(api.ctx); err != nil {
424+
api.logger.Error(api.ctx, "refreshing containers after discovery", slog.Error(err))
425+
}
426+
}
427+
428+
func (api *API) discoverDevcontainerProjects() error {
429+
isGitProject, err := afero.DirExists(api.fs, filepath.Join(api.agentDirectory, ".git"))
430+
if err != nil {
431+
return xerrors.Errorf(".git dir exists: %w", err)
432+
}
433+
434+
// If the agent directory is a git project, we'll search
435+
// the project for any `.devcontainer/devcontainer.json`
436+
// files.
437+
if isGitProject {
438+
return api.discoverDevcontainersInProject(api.agentDirectory)
439+
}
440+
441+
// The agent directory is _not_ a git project, so we'll
442+
// search the top level of the agent directory for any
443+
// git projects, and search those.
444+
entries, err := afero.ReadDir(api.fs, api.agentDirectory)
445+
if err != nil {
446+
return xerrors.Errorf("read agent directory: %w", err)
447+
}
448+
449+
for _, entry := range entries {
450+
if !entry.IsDir() {
451+
continue
452+
}
453+
454+
isGitProject, err = afero.DirExists(api.fs, filepath.Join(api.agentDirectory, entry.Name(), ".git"))
455+
if err != nil {
456+
return xerrors.Errorf(".git dir exists: %w", err)
457+
}
458+
459+
// If this directory is a git project, we'll search
460+
// it for any `.devcontainer/devcontainer.json` files.
461+
if isGitProject {
462+
if err := api.discoverDevcontainersInProject(filepath.Join(api.agentDirectory, entry.Name())); err != nil {
463+
return err
464+
}
465+
}
466+
}
467+
468+
return nil
469+
}
470+
471+
func (api *API) discoverDevcontainersInProject(projectPath string) error {
472+
devcontainerConfigPaths := []string{
473+
"/.devcontainer/devcontainer.json",
474+
"/.devcontainer.json",
475+
}
476+
477+
return afero.Walk(api.fs, projectPath, func(path string, info fs.FileInfo, _ error) error {
478+
if info.IsDir() {
479+
return nil
480+
}
481+
482+
for _, relativeConfigPath := range devcontainerConfigPaths {
483+
if !strings.HasSuffix(path, relativeConfigPath) {
484+
continue
485+
}
486+
487+
workspaceFolder := strings.TrimSuffix(path, relativeConfigPath)
488+
489+
api.logger.Debug(api.ctx, "discovered dev container project", slog.F("workspace_folder", workspaceFolder))
490+
491+
api.mu.Lock()
492+
if _, found := api.knownDevcontainers[workspaceFolder]; !found {
493+
api.logger.Debug(api.ctx, "adding dev container project", slog.F("workspace_folder", workspaceFolder))
494+
495+
dc := codersdk.WorkspaceAgentDevcontainer{
496+
ID: uuid.New(),
497+
Name: "", // Updated later based on container state.
498+
WorkspaceFolder: workspaceFolder,
499+
ConfigPath: path,
500+
Status: "", // Updated later based on container state.
501+
Dirty: false, // Updated later based on config file changes.
502+
Container: nil,
503+
}
504+
505+
api.knownDevcontainers[workspaceFolder] = dc
506+
}
507+
api.mu.Unlock()
508+
}
509+
510+
return nil
511+
})
512+
}
513+
382514
func (api *API) watcherLoop() {
383515
defer close(api.watcherDone)
384516
defer api.logger.Debug(api.ctx, "watcher loop stopped")
@@ -1808,6 +1940,9 @@ func (api *API) Close() error {
18081940
if api.updaterDone != nil {
18091941
<-api.updaterDone
18101942
}
1943+
if api.discoverDone != nil {
1944+
<-api.discoverDone
1945+
}
18111946

18121947
// Wait for all async tasks to complete.
18131948
api.asyncWg.Wait()

0 commit comments

Comments
 (0)