5
5
"encoding/json"
6
6
"errors"
7
7
"fmt"
8
+ "io/fs"
8
9
"maps"
9
10
"net/http"
10
11
"os"
@@ -20,10 +21,13 @@ import (
20
21
21
22
"github.com/fsnotify/fsnotify"
22
23
"github.com/go-chi/chi/v5"
24
+ "github.com/go-git/go-git/v5/plumbing/format/gitignore"
23
25
"github.com/google/uuid"
26
+ "github.com/spf13/afero"
24
27
"golang.org/x/xerrors"
25
28
26
29
"cdr.dev/slog"
30
+ "github.com/coder/coder/v2/agent/agentcontainers/ignore"
27
31
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
28
32
"github.com/coder/coder/v2/agent/agentexec"
29
33
"github.com/coder/coder/v2/agent/usershell"
@@ -56,10 +60,12 @@ type API struct {
56
60
cancel context.CancelFunc
57
61
watcherDone chan struct {}
58
62
updaterDone chan struct {}
63
+ discoverDone chan struct {}
59
64
updateTrigger chan chan error // Channel to trigger manual refresh.
60
65
updateInterval time.Duration // Interval for periodic container updates.
61
66
logger slog.Logger
62
67
watcher watcher.Watcher
68
+ fs afero.Fs
63
69
execer agentexec.Execer
64
70
commandEnv CommandEnv
65
71
ccli ContainerCLI
@@ -71,9 +77,12 @@ type API struct {
71
77
subAgentURL string
72
78
subAgentEnv []string
73
79
74
- ownerName string
75
- workspaceName string
76
- parentAgent string
80
+ projectDiscovery bool // If we should perform project discovery or not.
81
+
82
+ ownerName string
83
+ workspaceName string
84
+ parentAgent string
85
+ agentDirectory string
77
86
78
87
mu sync.RWMutex // Protects the following fields.
79
88
initDone chan struct {} // Closed by Init.
@@ -134,7 +143,8 @@ func WithCommandEnv(ce CommandEnv) Option {
134
143
strings .HasPrefix (s , "CODER_WORKSPACE_AGENT_URL=" ) ||
135
144
strings .HasPrefix (s , "CODER_AGENT_TOKEN=" ) ||
136
145
strings .HasPrefix (s , "CODER_AGENT_AUTH=" ) ||
137
- strings .HasPrefix (s , "CODER_AGENT_DEVCONTAINERS_ENABLE=" )
146
+ strings .HasPrefix (s , "CODER_AGENT_DEVCONTAINERS_ENABLE=" ) ||
147
+ strings .HasPrefix (s , "CODER_AGENT_DEVCONTAINERS_PROJECT_DISCOVERY_ENABLE=" )
138
148
})
139
149
return shell , dir , env , nil
140
150
}
@@ -192,11 +202,12 @@ func WithSubAgentEnv(env ...string) Option {
192
202
193
203
// WithManifestInfo sets the owner name, and workspace name
194
204
// for the sub-agent.
195
- func WithManifestInfo (owner , workspace , parentAgent string ) Option {
205
+ func WithManifestInfo (owner , workspace , parentAgent , agentDirectory string ) Option {
196
206
return func (api * API ) {
197
207
api .ownerName = owner
198
208
api .workspaceName = workspace
199
209
api .parentAgent = parentAgent
210
+ api .agentDirectory = agentDirectory
200
211
}
201
212
}
202
213
@@ -261,6 +272,21 @@ func WithWatcher(w watcher.Watcher) Option {
261
272
}
262
273
}
263
274
275
+ // WithFileSystem sets the file system used for discovering projects.
276
+ func WithFileSystem (fileSystem afero.Fs ) Option {
277
+ return func (api * API ) {
278
+ api .fs = fileSystem
279
+ }
280
+ }
281
+
282
+ // WithProjectDiscovery sets if the API should attempt to discover
283
+ // projects on the filesystem.
284
+ func WithProjectDiscovery (projectDiscovery bool ) Option {
285
+ return func (api * API ) {
286
+ api .projectDiscovery = projectDiscovery
287
+ }
288
+ }
289
+
264
290
// ScriptLogger is an interface for sending devcontainer logs to the
265
291
// controlplane.
266
292
type ScriptLogger interface {
@@ -331,6 +357,9 @@ func NewAPI(logger slog.Logger, options ...Option) *API {
331
357
api .watcher = watcher .NewNoop ()
332
358
}
333
359
}
360
+ if api .fs == nil {
361
+ api .fs = afero .NewOsFs ()
362
+ }
334
363
if api .subAgentClient .Load () == nil {
335
364
var c SubAgentClient = noopSubAgentClient {}
336
365
api .subAgentClient .Store (& c )
@@ -372,13 +401,173 @@ func (api *API) Start() {
372
401
return
373
402
}
374
403
404
+ if api .projectDiscovery && api .agentDirectory != "" {
405
+ api .discoverDone = make (chan struct {})
406
+
407
+ go api .discover ()
408
+ }
409
+
375
410
api .watcherDone = make (chan struct {})
376
411
api .updaterDone = make (chan struct {})
377
412
378
413
go api .watcherLoop ()
379
414
go api .updaterLoop ()
380
415
}
381
416
417
+ func (api * API ) discover () {
418
+ defer close (api .discoverDone )
419
+ defer api .logger .Debug (api .ctx , "project discovery finished" )
420
+ api .logger .Debug (api .ctx , "project discovery started" )
421
+
422
+ if err := api .discoverDevcontainerProjects (); err != nil {
423
+ api .logger .Error (api .ctx , "discovering dev container projects" , slog .Error (err ))
424
+ }
425
+
426
+ if err := api .RefreshContainers (api .ctx ); err != nil {
427
+ api .logger .Error (api .ctx , "refreshing containers after discovery" , slog .Error (err ))
428
+ }
429
+ }
430
+
431
+ func (api * API ) discoverDevcontainerProjects () error {
432
+ isGitProject , err := afero .DirExists (api .fs , filepath .Join (api .agentDirectory , ".git" ))
433
+ if err != nil {
434
+ return xerrors .Errorf (".git dir exists: %w" , err )
435
+ }
436
+
437
+ // If the agent directory is a git project, we'll search
438
+ // the project for any `.devcontainer/devcontainer.json`
439
+ // files.
440
+ if isGitProject {
441
+ return api .discoverDevcontainersInProject (api .agentDirectory )
442
+ }
443
+
444
+ // The agent directory is _not_ a git project, so we'll
445
+ // search the top level of the agent directory for any
446
+ // git projects, and search those.
447
+ entries , err := afero .ReadDir (api .fs , api .agentDirectory )
448
+ if err != nil {
449
+ return xerrors .Errorf ("read agent directory: %w" , err )
450
+ }
451
+
452
+ for _ , entry := range entries {
453
+ if ! entry .IsDir () {
454
+ continue
455
+ }
456
+
457
+ isGitProject , err = afero .DirExists (api .fs , filepath .Join (api .agentDirectory , entry .Name (), ".git" ))
458
+ if err != nil {
459
+ return xerrors .Errorf (".git dir exists: %w" , err )
460
+ }
461
+
462
+ // If this directory is a git project, we'll search
463
+ // it for any `.devcontainer/devcontainer.json` files.
464
+ if isGitProject {
465
+ if err := api .discoverDevcontainersInProject (filepath .Join (api .agentDirectory , entry .Name ())); err != nil {
466
+ return err
467
+ }
468
+ }
469
+ }
470
+
471
+ return nil
472
+ }
473
+
474
+ func (api * API ) discoverDevcontainersInProject (projectPath string ) error {
475
+ logger := api .logger .
476
+ Named ("project-discovery" ).
477
+ With (slog .F ("project_path" , projectPath ))
478
+
479
+ globalPatterns , err := ignore .LoadGlobalPatterns (api .fs )
480
+ if err != nil {
481
+ return xerrors .Errorf ("read global git ignore patterns: %w" , err )
482
+ }
483
+
484
+ patterns , err := ignore .ReadPatterns (api .ctx , logger , api .fs , projectPath )
485
+ if err != nil {
486
+ return xerrors .Errorf ("read git ignore patterns: %w" , err )
487
+ }
488
+
489
+ matcher := gitignore .NewMatcher (append (globalPatterns , patterns ... ))
490
+
491
+ devcontainerConfigPaths := []string {
492
+ "/.devcontainer/devcontainer.json" ,
493
+ "/.devcontainer.json" ,
494
+ }
495
+
496
+ return afero .Walk (api .fs , projectPath , func (path string , info fs.FileInfo , err error ) error {
497
+ if err != nil {
498
+ logger .Error (api .ctx , "encountered error while walking for dev container projects" ,
499
+ slog .F ("path" , path ),
500
+ slog .Error (err ))
501
+ return nil
502
+ }
503
+
504
+ pathParts := ignore .FilePathToParts (path )
505
+
506
+ // We know that a directory entry cannot be a `devcontainer.json` file, so we
507
+ // always skip processing directories. If the directory happens to be ignored
508
+ // by git then we'll make sure to ignore all of the children of that directory.
509
+ if info .IsDir () {
510
+ if matcher .Match (pathParts , true ) {
511
+ return fs .SkipDir
512
+ }
513
+
514
+ return nil
515
+ }
516
+
517
+ if matcher .Match (pathParts , false ) {
518
+ return nil
519
+ }
520
+
521
+ for _ , relativeConfigPath := range devcontainerConfigPaths {
522
+ if ! strings .HasSuffix (path , relativeConfigPath ) {
523
+ continue
524
+ }
525
+
526
+ workspaceFolder := strings .TrimSuffix (path , relativeConfigPath )
527
+
528
+ logger := logger .With (slog .F ("workspace_folder" , workspaceFolder ))
529
+ logger .Debug (api .ctx , "discovered dev container project" )
530
+
531
+ api .mu .Lock ()
532
+ if _ , found := api .knownDevcontainers [workspaceFolder ]; ! found {
533
+ logger .Debug (api .ctx , "adding dev container project" )
534
+
535
+ dc := codersdk.WorkspaceAgentDevcontainer {
536
+ ID : uuid .New (),
537
+ Name : "" , // Updated later based on container state.
538
+ WorkspaceFolder : workspaceFolder ,
539
+ ConfigPath : path ,
540
+ Status : codersdk .WorkspaceAgentDevcontainerStatusStopped ,
541
+ Dirty : false , // Updated later based on config file changes.
542
+ Container : nil ,
543
+ }
544
+
545
+ config , err := api .dccli .ReadConfig (api .ctx , workspaceFolder , path , []string {})
546
+ if err != nil {
547
+ logger .Error (api .ctx , "read project configuration" , slog .Error (err ))
548
+ } else if config .Configuration .Customizations .Coder .AutoStart {
549
+ dc .Status = codersdk .WorkspaceAgentDevcontainerStatusStarting
550
+ }
551
+
552
+ api .knownDevcontainers [workspaceFolder ] = dc
553
+ api .broadcastUpdatesLocked ()
554
+
555
+ if dc .Status == codersdk .WorkspaceAgentDevcontainerStatusStarting {
556
+ api .asyncWg .Add (1 )
557
+ go func () {
558
+ defer api .asyncWg .Done ()
559
+
560
+ _ = api .CreateDevcontainer (dc .WorkspaceFolder , dc .ConfigPath )
561
+ }()
562
+ }
563
+ }
564
+ api .mu .Unlock ()
565
+ }
566
+
567
+ return nil
568
+ })
569
+ }
570
+
382
571
func (api * API ) watcherLoop () {
383
572
defer close (api .watcherDone )
384
573
defer api .logger .Debug (api .ctx , "watcher loop stopped" )
@@ -1808,6 +1997,9 @@ func (api *API) Close() error {
1808
1997
if api .updaterDone != nil {
1809
1998
<- api .updaterDone
1810
1999
}
2000
+ if api .discoverDone != nil {
2001
+ <- api .discoverDone
2002
+ }
1811
2003
1812
2004
// Wait for all async tasks to complete.
1813
2005
api .asyncWg .Wait ()
0 commit comments