From f601a7facb7d8d571bb4dd187a40bd64f5b82dcd Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 27 Jul 2026 05:22:54 -0600 Subject: [PATCH] fix: persist Homebrew across workspace restarts, fix brew upgrade EXDEV error Homebrew (/home/linuxbrew/.linuxbrew) lived on the container's ephemeral image/root filesystem in all three templates, not on any persisted volume. Two consequences: 1. brew upgrade/brew install could fail outright with a cross-filesystem link error (EXDEV). Homebrew's default cache ($HOME/.cache/Homebrew) resolves to /home/coder/.cache/Homebrew -- a different, volume-mounted filesystem than the Cellar at /home/linuxbrew/.linuxbrew. Renaming a downloaded bottle across that boundary throws EXDEV. 2. Even when an upgrade succeeded, it didn't stick. docker_container.workspace is destroyed and recreated fresh from the image on every workspace stop/start, so anything installed/upgraded on the container's own root filesystem was silently lost on the next restart -- only the bind-mounted /home/coder and the dind-cache volume survived. Fix both, in all three templates: - Relocate HOMEBREW_CACHE to /home/linuxbrew/.cache/Homebrew via a Dockerfile ENV, so cache and Cellar are guaranteed to be on the same filesystem. - Add a new per-workspace docker_volume mounted at /home/linuxbrew, matching the existing dind-cache pattern (unconditional, not gated by start_count, so it survives stop/start). Docker auto-populates a fresh named volume from the image's existing directory contents on first mount, so no manual seed-copy step is needed -- just an ownership fix mirroring the existing /home/coder chown. Bumps VERSION to v0.5 to match the image already rebuilt and pushed on amd64 with this Dockerfile change. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 3 ++- VERSION | 2 +- drupal-contrib/template.tf | 16 ++++++++++++++++ drupal-contrib/tests/validate.tftest.hcl | 8 ++++++++ drupal-core/template.tf | 16 ++++++++++++++++ drupal-core/tests/validate.tftest.hcl | 11 +++++++++++ freeform/template.tf | 16 ++++++++++++++++ freeform/tests/validate.tftest.hcl | 8 ++++++++ image/Dockerfile | 10 +++++++++- 9 files changed, 87 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d161fd0..8a8cdca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,7 +233,8 @@ The startup script is inline in each template's `template.tf` (e.g. `freeform/te ### Volume Strategy - **Home directory**: Host path `/coder-workspaces/-` → Container `/home/coder` - **Docker cache**: Named volume `coder---dind-cache` → `/var/lib/docker` -- **Isolation**: Each workspace gets separate host directory and Docker volume +- **Homebrew**: Named volume `coder---linuxbrew` → `/home/linuxbrew` — persists the Homebrew Cellar across workspace stop/start, so `brew upgrade`/`brew install` done inside a workspace survives restarts. Unlike the `/home/coder` bind mount, a fresh named volume is auto-populated by Docker from the image's existing `/home/linuxbrew` contents on first mount, so no manual copy step is needed — only an ownership fix (`sudo chown -R coder:coder /home/linuxbrew`) in the startup script. `HOMEBREW_CACHE` is set to `/home/linuxbrew/.cache/Homebrew` (via the Dockerfile) so the cache and Cellar always stay on the same filesystem, avoiding cross-device link (`EXDEV`) errors. +- **Isolation**: Each workspace gets separate host directory and Docker volumes ### Terraform Variables Key template variables (e.g. in `freeform/template.tf`): diff --git a/VERSION b/VERSION index 1811f96..83b4ac5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.4 +v0.5 diff --git a/drupal-contrib/template.tf b/drupal-contrib/template.tf index 7053689..8891590 100644 --- a/drupal-contrib/template.tf +++ b/drupal-contrib/template.tf @@ -313,6 +313,7 @@ resource "coder_agent" "main" { fi sudo chown coder:coder /home/coder + sudo chown -R coder:coder /home/linuxbrew if [ ! -f "/home/coder/.bashrc" ]; then echo "Initializing home directory..." @@ -1073,6 +1074,15 @@ resource "docker_volume" "coder_dind_cache" { name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-dind-cache" } +# Persists /home/linuxbrew (Homebrew Cellar) across workspace stop/start so +# `brew upgrade`/`brew install` survives restarts. Not gated by start_count, +# matching coder_dind_cache above. Docker auto-populates a newly-created +# named volume from the image's existing directory contents on first mount, +# so no manual copy-from-image seed step is needed here. +resource "docker_volume" "coder_linuxbrew" { + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-linuxbrew" +} + module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" @@ -1188,6 +1198,12 @@ resource "docker_container" "workspace" { target = "/var/lib/docker" } + mounts { + type = "volume" + source = docker_volume.coder_linuxbrew.name + target = "/home/linuxbrew" + } + env = [ "CODER_AGENT_TOKEN=${coder_agent.main.token}", "CODER_WORKSPACE_NAME=${data.coder_workspace.me.name}", diff --git a/drupal-contrib/tests/validate.tftest.hcl b/drupal-contrib/tests/validate.tftest.hcl index 609d7a9..5950625 100644 --- a/drupal-contrib/tests/validate.tftest.hcl +++ b/drupal-contrib/tests/validate.tftest.hcl @@ -78,3 +78,11 @@ run "memory_above_maximum" { } expect_failures = [var.memory] } + +run "linuxbrew_volume_created" { + command = plan + assert { + condition = docker_volume.coder_linuxbrew.name == "coder-testuser-test-workspace-linuxbrew" + error_message = "docker_volume.coder_linuxbrew must be named per the coder---linuxbrew convention" + } +} diff --git a/drupal-core/template.tf b/drupal-core/template.tf index 2c19b02..0dbf358 100644 --- a/drupal-core/template.tf +++ b/drupal-core/template.tf @@ -355,6 +355,7 @@ resource "coder_agent" "main" { # Since the volume comes from the host, it might have host permissions. # We fix this on every startup. sudo chown coder:coder /home/coder + sudo chown -R coder:coder /home/linuxbrew # Copy defaults if empty (first run) if [ ! -f "/home/coder/.bashrc" ]; then @@ -1362,6 +1363,15 @@ resource "docker_volume" "coder_dind_cache" { name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-dind-cache" } +# Persists /home/linuxbrew (Homebrew Cellar) across workspace stop/start so +# `brew upgrade`/`brew install` survives restarts. Not gated by start_count, +# matching coder_dind_cache above. Docker auto-populates a newly-created +# named volume from the image's existing directory contents on first mount, +# so no manual copy-from-image seed step is needed here. +resource "docker_volume" "coder_linuxbrew" { + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-linuxbrew" +} + # VS Code for Web module "vscode-web" { count = data.coder_workspace.me.start_count @@ -1523,6 +1533,12 @@ resource "docker_container" "workspace" { target = "/var/lib/docker" } + mounts { + type = "volume" + source = docker_volume.coder_linuxbrew.name + target = "/home/linuxbrew" + } + # Environment variables env = [ "CODER_AGENT_TOKEN=${coder_agent.main.token}", diff --git a/drupal-core/tests/validate.tftest.hcl b/drupal-core/tests/validate.tftest.hcl index 828b0fb..27c38aa 100644 --- a/drupal-core/tests/validate.tftest.hcl +++ b/drupal-core/tests/validate.tftest.hcl @@ -82,3 +82,14 @@ run "memory_above_maximum" { } expect_failures = [var.memory] } + +run "linuxbrew_volume_created" { + command = plan + variables { + cache_path = "/tmp/mock-cache" + } + assert { + condition = docker_volume.coder_linuxbrew.name == "coder-testuser-test-workspace-linuxbrew" + error_message = "docker_volume.coder_linuxbrew must be named per the coder---linuxbrew convention" + } +} diff --git a/freeform/template.tf b/freeform/template.tf index 9283a45..bba01e1 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -206,6 +206,7 @@ resource "coder_agent" "main" { fi sudo chown coder:coder /home/coder + sudo chown -R coder:coder /home/linuxbrew if [ ! -f "/home/coder/.bashrc" ]; then echo "Initializing home directory..." @@ -443,6 +444,15 @@ resource "docker_volume" "coder_dind_cache" { name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-dind-cache" } +# Persists /home/linuxbrew (Homebrew Cellar) across workspace stop/start so +# `brew upgrade`/`brew install` survives restarts. Not gated by start_count, +# matching coder_dind_cache above. Docker auto-populates a newly-created +# named volume from the image's existing directory contents on first mount, +# so no manual copy-from-image seed step is needed here. +resource "docker_volume" "coder_linuxbrew" { + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-linuxbrew" +} + module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" @@ -577,6 +587,12 @@ resource "docker_container" "workspace" { target = "/var/lib/docker" } + mounts { + type = "volume" + source = docker_volume.coder_linuxbrew.name + target = "/home/linuxbrew" + } + env = [ "CODER_AGENT_TOKEN=${coder_agent.main.token}", "CODER_WORKSPACE_NAME=${data.coder_workspace.me.name}", diff --git a/freeform/tests/validate.tftest.hcl b/freeform/tests/validate.tftest.hcl index 0edffe8..a126552 100644 --- a/freeform/tests/validate.tftest.hcl +++ b/freeform/tests/validate.tftest.hcl @@ -132,3 +132,11 @@ run "memory_above_maximum" { } expect_failures = [var.memory] } + +run "linuxbrew_volume_created" { + command = plan + assert { + condition = docker_volume.coder_linuxbrew.name == "coder-testuser-test-workspace-linuxbrew" + error_message = "docker_volume.coder_linuxbrew must be named per the coder---linuxbrew convention" + } +} diff --git a/image/Dockerfile b/image/Dockerfile index 0a29172..cc6c24d 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -165,9 +165,17 @@ RUN printf '#!/usr/bin/env bash\nddev php "$@"\n' > /usr/local/bin/php && \ RUN mkdir -p /home/linuxbrew/.linuxbrew && chown -R coder:coder /home/linuxbrew/ +# Homebrew's cache must live on the same filesystem as the Cellar +# (/home/linuxbrew/.linuxbrew) to avoid cross-device link (EXDEV) errors when +# brew hardlinks a downloaded bottle from cache into the Cellar. At runtime, +# /home/linuxbrew is mounted as a persistent per-workspace volume (see each +# template's template.tf), so this path stays on the same filesystem as the +# Cellar across workspace restarts too. +ENV HOMEBREW_CACHE=/home/linuxbrew/.cache/Homebrew + USER coder RUN /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -RUN /home/linuxbrew/.linuxbrew/bin/brew install \ +RUN mkdir -p "$HOMEBREW_CACHE" && /home/linuxbrew/.linuxbrew/bin/brew install \ bats-core \ bats-core/bats-core/bats-assert \ bats-core/bats-core/bats-file \