Skip to content

Commit c3620e2

Browse files
chore: add tests and fix bug
1 parent f528eb3 commit c3620e2

File tree

4 files changed

+268
-3
lines changed

4 files changed

+268
-3
lines changed

agent/agentcontainers/api.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,12 +444,25 @@ func (api *API) discoverDevcontainerProjects() error {
444444
}
445445

446446
func (api *API) discoverDevcontainersInProject(projectPath string) error {
447+
devcontainerConfigPaths := []string{
448+
"/.devcontainer/devcontainer.json",
449+
"/.devcontainer.json",
450+
}
451+
447452
return afero.Walk(api.fs, projectPath, func(path string, info fs.FileInfo, err error) error {
448-
if strings.HasSuffix(path, ".devcontainer/devcontainer.json") {
449-
workspaceFolder := strings.TrimSuffix(path, ".devcontainer/devcontainer.json")
453+
for _, relativeConfigPath := range devcontainerConfigPaths {
454+
if !strings.HasSuffix(path, relativeConfigPath) {
455+
continue
456+
}
457+
458+
workspaceFolder := strings.TrimSuffix(path, relativeConfigPath)
459+
460+
api.logger.Debug(api.ctx, "discovered dev container project", slog.F("workspace_folder", workspaceFolder))
450461

451462
api.mu.Lock()
452463
if _, found := api.knownDevcontainers[workspaceFolder]; !found {
464+
api.logger.Debug(api.ctx, "adding dev container project", slog.F("workspace_folder", workspaceFolder))
465+
453466
dc := codersdk.WorkspaceAgentDevcontainer{
454467
ID: uuid.New(),
455468
Name: "", // Updated later based on container state.

agent/agentcontainers/api_test.go

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/go-chi/chi/v5"
2121
"github.com/google/uuid"
2222
"github.com/lib/pq"
23+
"github.com/spf13/afero"
2324
"github.com/stretchr/testify/assert"
2425
"github.com/stretchr/testify/require"
2526
"go.uber.org/mock/gomock"
@@ -3189,3 +3190,234 @@ func TestWithDevcontainersNameGeneration(t *testing.T) {
31893190
assert.Equal(t, "bar-project", response.Devcontainers[0].Name, "second devcontainer should has a collision and uses the folder name with a prefix")
31903191
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")
31913192
}
3193+
3194+
func TestDevcontainerDiscovery(t *testing.T) {
3195+
t.Parallel()
3196+
3197+
// We discover dev container projects by searching
3198+
// for git repositories at the agent's directory,
3199+
// and then recursively walking through these git
3200+
// repositories to find any `.devcontainer/devcontainer.json`
3201+
// files. These tests are to validate that behavior.
3202+
3203+
tests := []struct {
3204+
name string
3205+
agentDir string
3206+
fs map[string]string
3207+
expected []codersdk.WorkspaceAgentDevcontainer
3208+
}{
3209+
{
3210+
name: "GitProjectInRootDir/SingleProject",
3211+
agentDir: "/home/coder",
3212+
fs: map[string]string{
3213+
"/home/coder/.git/HEAD": "",
3214+
"/home/coder/.devcontainer/devcontainer.json": "",
3215+
},
3216+
expected: []codersdk.WorkspaceAgentDevcontainer{
3217+
{
3218+
WorkspaceFolder: "/home/coder",
3219+
ConfigPath: "/home/coder/.devcontainer/devcontainer.json",
3220+
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
3221+
},
3222+
},
3223+
},
3224+
{
3225+
name: "GitProjectInRootDir/MultipleProjects",
3226+
agentDir: "/home/coder",
3227+
fs: map[string]string{
3228+
"/home/coder/.git/HEAD": "",
3229+
"/home/coder/.devcontainer/devcontainer.json": "",
3230+
"/home/coder/site/.devcontainer/devcontainer.json": "",
3231+
},
3232+
expected: []codersdk.WorkspaceAgentDevcontainer{
3233+
{
3234+
WorkspaceFolder: "/home/coder",
3235+
ConfigPath: "/home/coder/.devcontainer/devcontainer.json",
3236+
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
3237+
},
3238+
{
3239+
WorkspaceFolder: "/home/coder/site",
3240+
ConfigPath: "/home/coder/site/.devcontainer/devcontainer.json",
3241+
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
3242+
},
3243+
},
3244+
},
3245+
{
3246+
name: "GitProjectInChildDir/SingleProject",
3247+
agentDir: "/home/coder",
3248+
fs: map[string]string{
3249+
"/home/coder/coder/.git/HEAD": "",
3250+
"/home/coder/coder/.devcontainer/devcontainer.json": "",
3251+
},
3252+
expected: []codersdk.WorkspaceAgentDevcontainer{
3253+
{
3254+
WorkspaceFolder: "/home/coder/coder",
3255+
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
3256+
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
3257+
},
3258+
},
3259+
},
3260+
{
3261+
name: "GitProjectInChildDir/MultipleProjects",
3262+
agentDir: "/home/coder",
3263+
fs: map[string]string{
3264+
"/home/coder/coder/.git/HEAD": "",
3265+
"/home/coder/coder/.devcontainer/devcontainer.json": "",
3266+
"/home/coder/coder/site/.devcontainer/devcontainer.json": "",
3267+
},
3268+
expected: []codersdk.WorkspaceAgentDevcontainer{
3269+
{
3270+
WorkspaceFolder: "/home/coder/coder",
3271+
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
3272+
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
3273+
},
3274+
{
3275+
WorkspaceFolder: "/home/coder/coder/site",
3276+
ConfigPath: "/home/coder/coder/site/.devcontainer/devcontainer.json",
3277+
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
3278+
},
3279+
},
3280+
},
3281+
{
3282+
name: "GitProjectInMultipleChildDirs/SingleProjectEach",
3283+
agentDir: "/home/coder",
3284+
fs: map[string]string{
3285+
"/home/coder/coder/.git/HEAD": "",
3286+
"/home/coder/coder/.devcontainer/devcontainer.json": "",
3287+
"/home/coder/envbuilder/.git/HEAD": "",
3288+
"/home/coder/envbuilder/.devcontainer/devcontainer.json": "",
3289+
},
3290+
expected: []codersdk.WorkspaceAgentDevcontainer{
3291+
{
3292+
WorkspaceFolder: "/home/coder/coder",
3293+
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
3294+
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
3295+
},
3296+
{
3297+
WorkspaceFolder: "/home/coder/envbuilder",
3298+
ConfigPath: "/home/coder/envbuilder/.devcontainer/devcontainer.json",
3299+
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
3300+
},
3301+
},
3302+
},
3303+
{
3304+
name: "GitProjectInMultipleChildDirs/MultipleProjectEach",
3305+
agentDir: "/home/coder",
3306+
fs: map[string]string{
3307+
"/home/coder/coder/.git/HEAD": "",
3308+
"/home/coder/coder/.devcontainer/devcontainer.json": "",
3309+
"/home/coder/coder/site/.devcontainer/devcontainer.json": "",
3310+
"/home/coder/envbuilder/.git/HEAD": "",
3311+
"/home/coder/envbuilder/.devcontainer/devcontainer.json": "",
3312+
"/home/coder/envbuilder/x/.devcontainer/devcontainer.json": "",
3313+
},
3314+
expected: []codersdk.WorkspaceAgentDevcontainer{
3315+
{
3316+
WorkspaceFolder: "/home/coder/coder",
3317+
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
3318+
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
3319+
},
3320+
{
3321+
WorkspaceFolder: "/home/coder/coder/site",
3322+
ConfigPath: "/home/coder/coder/site/.devcontainer/devcontainer.json",
3323+
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
3324+
},
3325+
{
3326+
WorkspaceFolder: "/home/coder/envbuilder",
3327+
ConfigPath: "/home/coder/envbuilder/.devcontainer/devcontainer.json",
3328+
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
3329+
},
3330+
{
3331+
WorkspaceFolder: "/home/coder/envbuilder/x",
3332+
ConfigPath: "/home/coder/envbuilder/x/.devcontainer/devcontainer.json",
3333+
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
3334+
},
3335+
},
3336+
},
3337+
}
3338+
3339+
initFS := func(t *testing.T, files map[string]string) afero.Fs {
3340+
t.Helper()
3341+
3342+
fs := afero.NewMemMapFs()
3343+
for name, content := range files {
3344+
err := afero.WriteFile(fs, name, []byte(content+"\n"), 0o600)
3345+
require.NoError(t, err)
3346+
}
3347+
return fs
3348+
}
3349+
3350+
for _, tt := range tests {
3351+
t.Run(tt.name, func(t *testing.T) {
3352+
t.Parallel()
3353+
3354+
var (
3355+
ctx = testutil.Context(t, testutil.WaitShort)
3356+
logger = testutil.Logger(t)
3357+
mClock = quartz.NewMock(t)
3358+
tickerTrap = mClock.Trap().TickerFunc("updaterLoop")
3359+
3360+
r = chi.NewRouter()
3361+
)
3362+
3363+
api := agentcontainers.NewAPI(logger,
3364+
agentcontainers.WithClock(mClock),
3365+
agentcontainers.WithWatcher(watcher.NewNoop()),
3366+
agentcontainers.WithFileSystem(initFS(t, tt.fs)),
3367+
agentcontainers.WithManifestInfo("owner", "workspace", "parent-agent", tt.agentDir),
3368+
agentcontainers.WithContainerCLI(&fakeContainerCLI{}),
3369+
agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}),
3370+
)
3371+
api.Start()
3372+
defer api.Close()
3373+
r.Mount("/", api.Routes())
3374+
3375+
tickerTrap.MustWait(ctx).MustRelease(ctx)
3376+
tickerTrap.Close()
3377+
3378+
// Wait until all projects have been discovered
3379+
require.Eventuallyf(t, func() bool {
3380+
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
3381+
rec := httptest.NewRecorder()
3382+
r.ServeHTTP(rec, req)
3383+
3384+
got := codersdk.WorkspaceAgentListContainersResponse{}
3385+
err := json.NewDecoder(rec.Body).Decode(&got)
3386+
require.NoError(t, err)
3387+
3388+
return len(got.Devcontainers) == len(tt.expected)
3389+
}, testutil.WaitShort, testutil.IntervalFast, "dev containers never found")
3390+
3391+
// Now projects have been discovered, we'll allow the updater loop
3392+
// to set the appropriate status for these containers.
3393+
_, aw := mClock.AdvanceNext()
3394+
aw.MustWait(ctx)
3395+
3396+
// Now we'll fetch the list of dev containers
3397+
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
3398+
rec := httptest.NewRecorder()
3399+
r.ServeHTTP(rec, req)
3400+
3401+
got := codersdk.WorkspaceAgentListContainersResponse{}
3402+
err := json.NewDecoder(rec.Body).Decode(&got)
3403+
require.NoError(t, err)
3404+
3405+
// We will set the IDs of each dev container to uuid.Nil to simplify
3406+
// this check.
3407+
for idx := range got.Devcontainers {
3408+
got.Devcontainers[idx].ID = uuid.Nil
3409+
}
3410+
3411+
// Sort the expected dev containers and got dev containers by their workspace folder.
3412+
// This helps ensure a deterministic test.
3413+
slices.SortFunc(tt.expected, func(a, b codersdk.WorkspaceAgentDevcontainer) int {
3414+
return strings.Compare(a.WorkspaceFolder, b.WorkspaceFolder)
3415+
})
3416+
slices.SortFunc(got.Devcontainers, func(a, b codersdk.WorkspaceAgentDevcontainer) int {
3417+
return strings.Compare(a.WorkspaceFolder, b.WorkspaceFolder)
3418+
})
3419+
3420+
require.Equal(t, tt.expected, got.Devcontainers)
3421+
})
3422+
}
3423+
}

site/src/modules/resources/AgentDevcontainerCard.stories.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ export const Recreating: Story = {
9191
},
9292
};
9393

94+
export const NoContainerOrSubAgent: Story = {
95+
args: {
96+
devcontainer: {
97+
...MockWorkspaceAgentDevcontainer,
98+
container: undefined,
99+
agent: undefined,
100+
},
101+
subAgents: [],
102+
},
103+
};
104+
94105
export const NoSubAgent: Story = {
95106
args: {
96107
devcontainer: {

site/src/modules/resources/AgentRow.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,16 @@ export const AgentRow: FC<AgentRowProps> = ({
137137
const [showParentApps, setShowParentApps] = useState(false);
138138

139139
let shouldDisplayAppsSection = shouldDisplayAgentApps;
140-
if (devcontainers && devcontainers.length > 0 && !showParentApps) {
140+
if (
141+
devcontainers &&
142+
devcontainers.find(
143+
// We only want to hide the parent apps by default when there are dev
144+
// containers that are either starting or running. If they are all in
145+
// the stopped state, it doesn't make sense to hide the parent apps.
146+
(dc) => dc.status === "running" || dc.status === "starting",
147+
) !== undefined &&
148+
!showParentApps
149+
) {
141150
shouldDisplayAppsSection = false;
142151
}
143152

0 commit comments

Comments
 (0)