Skip to content

Commit ddb5b87

Browse files
chore(agent/agentcontainers): test current prebuilds integration (#19074)
As it turns out, prebuilds + devcontainers appear to already work together. This PR has created a test that simulates a prebuild claim happening to `agentcontainers.API`, to see how we handle it.
1 parent ed62ddc commit ddb5b87

File tree

2 files changed

+388
-0
lines changed

2 files changed

+388
-0
lines changed

agent/agent_test.go

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2458,6 +2458,212 @@ func TestAgent_DevcontainersDisabledForSubAgent(t *testing.T) {
24582458
require.Contains(t, err.Error(), "Dev Container integration inside other Dev Containers is explicitly not supported.")
24592459
}
24602460

2461+
// TestAgent_DevcontainerPrebuildClaim tests that we correctly handle
2462+
// the claiming process for running devcontainers.
2463+
//
2464+
// You can run it manually as follows:
2465+
//
2466+
// CODER_TEST_USE_DOCKER=1 go test -count=1 ./agent -run TestAgent_DevcontainerPrebuildClaim
2467+
//
2468+
//nolint:paralleltest // This test sets an environment variable.
2469+
func TestAgent_DevcontainerPrebuildClaim(t *testing.T) {
2470+
if os.Getenv("CODER_TEST_USE_DOCKER") != "1" {
2471+
t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test")
2472+
}
2473+
if _, err := exec.LookPath("devcontainer"); err != nil {
2474+
t.Skip("This test requires the devcontainer CLI: npm install -g @devcontainers/cli")
2475+
}
2476+
2477+
pool, err := dockertest.NewPool("")
2478+
require.NoError(t, err, "Could not connect to docker")
2479+
2480+
var (
2481+
ctx = testutil.Context(t, testutil.WaitShort)
2482+
2483+
devcontainerID = uuid.New()
2484+
devcontainerLogSourceID = uuid.New()
2485+
2486+
workspaceFolder = filepath.Join(t.TempDir(), "project")
2487+
devcontainerPath = filepath.Join(workspaceFolder, ".devcontainer")
2488+
devcontainerConfig = filepath.Join(devcontainerPath, "devcontainer.json")
2489+
)
2490+
2491+
// Given: A devcontainer project.
2492+
t.Logf("Workspace folder: %s", workspaceFolder)
2493+
2494+
err = os.MkdirAll(devcontainerPath, 0o755)
2495+
require.NoError(t, err, "create dev container directory")
2496+
2497+
// Given: This devcontainer project specifies an app that uses the owner name and workspace name.
2498+
err = os.WriteFile(devcontainerConfig, []byte(`{
2499+
"name": "project",
2500+
"image": "busybox:latest",
2501+
"cmd": ["sleep", "infinity"],
2502+
"runArgs": ["--label=`+agentcontainers.DevcontainerIsTestRunLabel+`=true"],
2503+
"customizations": {
2504+
"coder": {
2505+
"apps": [{
2506+
"slug": "zed",
2507+
"url": "zed://ssh/${localEnv:CODER_WORKSPACE_AGENT_NAME}.${localEnv:CODER_WORKSPACE_NAME}.${localEnv:CODER_WORKSPACE_OWNER_NAME}.coder${containerWorkspaceFolder}"
2508+
}]
2509+
}
2510+
}
2511+
}`), 0o600)
2512+
require.NoError(t, err, "write devcontainer config")
2513+
2514+
// Given: A manifest with a prebuild username and workspace name.
2515+
manifest := agentsdk.Manifest{
2516+
OwnerName: "prebuilds",
2517+
WorkspaceName: "prebuilds-xyz-123",
2518+
2519+
Devcontainers: []codersdk.WorkspaceAgentDevcontainer{
2520+
{ID: devcontainerID, Name: "test", WorkspaceFolder: workspaceFolder},
2521+
},
2522+
Scripts: []codersdk.WorkspaceAgentScript{
2523+
{ID: devcontainerID, LogSourceID: devcontainerLogSourceID},
2524+
},
2525+
}
2526+
2527+
// When: We create an agent with devcontainers enabled.
2528+
//nolint:dogsled
2529+
conn, client, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) {
2530+
o.Devcontainers = true
2531+
o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions,
2532+
agentcontainers.WithContainerLabelIncludeFilter(agentcontainers.DevcontainerLocalFolderLabel, workspaceFolder),
2533+
agentcontainers.WithContainerLabelIncludeFilter(agentcontainers.DevcontainerIsTestRunLabel, "true"),
2534+
)
2535+
})
2536+
2537+
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
2538+
return slices.Contains(client.GetLifecycleStates(), codersdk.WorkspaceAgentLifecycleReady)
2539+
}, testutil.IntervalMedium, "agent not ready")
2540+
2541+
var dcPrebuild codersdk.WorkspaceAgentDevcontainer
2542+
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
2543+
resp, err := conn.ListContainers(ctx)
2544+
require.NoError(t, err)
2545+
2546+
for _, dc := range resp.Devcontainers {
2547+
if dc.Container == nil {
2548+
continue
2549+
}
2550+
2551+
v, ok := dc.Container.Labels[agentcontainers.DevcontainerLocalFolderLabel]
2552+
if ok && v == workspaceFolder {
2553+
dcPrebuild = dc
2554+
return true
2555+
}
2556+
}
2557+
2558+
return false
2559+
}, testutil.IntervalMedium, "devcontainer not found")
2560+
defer func() {
2561+
pool.Client.RemoveContainer(docker.RemoveContainerOptions{
2562+
ID: dcPrebuild.Container.ID,
2563+
RemoveVolumes: true,
2564+
Force: true,
2565+
})
2566+
}()
2567+
2568+
// Then: We expect a sub agent to have been created.
2569+
subAgents := client.GetSubAgents()
2570+
require.Len(t, subAgents, 1)
2571+
2572+
subAgent := subAgents[0]
2573+
subAgentID, err := uuid.FromBytes(subAgent.GetId())
2574+
require.NoError(t, err)
2575+
2576+
// And: We expect there to be 1 app.
2577+
subAgentApps, err := client.GetSubAgentApps(subAgentID)
2578+
require.NoError(t, err)
2579+
require.Len(t, subAgentApps, 1)
2580+
2581+
// And: This app should contain the prebuild workspace name and owner name.
2582+
subAgentApp := subAgentApps[0]
2583+
require.Equal(t, "zed://ssh/project.prebuilds-xyz-123.prebuilds.coder/workspaces/project", subAgentApp.GetUrl())
2584+
2585+
// Given: We close the client and connection
2586+
client.Close()
2587+
conn.Close()
2588+
2589+
// Given: A new manifest with a regular user owner name and workspace name.
2590+
manifest = agentsdk.Manifest{
2591+
OwnerName: "user",
2592+
WorkspaceName: "user-workspace",
2593+
2594+
Devcontainers: []codersdk.WorkspaceAgentDevcontainer{
2595+
{ID: devcontainerID, Name: "test", WorkspaceFolder: workspaceFolder},
2596+
},
2597+
Scripts: []codersdk.WorkspaceAgentScript{
2598+
{ID: devcontainerID, LogSourceID: devcontainerLogSourceID},
2599+
},
2600+
}
2601+
2602+
// When: We create an agent with devcontainers enabled.
2603+
//nolint:dogsled
2604+
conn, client, _, _, _ = setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) {
2605+
o.Devcontainers = true
2606+
o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions,
2607+
agentcontainers.WithContainerLabelIncludeFilter(agentcontainers.DevcontainerLocalFolderLabel, workspaceFolder),
2608+
agentcontainers.WithContainerLabelIncludeFilter(agentcontainers.DevcontainerIsTestRunLabel, "true"),
2609+
)
2610+
})
2611+
2612+
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
2613+
return slices.Contains(client.GetLifecycleStates(), codersdk.WorkspaceAgentLifecycleReady)
2614+
}, testutil.IntervalMedium, "agent not ready")
2615+
2616+
var dcClaimed codersdk.WorkspaceAgentDevcontainer
2617+
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
2618+
resp, err := conn.ListContainers(ctx)
2619+
require.NoError(t, err)
2620+
2621+
for _, dc := range resp.Devcontainers {
2622+
if dc.Container == nil {
2623+
continue
2624+
}
2625+
2626+
v, ok := dc.Container.Labels[agentcontainers.DevcontainerLocalFolderLabel]
2627+
if ok && v == workspaceFolder {
2628+
dcClaimed = dc
2629+
return true
2630+
}
2631+
}
2632+
2633+
return false
2634+
}, testutil.IntervalMedium, "devcontainer not found")
2635+
defer func() {
2636+
if dcClaimed.Container.ID != dcPrebuild.Container.ID {
2637+
pool.Client.RemoveContainer(docker.RemoveContainerOptions{
2638+
ID: dcClaimed.Container.ID,
2639+
RemoveVolumes: true,
2640+
Force: true,
2641+
})
2642+
}
2643+
}()
2644+
2645+
// Then: We expect the claimed devcontainer and prebuild devcontainer
2646+
// to be using the same underlying container.
2647+
require.Equal(t, dcPrebuild.Container.ID, dcClaimed.Container.ID)
2648+
2649+
// And: We expect there to be a sub agent created.
2650+
subAgents = client.GetSubAgents()
2651+
require.Len(t, subAgents, 1)
2652+
2653+
subAgent = subAgents[0]
2654+
subAgentID, err = uuid.FromBytes(subAgent.GetId())
2655+
require.NoError(t, err)
2656+
2657+
// And: We expect there to be an app.
2658+
subAgentApps, err = client.GetSubAgentApps(subAgentID)
2659+
require.NoError(t, err)
2660+
require.Len(t, subAgentApps, 1)
2661+
2662+
// And: We expect this app to have the user's owner name and workspace name.
2663+
subAgentApp = subAgentApps[0]
2664+
require.Equal(t, "zed://ssh/project.user-workspace.user.coder/workspaces/project", subAgentApp.GetUrl())
2665+
}
2666+
24612667
func TestAgent_Dial(t *testing.T) {
24622668
t.Parallel()
24632669

agent/agentcontainers/api_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3815,3 +3815,185 @@ func TestDevcontainerDiscovery(t *testing.T) {
38153815
}
38163816
})
38173817
}
3818+
3819+
// TestDevcontainerPrebuildSupport validates that devcontainers survive the transition
3820+
// from prebuild to claimed workspace, ensuring the existing container is reused
3821+
// with updated configuration rather than being recreated.
3822+
func TestDevcontainerPrebuildSupport(t *testing.T) {
3823+
t.Parallel()
3824+
3825+
if runtime.GOOS == "windows" {
3826+
t.Skip("Dev Container tests are not supported on Windows")
3827+
}
3828+
3829+
var (
3830+
ctx = testutil.Context(t, testutil.WaitShort)
3831+
logger = testutil.Logger(t)
3832+
3833+
fDCCLI = &fakeDevcontainerCLI{readConfigErrC: make(chan func(envs []string) error, 1)}
3834+
fCCLI = &fakeContainerCLI{arch: runtime.GOARCH}
3835+
fSAC = &fakeSubAgentClient{}
3836+
3837+
testDC = codersdk.WorkspaceAgentDevcontainer{
3838+
ID: uuid.New(),
3839+
WorkspaceFolder: "/home/coder/coder",
3840+
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
3841+
}
3842+
3843+
testContainer = newFakeContainer("test-container-id", testDC.ConfigPath, testDC.WorkspaceFolder)
3844+
3845+
prebuildOwner = "prebuilds"
3846+
prebuildWorkspace = "prebuilds-xyz-123"
3847+
prebuildAppURL = "prebuilds.zed"
3848+
3849+
userOwner = "user"
3850+
userWorkspace = "user-workspace"
3851+
userAppURL = "user.zed"
3852+
)
3853+
3854+
// ==================================================
3855+
// PHASE 1: Prebuild workspace creates devcontainer
3856+
// ==================================================
3857+
3858+
// Given: There are no containers initially.
3859+
fCCLI.containers = codersdk.WorkspaceAgentListContainersResponse{}
3860+
3861+
api := agentcontainers.NewAPI(logger,
3862+
// We want this first `agentcontainers.API` to have a manifest info
3863+
// that is consistent with what a prebuild workspace would have.
3864+
agentcontainers.WithManifestInfo(prebuildOwner, prebuildWorkspace, "dev", "/home/coder"),
3865+
// Given: We start with a single dev container resource.
3866+
agentcontainers.WithDevcontainers(
3867+
[]codersdk.WorkspaceAgentDevcontainer{testDC},
3868+
[]codersdk.WorkspaceAgentScript{{ID: testDC.ID, LogSourceID: uuid.New()}},
3869+
),
3870+
agentcontainers.WithSubAgentClient(fSAC),
3871+
agentcontainers.WithContainerCLI(fCCLI),
3872+
agentcontainers.WithDevcontainerCLI(fDCCLI),
3873+
agentcontainers.WithWatcher(watcher.NewNoop()),
3874+
)
3875+
api.Start()
3876+
3877+
fCCLI.containers = codersdk.WorkspaceAgentListContainersResponse{
3878+
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
3879+
}
3880+
3881+
// Given: We allow the dev container to be created.
3882+
fDCCLI.upID = testContainer.ID
3883+
fDCCLI.readConfig = agentcontainers.DevcontainerConfig{
3884+
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
3885+
Customizations: agentcontainers.DevcontainerMergedCustomizations{
3886+
Coder: []agentcontainers.CoderCustomization{{
3887+
Apps: []agentcontainers.SubAgentApp{
3888+
{Slug: "zed", URL: prebuildAppURL},
3889+
},
3890+
}},
3891+
},
3892+
},
3893+
}
3894+
3895+
var readConfigEnvVars []string
3896+
testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(env []string) error {
3897+
readConfigEnvVars = env
3898+
return nil
3899+
})
3900+
3901+
// When: We create the dev container resource
3902+
err := api.CreateDevcontainer(testDC.WorkspaceFolder, testDC.ConfigPath)
3903+
require.NoError(t, err)
3904+
3905+
require.Contains(t, readConfigEnvVars, "CODER_WORKSPACE_OWNER_NAME="+prebuildOwner)
3906+
require.Contains(t, readConfigEnvVars, "CODER_WORKSPACE_NAME="+prebuildWorkspace)
3907+
3908+
// Then: We there to be only 1 agent.
3909+
require.Len(t, fSAC.agents, 1)
3910+
3911+
// And: We expect only 1 agent to have been created.
3912+
require.Len(t, fSAC.created, 1)
3913+
firstAgent := fSAC.created[0]
3914+
3915+
// And: We expect this agent to be the current agent.
3916+
_, found := fSAC.agents[firstAgent.ID]
3917+
require.True(t, found, "first agent expected to be current agent")
3918+
3919+
// And: We expect there to be a single app.
3920+
require.Len(t, firstAgent.Apps, 1)
3921+
firstApp := firstAgent.Apps[0]
3922+
3923+
// And: We expect this app to have the pre-claim URL.
3924+
require.Equal(t, prebuildAppURL, firstApp.URL)
3925+
3926+
// Given: We now close the API
3927+
api.Close()
3928+
3929+
// =============================================================
3930+
// PHASE 2: User claims workspace, devcontainer should be reused
3931+
// =============================================================
3932+
3933+
// Given: We create a new claimed API
3934+
api = agentcontainers.NewAPI(logger,
3935+
// We want this second `agentcontainers.API` to have a manifest info
3936+
// that is consistent with what a claimed workspace would have.
3937+
agentcontainers.WithManifestInfo(userOwner, userWorkspace, "dev", "/home/coder"),
3938+
// Given: We start with a single dev container resource.
3939+
agentcontainers.WithDevcontainers(
3940+
[]codersdk.WorkspaceAgentDevcontainer{testDC},
3941+
[]codersdk.WorkspaceAgentScript{{ID: testDC.ID, LogSourceID: uuid.New()}},
3942+
),
3943+
agentcontainers.WithSubAgentClient(fSAC),
3944+
agentcontainers.WithContainerCLI(fCCLI),
3945+
agentcontainers.WithDevcontainerCLI(fDCCLI),
3946+
agentcontainers.WithWatcher(watcher.NewNoop()),
3947+
)
3948+
api.Start()
3949+
defer func() {
3950+
close(fDCCLI.readConfigErrC)
3951+
3952+
api.Close()
3953+
}()
3954+
3955+
// Given: We allow the dev container to be created.
3956+
fDCCLI.upID = testContainer.ID
3957+
fDCCLI.readConfig = agentcontainers.DevcontainerConfig{
3958+
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
3959+
Customizations: agentcontainers.DevcontainerMergedCustomizations{
3960+
Coder: []agentcontainers.CoderCustomization{{
3961+
Apps: []agentcontainers.SubAgentApp{
3962+
{Slug: "zed", URL: userAppURL},
3963+
},
3964+
}},
3965+
},
3966+
},
3967+
}
3968+
3969+
testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(env []string) error {
3970+
readConfigEnvVars = env
3971+
return nil
3972+
})
3973+
3974+
// When: We create the dev container resource.
3975+
err = api.CreateDevcontainer(testDC.WorkspaceFolder, testDC.ConfigPath)
3976+
require.NoError(t, err)
3977+
3978+
// Then: We expect the environment variables were passed correctly.
3979+
require.Contains(t, readConfigEnvVars, "CODER_WORKSPACE_OWNER_NAME="+userOwner)
3980+
require.Contains(t, readConfigEnvVars, "CODER_WORKSPACE_NAME="+userWorkspace)
3981+
3982+
// And: We expect there to be only 1 agent.
3983+
require.Len(t, fSAC.agents, 1)
3984+
3985+
// And: We expect _a separate agent_ to have been created.
3986+
require.Len(t, fSAC.created, 2)
3987+
secondAgent := fSAC.created[1]
3988+
3989+
// And: We expect this new agent to be the current agent.
3990+
_, found = fSAC.agents[secondAgent.ID]
3991+
require.True(t, found, "second agent expected to be current agent")
3992+
3993+
// And: We expect there to be a single app.
3994+
require.Len(t, secondAgent.Apps, 1)
3995+
secondApp := secondAgent.Apps[0]
3996+
3997+
// And: We expect this app to have the post-claim URL.
3998+
require.Equal(t, userAppURL, secondApp.URL)
3999+
}

0 commit comments

Comments
 (0)