diff --git a/agent/agent_test.go b/agent/agent_test.go index d87148be9ad15..15c653d56ad62 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -2458,6 +2458,212 @@ func TestAgent_DevcontainersDisabledForSubAgent(t *testing.T) { require.Contains(t, err.Error(), "Dev Container integration inside other Dev Containers is explicitly not supported.") } +// TestAgent_DevcontainerPrebuildClaim tests that we correctly handle +// the claiming process for running devcontainers. +// +// You can run it manually as follows: +// +// CODER_TEST_USE_DOCKER=1 go test -count=1 ./agent -run TestAgent_DevcontainerPrebuildClaim +// +//nolint:paralleltest // This test sets an environment variable. +func TestAgent_DevcontainerPrebuildClaim(t *testing.T) { + if os.Getenv("CODER_TEST_USE_DOCKER") != "1" { + t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test") + } + if _, err := exec.LookPath("devcontainer"); err != nil { + t.Skip("This test requires the devcontainer CLI: npm install -g @devcontainers/cli") + } + + pool, err := dockertest.NewPool("") + require.NoError(t, err, "Could not connect to docker") + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + + devcontainerID = uuid.New() + devcontainerLogSourceID = uuid.New() + + workspaceFolder = filepath.Join(t.TempDir(), "project") + devcontainerPath = filepath.Join(workspaceFolder, ".devcontainer") + devcontainerConfig = filepath.Join(devcontainerPath, "devcontainer.json") + ) + + // Given: A devcontainer project. + t.Logf("Workspace folder: %s", workspaceFolder) + + err = os.MkdirAll(devcontainerPath, 0o755) + require.NoError(t, err, "create dev container directory") + + // Given: This devcontainer project specifies an app that uses the owner name and workspace name. + err = os.WriteFile(devcontainerConfig, []byte(`{ + "name": "project", + "image": "busybox:latest", + "cmd": ["sleep", "infinity"], + "runArgs": ["--label=`+agentcontainers.DevcontainerIsTestRunLabel+`=true"], + "customizations": { + "coder": { + "apps": [{ + "slug": "zed", + "url": "zed://ssh/${localEnv:CODER_WORKSPACE_AGENT_NAME}.${localEnv:CODER_WORKSPACE_NAME}.${localEnv:CODER_WORKSPACE_OWNER_NAME}.coder${containerWorkspaceFolder}" + }] + } + } + }`), 0o600) + require.NoError(t, err, "write devcontainer config") + + // Given: A manifest with a prebuild username and workspace name. + manifest := agentsdk.Manifest{ + OwnerName: "prebuilds", + WorkspaceName: "prebuilds-xyz-123", + + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + {ID: devcontainerID, Name: "test", WorkspaceFolder: workspaceFolder}, + }, + Scripts: []codersdk.WorkspaceAgentScript{ + {ID: devcontainerID, LogSourceID: devcontainerLogSourceID}, + }, + } + + // When: We create an agent with devcontainers enabled. + //nolint:dogsled + conn, client, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) { + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, + agentcontainers.WithContainerLabelIncludeFilter(agentcontainers.DevcontainerLocalFolderLabel, workspaceFolder), + agentcontainers.WithContainerLabelIncludeFilter(agentcontainers.DevcontainerIsTestRunLabel, "true"), + ) + }) + + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + return slices.Contains(client.GetLifecycleStates(), codersdk.WorkspaceAgentLifecycleReady) + }, testutil.IntervalMedium, "agent not ready") + + var dcPrebuild codersdk.WorkspaceAgentDevcontainer + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + resp, err := conn.ListContainers(ctx) + require.NoError(t, err) + + for _, dc := range resp.Devcontainers { + if dc.Container == nil { + continue + } + + v, ok := dc.Container.Labels[agentcontainers.DevcontainerLocalFolderLabel] + if ok && v == workspaceFolder { + dcPrebuild = dc + return true + } + } + + return false + }, testutil.IntervalMedium, "devcontainer not found") + defer func() { + pool.Client.RemoveContainer(docker.RemoveContainerOptions{ + ID: dcPrebuild.Container.ID, + RemoveVolumes: true, + Force: true, + }) + }() + + // Then: We expect a sub agent to have been created. + subAgents := client.GetSubAgents() + require.Len(t, subAgents, 1) + + subAgent := subAgents[0] + subAgentID, err := uuid.FromBytes(subAgent.GetId()) + require.NoError(t, err) + + // And: We expect there to be 1 app. + subAgentApps, err := client.GetSubAgentApps(subAgentID) + require.NoError(t, err) + require.Len(t, subAgentApps, 1) + + // And: This app should contain the prebuild workspace name and owner name. + subAgentApp := subAgentApps[0] + require.Equal(t, "zed://ssh/project.prebuilds-xyz-123.prebuilds.coder/workspaces/project", subAgentApp.GetUrl()) + + // Given: We close the client and connection + client.Close() + conn.Close() + + // Given: A new manifest with a regular user owner name and workspace name. + manifest = agentsdk.Manifest{ + OwnerName: "user", + WorkspaceName: "user-workspace", + + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + {ID: devcontainerID, Name: "test", WorkspaceFolder: workspaceFolder}, + }, + Scripts: []codersdk.WorkspaceAgentScript{ + {ID: devcontainerID, LogSourceID: devcontainerLogSourceID}, + }, + } + + // When: We create an agent with devcontainers enabled. + //nolint:dogsled + conn, client, _, _, _ = setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) { + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, + agentcontainers.WithContainerLabelIncludeFilter(agentcontainers.DevcontainerLocalFolderLabel, workspaceFolder), + agentcontainers.WithContainerLabelIncludeFilter(agentcontainers.DevcontainerIsTestRunLabel, "true"), + ) + }) + + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + return slices.Contains(client.GetLifecycleStates(), codersdk.WorkspaceAgentLifecycleReady) + }, testutil.IntervalMedium, "agent not ready") + + var dcClaimed codersdk.WorkspaceAgentDevcontainer + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + resp, err := conn.ListContainers(ctx) + require.NoError(t, err) + + for _, dc := range resp.Devcontainers { + if dc.Container == nil { + continue + } + + v, ok := dc.Container.Labels[agentcontainers.DevcontainerLocalFolderLabel] + if ok && v == workspaceFolder { + dcClaimed = dc + return true + } + } + + return false + }, testutil.IntervalMedium, "devcontainer not found") + defer func() { + if dcClaimed.Container.ID != dcPrebuild.Container.ID { + pool.Client.RemoveContainer(docker.RemoveContainerOptions{ + ID: dcClaimed.Container.ID, + RemoveVolumes: true, + Force: true, + }) + } + }() + + // Then: We expect the claimed devcontainer and prebuild devcontainer + // to be using the same underlying container. + require.Equal(t, dcPrebuild.Container.ID, dcClaimed.Container.ID) + + // And: We expect there to be a sub agent created. + subAgents = client.GetSubAgents() + require.Len(t, subAgents, 1) + + subAgent = subAgents[0] + subAgentID, err = uuid.FromBytes(subAgent.GetId()) + require.NoError(t, err) + + // And: We expect there to be an app. + subAgentApps, err = client.GetSubAgentApps(subAgentID) + require.NoError(t, err) + require.Len(t, subAgentApps, 1) + + // And: We expect this app to have the user's owner name and workspace name. + subAgentApp = subAgentApps[0] + require.Equal(t, "zed://ssh/project.user-workspace.user.coder/workspaces/project", subAgentApp.GetUrl()) +} + func TestAgent_Dial(t *testing.T) { t.Parallel() diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index c8cba67cea73f..48ade4bb195f4 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -161,8 +161,8 @@ func WithContainerCLI(ccli ContainerCLI) Option { // WithContainerLabelIncludeFilter sets a label filter for containers. // This option can be given multiple times to filter by multiple labels. -// The behavior is such that only containers matching one or more of the -// provided labels will be included. +// The behavior is such that only containers matching all of the provided +// labels will be included. func WithContainerLabelIncludeFilter(label, value string) Option { return func(api *API) { api.containerLabelIncludeFilter[label] = value @@ -927,17 +927,22 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code slog.F("config_file", configFile), ) + // If we haven't set any include filters, we should explicitly ignore test devcontainers. + if len(api.containerLabelIncludeFilter) == 0 && container.Labels[DevcontainerIsTestRunLabel] == "true" { + continue + } + // Filter out devcontainer tests, unless explicitly set in include filters. - if len(api.containerLabelIncludeFilter) > 0 || container.Labels[DevcontainerIsTestRunLabel] == "true" { - var ok bool + if len(api.containerLabelIncludeFilter) > 0 { + includeContainer := true for label, value := range api.containerLabelIncludeFilter { - if v, found := container.Labels[label]; found && v == value { - ok = true - } + v, found := container.Labels[label] + + includeContainer = includeContainer && (found && v == value) } // Verbose debug logging is fine here since typically filters // are only used in development or testing environments. - if !ok { + if !includeContainer { logger.Debug(ctx, "container does not match include filter, ignoring devcontainer", slog.F("container_labels", container.Labels), slog.F("include_filter", api.containerLabelIncludeFilter)) continue } diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index e6e25ed92558e..340853a91d360 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -3815,3 +3815,185 @@ func TestDevcontainerDiscovery(t *testing.T) { } }) } + +// TestDevcontainerPrebuildSupport validates that devcontainers survive the transition +// from prebuild to claimed workspace, ensuring the existing container is reused +// with updated configuration rather than being recreated. +func TestDevcontainerPrebuildSupport(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Dev Container tests are not supported on Windows") + } + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + logger = testutil.Logger(t) + + fDCCLI = &fakeDevcontainerCLI{readConfigErrC: make(chan func(envs []string) error, 1)} + fCCLI = &fakeContainerCLI{arch: runtime.GOARCH} + fSAC = &fakeSubAgentClient{} + + testDC = codersdk.WorkspaceAgentDevcontainer{ + ID: uuid.New(), + WorkspaceFolder: "/home/coder/coder", + ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json", + } + + testContainer = newFakeContainer("test-container-id", testDC.ConfigPath, testDC.WorkspaceFolder) + + prebuildOwner = "prebuilds" + prebuildWorkspace = "prebuilds-xyz-123" + prebuildAppURL = "prebuilds.zed" + + userOwner = "user" + userWorkspace = "user-workspace" + userAppURL = "user.zed" + ) + + // ================================================== + // PHASE 1: Prebuild workspace creates devcontainer + // ================================================== + + // Given: There are no containers initially. + fCCLI.containers = codersdk.WorkspaceAgentListContainersResponse{} + + api := agentcontainers.NewAPI(logger, + // We want this first `agentcontainers.API` to have a manifest info + // that is consistent with what a prebuild workspace would have. + agentcontainers.WithManifestInfo(prebuildOwner, prebuildWorkspace, "dev", "/home/coder"), + // Given: We start with a single dev container resource. + agentcontainers.WithDevcontainers( + []codersdk.WorkspaceAgentDevcontainer{testDC}, + []codersdk.WorkspaceAgentScript{{ID: testDC.ID, LogSourceID: uuid.New()}}, + ), + agentcontainers.WithSubAgentClient(fSAC), + agentcontainers.WithContainerCLI(fCCLI), + agentcontainers.WithDevcontainerCLI(fDCCLI), + agentcontainers.WithWatcher(watcher.NewNoop()), + ) + api.Start() + + fCCLI.containers = codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{testContainer}, + } + + // Given: We allow the dev container to be created. + fDCCLI.upID = testContainer.ID + fDCCLI.readConfig = agentcontainers.DevcontainerConfig{ + MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{ + Customizations: agentcontainers.DevcontainerMergedCustomizations{ + Coder: []agentcontainers.CoderCustomization{{ + Apps: []agentcontainers.SubAgentApp{ + {Slug: "zed", URL: prebuildAppURL}, + }, + }}, + }, + }, + } + + var readConfigEnvVars []string + testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(env []string) error { + readConfigEnvVars = env + return nil + }) + + // When: We create the dev container resource + err := api.CreateDevcontainer(testDC.WorkspaceFolder, testDC.ConfigPath) + require.NoError(t, err) + + require.Contains(t, readConfigEnvVars, "CODER_WORKSPACE_OWNER_NAME="+prebuildOwner) + require.Contains(t, readConfigEnvVars, "CODER_WORKSPACE_NAME="+prebuildWorkspace) + + // Then: We there to be only 1 agent. + require.Len(t, fSAC.agents, 1) + + // And: We expect only 1 agent to have been created. + require.Len(t, fSAC.created, 1) + firstAgent := fSAC.created[0] + + // And: We expect this agent to be the current agent. + _, found := fSAC.agents[firstAgent.ID] + require.True(t, found, "first agent expected to be current agent") + + // And: We expect there to be a single app. + require.Len(t, firstAgent.Apps, 1) + firstApp := firstAgent.Apps[0] + + // And: We expect this app to have the pre-claim URL. + require.Equal(t, prebuildAppURL, firstApp.URL) + + // Given: We now close the API + api.Close() + + // ============================================================= + // PHASE 2: User claims workspace, devcontainer should be reused + // ============================================================= + + // Given: We create a new claimed API + api = agentcontainers.NewAPI(logger, + // We want this second `agentcontainers.API` to have a manifest info + // that is consistent with what a claimed workspace would have. + agentcontainers.WithManifestInfo(userOwner, userWorkspace, "dev", "/home/coder"), + // Given: We start with a single dev container resource. + agentcontainers.WithDevcontainers( + []codersdk.WorkspaceAgentDevcontainer{testDC}, + []codersdk.WorkspaceAgentScript{{ID: testDC.ID, LogSourceID: uuid.New()}}, + ), + agentcontainers.WithSubAgentClient(fSAC), + agentcontainers.WithContainerCLI(fCCLI), + agentcontainers.WithDevcontainerCLI(fDCCLI), + agentcontainers.WithWatcher(watcher.NewNoop()), + ) + api.Start() + defer func() { + close(fDCCLI.readConfigErrC) + + api.Close() + }() + + // Given: We allow the dev container to be created. + fDCCLI.upID = testContainer.ID + fDCCLI.readConfig = agentcontainers.DevcontainerConfig{ + MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{ + Customizations: agentcontainers.DevcontainerMergedCustomizations{ + Coder: []agentcontainers.CoderCustomization{{ + Apps: []agentcontainers.SubAgentApp{ + {Slug: "zed", URL: userAppURL}, + }, + }}, + }, + }, + } + + testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(env []string) error { + readConfigEnvVars = env + return nil + }) + + // When: We create the dev container resource. + err = api.CreateDevcontainer(testDC.WorkspaceFolder, testDC.ConfigPath) + require.NoError(t, err) + + // Then: We expect the environment variables were passed correctly. + require.Contains(t, readConfigEnvVars, "CODER_WORKSPACE_OWNER_NAME="+userOwner) + require.Contains(t, readConfigEnvVars, "CODER_WORKSPACE_NAME="+userWorkspace) + + // And: We expect there to be only 1 agent. + require.Len(t, fSAC.agents, 1) + + // And: We expect _a separate agent_ to have been created. + require.Len(t, fSAC.created, 2) + secondAgent := fSAC.created[1] + + // And: We expect this new agent to be the current agent. + _, found = fSAC.agents[secondAgent.ID] + require.True(t, found, "second agent expected to be current agent") + + // And: We expect there to be a single app. + require.Len(t, secondAgent.Apps, 1) + secondApp := secondAgent.Apps[0] + + // And: We expect this app to have the post-claim URL. + require.Equal(t, userAppURL, secondApp.URL) +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7087b5dcc977f..c30248734171a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12833,7 +12833,8 @@ const docTemplate = `{ "workspace-usage", "web-push", "oauth2", - "mcp-server-http" + "mcp-server-http", + "workspace-sharing" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", @@ -12842,6 +12843,7 @@ const docTemplate = `{ "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentOAuth2": "Enables OAuth2 provider functionality.", "ExperimentWebPush": "Enables web push notifications through the browser.", + "ExperimentWorkspaceSharing": "Enables updating workspace ACLs for sharing with users and groups.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, "x-enum-varnames": [ @@ -12851,7 +12853,8 @@ const docTemplate = `{ "ExperimentWorkspaceUsage", "ExperimentWebPush", "ExperimentOAuth2", - "ExperimentMCPServerHTTP" + "ExperimentMCPServerHTTP", + "ExperimentWorkspaceSharing" ] }, "codersdk.ExternalAuth": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3a1443cd76dec..2212d91e35650 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11501,7 +11501,8 @@ "workspace-usage", "web-push", "oauth2", - "mcp-server-http" + "mcp-server-http", + "workspace-sharing" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", @@ -11510,6 +11511,7 @@ "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentOAuth2": "Enables OAuth2 provider functionality.", "ExperimentWebPush": "Enables web push notifications through the browser.", + "ExperimentWorkspaceSharing": "Enables updating workspace ACLs for sharing with users and groups.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, "x-enum-varnames": [ @@ -11519,7 +11521,8 @@ "ExperimentWorkspaceUsage", "ExperimentWebPush", "ExperimentOAuth2", - "ExperimentMCPServerHTTP" + "ExperimentMCPServerHTTP", + "ExperimentWorkspaceSharing" ] }, "codersdk.ExternalAuth": { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 3844523063db7..1d6fa4572772e 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3432,6 +3432,7 @@ const ( ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser. ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality. ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality. + ExperimentWorkspaceSharing Experiment = "workspace-sharing" // Enables updating workspace ACLs for sharing with users and groups. ) func (e Experiment) DisplayName() string { @@ -3450,6 +3451,8 @@ func (e Experiment) DisplayName() string { return "OAuth2 Provider Functionality" case ExperimentMCPServerHTTP: return "MCP HTTP Server Functionality" + case ExperimentWorkspaceSharing: + return "Workspace Sharing" default: // Split on hyphen and convert to title case // e.g. "web-push" -> "Web Push", "mcp-server-http" -> "Mcp Server Http" @@ -3467,6 +3470,7 @@ var ExperimentsKnown = Experiments{ ExperimentWebPush, ExperimentOAuth2, ExperimentMCPServerHTTP, + ExperimentWorkspaceSharing, } // ExperimentsSafe should include all experiments that are safe for diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index dcbd00628e979..5d9866658c71c 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3320,6 +3320,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `web-push` | | `oauth2` | | `mcp-server-http` | +| `workspace-sharing` | ## codersdk.ExternalAuth diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3bd870d60159f..bd14a6edf0267 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -917,6 +917,7 @@ export type Experiment = | "notifications" | "oauth2" | "web-push" + | "workspace-sharing" | "workspace-usage"; export const Experiments: Experiment[] = [ @@ -926,6 +927,7 @@ export const Experiments: Experiment[] = [ "notifications", "oauth2", "web-push", + "workspace-sharing", "workspace-usage", ];