From 61b6562f9aa5ac03a6a5676fb37861e750419d94 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 3 Jul 2025 10:55:20 +0100 Subject: [PATCH 1/5] feat: display descriptions in multi-select component (#18730) Screenshot 2025-07-02 at 23 06 51 --- .../MultiSelectCombobox.tsx | 26 ++++++++++++++++++- .../DynamicParameter/DynamicParameter.tsx | 1 + 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx index 2d25ae983c3d9..936e93034c705 100644 --- a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx @@ -11,8 +11,14 @@ import { CommandItem, CommandList, } from "components/Command/Command"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; import { useDebouncedValue } from "hooks/debounce"; -import { ChevronDown, X } from "lucide-react"; +import { ChevronDown, Info, X } from "lucide-react"; import { type ComponentProps, type ComponentPropsWithoutRef, @@ -33,6 +39,7 @@ export interface Option { label: string; icon?: string; disable?: boolean; + description?: string; /** fixed option that can't be removed. */ fixed?: boolean; /** Group the options by providing key. */ @@ -662,6 +669,23 @@ export const MultiSelectCombobox = forwardRef< /> )} {option.label} + {option.description && ( + + + + + + + + + {option.description} + + + + )} ); diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 5665129eadba3..ac0df20355205 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -488,6 +488,7 @@ const ParameterField: FC = ({ value: opt.value.value, label: opt.name, icon: opt.icon, + description: opt.description, disable: false, })); From 351745752be266bfb0f5deb50dbfd84653845560 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Thu, 3 Jul 2025 16:20:16 +0500 Subject: [PATCH 2/5] docs: update release calendar with 2.24 release (#18742) --- docs/install/releases/index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/install/releases/index.md b/docs/install/releases/index.md index c23bbc25367ab..49ef62aa46759 100644 --- a/docs/install/releases/index.md +++ b/docs/install/releases/index.md @@ -57,13 +57,13 @@ pages. | Release name | Release Date | Status | Latest Release | |------------------------------------------------|-------------------|------------------|----------------------------------------------------------------| -| [2.18](https://coder.com/changelog/coder-2-18) | December 03, 2024 | Not Supported | [v2.18.5](https://github.com/coder/coder/releases/tag/v2.18.5) | | [2.19](https://coder.com/changelog/coder-2-19) | February 04, 2025 | Not Supported | [v2.19.3](https://github.com/coder/coder/releases/tag/v2.19.3) | | [2.20](https://coder.com/changelog/coder-2-20) | March 04, 2025 | Not Supported | [v2.20.3](https://github.com/coder/coder/releases/tag/v2.20.3) | -| [2.21](https://coder.com/changelog/coder-2-21) | April 02, 2025 | Security Support | [v2.21.3](https://github.com/coder/coder/releases/tag/v2.21.3) | -| [2.22](https://coder.com/changelog/coder-2-22) | May 16, 2025 | Stable | [v2.22.1](https://github.com/coder/coder/releases/tag/v2.22.1) | -| [2.23](https://coder.com/changelog/coder-2-23) | June 03, 2025 | Mainline | [v2.23.1](https://github.com/coder/coder/releases/tag/v2.23.1) | -| 2.24 | | Not Released | N/A | +| [2.21](https://coder.com/changelog/coder-2-21) | April 02, 2025 | Not Supported | [v2.21.3](https://github.com/coder/coder/releases/tag/v2.21.3) | +| [2.22](https://coder.com/changelog/coder-2-22) | May 16, 2025 | Security Support | [v2.22.1](https://github.com/coder/coder/releases/tag/v2.22.1) | +| [2.23](https://coder.com/changelog/coder-2-23) | June 03, 2025 | Stable | [v2.23.2](https://github.com/coder/coder/releases/tag/v2.23.2) | +| [2.24](https://coder.com/changelog/coder-2-24) | July 01, 2025 | Mainline | [v2.24.1](https://github.com/coder/coder/releases/tag/v2.24.1) | +| 2.25 | | Not Released | N/A | > [!TIP] From 8b6d70bf1fd7ac953fbbfdcb7dbad7f073e115a6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 3 Jul 2025 15:03:05 +0300 Subject: [PATCH 3/5] fix(site): update vs code dev container button URLs (#18696) --- .devcontainer/devcontainer.json | 4 ++-- site/src/modules/resources/AgentDevcontainerCard.tsx | 6 +++++- .../VSCodeDevContainerButton.stories.tsx | 6 ++++++ .../VSCodeDevContainerButton.tsx | 10 ++++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 06b6192b7cdb4..591848bfb09dd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -24,7 +24,7 @@ { "slug": "cursor", "displayName": "Cursor Desktop", - "url": "cursor://coder.coder-remote/openDevContainer?owner=${localEnv:CODER_WORKSPACE_OWNER_NAME}&workspace=${localEnv:CODER_WORKSPACE_NAME}&agent=${localEnv:CODER_WORKSPACE_PARENT_AGENT_NAME}&url=${localEnv:CODER_URL}&token=$SESSION_TOKEN&devContainerName=${localEnv:CONTAINER_ID}&devContainerFolder=${containerWorkspaceFolder}", + "url": "cursor://coder.coder-remote/openDevContainer?owner=${localEnv:CODER_WORKSPACE_OWNER_NAME}&workspace=${localEnv:CODER_WORKSPACE_NAME}&agent=${localEnv:CODER_WORKSPACE_PARENT_AGENT_NAME}&url=${localEnv:CODER_URL}&token=$SESSION_TOKEN&devContainerName=${localEnv:CONTAINER_ID}&devContainerFolder=${containerWorkspaceFolder}&localWorkspaceFolder=${localWorkspaceFolder}", "external": true, "icon": "/icon/cursor.svg", "order": 1 @@ -32,7 +32,7 @@ { "slug": "windsurf", "displayName": "Windsurf Editor", - "url": "windsurf://coder.coder-remote/openDevContainer?owner=${localEnv:CODER_WORKSPACE_OWNER_NAME}&workspace=${localEnv:CODER_WORKSPACE_NAME}&agent=${localEnv:CODER_WORKSPACE_PARENT_AGENT_NAME}&url=${localEnv:CODER_URL}&token=$SESSION_TOKEN&devContainerName=${localEnv:CONTAINER_ID}&devContainerFolder=${containerWorkspaceFolder}", + "url": "windsurf://coder.coder-remote/openDevContainer?owner=${localEnv:CODER_WORKSPACE_OWNER_NAME}&workspace=${localEnv:CODER_WORKSPACE_NAME}&agent=${localEnv:CODER_WORKSPACE_PARENT_AGENT_NAME}&url=${localEnv:CODER_URL}&token=$SESSION_TOKEN&devContainerName=${localEnv:CONTAINER_ID}&devContainerFolder=${containerWorkspaceFolder}&localWorkspaceFolder=${localWorkspaceFolder}", "external": true, "icon": "/icon/windsurf.svg", "order": 4 diff --git a/site/src/modules/resources/AgentDevcontainerCard.tsx b/site/src/modules/resources/AgentDevcontainerCard.tsx index 77118ad26d7e1..c7516dde15c39 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.tsx @@ -307,6 +307,8 @@ export const AgentDevcontainerCard: FC = ({ workspaceName={workspace.name} devContainerName={devcontainer.container.name} devContainerFolder={subAgent?.directory ?? ""} + localWorkspaceFolder={devcontainer.workspace_folder} + localConfigFile={devcontainer.config_path || ""} displayApps={displayApps} // TODO(mafredri): We could use subAgent display apps here but we currently set none. agentName={parentAgent.name} /> @@ -331,7 +333,9 @@ export const AgentDevcontainerCard: FC = ({ {wildcardHostname !== "" && devcontainer.container?.ports.map((port) => { - const portLabel = `${port.port}/${port.network.toUpperCase()}`; + const portLabel = `${ + port.port + }/${port.network.toUpperCase()}`; const hasHostBind = port.host_port !== undefined && port.host_ip !== undefined; const helperText = hasHostBind diff --git a/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.stories.tsx b/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.stories.tsx index a16eb58ba72b3..0a3838e4251c0 100644 --- a/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.stories.tsx +++ b/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.stories.tsx @@ -17,6 +17,8 @@ export const Default: Story = { agentName: MockWorkspaceAgent.name, devContainerName: "musing_ride", devContainerFolder: "/workspace/coder", + localWorkspaceFolder: "/home/coder/coder", + localConfigFile: "/home/coder/coder/.devcontainer/devcontainer.json", displayApps: [ "vscode", "vscode_insiders", @@ -34,6 +36,8 @@ export const VSCodeOnly: Story = { agentName: MockWorkspaceAgent.name, devContainerName: "nifty_borg", devContainerFolder: "/workspace/coder", + localWorkspaceFolder: "/home/coder/coder", + localConfigFile: "/home/coder/coder/.devcontainer/devcontainer.json", displayApps: [ "vscode", "port_forwarding_helper", @@ -50,6 +54,8 @@ export const InsidersOnly: Story = { agentName: MockWorkspaceAgent.name, devContainerName: "amazing_swartz", devContainerFolder: "/workspace/coder", + localWorkspaceFolder: "/home/coder/coder", + localConfigFile: "/home/coder/coder/.devcontainer/devcontainer.json", displayApps: [ "vscode_insiders", "port_forwarding_helper", diff --git a/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.tsx b/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.tsx index ffaef3e13016c..efd6ecc9bc00c 100644 --- a/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.tsx +++ b/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.tsx @@ -15,6 +15,8 @@ interface VSCodeDevContainerButtonProps { agentName?: string; devContainerName: string; devContainerFolder: string; + localWorkspaceFolder: string; + localConfigFile: string; displayApps: readonly DisplayApp[]; } @@ -112,6 +114,8 @@ const VSCodeButton: FC = ({ agentName, devContainerName, devContainerFolder, + localWorkspaceFolder, + localConfigFile, }) => { const [loading, setLoading] = useState(false); @@ -129,6 +133,8 @@ const VSCodeButton: FC = ({ token: key, devContainerName, devContainerFolder, + localWorkspaceFolder, + localConfigFile, }); if (agentName) { query.set("agent", agentName); @@ -156,6 +162,8 @@ const VSCodeInsidersButton: FC = ({ agentName, devContainerName, devContainerFolder, + localWorkspaceFolder, + localConfigFile, }) => { const [loading, setLoading] = useState(false); @@ -173,6 +181,8 @@ const VSCodeInsidersButton: FC = ({ token: key, devContainerName, devContainerFolder, + localWorkspaceFolder, + localConfigFile, }); if (agentName) { query.set("agent", agentName); From 7d412c2272ee57f8ff43d93806ada457e6cd1adf Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 3 Jul 2025 15:50:08 +0300 Subject: [PATCH 4/5] feat(examples/templates): add `docker-devcontainer` template and rename envbuilder template (#18741) This change adds a new `docker-devcontainer` template which allows you to provision a workspace running in Docker, that also creates workspaces via Docker running inside (DinD). - **chore(examples/templates): rename `docker-devcontainer` to `docker-envbuilder`** - **feat(examples/templates): add `docker-devcontainer` example template** --- .../coder_templates_init_--help.golden | 2 +- docs/reference/cli/templates_init.md | 6 +- examples/examples.gen.json | 20 +- examples/examples.go | 1 + .../templates/docker-devcontainer/README.md | 62 +-- .../templates/docker-devcontainer/main.tf | 443 ++++++++---------- .../scripts/init-docker-in-docker.sh | 101 ++++ .../templates/docker-envbuilder/README.md | 77 +++ examples/templates/docker-envbuilder/main.tf | 372 +++++++++++++++ 9 files changed, 779 insertions(+), 305 deletions(-) create mode 100755 examples/templates/docker-devcontainer/scripts/init-docker-in-docker.sh create mode 100644 examples/templates/docker-envbuilder/README.md create mode 100644 examples/templates/docker-envbuilder/main.tf diff --git a/cli/testdata/coder_templates_init_--help.golden b/cli/testdata/coder_templates_init_--help.golden index 4d3cd1c2a1228..d44db24aee27b 100644 --- a/cli/testdata/coder_templates_init_--help.golden +++ b/cli/testdata/coder_templates_init_--help.golden @@ -6,7 +6,7 @@ USAGE: Get started with a templated template. OPTIONS: - --id aws-devcontainer|aws-linux|aws-windows|azure-linux|digitalocean-linux|docker|docker-devcontainer|gcp-devcontainer|gcp-linux|gcp-vm-container|gcp-windows|kubernetes|kubernetes-devcontainer|nomad-docker|scratch + --id aws-devcontainer|aws-linux|aws-windows|azure-linux|digitalocean-linux|docker|docker-devcontainer|docker-envbuilder|gcp-devcontainer|gcp-linux|gcp-vm-container|gcp-windows|kubernetes|kubernetes-devcontainer|nomad-docker|scratch Specify a given example template by ID. ——— diff --git a/docs/reference/cli/templates_init.md b/docs/reference/cli/templates_init.md index 30df7bb9c0ad3..7613144e66018 100644 --- a/docs/reference/cli/templates_init.md +++ b/docs/reference/cli/templates_init.md @@ -13,8 +13,8 @@ coder templates init [flags] [directory] ### --id -| | | -|------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Type | aws-devcontainer\|aws-linux\|aws-windows\|azure-linux\|digitalocean-linux\|docker\|docker-devcontainer\|gcp-devcontainer\|gcp-linux\|gcp-vm-container\|gcp-windows\|kubernetes\|kubernetes-devcontainer\|nomad-docker\|scratch | +| | | +|------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Type | aws-devcontainer\|aws-linux\|aws-windows\|azure-linux\|digitalocean-linux\|docker\|docker-devcontainer\|docker-envbuilder\|gcp-devcontainer\|gcp-linux\|gcp-vm-container\|gcp-windows\|kubernetes\|kubernetes-devcontainer\|nomad-docker\|scratch | Specify a given example template by ID. diff --git a/examples/examples.gen.json b/examples/examples.gen.json index 8939c0efd30b1..c891389568a55 100644 --- a/examples/examples.gen.json +++ b/examples/examples.gen.json @@ -83,15 +83,29 @@ { "id": "docker-devcontainer", "url": "", - "name": "Docker (Devcontainer)", + "name": "Docker-in-Docker Dev Containers", + "description": "Provision Docker containers as Coder workspaces running Dev Containers via Docker-in-Docker.", + "icon": "/icon/docker.png", + "tags": [ + "docker", + "container", + "devcontainer" + ], + "markdown": "\n# Remote Development on Dev Containers\n\nProvision Docker containers as [Coder workspaces](https://coder.com/docs/workspaces) running [Dev Containers](https://code.visualstudio.com/docs/devcontainers/containers) via Docker-in-Docker.\n\n\u003c!-- TODO: Add screenshot --\u003e\n\n## Prerequisites\n\n### Infrastructure\n\nThe VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:\n\n```sh\n# Add coder user to Docker group\nsudo adduser coder docker\n\n# Restart Coder server\nsudo systemctl restart coder\n\n# Test Docker\nsudo -u coder docker ps\n```\n\n## Architecture\n\nThis example uses the `codercom/enterprise-node:ubuntu` Docker image as a base image for the workspace. It includes necessary tools like Docker and Node.js, which are required for running Dev Containers via the `@devcontainers/cli` tool.\n\nThis template provisions the following resources:\n\n- Docker image (built by Docker socket and kept locally)\n- Docker container (ephemeral)\n- Docker volume (persistent on `/home/coder`)\n- Docker volume (persistent on `/var/lib/docker`)\n\nThis means, when the workspace restarts, any tools or files outside of the home directory or docker library are not persisted.\n\nFor devcontainers running inside the workspace, data persistence is dependent on each projects `devcontainer.json` configuration.\n\n\u003e **Note**\n\u003e This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case.\n" + }, + { + "id": "docker-envbuilder", + "url": "", + "name": "Docker (Envbuilder)", "description": "Provision envbuilder containers as Coder workspaces", "icon": "/icon/docker.png", "tags": [ "container", "docker", - "devcontainer" + "devcontainer", + "envbuilder" ], - "markdown": "\n# Remote Development on Docker Containers (with Devcontainers)\n\nProvision Devcontainers as [Coder workspaces](https://coder.com/docs/workspaces) in Docker with this example template.\n\n## Prerequisites\n\n### Infrastructure\n\nCoder must have access to a running Docker socket, and the `coder` user must be a member of the `docker` group:\n\n```shell\n# Add coder user to Docker group\nsudo usermod -aG docker coder\n\n# Restart Coder server\nsudo systemctl restart coder\n\n# Test Docker\nsudo -u coder docker ps\n```\n\n## Architecture\n\nCoder supports Devcontainers via [envbuilder](https://github.com/coder/envbuilder), an open source project. Read more about this in [Coder's documentation](https://coder.com/docs/templates/dev-containers).\n\nThis template provisions the following resources:\n\n- Envbuilder cached image (conditional, persistent) using [`terraform-provider-envbuilder`](https://github.com/coder/terraform-provider-envbuilder)\n- Docker image (persistent) using [`envbuilder`](https://github.com/coder/envbuilder)\n- Docker container (ephemeral)\n- Docker volume (persistent on `/workspaces`)\n\nThe Git repository is cloned inside the `/workspaces` volume if not present.\nAny local changes to the Devcontainer files inside the volume will be applied when you restart the workspace.\nKeep in mind that any tools or files outside of `/workspaces` or not added as part of the Devcontainer specification are not persisted.\nEdit the `devcontainer.json` instead!\n\n\u003e **Note**\n\u003e This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case.\n\n## Docker-in-Docker\n\nSee the [Envbuilder documentation](https://github.com/coder/envbuilder/blob/main/docs/docker.md) for information on running Docker containers inside a devcontainer built by Envbuilder.\n\n## Caching\n\nTo speed up your builds, you can use a container registry as a cache.\nWhen creating the template, set the parameter `cache_repo` to a valid Docker repository.\n\nFor example, you can run a local registry:\n\n```shell\ndocker run --detach \\\n --volume registry-cache:/var/lib/registry \\\n --publish 5000:5000 \\\n --name registry-cache \\\n --net=host \\\n registry:2\n```\n\nThen, when creating the template, enter `localhost:5000/devcontainer-cache` for the parameter `cache_repo`.\n\nSee the [Envbuilder Terraform Provider Examples](https://github.com/coder/terraform-provider-envbuilder/blob/main/examples/resources/envbuilder_cached_image/envbuilder_cached_image_resource.tf/) for a more complete example of how the provider works.\n\n\u003e [!NOTE]\n\u003e We recommend using a registry cache with authentication enabled.\n\u003e To allow Envbuilder to authenticate with the registry cache, specify the variable `cache_repo_docker_config_path`\n\u003e with the path to a Docker config `.json` on disk containing valid credentials for the registry.\n" + "markdown": "\n# Remote Development on Docker Containers (with Envbuilder)\n\nProvision Envbuilder containers based on `devcontainer.json` as [Coder workspaces](https://coder.com/docs/workspaces) in Docker with this example template.\n\n## Prerequisites\n\n### Infrastructure\n\nCoder must have access to a running Docker socket, and the `coder` user must be a member of the `docker` group:\n\n```shell\n# Add coder user to Docker group\nsudo usermod -aG docker coder\n\n# Restart Coder server\nsudo systemctl restart coder\n\n# Test Docker\nsudo -u coder docker ps\n```\n\n## Architecture\n\nCoder supports Envbuilder containers based on `devcontainer.json` via [envbuilder](https://github.com/coder/envbuilder), an open source project. Read more about this in [Coder's documentation](https://coder.com/docs/templates/dev-containers).\n\nThis template provisions the following resources:\n\n- Envbuilder cached image (conditional, persistent) using [`terraform-provider-envbuilder`](https://github.com/coder/terraform-provider-envbuilder)\n- Docker image (persistent) using [`envbuilder`](https://github.com/coder/envbuilder)\n- Docker container (ephemeral)\n- Docker volume (persistent on `/workspaces`)\n\nThe Git repository is cloned inside the `/workspaces` volume if not present.\nAny local changes to the Devcontainer files inside the volume will be applied when you restart the workspace.\nKeep in mind that any tools or files outside of `/workspaces` or not added as part of the Devcontainer specification are not persisted.\nEdit the `devcontainer.json` instead!\n\n\u003e **Note**\n\u003e This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case.\n\n## Docker-in-Docker\n\nSee the [Envbuilder documentation](https://github.com/coder/envbuilder/blob/main/docs/docker.md) for information on running Docker containers inside an Envbuilder container.\n\n## Caching\n\nTo speed up your builds, you can use a container registry as a cache.\nWhen creating the template, set the parameter `cache_repo` to a valid Docker repository.\n\nFor example, you can run a local registry:\n\n```shell\ndocker run --detach \\\n --volume registry-cache:/var/lib/registry \\\n --publish 5000:5000 \\\n --name registry-cache \\\n --net=host \\\n registry:2\n```\n\nThen, when creating the template, enter `localhost:5000/envbuilder-cache` for the parameter `cache_repo`.\n\nSee the [Envbuilder Terraform Provider Examples](https://github.com/coder/terraform-provider-envbuilder/blob/main/examples/resources/envbuilder_cached_image/envbuilder_cached_image_resource.tf/) for a more complete example of how the provider works.\n\n\u003e [!NOTE]\n\u003e We recommend using a registry cache with authentication enabled.\n\u003e To allow Envbuilder to authenticate with the registry cache, specify the variable `cache_repo_docker_config_path`\n\u003e with the path to a Docker config `.json` on disk containing valid credentials for the registry.\n" }, { "id": "gcp-devcontainer", diff --git a/examples/examples.go b/examples/examples.go index 929d6d2bcce04..7deff18f5f9ad 100644 --- a/examples/examples.go +++ b/examples/examples.go @@ -31,6 +31,7 @@ var ( //go:embed templates/digitalocean-linux //go:embed templates/docker //go:embed templates/docker-devcontainer + //go:embed templates/docker-envbuilder //go:embed templates/gcp-devcontainer //go:embed templates/gcp-linux //go:embed templates/gcp-vm-container diff --git a/examples/templates/docker-devcontainer/README.md b/examples/templates/docker-devcontainer/README.md index 3026a21fc8657..2b4ac19cc668e 100644 --- a/examples/templates/docker-devcontainer/README.md +++ b/examples/templates/docker-devcontainer/README.md @@ -1,25 +1,27 @@ --- -display_name: Docker (Devcontainer) -description: Provision envbuilder containers as Coder workspaces +display_name: Docker-in-Docker Dev Containers +description: Provision Docker containers as Coder workspaces running Dev Containers via Docker-in-Docker. icon: ../../../site/static/icon/docker.png maintainer_github: coder verified: true -tags: [container, docker, devcontainer] +tags: [docker, container, devcontainer] --- -# Remote Development on Docker Containers (with Devcontainers) +# Remote Development on Dev Containers -Provision Devcontainers as [Coder workspaces](https://coder.com/docs/workspaces) in Docker with this example template. +Provision Docker containers as [Coder workspaces](https://coder.com/docs/workspaces) running [Dev Containers](https://code.visualstudio.com/docs/devcontainers/containers) via Docker-in-Docker. + + ## Prerequisites ### Infrastructure -Coder must have access to a running Docker socket, and the `coder` user must be a member of the `docker` group: +The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group: -```shell +```sh # Add coder user to Docker group -sudo usermod -aG docker coder +sudo adduser coder docker # Restart Coder server sudo systemctl restart coder @@ -30,48 +32,18 @@ sudo -u coder docker ps ## Architecture -Coder supports Devcontainers via [envbuilder](https://github.com/coder/envbuilder), an open source project. Read more about this in [Coder's documentation](https://coder.com/docs/templates/dev-containers). +This example uses the `codercom/enterprise-node:ubuntu` Docker image as a base image for the workspace. It includes necessary tools like Docker and Node.js, which are required for running Dev Containers via the `@devcontainers/cli` tool. This template provisions the following resources: -- Envbuilder cached image (conditional, persistent) using [`terraform-provider-envbuilder`](https://github.com/coder/terraform-provider-envbuilder) -- Docker image (persistent) using [`envbuilder`](https://github.com/coder/envbuilder) +- Docker image (built by Docker socket and kept locally) - Docker container (ephemeral) -- Docker volume (persistent on `/workspaces`) +- Docker volume (persistent on `/home/coder`) +- Docker volume (persistent on `/var/lib/docker`) + +This means, when the workspace restarts, any tools or files outside of the home directory or docker library are not persisted. -The Git repository is cloned inside the `/workspaces` volume if not present. -Any local changes to the Devcontainer files inside the volume will be applied when you restart the workspace. -Keep in mind that any tools or files outside of `/workspaces` or not added as part of the Devcontainer specification are not persisted. -Edit the `devcontainer.json` instead! +For devcontainers running inside the workspace, data persistence is dependent on each projects `devcontainer.json` configuration. > **Note** > This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. - -## Docker-in-Docker - -See the [Envbuilder documentation](https://github.com/coder/envbuilder/blob/main/docs/docker.md) for information on running Docker containers inside a devcontainer built by Envbuilder. - -## Caching - -To speed up your builds, you can use a container registry as a cache. -When creating the template, set the parameter `cache_repo` to a valid Docker repository. - -For example, you can run a local registry: - -```shell -docker run --detach \ - --volume registry-cache:/var/lib/registry \ - --publish 5000:5000 \ - --name registry-cache \ - --net=host \ - registry:2 -``` - -Then, when creating the template, enter `localhost:5000/devcontainer-cache` for the parameter `cache_repo`. - -See the [Envbuilder Terraform Provider Examples](https://github.com/coder/terraform-provider-envbuilder/blob/main/examples/resources/envbuilder_cached_image/envbuilder_cached_image_resource.tf/) for a more complete example of how the provider works. - -> [!NOTE] -> We recommend using a registry cache with authentication enabled. -> To allow Envbuilder to authenticate with the registry cache, specify the variable `cache_repo_docker_config_path` -> with the path to a Docker config `.json` on disk containing valid credentials for the registry. diff --git a/examples/templates/docker-devcontainer/main.tf b/examples/templates/docker-devcontainer/main.tf index 2765874f80181..a0275067a57e7 100644 --- a/examples/templates/docker-devcontainer/main.tf +++ b/examples/templates/docker-devcontainer/main.tf @@ -1,258 +1,87 @@ terraform { required_providers { coder = { - source = "coder/coder" - version = "~> 2.0" + source = "coder/coder" } docker = { source = "kreuzwerker/docker" } - envbuilder = { - source = "coder/envbuilder" - } } } +locals { + username = data.coder_workspace_owner.me.name + + # Use a workspace image that supports rootless Docker + # (Docker-in-Docker) and Node.js. + workspace_image = "codercom/enterprise-node:ubuntu" +} + variable "docker_socket" { default = "" description = "(Optional) Docker socket URI" type = string } -provider "coder" {} +data "coder_parameter" "repo_url" { + type = "string" + name = "repo_url" + display_name = "Git Repository" + description = "Enter the URL of the Git repository to clone into your workspace. This repository should contain a devcontainer.json file to configure your development environment." + default = "https://github.com/coder/coder" + mutable = true +} + provider "docker" { # Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default host = var.docker_socket != "" ? var.docker_socket : null } -provider "envbuilder" {} data "coder_provisioner" "me" {} data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} -data "coder_parameter" "repo" { - description = "Select a repository to automatically clone and start working with a devcontainer." - display_name = "Repository (auto)" - mutable = true - name = "repo" - option { - name = "vercel/next.js" - description = "The React Framework" - value = "https://github.com/vercel/next.js" - } - option { - name = "home-assistant/core" - description = "🏡 Open source home automation that puts local control and privacy first." - value = "https://github.com/home-assistant/core" - } - option { - name = "discourse/discourse" - description = "A platform for community discussion. Free, open, simple." - value = "https://github.com/discourse/discourse" - } - option { - name = "denoland/deno" - description = "A modern runtime for JavaScript and TypeScript." - value = "https://github.com/denoland/deno" - } - option { - name = "microsoft/vscode" - icon = "/icon/code.svg" - description = "Code editing. Redefined." - value = "https://github.com/microsoft/vscode" - } - option { - name = "Custom" - icon = "/emojis/1f5c3.png" - description = "Specify a custom repo URL below" - value = "custom" - } - order = 1 -} - -data "coder_parameter" "custom_repo_url" { - default = "" - description = "Optionally enter a custom repository URL, see [awesome-devcontainers](https://github.com/manekinekko/awesome-devcontainers)." - display_name = "Repository URL (custom)" - name = "custom_repo_url" - mutable = true - order = 2 -} - -data "coder_parameter" "fallback_image" { - default = "codercom/enterprise-base:ubuntu" - description = "This image runs if the devcontainer fails to build." - display_name = "Fallback Image" - mutable = true - name = "fallback_image" - order = 3 -} - -data "coder_parameter" "devcontainer_builder" { - description = <<-EOF -Image that will build the devcontainer. -We highly recommend using a specific release as the `:latest` tag will change. -Find the latest version of Envbuilder here: https://github.com/coder/envbuilder/pkgs/container/envbuilder -EOF - display_name = "Devcontainer Builder" - mutable = true - name = "devcontainer_builder" - default = "ghcr.io/coder/envbuilder:latest" - order = 4 -} - -variable "cache_repo" { - default = "" - description = "(Optional) Use a container registry as a cache to speed up builds." - type = string -} - -variable "insecure_cache_repo" { - default = false - description = "Enable this option if your cache registry does not serve HTTPS." - type = bool -} - -variable "cache_repo_docker_config_path" { - default = "" - description = "(Optional) Path to a docker config.json containing credentials to the provided cache repo, if required." - sensitive = true - type = string -} - -locals { - container_name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" - devcontainer_builder_image = data.coder_parameter.devcontainer_builder.value - git_author_name = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) - git_author_email = data.coder_workspace_owner.me.email - repo_url = data.coder_parameter.repo.value == "custom" ? data.coder_parameter.custom_repo_url.value : data.coder_parameter.repo.value - # The envbuilder provider requires a key-value map of environment variables. - envbuilder_env = { - # ENVBUILDER_GIT_URL and ENVBUILDER_CACHE_REPO will be overridden by the provider - # if the cache repo is enabled. - "ENVBUILDER_GIT_URL" : local.repo_url, - "ENVBUILDER_CACHE_REPO" : var.cache_repo, - "CODER_AGENT_TOKEN" : coder_agent.main.token, - # Use the docker gateway if the access URL is 127.0.0.1 - "CODER_AGENT_URL" : replace(data.coder_workspace.me.access_url, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal"), - # Use the docker gateway if the access URL is 127.0.0.1 - "ENVBUILDER_INIT_SCRIPT" : replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal"), - "ENVBUILDER_FALLBACK_IMAGE" : data.coder_parameter.fallback_image.value, - "ENVBUILDER_DOCKER_CONFIG_BASE64" : try(data.local_sensitive_file.cache_repo_dockerconfigjson[0].content_base64, ""), - "ENVBUILDER_PUSH_IMAGE" : var.cache_repo == "" ? "" : "true", - "ENVBUILDER_INSECURE" : "${var.insecure_cache_repo}", - } - # Convert the above map to the format expected by the docker provider. - docker_env = [ - for k, v in local.envbuilder_env : "${k}=${v}" - ] -} - -data "local_sensitive_file" "cache_repo_dockerconfigjson" { - count = var.cache_repo_docker_config_path == "" ? 0 : 1 - filename = var.cache_repo_docker_config_path -} - -resource "docker_image" "devcontainer_builder_image" { - name = local.devcontainer_builder_image - keep_locally = true -} - -resource "docker_volume" "workspaces" { - name = "coder-${data.coder_workspace.me.id}" - # Protect the volume from being deleted due to changes in attributes. - lifecycle { - ignore_changes = all - } - # Add labels in Docker to keep track of orphan resources. - labels { - label = "coder.owner" - value = data.coder_workspace_owner.me.name - } - labels { - label = "coder.owner_id" - value = data.coder_workspace_owner.me.id - } - labels { - label = "coder.workspace_id" - value = data.coder_workspace.me.id - } - # This field becomes outdated if the workspace is renamed but can - # be useful for debugging or cleaning out dangling volumes. - labels { - label = "coder.workspace_name_at_creation" - value = data.coder_workspace.me.name - } -} +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = "linux" + startup_script = <<-EOT + set -e -# Check for the presence of a prebuilt image in the cache repo -# that we can use instead. -resource "envbuilder_cached_image" "cached" { - count = var.cache_repo == "" ? 0 : data.coder_workspace.me.start_count - builder_image = local.devcontainer_builder_image - git_url = local.repo_url - cache_repo = var.cache_repo - extra_env = local.envbuilder_env - insecure = var.insecure_cache_repo -} + # Prepare user home with default files on first start. + if [ ! -f ~/.init_done ]; then + cp -rT /etc/skel ~ + touch ~/.init_done + fi -resource "docker_container" "workspace" { - count = data.coder_workspace.me.start_count - image = var.cache_repo == "" ? local.devcontainer_builder_image : envbuilder_cached_image.cached.0.image - # Uses lower() to avoid Docker restriction on container names. - name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" - # Hostname makes the shell more user friendly: coder@my-workspace:~$ - hostname = data.coder_workspace.me.name - # Use the environment specified by the envbuilder provider, if available. - env = var.cache_repo == "" ? local.docker_env : envbuilder_cached_image.cached.0.env - # network_mode = "host" # Uncomment if testing with a registry running on `localhost`. - host { - host = "host.docker.internal" - ip = "host-gateway" - } - volumes { - container_path = "/workspaces" - volume_name = docker_volume.workspaces.name - read_only = false - } - # Add labels in Docker to keep track of orphan resources. - labels { - label = "coder.owner" - value = data.coder_workspace_owner.me.name - } - labels { - label = "coder.owner_id" - value = data.coder_workspace_owner.me.id - } - labels { - label = "coder.workspace_id" - value = data.coder_workspace.me.id - } - labels { - label = "coder.workspace_name" - value = data.coder_workspace.me.name - } -} - -resource "coder_agent" "main" { - arch = data.coder_provisioner.me.arch - os = "linux" - startup_script = <<-EOT + # Add any commands that should be executed at workspace startup + # (e.g. install requirements, start a program, etc) here. + EOT + shutdown_script = <<-EOT set -e - # Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here + # Clean up the docker volume from unused resources to keep storage + # usage low. + # + # WARNING! This will remove: + # - all stopped containers + # - all networks not used by at least one container + # - all images without at least one container associated to them + # - all build cache + docker system prune -a -f + + # Stop the Docker service. + sudo service docker stop EOT - dir = "/workspaces" # These environment variables allow you to make Git commits right away after creating a # workspace. Note that they take precedence over configuration defined in ~/.gitconfig! # You can remove this block if you'd prefer to configure Git manually or using # dotfiles. (see docs/dotfiles.md) env = { - GIT_AUTHOR_NAME = local.git_author_name - GIT_AUTHOR_EMAIL = local.git_author_email - GIT_COMMITTER_NAME = local.git_author_name - GIT_COMMITTER_EMAIL = local.git_author_email + GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}" + GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}" } # The following metadata blocks are optional. They are used to display @@ -279,7 +108,7 @@ resource "coder_agent" "main" { metadata { display_name = "Home Disk" key = "3_home_disk" - script = "coder stat disk --path $HOME" + script = "coder stat disk --path $${HOME}" interval = 60 timeout = 1 } @@ -322,51 +151,159 @@ resource "coder_agent" "main" { } } -# See https://registry.coder.com/modules/coder/code-server -module "code-server" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/code-server/coder" +resource "coder_script" "init_docker_in_docker" { + count = data.coder_workspace.me.start_count + agent_id = coder_agent.main.id + display_name = "Initialize Docker-in-Docker" + run_on_start = true + icon = "/icon/docker.svg" + script = file("${path.module}/scripts/init-docker-in-docker.sh") +} + +# See https://registry.coder.com/modules/coder/devcontainers-cli +module "devcontainers-cli" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/devcontainers-cli/coder" + agent_id = coder_agent.main.id - # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + # This ensures that the latest non-breaking version of the module gets + # downloaded, you can also pin the module version to prevent breaking + # changes in production. version = "~> 1.0" +} +# See https://registry.coder.com/modules/coder/git-clone +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/git-clone/coder" agent_id = coder_agent.main.id - order = 1 + url = data.coder_parameter.repo_url.value + base_dir = "~" + # This ensures that the latest non-breaking version of the module gets + # downloaded, you can also pin the module version to prevent breaking + # changes in production. + version = "~> 1.0" } -# See https://registry.coder.com/modules/coder/jetbrains-gateway -module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/jetbrains-gateway/coder" +# Automatically start the devcontainer for the workspace. +resource "coder_devcontainer" "repo" { + count = data.coder_workspace.me.start_count + agent_id = coder_agent.main.id + workspace_folder = "~/${module.git-clone[0].folder_name}" +} - # JetBrains IDEs to make available for the user to select - jetbrains_ides = ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"] - default = "IU" +resource "docker_volume" "home_volume" { + name = "coder-${data.coder_workspace.me.id}-home" + # Protect the volume from being deleted due to changes in attributes. + lifecycle { + ignore_changes = all + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + # This field becomes outdated if the workspace is renamed but can + # be useful for debugging or cleaning out dangling volumes. + labels { + label = "coder.workspace_name_at_creation" + value = data.coder_workspace.me.name + } +} - # Default folder to open when starting a JetBrains IDE - folder = "/workspaces" +resource "docker_volume" "docker_volume" { + name = "coder-${data.coder_workspace.me.id}-docker" + # Protect the volume from being deleted due to changes in attributes. + lifecycle { + ignore_changes = all + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + # This field becomes outdated if the workspace is renamed but can + # be useful for debugging or cleaning out dangling volumes. + labels { + label = "coder.workspace_name_at_creation" + value = data.coder_workspace.me.name + } +} - # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. - version = "~> 1.0" +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = local.workspace_image + + # NOTE: The `privileged` mode is one way to run Docker-in-Docker, + # which is required for the devcontainer to work. If this is not + # desired, you can remove this line. However, you will need to ensure + # that the devcontainer can run Docker commands in some other way. + # Mounting the host Docker socket is strongly discouraged because + # workspaces will then compete for control of the devcontainers. + # For more information, see: + # https://coder.com/docs/admin/templates/extending-templates/docker-in-workspaces + privileged = true - agent_id = coder_agent.main.id - agent_name = "main" - order = 2 -} + # Uses lower() to avoid Docker restriction on container names. + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + # Hostname makes the shell more user friendly: coder@my-workspace:~$ + hostname = data.coder_workspace.me.name + # Use the docker gateway if the access URL is 127.0.0.1 + command = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")] + env = [ + "CODER_AGENT_TOKEN=${coder_agent.main.token}" + ] + host { + host = "host.docker.internal" + ip = "host-gateway" + } + + # Workspace home volume persists user data across workspace restarts. + volumes { + container_path = "/home/coder" + volume_name = docker_volume.home_volume.name + read_only = false + } -resource "coder_metadata" "container_info" { - count = data.coder_workspace.me.start_count - resource_id = coder_agent.main.id - item { - key = "workspace image" - value = var.cache_repo == "" ? local.devcontainer_builder_image : envbuilder_cached_image.cached.0.image + # Workspace docker volume persists Docker data across workspace + # restarts, allowing the devcontainer cache to be reused. + volumes { + container_path = "/var/lib/docker" + volume_name = docker_volume.docker_volume.name + read_only = false + } + + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id } - item { - key = "git url" - value = local.repo_url + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id } - item { - key = "cache repo" - value = var.cache_repo == "" ? "not enabled" : var.cache_repo + labels { + label = "coder.workspace_name" + value = data.coder_workspace.me.name } } diff --git a/examples/templates/docker-devcontainer/scripts/init-docker-in-docker.sh b/examples/templates/docker-devcontainer/scripts/init-docker-in-docker.sh new file mode 100755 index 0000000000000..57022d22a47b4 --- /dev/null +++ b/examples/templates/docker-devcontainer/scripts/init-docker-in-docker.sh @@ -0,0 +1,101 @@ +#!/bin/sh +set -e + +# Docker-in-Docker setup for Coder dev containers using host.docker.internal +# URLs. When Docker runs inside a container, the "docker0" bridge interface +# can interfere with host.docker.internal DNS resolution, breaking +# connectivity to the Coder server. + +if [ "${CODER_AGENT_URL#*host.docker.internal}" = "$CODER_AGENT_URL" ]; then + # External access URL detected, no networking workarounds needed. + sudo service docker start + exit 0 +fi + +# host.docker.internal URL detected. Docker's default bridge network creates +# a "docker0" interface that can shadow the host.docker.internal hostname +# resolution. This typically happens when Docker starts inside a devcontainer, +# as the inner Docker daemon creates its own bridge network that conflicts +# with the outer one. + +# Enable IP forwarding to allow packets to route between the host network and +# the devcontainer networks. Without this, traffic cannot flow properly +# between the different Docker bridge networks. +echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward +sudo iptables -t nat -A POSTROUTING -j MASQUERADE + +# Set up port forwarding to the host Docker gateway (typically 172.17.0.1). +# We resolve host.docker.internal to get the actual IP and create NAT rules +# to forward traffic from this workspace to the host. +host_ip=$(getent hosts host.docker.internal | awk '{print $1}') + +echo "Host IP for host.docker.internal: $host_ip" + +# Extract the port from CODER_AGENT_URL. The URL format is typically +# http://host.docker.internal:port/. +port="${CODER_AGENT_URL##*:}" +port="${port%%/*}" +case "$port" in +[0-9]*) + # Specific port found, forward it to the host gateway. + sudo iptables -t nat -A PREROUTING -p tcp --dport "$port" -j DNAT --to-destination "$host_ip:$port" + echo "Forwarded port $port to $host_ip" + ;; +*) + # No specific port or non-numeric port, forward standard web ports. + sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j DNAT --to-destination "$host_ip:80" + sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j DNAT --to-destination "$host_ip:443" + echo "Forwarded default ports 80/443 to $host_ip" + ;; +esac + +# Start Docker service, which creates the "docker0" interface if it doesn't +# exist. We need the interface to extract the second IP address for DNS +# resolution. +sudo service docker start + +# Configure DNS resolution to avoid requiring devcontainer project modifications. +# While devcontainers can use the "--add-host" flag, it requires explicit +# definition in devcontainer.json. Using a DNS server instead means every +# devcontainer project doesn't need to accommodate this. + +# Wait for the workspace to acquire its Docker bridge IP address. The +# "hostname -I" command returns multiple IPs: the first is typically the host +# Docker bridge (172.17.0.0/16 range) and the second is the workspace Docker +# bridge (172.18.0.0/16). We need the second IP because that's where +# devcontainers will be able to reach us. +dns_ip= +while [ -z "$dns_ip" ]; do + dns_ip=$(hostname -I | awk '{print $2}') + if [ -z "$dns_ip" ]; then + echo "Waiting for hostname -I to return a valid second IP address..." + sleep 1 + fi +done + +echo "Using DNS IP: $dns_ip" + +# Install dnsmasq to provide custom DNS resolution. This lightweight DNS +# server allows us to override specific hostname lookups without affecting +# other DNS queries. +sudo apt-get update -y +sudo apt-get install -y dnsmasq + +# Configure dnsmasq to resolve host.docker.internal to this workspace's IP. +# This ensures devcontainers can find the Coder server even when the "docker0" +# interface would normally shadow the hostname resolution. +echo "no-hosts" | sudo tee /etc/dnsmasq.conf +echo "address=/host.docker.internal/$dns_ip" | sudo tee -a /etc/dnsmasq.conf +echo "resolv-file=/etc/resolv.conf" | sudo tee -a /etc/dnsmasq.conf +echo "no-dhcp-interface=" | sudo tee -a /etc/dnsmasq.conf +echo "bind-interfaces" | sudo tee -a /etc/dnsmasq.conf +echo "listen-address=127.0.0.1,$dns_ip" | sudo tee -a /etc/dnsmasq.conf + +sudo service dnsmasq restart + +# Configure Docker daemon to use our custom DNS server. This is the critical +# piece that ensures all containers (including devcontainers) use our dnsmasq +# server for hostname resolution, allowing them to properly resolve +# host.docker.internal. +echo "{\"dns\": [\"$dns_ip\"]}" | sudo tee /etc/docker/daemon.json +sudo service docker restart diff --git a/examples/templates/docker-envbuilder/README.md b/examples/templates/docker-envbuilder/README.md new file mode 100644 index 0000000000000..828442d621684 --- /dev/null +++ b/examples/templates/docker-envbuilder/README.md @@ -0,0 +1,77 @@ +--- +display_name: Docker (Envbuilder) +description: Provision envbuilder containers as Coder workspaces +icon: ../../../site/static/icon/docker.png +maintainer_github: coder +verified: true +tags: [container, docker, devcontainer, envbuilder] +--- + +# Remote Development on Docker Containers (with Envbuilder) + +Provision Envbuilder containers based on `devcontainer.json` as [Coder workspaces](https://coder.com/docs/workspaces) in Docker with this example template. + +## Prerequisites + +### Infrastructure + +Coder must have access to a running Docker socket, and the `coder` user must be a member of the `docker` group: + +```shell +# Add coder user to Docker group +sudo usermod -aG docker coder + +# Restart Coder server +sudo systemctl restart coder + +# Test Docker +sudo -u coder docker ps +``` + +## Architecture + +Coder supports Envbuilder containers based on `devcontainer.json` via [envbuilder](https://github.com/coder/envbuilder), an open source project. Read more about this in [Coder's documentation](https://coder.com/docs/templates/dev-containers). + +This template provisions the following resources: + +- Envbuilder cached image (conditional, persistent) using [`terraform-provider-envbuilder`](https://github.com/coder/terraform-provider-envbuilder) +- Docker image (persistent) using [`envbuilder`](https://github.com/coder/envbuilder) +- Docker container (ephemeral) +- Docker volume (persistent on `/workspaces`) + +The Git repository is cloned inside the `/workspaces` volume if not present. +Any local changes to the Devcontainer files inside the volume will be applied when you restart the workspace. +Keep in mind that any tools or files outside of `/workspaces` or not added as part of the Devcontainer specification are not persisted. +Edit the `devcontainer.json` instead! + +> **Note** +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +## Docker-in-Docker + +See the [Envbuilder documentation](https://github.com/coder/envbuilder/blob/main/docs/docker.md) for information on running Docker containers inside an Envbuilder container. + +## Caching + +To speed up your builds, you can use a container registry as a cache. +When creating the template, set the parameter `cache_repo` to a valid Docker repository. + +For example, you can run a local registry: + +```shell +docker run --detach \ + --volume registry-cache:/var/lib/registry \ + --publish 5000:5000 \ + --name registry-cache \ + --net=host \ + registry:2 +``` + +Then, when creating the template, enter `localhost:5000/envbuilder-cache` for the parameter `cache_repo`. + +See the [Envbuilder Terraform Provider Examples](https://github.com/coder/terraform-provider-envbuilder/blob/main/examples/resources/envbuilder_cached_image/envbuilder_cached_image_resource.tf/) for a more complete example of how the provider works. + +> [!NOTE] +> We recommend using a registry cache with authentication enabled. +> To allow Envbuilder to authenticate with the registry cache, specify the variable `cache_repo_docker_config_path` +> with the path to a Docker config `.json` on disk containing valid credentials for the registry. diff --git a/examples/templates/docker-envbuilder/main.tf b/examples/templates/docker-envbuilder/main.tf new file mode 100644 index 0000000000000..2765874f80181 --- /dev/null +++ b/examples/templates/docker-envbuilder/main.tf @@ -0,0 +1,372 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "~> 2.0" + } + docker = { + source = "kreuzwerker/docker" + } + envbuilder = { + source = "coder/envbuilder" + } + } +} + +variable "docker_socket" { + default = "" + description = "(Optional) Docker socket URI" + type = string +} + +provider "coder" {} +provider "docker" { + # Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default + host = var.docker_socket != "" ? var.docker_socket : null +} +provider "envbuilder" {} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "repo" { + description = "Select a repository to automatically clone and start working with a devcontainer." + display_name = "Repository (auto)" + mutable = true + name = "repo" + option { + name = "vercel/next.js" + description = "The React Framework" + value = "https://github.com/vercel/next.js" + } + option { + name = "home-assistant/core" + description = "🏡 Open source home automation that puts local control and privacy first." + value = "https://github.com/home-assistant/core" + } + option { + name = "discourse/discourse" + description = "A platform for community discussion. Free, open, simple." + value = "https://github.com/discourse/discourse" + } + option { + name = "denoland/deno" + description = "A modern runtime for JavaScript and TypeScript." + value = "https://github.com/denoland/deno" + } + option { + name = "microsoft/vscode" + icon = "/icon/code.svg" + description = "Code editing. Redefined." + value = "https://github.com/microsoft/vscode" + } + option { + name = "Custom" + icon = "/emojis/1f5c3.png" + description = "Specify a custom repo URL below" + value = "custom" + } + order = 1 +} + +data "coder_parameter" "custom_repo_url" { + default = "" + description = "Optionally enter a custom repository URL, see [awesome-devcontainers](https://github.com/manekinekko/awesome-devcontainers)." + display_name = "Repository URL (custom)" + name = "custom_repo_url" + mutable = true + order = 2 +} + +data "coder_parameter" "fallback_image" { + default = "codercom/enterprise-base:ubuntu" + description = "This image runs if the devcontainer fails to build." + display_name = "Fallback Image" + mutable = true + name = "fallback_image" + order = 3 +} + +data "coder_parameter" "devcontainer_builder" { + description = <<-EOF +Image that will build the devcontainer. +We highly recommend using a specific release as the `:latest` tag will change. +Find the latest version of Envbuilder here: https://github.com/coder/envbuilder/pkgs/container/envbuilder +EOF + display_name = "Devcontainer Builder" + mutable = true + name = "devcontainer_builder" + default = "ghcr.io/coder/envbuilder:latest" + order = 4 +} + +variable "cache_repo" { + default = "" + description = "(Optional) Use a container registry as a cache to speed up builds." + type = string +} + +variable "insecure_cache_repo" { + default = false + description = "Enable this option if your cache registry does not serve HTTPS." + type = bool +} + +variable "cache_repo_docker_config_path" { + default = "" + description = "(Optional) Path to a docker config.json containing credentials to the provided cache repo, if required." + sensitive = true + type = string +} + +locals { + container_name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + devcontainer_builder_image = data.coder_parameter.devcontainer_builder.value + git_author_name = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + git_author_email = data.coder_workspace_owner.me.email + repo_url = data.coder_parameter.repo.value == "custom" ? data.coder_parameter.custom_repo_url.value : data.coder_parameter.repo.value + # The envbuilder provider requires a key-value map of environment variables. + envbuilder_env = { + # ENVBUILDER_GIT_URL and ENVBUILDER_CACHE_REPO will be overridden by the provider + # if the cache repo is enabled. + "ENVBUILDER_GIT_URL" : local.repo_url, + "ENVBUILDER_CACHE_REPO" : var.cache_repo, + "CODER_AGENT_TOKEN" : coder_agent.main.token, + # Use the docker gateway if the access URL is 127.0.0.1 + "CODER_AGENT_URL" : replace(data.coder_workspace.me.access_url, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal"), + # Use the docker gateway if the access URL is 127.0.0.1 + "ENVBUILDER_INIT_SCRIPT" : replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal"), + "ENVBUILDER_FALLBACK_IMAGE" : data.coder_parameter.fallback_image.value, + "ENVBUILDER_DOCKER_CONFIG_BASE64" : try(data.local_sensitive_file.cache_repo_dockerconfigjson[0].content_base64, ""), + "ENVBUILDER_PUSH_IMAGE" : var.cache_repo == "" ? "" : "true", + "ENVBUILDER_INSECURE" : "${var.insecure_cache_repo}", + } + # Convert the above map to the format expected by the docker provider. + docker_env = [ + for k, v in local.envbuilder_env : "${k}=${v}" + ] +} + +data "local_sensitive_file" "cache_repo_dockerconfigjson" { + count = var.cache_repo_docker_config_path == "" ? 0 : 1 + filename = var.cache_repo_docker_config_path +} + +resource "docker_image" "devcontainer_builder_image" { + name = local.devcontainer_builder_image + keep_locally = true +} + +resource "docker_volume" "workspaces" { + name = "coder-${data.coder_workspace.me.id}" + # Protect the volume from being deleted due to changes in attributes. + lifecycle { + ignore_changes = all + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + # This field becomes outdated if the workspace is renamed but can + # be useful for debugging or cleaning out dangling volumes. + labels { + label = "coder.workspace_name_at_creation" + value = data.coder_workspace.me.name + } +} + +# Check for the presence of a prebuilt image in the cache repo +# that we can use instead. +resource "envbuilder_cached_image" "cached" { + count = var.cache_repo == "" ? 0 : data.coder_workspace.me.start_count + builder_image = local.devcontainer_builder_image + git_url = local.repo_url + cache_repo = var.cache_repo + extra_env = local.envbuilder_env + insecure = var.insecure_cache_repo +} + +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = var.cache_repo == "" ? local.devcontainer_builder_image : envbuilder_cached_image.cached.0.image + # Uses lower() to avoid Docker restriction on container names. + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + # Hostname makes the shell more user friendly: coder@my-workspace:~$ + hostname = data.coder_workspace.me.name + # Use the environment specified by the envbuilder provider, if available. + env = var.cache_repo == "" ? local.docker_env : envbuilder_cached_image.cached.0.env + # network_mode = "host" # Uncomment if testing with a registry running on `localhost`. + host { + host = "host.docker.internal" + ip = "host-gateway" + } + volumes { + container_path = "/workspaces" + volume_name = docker_volume.workspaces.name + read_only = false + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name" + value = data.coder_workspace.me.name + } +} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = "linux" + startup_script = <<-EOT + set -e + + # Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here + EOT + dir = "/workspaces" + + # These environment variables allow you to make Git commits right away after creating a + # workspace. Note that they take precedence over configuration defined in ~/.gitconfig! + # You can remove this block if you'd prefer to configure Git manually or using + # dotfiles. (see docs/dotfiles.md) + env = { + GIT_AUTHOR_NAME = local.git_author_name + GIT_AUTHOR_EMAIL = local.git_author_email + GIT_COMMITTER_NAME = local.git_author_name + GIT_COMMITTER_EMAIL = local.git_author_email + } + + # The following metadata blocks are optional. They are used to display + # information about your workspace in the dashboard. You can remove them + # if you don't want to display any information. + # For basic resources, you can use the `coder stat` command. + # If you need more control, you can write your own script. + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path $HOME" + interval = 60 + timeout = 1 + } + + metadata { + display_name = "CPU Usage (Host)" + key = "4_cpu_usage_host" + script = "coder stat cpu --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Memory Usage (Host)" + key = "5_mem_usage_host" + script = "coder stat mem --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Load Average (Host)" + key = "6_load_host" + # get load avg scaled by number of cores + script = < Date: Thu, 3 Jul 2025 08:33:45 -0500 Subject: [PATCH 5/5] chore: create interface for pkgs to return codersdk errors (#18719) This interface allows it to create rich codersdk errors and pass them up to the `wsbuilder` error handling. --- coderd/dynamicparameters/error.go | 129 +++++++++++++++++++++++ coderd/dynamicparameters/resolver.go | 51 +-------- coderd/httpapi/httperror/responserror.go | 19 ++++ coderd/httpapi/httperror/wsbuild.go | 74 +------------ coderd/wsbuilder/wsbuilder.go | 10 ++ coderd/wsbuilder/wsbuilder_test.go | 18 ++++ 6 files changed, 182 insertions(+), 119 deletions(-) create mode 100644 coderd/dynamicparameters/error.go create mode 100644 coderd/httpapi/httperror/responserror.go diff --git a/coderd/dynamicparameters/error.go b/coderd/dynamicparameters/error.go new file mode 100644 index 0000000000000..3af270569acea --- /dev/null +++ b/coderd/dynamicparameters/error.go @@ -0,0 +1,129 @@ +package dynamicparameters + +import ( + "fmt" + "net/http" + "sort" + + "github.com/hashicorp/hcl/v2" + + "github.com/coder/coder/v2/codersdk" +) + +func ParameterValidationError(diags hcl.Diagnostics) *DiagnosticError { + return &DiagnosticError{ + Message: "Unable to validate parameters", + Diagnostics: diags, + KeyedDiagnostics: make(map[string]hcl.Diagnostics), + } +} + +func TagValidationError(diags hcl.Diagnostics) *DiagnosticError { + return &DiagnosticError{ + Message: "Failed to parse workspace tags", + Diagnostics: diags, + KeyedDiagnostics: make(map[string]hcl.Diagnostics), + } +} + +type DiagnosticError struct { + // Message is the human-readable message that will be returned to the user. + Message string + // Diagnostics are top level diagnostics that will be returned as "Detail" in the response. + Diagnostics hcl.Diagnostics + // KeyedDiagnostics translate to Validation errors in the response. A key could + // be a parameter name, or a tag name. This allows diagnostics to be more closely + // associated with a specific index/parameter/tag. + KeyedDiagnostics map[string]hcl.Diagnostics +} + +// Error is a pretty bad format for these errors. Try to avoid using this. +func (e *DiagnosticError) Error() string { + var diags hcl.Diagnostics + diags = diags.Extend(e.Diagnostics) + for _, d := range e.KeyedDiagnostics { + diags = diags.Extend(d) + } + + return diags.Error() +} + +func (e *DiagnosticError) HasError() bool { + if e.Diagnostics.HasErrors() { + return true + } + + for _, diags := range e.KeyedDiagnostics { + if diags.HasErrors() { + return true + } + } + return false +} + +func (e *DiagnosticError) Append(key string, diag *hcl.Diagnostic) { + e.Extend(key, hcl.Diagnostics{diag}) +} + +func (e *DiagnosticError) Extend(key string, diag hcl.Diagnostics) { + if e.KeyedDiagnostics == nil { + e.KeyedDiagnostics = make(map[string]hcl.Diagnostics) + } + if _, ok := e.KeyedDiagnostics[key]; !ok { + e.KeyedDiagnostics[key] = hcl.Diagnostics{} + } + e.KeyedDiagnostics[key] = e.KeyedDiagnostics[key].Extend(diag) +} + +func (e *DiagnosticError) Response() (int, codersdk.Response) { + resp := codersdk.Response{ + Message: e.Message, + Validations: nil, + } + + // Sort the parameter names so that the order is consistent. + sortedNames := make([]string, 0, len(e.KeyedDiagnostics)) + for name := range e.KeyedDiagnostics { + sortedNames = append(sortedNames, name) + } + sort.Strings(sortedNames) + + for _, name := range sortedNames { + diag := e.KeyedDiagnostics[name] + resp.Validations = append(resp.Validations, codersdk.ValidationError{ + Field: name, + Detail: DiagnosticsErrorString(diag), + }) + } + + if e.Diagnostics.HasErrors() { + resp.Detail = DiagnosticsErrorString(e.Diagnostics) + } + + return http.StatusBadRequest, resp +} + +func DiagnosticErrorString(d *hcl.Diagnostic) string { + return fmt.Sprintf("%s; %s", d.Summary, d.Detail) +} + +func DiagnosticsErrorString(d hcl.Diagnostics) string { + count := len(d) + switch { + case count == 0: + return "no diagnostics" + case count == 1: + return DiagnosticErrorString(d[0]) + default: + for _, d := range d { + // Render the first error diag. + // If there are warnings, do not priority them over errors. + if d.Severity == hcl.DiagError { + return fmt.Sprintf("%s, and %d other diagnostic(s)", DiagnosticErrorString(d), count-1) + } + } + + // All warnings? ok... + return fmt.Sprintf("%s, and %d other diagnostic(s)", DiagnosticErrorString(d[0]), count-1) + } +} diff --git a/coderd/dynamicparameters/resolver.go b/coderd/dynamicparameters/resolver.go index 3cb9c59f286d6..7007fccc9f213 100644 --- a/coderd/dynamicparameters/resolver.go +++ b/coderd/dynamicparameters/resolver.go @@ -26,45 +26,6 @@ type parameterValue struct { Source parameterValueSource } -type ResolverError struct { - Diagnostics hcl.Diagnostics - Parameter map[string]hcl.Diagnostics -} - -// Error is a pretty bad format for these errors. Try to avoid using this. -func (e *ResolverError) Error() string { - var diags hcl.Diagnostics - diags = diags.Extend(e.Diagnostics) - for _, d := range e.Parameter { - diags = diags.Extend(d) - } - - return diags.Error() -} - -func (e *ResolverError) HasError() bool { - if e.Diagnostics.HasErrors() { - return true - } - - for _, diags := range e.Parameter { - if diags.HasErrors() { - return true - } - } - return false -} - -func (e *ResolverError) Extend(parameterName string, diag hcl.Diagnostics) { - if e.Parameter == nil { - e.Parameter = make(map[string]hcl.Diagnostics) - } - if _, ok := e.Parameter[parameterName]; !ok { - e.Parameter[parameterName] = hcl.Diagnostics{} - } - e.Parameter[parameterName] = e.Parameter[parameterName].Extend(diag) -} - //nolint:revive // firstbuild is a control flag to turn on immutable validation func ResolveParameters( ctx context.Context, @@ -112,10 +73,7 @@ func ResolveParameters( // always be valid. If there is a case where this is not true, then this has to // be changed to allow the build to continue with a different set of values. - return nil, &ResolverError{ - Diagnostics: diags, - Parameter: nil, - } + return nil, ParameterValidationError(diags) } // The user's input now needs to be validated against the parameters. @@ -155,16 +113,13 @@ func ResolveParameters( // are fatal. Additional validation for immutability has to be done manually. output, diags = renderer.Render(ctx, ownerID, values.ValuesMap()) if diags.HasErrors() { - return nil, &ResolverError{ - Diagnostics: diags, - Parameter: nil, - } + return nil, ParameterValidationError(diags) } // parameterNames is going to be used to remove any excess values that were left // around without a parameter. parameterNames := make(map[string]struct{}, len(output.Parameters)) - parameterError := &ResolverError{} + parameterError := ParameterValidationError(nil) for _, parameter := range output.Parameters { parameterNames[parameter.Name] = struct{}{} diff --git a/coderd/httpapi/httperror/responserror.go b/coderd/httpapi/httperror/responserror.go new file mode 100644 index 0000000000000..be219f538bcf7 --- /dev/null +++ b/coderd/httpapi/httperror/responserror.go @@ -0,0 +1,19 @@ +package httperror + +import ( + "errors" + + "github.com/coder/coder/v2/codersdk" +) + +type Responder interface { + Response() (int, codersdk.Response) +} + +func IsResponder(err error) (Responder, bool) { + var responseErr Responder + if errors.As(err, &responseErr) { + return responseErr, true + } + return nil, false +} diff --git a/coderd/httpapi/httperror/wsbuild.go b/coderd/httpapi/httperror/wsbuild.go index 17436b55d5ae0..24c69858133ab 100644 --- a/coderd/httpapi/httperror/wsbuild.go +++ b/coderd/httpapi/httperror/wsbuild.go @@ -2,60 +2,17 @@ package httperror import ( "context" - "errors" - "fmt" "net/http" - "sort" - "github.com/hashicorp/hcl/v2" - - "github.com/coder/coder/v2/coderd/dynamicparameters" "github.com/coder/coder/v2/coderd/httpapi" - "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/coder/coder/v2/codersdk" ) func WriteWorkspaceBuildError(ctx context.Context, rw http.ResponseWriter, err error) { - var buildErr wsbuilder.BuildError - if errors.As(err, &buildErr) { - if httpapi.IsUnauthorizedError(err) { - buildErr.Status = http.StatusForbidden - } - - httpapi.Write(ctx, rw, buildErr.Status, codersdk.Response{ - Message: buildErr.Message, - Detail: buildErr.Error(), - }) - return - } - - var parameterErr *dynamicparameters.ResolverError - if errors.As(err, ¶meterErr) { - resp := codersdk.Response{ - Message: "Unable to validate parameters", - Validations: nil, - } - - // Sort the parameter names so that the order is consistent. - sortedNames := make([]string, 0, len(parameterErr.Parameter)) - for name := range parameterErr.Parameter { - sortedNames = append(sortedNames, name) - } - sort.Strings(sortedNames) - - for _, name := range sortedNames { - diag := parameterErr.Parameter[name] - resp.Validations = append(resp.Validations, codersdk.ValidationError{ - Field: name, - Detail: DiagnosticsErrorString(diag), - }) - } + if responseErr, ok := IsResponder(err); ok { + code, resp := responseErr.Response() - if parameterErr.Diagnostics.HasErrors() { - resp.Detail = DiagnosticsErrorString(parameterErr.Diagnostics) - } - - httpapi.Write(ctx, rw, http.StatusBadRequest, resp) + httpapi.Write(ctx, rw, code, resp) return } @@ -64,28 +21,3 @@ func WriteWorkspaceBuildError(ctx context.Context, rw http.ResponseWriter, err e Detail: err.Error(), }) } - -func DiagnosticError(d *hcl.Diagnostic) string { - return fmt.Sprintf("%s; %s", d.Summary, d.Detail) -} - -func DiagnosticsErrorString(d hcl.Diagnostics) string { - count := len(d) - switch { - case count == 0: - return "no diagnostics" - case count == 1: - return DiagnosticError(d[0]) - default: - for _, d := range d { - // Render the first error diag. - // If there are warnings, do not priority them over errors. - if d.Severity == hcl.DiagError { - return fmt.Sprintf("%s, and %d other diagnostic(s)", DiagnosticError(d), count-1) - } - } - - // All warnings? ok... - return fmt.Sprintf("%s, and %d other diagnostic(s)", DiagnosticError(d[0]), count-1) - } -} diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index bd3677614415e..ec0ef4df16b43 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -240,6 +240,9 @@ type BuildError struct { } func (e BuildError) Error() string { + if e.Wrapped == nil { + return e.Message + } return e.Wrapped.Error() } @@ -247,6 +250,13 @@ func (e BuildError) Unwrap() error { return e.Wrapped } +func (e BuildError) Response() (int, codersdk.Response) { + return e.Status, codersdk.Response{ + Message: e.Message, + Detail: e.Error(), + } +} + // Build computes and inserts a new workspace build into the database. If authFunc is provided, it also performs // authorization preflight checks. func (b *Builder) Build( diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index f07e2f99f774a..41ea3fe2c9921 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -12,6 +12,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/files" + "github.com/coder/coder/v2/coderd/httpapi/httperror" "github.com/coder/coder/v2/provisionersdk" "github.com/google/uuid" @@ -1000,6 +1001,23 @@ func TestWorkspaceBuildDeleteOrphan(t *testing.T) { }) } +func TestWsbuildError(t *testing.T) { + t.Parallel() + + const msg = "test error" + var buildErr error = wsbuilder.BuildError{ + Status: http.StatusBadRequest, + Message: msg, + } + + respErr, ok := httperror.IsResponder(buildErr) + require.True(t, ok, "should be a Coder SDK error") + + code, resp := respErr.Response() + require.Equal(t, http.StatusBadRequest, code) + require.Equal(t, msg, resp.Message) +} + type txExpect func(mTx *dbmock.MockStore) func expectDB(t *testing.T, opts ...txExpect) *dbmock.MockStore {