mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(agent/agentcontainers): auto detect dev containers (#18950)
Relates to https://github.com/coder/internal/issues/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.
This commit is contained in:
+1
-1
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
@@ -56,10 +58,12 @@ 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
|
||||
watcher watcher.Watcher
|
||||
fs afero.Fs
|
||||
execer agentexec.Execer
|
||||
commandEnv CommandEnv
|
||||
ccli ContainerCLI
|
||||
@@ -71,9 +75,12 @@ type API struct {
|
||||
subAgentURL string
|
||||
subAgentEnv []string
|
||||
|
||||
ownerName string
|
||||
workspaceName string
|
||||
parentAgent string
|
||||
projectDiscovery bool // If we should perform project discovery or not.
|
||||
|
||||
ownerName string
|
||||
workspaceName string
|
||||
parentAgent string
|
||||
agentDirectory string
|
||||
|
||||
mu sync.RWMutex // Protects the following fields.
|
||||
initDone chan struct{} // Closed by Init.
|
||||
@@ -192,11 +199,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 +269,21 @@ func WithWatcher(w watcher.Watcher) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithFileSystem sets the file system used for discovering projects.
|
||||
func WithFileSystem(fileSystem afero.Fs) Option {
|
||||
return func(api *API) {
|
||||
api.fs = fileSystem
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -331,6 +354,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)
|
||||
@@ -372,6 +398,12 @@ func (api *API) Start() {
|
||||
return
|
||||
}
|
||||
|
||||
if api.projectDiscovery && api.agentDirectory != "" {
|
||||
api.discoverDone = make(chan struct{})
|
||||
|
||||
go api.discover()
|
||||
}
|
||||
|
||||
api.watcherDone = make(chan struct{})
|
||||
api.updaterDone = make(chan struct{})
|
||||
|
||||
@@ -379,6 +411,106 @@ func (api *API) Start() {
|
||||
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))
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
devcontainerConfigPaths := []string{
|
||||
"/.devcontainer/devcontainer.json",
|
||||
"/.devcontainer.json",
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
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")
|
||||
@@ -1808,6 +1940,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()
|
||||
|
||||
@@ -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"
|
||||
@@ -1685,7 +1686,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() {
|
||||
@@ -2669,7 +2670,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()
|
||||
@@ -3196,3 +3197,264 @@ 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()
|
||||
|
||||
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
|
||||
// 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{}),
|
||||
agentcontainers.WithProjectDiscovery(true),
|
||||
)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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.
|
||||
})
|
||||
}
|
||||
|
||||
+25
-16
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
})
|
||||
|
||||
+3
@@ -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.
|
||||
|
||||
|
||||
@@ -91,6 +91,29 @@ export const Recreating: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const NoContainerOrSubAgent: Story = {
|
||||
args: {
|
||||
devcontainer: {
|
||||
...MockWorkspaceAgentDevcontainer,
|
||||
container: undefined,
|
||||
agent: undefined,
|
||||
},
|
||||
subAgents: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const NoContainerOrAgentOrName: Story = {
|
||||
args: {
|
||||
devcontainer: {
|
||||
...MockWorkspaceAgentDevcontainer,
|
||||
container: undefined,
|
||||
agent: undefined,
|
||||
name: "",
|
||||
},
|
||||
subAgents: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const NoSubAgent: Story = {
|
||||
args: {
|
||||
devcontainer: {
|
||||
|
||||
@@ -218,7 +218,8 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
|
||||
text-sm font-semibold text-content-primary
|
||||
md:overflow-visible"
|
||||
>
|
||||
{subAgent?.name ?? devcontainer.name}
|
||||
{subAgent?.name ??
|
||||
(devcontainer.name || devcontainer.config_path)}
|
||||
{devcontainer.container && (
|
||||
<span className="text-content-tertiary">
|
||||
{" "}
|
||||
@@ -253,7 +254,8 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
|
||||
disabled={devcontainer.status === "starting"}
|
||||
>
|
||||
<Spinner loading={devcontainer.status === "starting"} />
|
||||
Rebuild
|
||||
|
||||
{devcontainer.container === undefined ? "Start" : "Rebuild"}
|
||||
</Button>
|
||||
|
||||
{showDevcontainerControls && displayApps.includes("ssh_helper") && (
|
||||
|
||||
@@ -137,7 +137,16 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user