5
5
"encoding/json"
6
6
"errors"
7
7
"fmt"
8
+ "io/fs"
8
9
"maps"
9
10
"net/http"
10
11
"os"
@@ -21,6 +22,7 @@ import (
21
22
"github.com/fsnotify/fsnotify"
22
23
"github.com/go-chi/chi/v5"
23
24
"github.com/google/uuid"
25
+ "github.com/spf13/afero"
24
26
"golang.org/x/xerrors"
25
27
26
28
"cdr.dev/slog"
@@ -56,10 +58,12 @@ type API struct {
56
58
cancel context.CancelFunc
57
59
watcherDone chan struct {}
58
60
updaterDone chan struct {}
61
+ discoverDone chan struct {}
59
62
updateTrigger chan chan error // Channel to trigger manual refresh.
60
63
updateInterval time.Duration // Interval for periodic container updates.
61
64
logger slog.Logger
62
65
watcher watcher.Watcher
66
+ fs afero.Fs
63
67
execer agentexec.Execer
64
68
commandEnv CommandEnv
65
69
ccli ContainerCLI
@@ -71,9 +75,12 @@ type API struct {
71
75
subAgentURL string
72
76
subAgentEnv []string
73
77
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
77
84
78
85
mu sync.RWMutex // Protects the following fields.
79
86
initDone chan struct {} // Closed by Init.
@@ -192,11 +199,12 @@ func WithSubAgentEnv(env ...string) Option {
192
199
193
200
// WithManifestInfo sets the owner name, and workspace name
194
201
// for the sub-agent.
195
- func WithManifestInfo (owner , workspace , parentAgent string ) Option {
202
+ func WithManifestInfo (owner , workspace , parentAgent , agentDirectory string ) Option {
196
203
return func (api * API ) {
197
204
api .ownerName = owner
198
205
api .workspaceName = workspace
199
206
api .parentAgent = parentAgent
207
+ api .agentDirectory = agentDirectory
200
208
}
201
209
}
202
210
@@ -261,6 +269,21 @@ func WithWatcher(w watcher.Watcher) Option {
261
269
}
262
270
}
263
271
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
+
264
287
// ScriptLogger is an interface for sending devcontainer logs to the
265
288
// controlplane.
266
289
type ScriptLogger interface {
@@ -331,6 +354,9 @@ func NewAPI(logger slog.Logger, options ...Option) *API {
331
354
api .watcher = watcher .NewNoop ()
332
355
}
333
356
}
357
+ if api .fs == nil {
358
+ api .fs = afero .NewOsFs ()
359
+ }
334
360
if api .subAgentClient .Load () == nil {
335
361
var c SubAgentClient = noopSubAgentClient {}
336
362
api .subAgentClient .Store (& c )
@@ -372,13 +398,119 @@ func (api *API) Start() {
372
398
return
373
399
}
374
400
401
+ if api .projectDiscovery && api .agentDirectory != "" {
402
+ api .discoverDone = make (chan struct {})
403
+
404
+ go api .discover ()
405
+ }
406
+
375
407
api .watcherDone = make (chan struct {})
376
408
api .updaterDone = make (chan struct {})
377
409
378
410
go api .watcherLoop ()
379
411
go api .updaterLoop ()
380
412
}
381
413
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
+
382
514
func (api * API ) watcherLoop () {
383
515
defer close (api .watcherDone )
384
516
defer api .logger .Debug (api .ctx , "watcher loop stopped" )
@@ -1808,6 +1940,9 @@ func (api *API) Close() error {
1808
1940
if api .updaterDone != nil {
1809
1941
<- api .updaterDone
1810
1942
}
1943
+ if api .discoverDone != nil {
1944
+ <- api .discoverDone
1945
+ }
1811
1946
1812
1947
// Wait for all async tasks to complete.
1813
1948
api .asyncWg .Wait ()
0 commit comments