From 8ff23474de346bfa3b60c5a410a4d9335038089b Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 04:21:38 +0000 Subject: [PATCH 01/28] fix: switch freeform DDEV router to port 8080, fix coder-routes and ddev launch, closes #87 - Set ddev config global --router-http-port=8080 in freeform startup script; port 80 conflicts with Coder's own proxy and is excluded from auto port-forwarding - Update coder_app.ddev-web to port 8080 (url and healthcheck) - coder-routes: detect web service on ext_port 8080 (was 80); sanitize DDEV project name as DNS-safe PROJECT_SLUG for per-project URL differentiation; write per-project coder-routes-{project}.yaml files so multiple projects coexist without overwriting - launch: remove hardcoded 80--agent--workspace--owner URL (wrong format); read all routes from per-project coder-routes file and derive correct URLs from Host/PathPrefix rules; falls back to legacy coder-routes.yaml for existing setups - coder-setup: add post-stop hook to remove per-project routes file on project stop Co-Authored-By: Claude Sonnet 4.6 --- freeform/template.tf | 7 +- .../scripts/.ddev/commands/host/coder-routes | 38 +++++---- image/scripts/.ddev/commands/host/coder-setup | 2 + image/scripts/.ddev/commands/host/launch | 79 +++++++++++-------- 4 files changed, 78 insertions(+), 48 deletions(-) diff --git a/freeform/template.tf b/freeform/template.tf index a16b60d..eb2f881 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -236,6 +236,7 @@ resource "coder_agent" "main" { fi mkdir -p ~/.ddev ddev config global --instrumentation-opt-in=true > /dev/null 2>&1 || true + ddev config global --router-http-port=8080 > /dev/null 2>&1 || true if [ -n "$CODER_WORKSPACE_OWNER_NAME" ]; then git config --global user.name "$CODER_WORKSPACE_OWNER_NAME" fi @@ -429,7 +430,7 @@ module "vscode-web" { extensions = local.selected_extensions } -# Slug matches the workspace name, which is also the DDEV project name. +# Slug matches the workspace name. DDEV router HTTP port is 8080 (set via ddev config global in startup). # Coder subdomain URL: {workspace_name}--{workspace_name}--{owner}.{domain} # Traefik rule in coder-routes.yaml matches this exact host. resource "coder_app" "ddev-web" { @@ -437,13 +438,13 @@ resource "coder_app" "ddev-web" { slug = data.coder_workspace.me.name display_name = "DDEV Web" order = 1 - url = "http://localhost:80" + url = "http://localhost:8080" icon = "https://raw.githubusercontent.com/ddev/ddev/main/docs/content/developers/logos/SVG/Logo.svg" subdomain = true share = "owner" healthcheck { - url = "http://localhost:80" + url = "http://localhost:8080" interval = 10 threshold = 30 } diff --git a/image/scripts/.ddev/commands/host/coder-routes b/image/scripts/.ddev/commands/host/coder-routes index d54c1cc..2334407 100644 --- a/image/scripts/.ddev/commands/host/coder-routes +++ b/image/scripts/.ddev/commands/host/coder-routes @@ -22,6 +22,11 @@ if [ -z "$DDEV_PROJECT" ] || [ -z "$OWNER" ] || [ -z "$DOMAIN" ]; then exit 1 fi +# Sanitize DDEV project name for use as a DNS label in Coder subdomain URLs. +# Replace dots and underscores with dashes, lowercase. E.g. "ddev.com" → "ddev-com". +# When project name equals workspace name (standard case), the URL matches the coder_app slug. +PROJECT_SLUG=$(echo "$DDEV_PROJECT" | tr '[:upper:]' '[:lower:]' | tr '._' '--' | tr -s '-') + # Read from the DDEV-generated merged Traefik config — it has all routers and # service names already correctly computed (including addons), so we don't need # to parse docker-compose files ourselves. @@ -31,7 +36,11 @@ if [ ! -f "$MERGED" ]; then exit 1 fi -mkdir -p ~/.ddev/traefik/custom-global-config +ROUTES_DIR="$HOME/.ddev/traefik/custom-global-config" +mkdir -p "$ROUTES_DIR" + +# Write per-project file so multiple projects coexist without overwriting each other. +OUTPUT="$ROUTES_DIR/coder-routes-${DDEV_PROJECT}.yaml" # Seed the output file printf "http:\n routers: {}\n" > /tmp/coder-routes-raw.yaml @@ -55,9 +64,9 @@ while IFS= read -r router; do # Derive Coder slug from service name: strip {ddev_project}- prefix and -{port} suffix. # Examples (DDEV_PROJECT=myproject, WORKSPACE=myworkspace): - # myproject-web-80 → svc=web port=80 → slug=myworkspace (primary web, uses workspace name) + # myproject-web-8080 → svc=web port=8080 → slug=PROJECT_SLUG (primary web) # myproject-web-8025 → svc=web port=8025 → slug=mailpit - # myproject-adminer-8080 → svc=adminer → slug=adminer + # myproject-adminer-9100 → svc=adminer → slug=adminer svc_and_port="${service#${DDEV_PROJECT}-}" port="${svc_and_port##*-}" svc_name="${svc_and_port%-*}" @@ -65,7 +74,7 @@ while IFS= read -r router; do if [ "$svc_name" = "web" ] && [ "$port" = "8025" ]; then slug="mailpit" elif [ "$svc_name" = "web" ]; then - slug="$WORKSPACE" + slug="$PROJECT_SLUG" else slug="$svc_name" fi @@ -77,13 +86,11 @@ while IFS= read -r router; do # Determine external port from the entrypoint name (e.g. http-8143 → 8143). # Use this — NOT the service-name port — to decide routing strategy. - # Service names encode the container-internal port (e.g. d11-xhgui-80 → 80), - # which can differ from the ddev-router entrypoint (e.g. http-8143). ext_port="${entrypoints[0]#http-}" - if [ "$ext_port" = "80" ]; then + if [ "$ext_port" = "8080" ]; then # Primary web service: Host() rule so Coder's coder_app subdomain proxy works. - # slug=WORKSPACE and host uses WORKSPACE so the URL matches the Coder app subdomain. + # When PROJECT_SLUG equals WORKSPACE the URL matches the coder_app slug exactly. CODER_HOST="${slug}--${WORKSPACE}--${OWNER}.${DOMAIN}" RULE='Host(`'"${CODER_HOST}"'`)' RULE="$RULE" SVC="$service" \ @@ -111,6 +118,7 @@ while IFS= read -r router; do # Dynamic add-on services (no dedicated coder_app): PathPrefix("/") catches any # traffic arriving on this entrypoint, enabling Coder port-forwarding URLs: # https://{ext_port}--{agent}--{workspace}--{owner}.{domain} + AGENT="${CODER_AGENT_NAME:-main}" RULE='PathPrefix(`/`)' RULE="$RULE" SVC="$service" \ yq e -i \ @@ -120,18 +128,20 @@ while IFS= read -r router; do .http.routers.\"${ROUTER_NAME}\".tls = false | .http.routers.\"${ROUTER_NAME}\".priority = 1" \ /tmp/coder-routes-raw.yaml - echo " + ${slug}: ${entrypoints[*]} → ${service} (https://${ext_port}--main--${WORKSPACE}--${OWNER}.${DOMAIN})" + echo " + ${slug}: ${entrypoints[*]} → ${service} (https://${ext_port}--${AGENT}--${WORKSPACE}--${OWNER}.${DOMAIN})" fi done < <(yq e '.http.routers | keys | .[]' "$MERGED" 2>/dev/null) -yq e '.' /tmp/coder-routes-raw.yaml > ~/.ddev/traefik/custom-global-config/coder-routes.yaml -echo "✓ Wrote coder-routes.yaml" +yq e '.' /tmp/coder-routes-raw.yaml > "$OUTPUT" +echo "✓ Wrote $(basename "$OUTPUT")" -# Push to running ddev-router; Traefik's watch:true reloads within ~1s +# Push to running ddev-router; Traefik's watch:true reloads within ~1s. +# The bind-mount from ~/.ddev/traefik/custom-global-config/ means writing the file +# is usually sufficient, but docker cp guarantees an immediate reload. if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^ddev-router$"; then - docker cp ~/.ddev/traefik/custom-global-config/coder-routes.yaml \ - ddev-router:/mnt/ddev-global-cache/traefik/config/coder-routes.yaml + docker cp "$OUTPUT" \ + "ddev-router:/mnt/ddev-global-cache/traefik/config/$(basename "$OUTPUT")" echo "✓ Pushed to ddev-router (Traefik reloads within ~1s)" else echo "Note: ddev-router not running; config will be loaded on next ddev start" diff --git a/image/scripts/.ddev/commands/host/coder-setup b/image/scripts/.ddev/commands/host/coder-setup index 51f0c6c..b122003 100755 --- a/image/scripts/.ddev/commands/host/coder-setup +++ b/image/scripts/.ddev/commands/host/coder-setup @@ -39,6 +39,8 @@ web_environment: hooks: post-start: - exec-host: ddev coder-routes + post-stop: + - exec-host: rm -f $HOME/.ddev/traefik/custom-global-config/coder-routes-\${DDEV_SITENAME}.yaml EOF echo "✓ Wrote .ddev/config.coder.yaml" diff --git a/image/scripts/.ddev/commands/host/launch b/image/scripts/.ddev/commands/host/launch index 5d0ccaa..2de23c6 100644 --- a/image/scripts/.ddev/commands/host/launch +++ b/image/scripts/.ddev/commands/host/launch @@ -21,8 +21,6 @@ else fi # DDEV_SITENAME is the DDEV project name (may differ from the Coder workspace name). -# Coder URLs always use WORKSPACE (= CODER_WORKSPACE_NAME); PROJECT is only used -# to look up router keys in coder-routes.yaml (which coder-routes prefixes with DDEV_PROJECT). PROJECT="${DDEV_SITENAME}" AGENT="${CODER_AGENT_NAME:-main}" @@ -45,43 +43,62 @@ if [ -n "${1:-}" ]; then PATH_SUFFIX="/${1#/}" fi +# Per-project routes file (written by ddev coder-routes after each ddev start). +# Falls back to the legacy single-file name for older setups. +CODER_ROUTES="$HOME/.ddev/traefik/custom-global-config/coder-routes-${PROJECT}.yaml" +if [ ! -f "$CODER_ROUTES" ]; then + CODER_ROUTES="$HOME/.ddev/traefik/custom-global-config/coder-routes.yaml" +fi + +# Sanitize project name the same way coder-routes does, to reconstruct the router key. +PROJECT_SLUG=$(echo "$PROJECT" | tr '[:upper:]' '[:lower:]' | tr '._' '--' | tr -s '-') +WEB_ROUTER="${PROJECT}-coder-${PROJECT_SLUG}" + if [ "${MAILPIT}" = "true" ]; then + if [ -f "$CODER_ROUTES" ]; then + mailpit_rule=$(yq e ".http.routers.\"${PROJECT}-coder-mailpit\".rule // \"\"" "$CODER_ROUTES" 2>/dev/null) + if [ -n "$mailpit_rule" ] && [ "$mailpit_rule" != "null" ]; then + host=$(echo "$mailpit_rule" | sed -E 's/Host\(`(.+)`\)/\1/') + echo "https://${host}" + exit 0 + fi + fi + # fallback echo "https://mailpit--${WORKSPACE}--${OWNER}.${DOMAIN}" exit 0 fi echo "" echo "Coder URLs for project '${PROJECT}':" -echo " Web: https://80--${AGENT}--${WORKSPACE}--${OWNER}.${DOMAIN}${PATH_SUFFIX}" -echo " Mailpit: https://mailpit--${WORKSPACE}--${OWNER}.${DOMAIN}" - -# Show addon services from the generated coder-routes.yaml. -# Routers with a Host() rule have a known Coder app (slug subdomain URL). -# Routers with PathPrefix("/") are dynamic add-ons accessible via port-forwarding: -# https://{port}--{agent}--{workspace}--{owner}.{domain} -CODER_ROUTES="$HOME/.ddev/traefik/custom-global-config/coder-routes.yaml" -if [ -f "$CODER_ROUTES" ]; then - while IFS= read -r router; do - # Skip web and mailpit — already printed above. - # Web router key: {project}-coder-{workspace} (slug = workspace name). - [ "$router" = "${PROJECT}-coder-${WORKSPACE}" ] && continue - [ "$router" = "${PROJECT}-coder-mailpit" ] && continue - # Get first entrypoint and extract port number (http-9100 → 9100) - entrypoint=$(yq e ".http.routers.\"${router}\".entrypoints[0] // \"\"" "$CODER_ROUTES" 2>/dev/null) - [ -z "$entrypoint" ] || [ "$entrypoint" = "null" ] && continue - ext_port="${entrypoint#http-}" - # Slug is the router name with "{project}-coder-" prefix stripped - slug="${router#${PROJECT}-coder-}" - # Determine URL format from the rule type - rule=$(yq e ".http.routers.\"${router}\".rule // \"\"" "$CODER_ROUTES" 2>/dev/null) - if echo "$rule" | grep -q "^Host"; then - # Known Coder app (Host rule): use slug subdomain URL - echo " ${slug}: https://${slug}--${WORKSPACE}--${OWNER}.${DOMAIN}" + +if [ ! -f "$CODER_ROUTES" ]; then + echo " (no coder-routes file found; run 'ddev coder-setup' then 'ddev start')" + echo "" + exit 0 +fi + +while IFS= read -r router; do + entrypoint=$(yq e ".http.routers.\"${router}\".entrypoints[0] // \"\"" "$CODER_ROUTES" 2>/dev/null) + [ -z "$entrypoint" ] || [ "$entrypoint" = "null" ] && continue + + ext_port="${entrypoint#http-}" + slug="${router#${PROJECT}-coder-}" + rule=$(yq e ".http.routers.\"${router}\".rule // \"\"" "$CODER_ROUTES" 2>/dev/null) + + if echo "$rule" | grep -q "^Host"; then + # Extract hostname from Host(`...`) — these use Coder subdomain proxy + host=$(echo "$rule" | sed -E 's/Host\(`(.+)`\)/\1/') + if [ "$router" = "$WEB_ROUTER" ]; then + echo " Web: https://${host}${PATH_SUFFIX}" + elif [ "$slug" = "mailpit" ]; then + echo " Mailpit: https://${host}" else - # Dynamic add-on (PathPrefix rule): use Coder port-forwarding URL - echo " ${slug}: https://${ext_port}--${AGENT}--${WORKSPACE}--${OWNER}.${DOMAIN}" + echo " ${slug}: https://${host}" fi - done < <(yq e '.http.routers | keys | .[]' "$CODER_ROUTES" 2>/dev/null) -fi + else + # PathPrefix rule — dynamic add-on, accessible via Coder port-forwarding URL + echo " ${slug}: https://${ext_port}--${AGENT}--${WORKSPACE}--${OWNER}.${DOMAIN}" + fi +done < <(yq e '.http.routers | keys | .[]' "$CODER_ROUTES" 2>/dev/null) echo "" From 4c8da02c7ca676f937324e3a93cc89926ee3e254 Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 12:19:55 +0000 Subject: [PATCH 02/28] test: add two-PHP-project routing test for freeform template Adds four steps to the freeform integration test: - Start two PHP projects (ci-site1/ci-site2) with ddev coder-setup - Verify ddev launch shows the correct per-project Coder subdomain URL for each (catches slug mix-ups and coder-routes file overwrites) - Verify ddev describe shows the correct .ddev.site URL for each project - Cleanup both projects before workspace deletion Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration-test.yml | 74 ++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 56dc1f1..0335785 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -186,6 +186,80 @@ jobs: coder ssh ${{ env.WORKSPACE_NAME }} -- ddev delete ci-ddev-${{ github.run_id }} --omit-snapshot -y < /dev/null || true coder ssh ${{ env.WORKSPACE_NAME }} -- rm -rf /tmp/ci-ddev-${{ github.run_id }} < /dev/null || true + - name: Start two PHP projects — freeform routing test + if: ${{ matrix.template == 'freeform' }} + run: | + cat > /tmp/ci-freeform-setup-${{ github.run_id }}.sh << 'SCRIPT' + set -euo pipefail + RID="$1" + for N in 1 2; do + PROJ="ci-site${N}-${RID}" + TESTDIR="/tmp/${PROJ}" + echo "--- Creating project ${PROJ} ---" + mkdir -p "${TESTDIR}/web" + cd "${TESTDIR}" + ddev config --project-type=php --docroot=web --project-name="${PROJ}" + ddev coder-setup + ddev start -y + echo "--- ${PROJ} started ---" + done + SCRIPT + scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o ProxyCommand="coder ssh --stdio ${{ env.WORKSPACE_NAME }}" \ + /tmp/ci-freeform-setup-${{ github.run_id }}.sh \ + coder@workspace:/tmp/ci-freeform-setup-${{ github.run_id }}.sh + coder ssh ${{ env.WORKSPACE_NAME }} -- \ + env CI=${{ env.CI }} DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ + bash /tmp/ci-freeform-setup-${{ github.run_id }}.sh "${{ github.run_id }}" < /dev/null + + - name: Verify freeform — ddev launch shows per-project Coder URLs + if: ${{ matrix.template == 'freeform' }} + run: | + CODER_DOMAIN="${{ vars.TEST_CODER_URL }}" + CODER_DOMAIN="${CODER_DOMAIN#https://}" + for N in 1 2; do + PROJ="ci-site${N}-${{ github.run_id }}" + EXPECTED_URL="https://${PROJ}--${{ env.WORKSPACE_NAME }}--${OWNER}.${CODER_DOMAIN}" + echo "Checking ddev launch for project ${PROJ}, expecting: ${EXPECTED_URL}" + LAUNCH_OUTPUT=$(coder ssh ${{ env.WORKSPACE_NAME }} -- \ + env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ + bash -c "cd /tmp/${PROJ} && ddev launch" < /dev/null 2>&1) + echo " Launch output: ${LAUNCH_OUTPUT}" + echo "$LAUNCH_OUTPUT" | grep -qF "${EXPECTED_URL}" || { + echo "ERROR: expected URL ${EXPECTED_URL} not found in ddev launch output" >&2 + exit 1 + } + echo " OK: ddev launch for ${PROJ} shows correct URL" + done + + - name: Verify freeform — ddev describe shows per-project URL + if: ${{ matrix.template == 'freeform' }} + run: | + for N in 1 2; do + PROJ="ci-site${N}-${{ github.run_id }}" + echo "Checking ddev describe for ${PROJ}" + DESCRIBE_OUTPUT=$(coder ssh ${{ env.WORKSPACE_NAME }} -- \ + env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ + bash -c "cd /tmp/${PROJ} && ddev describe" < /dev/null 2>&1) + echo " Describe output: ${DESCRIBE_OUTPUT}" + echo "$DESCRIBE_OUTPUT" | grep -qF "${PROJ}.ddev.site" || { + echo "ERROR: expected URL ${PROJ}.ddev.site not found in ddev describe output" >&2 + exit 1 + } + echo " OK: ddev describe for ${PROJ} shows correct site URL" + done + + - name: Cleanup freeform PHP projects + if: ${{ always() && matrix.template == 'freeform' }} + run: | + for N in 1 2; do + PROJ="ci-site${N}-${{ github.run_id }}" + coder ssh ${{ env.WORKSPACE_NAME }} -- \ + env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} \ + bash -c "ddev delete ${PROJ} --omit-snapshot -y" < /dev/null || true + coder ssh ${{ env.WORKSPACE_NAME }} -- rm -rf "/tmp/${PROJ}" < /dev/null || true + done + - name: Delete workspace if: always() run: coder delete ${{ env.WORKSPACE_NAME }} --yes || true From 700102b80565fda2b7bbc69463837b31b96f4f06 Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 13:05:35 +0000 Subject: [PATCH 03/28] fix: use cd into project dir for ddev delete in freeform cleanup ddev delete with a positional project name triggers the TUI project selector in DDEV v1.25.x when not in a project directory. Use cd /tmp/PROJ && ddev delete instead to give DDEV unambiguous context. Also add NO_COLOR to the cleanup env. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 0335785..f25b2e2 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -255,8 +255,8 @@ jobs: for N in 1 2; do PROJ="ci-site${N}-${{ github.run_id }}" coder ssh ${{ env.WORKSPACE_NAME }} -- \ - env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} \ - bash -c "ddev delete ${PROJ} --omit-snapshot -y" < /dev/null || true + env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ + bash -c "cd /tmp/${PROJ} && ddev delete --omit-snapshot -y" < /dev/null || true coder ssh ${{ env.WORKSPACE_NAME }} -- rm -rf "/tmp/${PROJ}" < /dev/null || true done From ba0f835ec70c97e1fec56beeee787aa06a09c0f8 Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 13:14:21 +0000 Subject: [PATCH 04/28] fix: drop bash -c wrappers in freeform verify and cleanup steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit coder ssh joins post-'--' args with spaces before the remote shell parses them, so bash -c "multi word" becomes bash -c multi word — bash receives only the first word as its script and runs ddev bare, triggering the TUI. - Verify ddev launch: pass DDEV_SITENAME env var so the launch script finds the right project without needing cd - Verify ddev describe: use 'ddev describe ' (accepts project arg) - Cleanup: use 'ddev delete -Oy' directly, no bash -c Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration-test.yml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index f25b2e2..2aebb6a 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -222,8 +222,7 @@ jobs: EXPECTED_URL="https://${PROJ}--${{ env.WORKSPACE_NAME }}--${OWNER}.${CODER_DOMAIN}" echo "Checking ddev launch for project ${PROJ}, expecting: ${EXPECTED_URL}" LAUNCH_OUTPUT=$(coder ssh ${{ env.WORKSPACE_NAME }} -- \ - env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ - bash -c "cd /tmp/${PROJ} && ddev launch" < /dev/null 2>&1) + env DDEV_SITENAME=${PROJ} DDEV_NONINTERACTIVE=true NO_COLOR=1 ddev launch < /dev/null 2>&1) echo " Launch output: ${LAUNCH_OUTPUT}" echo "$LAUNCH_OUTPUT" | grep -qF "${EXPECTED_URL}" || { echo "ERROR: expected URL ${EXPECTED_URL} not found in ddev launch output" >&2 @@ -238,9 +237,7 @@ jobs: for N in 1 2; do PROJ="ci-site${N}-${{ github.run_id }}" echo "Checking ddev describe for ${PROJ}" - DESCRIBE_OUTPUT=$(coder ssh ${{ env.WORKSPACE_NAME }} -- \ - env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ - bash -c "cd /tmp/${PROJ} && ddev describe" < /dev/null 2>&1) + DESCRIBE_OUTPUT=$(coder ssh ${{ env.WORKSPACE_NAME }} -- ddev describe ${PROJ} < /dev/null 2>&1) echo " Describe output: ${DESCRIBE_OUTPUT}" echo "$DESCRIBE_OUTPUT" | grep -qF "${PROJ}.ddev.site" || { echo "ERROR: expected URL ${PROJ}.ddev.site not found in ddev describe output" >&2 @@ -254,10 +251,8 @@ jobs: run: | for N in 1 2; do PROJ="ci-site${N}-${{ github.run_id }}" - coder ssh ${{ env.WORKSPACE_NAME }} -- \ - env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ - bash -c "cd /tmp/${PROJ} && ddev delete --omit-snapshot -y" < /dev/null || true - coder ssh ${{ env.WORKSPACE_NAME }} -- rm -rf "/tmp/${PROJ}" < /dev/null || true + coder ssh ${{ env.WORKSPACE_NAME }} -- ddev delete ${PROJ} -Oy < /dev/null || true + coder ssh ${{ env.WORKSPACE_NAME }} -- rm -rf /tmp/${PROJ} < /dev/null || true done - name: Delete workspace From 39c549b252b82a0c839c8499f06aa753dbd94283 Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 13:38:59 +0000 Subject: [PATCH 05/28] fix: run launch script directly to prevent DDEV overriding DDEV_SITENAME When ddev launch is invoked as a global command outside a project directory, DDEV overwrites DDEV_SITENAME with empty (no project found in CWD). Run the script directly via bash so our DDEV_SITENAME env var is preserved and the routes file is found. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration-test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 2aebb6a..c895ec9 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -222,7 +222,8 @@ jobs: EXPECTED_URL="https://${PROJ}--${{ env.WORKSPACE_NAME }}--${OWNER}.${CODER_DOMAIN}" echo "Checking ddev launch for project ${PROJ}, expecting: ${EXPECTED_URL}" LAUNCH_OUTPUT=$(coder ssh ${{ env.WORKSPACE_NAME }} -- \ - env DDEV_SITENAME=${PROJ} DDEV_NONINTERACTIVE=true NO_COLOR=1 ddev launch < /dev/null 2>&1) + env DDEV_SITENAME=${PROJ} DDEV_NONINTERACTIVE=true NO_COLOR=1 \ + bash /home/coder/.ddev/commands/host/launch < /dev/null 2>&1) echo " Launch output: ${LAUNCH_OUTPUT}" echo "$LAUNCH_OUTPUT" | grep -qF "${EXPECTED_URL}" || { echo "ERROR: expected URL ${EXPECTED_URL} not found in ddev launch output" >&2 From 0e594fab90a54cd1900c03b7179f1cd913a62c28 Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 13:41:09 +0000 Subject: [PATCH 06/28] fix: use scp-script pattern for freeform verify steps ddev launch must run in project context (cd into project dir). The only safe way to do that through coder ssh without bash -c quoting issues is the same scp-script pattern used by the start step. Combines ddev launch and ddev describe checks into one verify step. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration-test.yml | 55 +++++++++++++------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index c895ec9..1efeeb6 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -212,40 +212,39 @@ jobs: env CI=${{ env.CI }} DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ bash /tmp/ci-freeform-setup-${{ github.run_id }}.sh "${{ github.run_id }}" < /dev/null - - name: Verify freeform — ddev launch shows per-project Coder URLs + - name: Verify freeform — ddev launch and describe per-project URLs if: ${{ matrix.template == 'freeform' }} run: | CODER_DOMAIN="${{ vars.TEST_CODER_URL }}" CODER_DOMAIN="${CODER_DOMAIN#https://}" + cat > /tmp/ci-freeform-verify-${{ github.run_id }}.sh << 'SCRIPT' + set -euo pipefail + RID="$1" WORKSPACE="$2" OWNER="$3" DOMAIN="$4" for N in 1 2; do - PROJ="ci-site${N}-${{ github.run_id }}" - EXPECTED_URL="https://${PROJ}--${{ env.WORKSPACE_NAME }}--${OWNER}.${CODER_DOMAIN}" - echo "Checking ddev launch for project ${PROJ}, expecting: ${EXPECTED_URL}" - LAUNCH_OUTPUT=$(coder ssh ${{ env.WORKSPACE_NAME }} -- \ - env DDEV_SITENAME=${PROJ} DDEV_NONINTERACTIVE=true NO_COLOR=1 \ - bash /home/coder/.ddev/commands/host/launch < /dev/null 2>&1) - echo " Launch output: ${LAUNCH_OUTPUT}" - echo "$LAUNCH_OUTPUT" | grep -qF "${EXPECTED_URL}" || { - echo "ERROR: expected URL ${EXPECTED_URL} not found in ddev launch output" >&2 - exit 1 - } - echo " OK: ddev launch for ${PROJ} shows correct URL" - done - - - name: Verify freeform — ddev describe shows per-project URL - if: ${{ matrix.template == 'freeform' }} - run: | - for N in 1 2; do - PROJ="ci-site${N}-${{ github.run_id }}" - echo "Checking ddev describe for ${PROJ}" - DESCRIBE_OUTPUT=$(coder ssh ${{ env.WORKSPACE_NAME }} -- ddev describe ${PROJ} < /dev/null 2>&1) - echo " Describe output: ${DESCRIBE_OUTPUT}" - echo "$DESCRIBE_OUTPUT" | grep -qF "${PROJ}.ddev.site" || { - echo "ERROR: expected URL ${PROJ}.ddev.site not found in ddev describe output" >&2 - exit 1 - } - echo " OK: ddev describe for ${PROJ} shows correct site URL" + PROJ="ci-site${N}-${RID}" + EXPECTED_URL="https://${PROJ}--${WORKSPACE}--${OWNER}.${DOMAIN}" + echo "--- ${PROJ}: ddev launch ---" + cd /tmp/${PROJ} + LAUNCH=$(ddev launch 2>&1) + echo "${LAUNCH}" + echo "${LAUNCH}" | grep -qF "${EXPECTED_URL}" || { echo "ERROR: expected ${EXPECTED_URL}" >&2; exit 1; } + echo " OK: ddev launch" + echo "--- ${PROJ}: ddev describe ---" + DESCRIBE=$(ddev describe 2>&1) + echo "${DESCRIBE}" + echo "${DESCRIBE}" | grep -qF "${PROJ}.ddev.site" || { echo "ERROR: ${PROJ}.ddev.site not found" >&2; exit 1; } + echo " OK: ddev describe" done + SCRIPT + scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o ProxyCommand="coder ssh --stdio ${{ env.WORKSPACE_NAME }}" \ + /tmp/ci-freeform-verify-${{ github.run_id }}.sh \ + coder@workspace:/tmp/ci-freeform-verify-${{ github.run_id }}.sh + coder ssh ${{ env.WORKSPACE_NAME }} -- \ + env DDEV_NONINTERACTIVE=true NO_COLOR=1 \ + bash /tmp/ci-freeform-verify-${{ github.run_id }}.sh \ + "${{ github.run_id }}" "${{ env.WORKSPACE_NAME }}" "${OWNER}" "${CODER_DOMAIN}" \ + < /dev/null - name: Cleanup freeform PHP projects if: ${{ always() && matrix.template == 'freeform' }} From dd539932a02ef386b59c8d1669c29d42a73c34cd Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 13:45:01 +0000 Subject: [PATCH 07/28] refactor: ship freeform test scripts alongside template Move inline heredoc scripts to freeform/scripts/ so they can be run manually in a workspace or by CI. Each script accepts a suffix arg (github.run_id in CI, any string manually) and derives workspace/owner/domain from Coder agent env vars when not provided. - test-freeform-start.sh create and start two PHP test projects - test-freeform-verify.sh check ddev launch and ddev describe URLs - test-freeform-cleanup.sh delete both projects Workflow steps are now SCP + bash /tmp/script.sh, no inline heredocs. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration-test.yml | 60 ++++++----------------- freeform/scripts/test-freeform-cleanup.sh | 16 ++++++ freeform/scripts/test-freeform-start.sh | 25 ++++++++++ freeform/scripts/test-freeform-verify.sh | 56 +++++++++++++++++++++ 4 files changed, 111 insertions(+), 46 deletions(-) create mode 100644 freeform/scripts/test-freeform-cleanup.sh create mode 100644 freeform/scripts/test-freeform-start.sh create mode 100644 freeform/scripts/test-freeform-verify.sh diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 1efeeb6..78a654e 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -189,71 +189,39 @@ jobs: - name: Start two PHP projects — freeform routing test if: ${{ matrix.template == 'freeform' }} run: | - cat > /tmp/ci-freeform-setup-${{ github.run_id }}.sh << 'SCRIPT' - set -euo pipefail - RID="$1" - for N in 1 2; do - PROJ="ci-site${N}-${RID}" - TESTDIR="/tmp/${PROJ}" - echo "--- Creating project ${PROJ} ---" - mkdir -p "${TESTDIR}/web" - cd "${TESTDIR}" - ddev config --project-type=php --docroot=web --project-name="${PROJ}" - ddev coder-setup - ddev start -y - echo "--- ${PROJ} started ---" - done - SCRIPT scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o ProxyCommand="coder ssh --stdio ${{ env.WORKSPACE_NAME }}" \ - /tmp/ci-freeform-setup-${{ github.run_id }}.sh \ - coder@workspace:/tmp/ci-freeform-setup-${{ github.run_id }}.sh + freeform/scripts/test-freeform-start.sh \ + coder@workspace:/tmp/test-freeform-start.sh coder ssh ${{ env.WORKSPACE_NAME }} -- \ env CI=${{ env.CI }} DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ - bash /tmp/ci-freeform-setup-${{ github.run_id }}.sh "${{ github.run_id }}" < /dev/null + bash /tmp/test-freeform-start.sh "${{ github.run_id }}" < /dev/null - name: Verify freeform — ddev launch and describe per-project URLs if: ${{ matrix.template == 'freeform' }} run: | CODER_DOMAIN="${{ vars.TEST_CODER_URL }}" CODER_DOMAIN="${CODER_DOMAIN#https://}" - cat > /tmp/ci-freeform-verify-${{ github.run_id }}.sh << 'SCRIPT' - set -euo pipefail - RID="$1" WORKSPACE="$2" OWNER="$3" DOMAIN="$4" - for N in 1 2; do - PROJ="ci-site${N}-${RID}" - EXPECTED_URL="https://${PROJ}--${WORKSPACE}--${OWNER}.${DOMAIN}" - echo "--- ${PROJ}: ddev launch ---" - cd /tmp/${PROJ} - LAUNCH=$(ddev launch 2>&1) - echo "${LAUNCH}" - echo "${LAUNCH}" | grep -qF "${EXPECTED_URL}" || { echo "ERROR: expected ${EXPECTED_URL}" >&2; exit 1; } - echo " OK: ddev launch" - echo "--- ${PROJ}: ddev describe ---" - DESCRIBE=$(ddev describe 2>&1) - echo "${DESCRIBE}" - echo "${DESCRIBE}" | grep -qF "${PROJ}.ddev.site" || { echo "ERROR: ${PROJ}.ddev.site not found" >&2; exit 1; } - echo " OK: ddev describe" - done - SCRIPT scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o ProxyCommand="coder ssh --stdio ${{ env.WORKSPACE_NAME }}" \ - /tmp/ci-freeform-verify-${{ github.run_id }}.sh \ - coder@workspace:/tmp/ci-freeform-verify-${{ github.run_id }}.sh + freeform/scripts/test-freeform-verify.sh \ + coder@workspace:/tmp/test-freeform-verify.sh coder ssh ${{ env.WORKSPACE_NAME }} -- \ - env DDEV_NONINTERACTIVE=true NO_COLOR=1 \ - bash /tmp/ci-freeform-verify-${{ github.run_id }}.sh \ + env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ + bash /tmp/test-freeform-verify.sh \ "${{ github.run_id }}" "${{ env.WORKSPACE_NAME }}" "${OWNER}" "${CODER_DOMAIN}" \ < /dev/null - name: Cleanup freeform PHP projects if: ${{ always() && matrix.template == 'freeform' }} run: | - for N in 1 2; do - PROJ="ci-site${N}-${{ github.run_id }}" - coder ssh ${{ env.WORKSPACE_NAME }} -- ddev delete ${PROJ} -Oy < /dev/null || true - coder ssh ${{ env.WORKSPACE_NAME }} -- rm -rf /tmp/${PROJ} < /dev/null || true - done + scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o ProxyCommand="coder ssh --stdio ${{ env.WORKSPACE_NAME }}" \ + freeform/scripts/test-freeform-cleanup.sh \ + coder@workspace:/tmp/test-freeform-cleanup.sh + coder ssh ${{ env.WORKSPACE_NAME }} -- \ + env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} \ + bash /tmp/test-freeform-cleanup.sh "${{ github.run_id }}" < /dev/null || true - name: Delete workspace if: always() diff --git a/freeform/scripts/test-freeform-cleanup.sh b/freeform/scripts/test-freeform-cleanup.sh new file mode 100644 index 0000000..fc47985 --- /dev/null +++ b/freeform/scripts/test-freeform-cleanup.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# test-freeform-cleanup.sh — Delete PHP test projects created by test-freeform-start.sh. +# Run inside a freeform workspace. +# +# Usage: bash test-freeform-cleanup.sh [suffix] +# suffix same suffix used with test-freeform-start.sh (default: current PID) + +set -euo pipefail + +SUFFIX="${1:-$$}" + +for N in 1 2; do + PROJ="ci-site${N}-${SUFFIX}" + ddev delete "${PROJ}" -Oy || true + rm -rf "/tmp/${PROJ}" || true +done diff --git a/freeform/scripts/test-freeform-start.sh b/freeform/scripts/test-freeform-start.sh new file mode 100644 index 0000000..02baf90 --- /dev/null +++ b/freeform/scripts/test-freeform-start.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# test-freeform-start.sh — Create and start two trivial PHP projects for freeform routing tests. +# Run inside a freeform workspace (requires ddev and Coder agent environment). +# +# Usage: bash test-freeform-start.sh [suffix] +# suffix string appended to project names (default: current PID) +# In CI: pass github.run_id. Manually: any unique string. +# +# Creates: ci-site1- and ci-site2- in /tmp/ + +set -euo pipefail + +SUFFIX="${1:-$$}" + +for N in 1 2; do + PROJ="ci-site${N}-${SUFFIX}" + TESTDIR="/tmp/${PROJ}" + echo "--- Creating project ${PROJ} ---" + mkdir -p "${TESTDIR}/web" + cd "${TESTDIR}" + ddev config --project-type=php --docroot=web --project-name="${PROJ}" + ddev coder-setup + ddev start -y + echo "--- ${PROJ} started ---" +done diff --git a/freeform/scripts/test-freeform-verify.sh b/freeform/scripts/test-freeform-verify.sh new file mode 100644 index 0000000..8cf454b --- /dev/null +++ b/freeform/scripts/test-freeform-verify.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# test-freeform-verify.sh — Verify ddev launch and ddev describe URLs for two PHP test projects. +# Run inside a freeform workspace after test-freeform-start.sh. +# +# Usage: bash test-freeform-verify.sh [suffix] [workspace] [owner] [domain] +# suffix same suffix used with test-freeform-start.sh (default: current PID) +# workspace Coder workspace name (default: $CODER_WORKSPACE_NAME) +# owner Coder workspace owner (default: $CODER_WORKSPACE_OWNER_NAME) +# domain Coder proxy domain, e.g. staging-coder.ddev.com +# (default: derived from $VSCODE_PROXY_URI or $CODER_AGENT_URL) + +set -euo pipefail + +SUFFIX="${1:-$$}" +WORKSPACE="${2:-${CODER_WORKSPACE_NAME:-}}" +OWNER="${3:-${CODER_WORKSPACE_OWNER_NAME:-}}" +DOMAIN="${4:-}" + +if [ -z "${DOMAIN}" ]; then + if [ -n "${VSCODE_PROXY_URI:-}" ]; then + DOMAIN=$(echo "${VSCODE_PROXY_URI}" | sed -E 's|https?://[^.]+\.(.+?)(/.*)?$|\1|') + elif [ -n "${CODER_AGENT_URL:-}" ]; then + DOMAIN=$(echo "${CODER_AGENT_URL}" | sed -E 's|https?://(.+?)(/.*)?$|\1|') + fi +fi + +if [ -z "${WORKSPACE}" ] || [ -z "${OWNER}" ] || [ -z "${DOMAIN}" ]; then + echo "Error: cannot determine workspace/owner/domain." >&2 + echo " Set CODER_WORKSPACE_NAME, CODER_WORKSPACE_OWNER_NAME, and VSCODE_PROXY_URI/CODER_AGENT_URL," >&2 + echo " or pass them as positional arguments." >&2 + exit 1 +fi + +for N in 1 2; do + PROJ="ci-site${N}-${SUFFIX}" + EXPECTED_URL="https://${PROJ}--${WORKSPACE}--${OWNER}.${DOMAIN}" + + echo "--- ${PROJ}: ddev launch ---" + cd "/tmp/${PROJ}" + LAUNCH=$(ddev launch 2>&1) + echo "${LAUNCH}" + echo "${LAUNCH}" | grep -qF "${EXPECTED_URL}" || { + echo "ERROR: expected ${EXPECTED_URL} not found in ddev launch output" >&2 + exit 1 + } + echo " OK: ddev launch shows correct URL" + + echo "--- ${PROJ}: ddev describe ---" + DESCRIBE=$(ddev describe 2>&1) + echo "${DESCRIBE}" + echo "${DESCRIBE}" | grep -qF "${PROJ}.ddev.site" || { + echo "ERROR: ${PROJ}.ddev.site not found in ddev describe output" >&2 + exit 1 + } + echo " OK: ddev describe shows correct URL" +done From 951cc2405d90883fcce5de511575630d860c71ed Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 14:06:11 +0000 Subject: [PATCH 08/28] fix: detect primary web by slug, filter foreign-project services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in coder-routes revealed by the multi-project CI test: 1. The ext_port="8080" check never fired because DDEV names its Traefik entrypoint after the internal web port (http-80), not the globally configured router-http-port (8080). Switch to detecting the primary web by slug=PROJECT_SLUG, which is set semantically when svc_name=web and port≠8025. 2. DDEV's _merged.yaml includes routers from all running projects. Add a service-prefix guard to skip any service not belonging to the current DDEV project, preventing ci-site2's routes from appearing in ci-site1's coder-routes file. Co-Authored-By: Claude Sonnet 4.6 --- image/scripts/.ddev/commands/host/coder-routes | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/image/scripts/.ddev/commands/host/coder-routes b/image/scripts/.ddev/commands/host/coder-routes index 2334407..16d51ac 100644 --- a/image/scripts/.ddev/commands/host/coder-routes +++ b/image/scripts/.ddev/commands/host/coder-routes @@ -58,6 +58,10 @@ while IFS= read -r router; do service=$(yq e ".http.routers.\"${router}\".service // \"\"" "$MERGED") [ -z "$service" ] || [ "$service" = "null" ] && continue + # DDEV's merged config can include routers from all running projects. + # Only process services that belong to this project. + [[ "$service" == "${DDEV_PROJECT}-"* ]] || continue + # Read entrypoints as a space-separated list mapfile -t entrypoints < <(yq e ".http.routers.\"${router}\".entrypoints[]" "$MERGED" 2>/dev/null) [ ${#entrypoints[@]} -eq 0 ] && continue @@ -85,12 +89,15 @@ while IFS= read -r router; do ENTRY_LIST=$(printf '"%s",' "${entrypoints[@]}" | sed 's/,$//') # Determine external port from the entrypoint name (e.g. http-8143 → 8143). - # Use this — NOT the service-name port — to decide routing strategy. + # Used for PathPrefix (port-forwarding) URLs on dynamic add-on services. ext_port="${entrypoints[0]#http-}" - if [ "$ext_port" = "8080" ]; then - # Primary web service: Host() rule so Coder's coder_app subdomain proxy works. + if [ "$slug" = "$PROJECT_SLUG" ]; then + # Primary web service: slug equals PROJECT_SLUG (set above when svc_name=web, port≠8025). + # Use Host() rule so Coder's coder_app subdomain proxy works. # When PROJECT_SLUG equals WORKSPACE the URL matches the coder_app slug exactly. + # Note: detect by slug, not by ext_port — DDEV names entrypoints after the internal + # port (e.g. http-80) even when the global router-http-port is 8080. CODER_HOST="${slug}--${WORKSPACE}--${OWNER}.${DOMAIN}" RULE='Host(`'"${CODER_HOST}"'`)' RULE="$RULE" SVC="$service" \ From 9a51fe84b93eff4cf517a9162abdc6045613aef9 Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 14:24:26 +0000 Subject: [PATCH 09/28] ci: inject current-branch DDEV scripts into workspace before freeform tests Ensures CI always tests coder-routes, coder-setup, and launch from the current branch rather than the stale image baked at workspace start time. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration-test.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 78a654e..6637cd4 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -186,6 +186,18 @@ jobs: coder ssh ${{ env.WORKSPACE_NAME }} -- ddev delete ci-ddev-${{ github.run_id }} --omit-snapshot -y < /dev/null || true coder ssh ${{ env.WORKSPACE_NAME }} -- rm -rf /tmp/ci-ddev-${{ github.run_id }} < /dev/null || true + - name: Inject current-branch DDEV scripts into freeform workspace + if: ${{ matrix.template == 'freeform' }} + run: | + coder ssh ${{ env.WORKSPACE_NAME }} -- mkdir -p /home/coder/.ddev/commands/host < /dev/null + for script in coder-routes coder-setup launch; do + scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o ProxyCommand="coder ssh --stdio ${{ env.WORKSPACE_NAME }}" \ + "image/scripts/.ddev/commands/host/${script}" \ + "coder@workspace:/home/coder/.ddev/commands/host/${script}" + done + coder ssh ${{ env.WORKSPACE_NAME }} -- chmod +x /home/coder/.ddev/commands/host/coder-routes /home/coder/.ddev/commands/host/coder-setup /home/coder/.ddev/commands/host/launch < /dev/null + - name: Start two PHP projects — freeform routing test if: ${{ matrix.template == 'freeform' }} run: | From d40612a6f9d57a61d5dc968fb8a669beab285b20 Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 17:19:32 +0000 Subject: [PATCH 10/28] fix: write docker-compose.coder-describe.yaml, improve WELCOME.txt, set 24h default TTL - coder-routes: write .ddev/docker-compose.coder-describe.yaml with x-ddev.describe-url-port labels so 'ddev describe' shows Coder URLs - coder-setup: also gitignore docker-compose.coder-describe.yaml - WELCOME.txt: point to github.com/ddev/coder-ddev, drop ~/projects, use placeholder to clarify any directory name works - freeform/README.md: same fix, correct coder-routes filename to coder-routes-.yaml, document new describe file - freeform/template.tf: use in startup Next steps output - Makefile: set --default-ttl 24h for freeform template Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 2 +- freeform/README.md | 11 +++++---- freeform/template.tf | 6 ++--- .../scripts/.ddev/commands/host/coder-routes | 24 +++++++++++++++++++ image/scripts/.ddev/commands/host/coder-setup | 4 ++++ image/scripts/WELCOME.txt | 14 +++++------ 6 files changed, 44 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 860bf3f..48b72c6 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ TEMPLATE_VARS_freeform := --variable workspace_image_registry=index.dock TEMPLATE_EDIT_user-defined-web := --display-name "DDEV Web Workspace" TEMPLATE_EDIT_drupal-core := --display-name "Drupal Core Development" \ --description "Drupal core dev environment: full DDEV stack, core clone, Umami demo site. Ready in about a minute." -TEMPLATE_EDIT_freeform := --display-name "DDEV Freeform (Traefik)" +TEMPLATE_EDIT_freeform := --display-name "DDEV Freeform (Traefik)" --default-ttl 24h # Shared recipe for pushing any template (call with template name as argument) define push_template diff --git a/freeform/README.md b/freeform/README.md index 8ee86c6..47bd931 100644 --- a/freeform/README.md +++ b/freeform/README.md @@ -32,9 +32,9 @@ coder create --template freeform myworkspace # SSH in coder ssh myworkspace -# Clone your project (or create a new directory) -git clone git@github.com:your-org/your-project.git ~/myproject -cd ~/myproject +# Clone your project into any directory you choose +git clone git@github.com:your-org/your-project.git +cd # Configure DDEV ddev config --project-type=wordpress --docroot=web @@ -50,13 +50,14 @@ Then click **DDEV Web** or **Mailpit** in the Coder dashboard. ## Project Structure -- `~/myproject/` — your project directory (any name, you create it) +- `/` — your project directory (any name, any location) - `.ddev/config.yaml` — DDEV project configuration - `.ddev/config.coder.yaml` — Coder post-start hook (written by `ddev coder-setup`, gitignored) + - `.ddev/docker-compose.coder-describe.yaml` — Coder URLs for `ddev describe` (written by `ddev coder-routes`, gitignored) - your project files - `~/WELCOME.txt` - `~/.ddev/global_config.yaml` — DDEV global settings -- `~/.ddev/traefik/custom-global-config/coder-routes.yaml` — Traefik routing rules (auto-generated) +- `~/.ddev/traefik/custom-global-config/coder-routes-.yaml` — Traefik routing rules (auto-generated per project) ## Coder Setup Command diff --git a/freeform/template.tf b/freeform/template.tf index eb2f881..4ef73a4 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -384,9 +384,9 @@ BASHCOMP echo "=== Setup Complete ===" echo "" echo "Next steps:" - echo " 1. Clone or create your project:" - echo " git clone ~/myproject" - echo " cd ~/myproject" + echo " 1. Clone or create your project (any directory):" + echo " git clone " + echo " cd " echo " 2. Configure DDEV:" echo " ddev config --project-type=" echo " 3. Install Coder routing hook (once per project):" diff --git a/image/scripts/.ddev/commands/host/coder-routes b/image/scripts/.ddev/commands/host/coder-routes index 16d51ac..843c5bb 100644 --- a/image/scripts/.ddev/commands/host/coder-routes +++ b/image/scripts/.ddev/commands/host/coder-routes @@ -45,6 +45,9 @@ OUTPUT="$ROUTES_DIR/coder-routes-${DDEV_PROJECT}.yaml" # Seed the output file printf "http:\n routers: {}\n" > /tmp/coder-routes-raw.yaml +PRIMARY_URL="" +MAILPIT_URL="" + echo "Building Coder Traefik routes from ${DDEV_PROJECT}_merged.yaml:" # Iterate over every router in the merged config. @@ -99,6 +102,7 @@ while IFS= read -r router; do # Note: detect by slug, not by ext_port — DDEV names entrypoints after the internal # port (e.g. http-80) even when the global router-http-port is 8080. CODER_HOST="${slug}--${WORKSPACE}--${OWNER}.${DOMAIN}" + PRIMARY_URL="https://${CODER_HOST}" RULE='Host(`'"${CODER_HOST}"'`)' RULE="$RULE" SVC="$service" \ yq e -i \ @@ -112,6 +116,7 @@ while IFS= read -r router; do # Known Coder app slugs (defined as coder_app resources in the Terraform template): # use Host() rule so the Coder subdomain proxy URL routes correctly. CODER_HOST="${slug}--${WORKSPACE}--${OWNER}.${DOMAIN}" + [ "$slug" = "mailpit" ] && MAILPIT_URL="https://${CODER_HOST}" RULE='Host(`'"${CODER_HOST}"'`)' RULE="$RULE" SVC="$service" \ yq e -i \ @@ -143,6 +148,25 @@ done < <(yq e '.http.routers | keys | .[]' "$MERGED" 2>/dev/null) yq e '.' /tmp/coder-routes-raw.yaml > "$OUTPUT" echo "✓ Wrote $(basename "$OUTPUT")" +# Write docker-compose.coder-describe.yaml so 'ddev describe' shows Coder URLs. +if [ -n "${DDEV_APPROOT:-}" ] && [ -n "$PRIMARY_URL" ]; then + DESCRIBE_FILE="$DDEV_APPROOT/.ddev/docker-compose.coder-describe.yaml" + { + printf '# Auto-generated by ddev coder-routes -- do not edit.\n' + printf 'services:\n' + printf ' web:\n' + printf ' x-ddev:\n' + if [ -n "$MAILPIT_URL" ]; then + printf ' describe-url-port: |\n' + printf ' %s\n' "$PRIMARY_URL" + printf ' Mailpit: %s\n' "$MAILPIT_URL" + else + printf ' describe-url-port: "%s"\n' "$PRIMARY_URL" + fi + } > "$DESCRIBE_FILE" + echo "✓ Wrote docker-compose.coder-describe.yaml" +fi + # Push to running ddev-router; Traefik's watch:true reloads within ~1s. # The bind-mount from ~/.ddev/traefik/custom-global-config/ means writing the file # is usually sufficient, but docker cp guarantees an immediate reload. diff --git a/image/scripts/.ddev/commands/host/coder-setup b/image/scripts/.ddev/commands/host/coder-setup index b122003..781aa15 100755 --- a/image/scripts/.ddev/commands/host/coder-setup +++ b/image/scripts/.ddev/commands/host/coder-setup @@ -50,6 +50,10 @@ if ! grep -qF ".ddev/config.coder.yaml" ~/.config/git/ignore 2>/dev/null; then echo ".ddev/config.coder.yaml" >> ~/.config/git/ignore echo "✓ Added .ddev/config.coder.yaml to ~/.config/git/ignore" fi +if ! grep -qF ".ddev/docker-compose.coder-describe.yaml" ~/.config/git/ignore 2>/dev/null; then + echo ".ddev/docker-compose.coder-describe.yaml" >> ~/.config/git/ignore + echo "✓ Added .ddev/docker-compose.coder-describe.yaml to ~/.config/git/ignore" +fi echo "" echo "Coder routing hook installed. Run 'ddev start' to activate routing." diff --git a/image/scripts/WELCOME.txt b/image/scripts/WELCOME.txt index 9966dfc..df08739 100644 --- a/image/scripts/WELCOME.txt +++ b/image/scripts/WELCOME.txt @@ -4,12 +4,10 @@ This workspace is set up for web development with DDEV. -🌐 PUBLISH YOUR PROJECT - Go to the coder project page and configure public access. - -📚 MORE INFORMATION - - Projects: ~/projects/README.md - - ddev Documentation: https://docs.ddev.com/ - -Good luck with your project! +Quick start (your project can live in any directory): + git clone && cd + ddev config --project-type= + ddev coder-setup + ddev start +📚 Documentation: https://github.com/ddev/coder-ddev From 3e1cc6b4172439c873c58de7b65fe9e88830b847 Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 17:40:20 +0000 Subject: [PATCH 11/28] fix: add #ddev-silent-no-warn to host commands and config.coder.yaml Suppresses DDEV's "Custom configuration detected" warnings for the Coder-managed files that are intentionally installed in every workspace. Co-Authored-By: Claude Sonnet 4.6 --- image/scripts/.ddev/commands/host/coder-routes | 1 + image/scripts/.ddev/commands/host/coder-setup | 2 ++ image/scripts/.ddev/commands/host/launch | 1 + 3 files changed, 4 insertions(+) diff --git a/image/scripts/.ddev/commands/host/coder-routes b/image/scripts/.ddev/commands/host/coder-routes index 843c5bb..0bbd6c0 100644 --- a/image/scripts/.ddev/commands/host/coder-routes +++ b/image/scripts/.ddev/commands/host/coder-routes @@ -1,4 +1,5 @@ #!/usr/bin/env bash +#ddev-silent-no-warn ## Description: Regenerate Coder Traefik routing rules for this DDEV project ## Usage: coder-routes diff --git a/image/scripts/.ddev/commands/host/coder-setup b/image/scripts/.ddev/commands/host/coder-setup index 781aa15..e21d2c8 100755 --- a/image/scripts/.ddev/commands/host/coder-setup +++ b/image/scripts/.ddev/commands/host/coder-setup @@ -1,4 +1,5 @@ #!/usr/bin/env bash +#ddev-silent-no-warn ## Description: Install Coder post-start hook into this DDEV project ## Usage: coder-setup @@ -16,6 +17,7 @@ if [ -z "${VSCODE_PROXY_URI:-}" ]; then echo "Warning: VSCODE_PROXY_URI not set — web container will not have it available" fi cat > .ddev/config.coder.yaml << EOF +#ddev-silent-no-warn # Coder-specific DDEV hooks (auto-generated by ddev coder-setup, do not edit) # # Coder environment variables injected into the DDEV web container so that diff --git a/image/scripts/.ddev/commands/host/launch b/image/scripts/.ddev/commands/host/launch index 2de23c6..af9232a 100644 --- a/image/scripts/.ddev/commands/host/launch +++ b/image/scripts/.ddev/commands/host/launch @@ -1,4 +1,5 @@ #!/usr/bin/env bash +#ddev-silent-no-warn ## Description: Show Coder URLs for this DDEV project (replaces browser-open in cloud) ## Usage: launch [path] [-m|--mailpit] From 339400661f26cf75d1d2bcb3e3de105989298c16 Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 18:10:25 +0000 Subject: [PATCH 12/28] fix: use WORKSPACE slug in primary web URL, run ddev config global after Docker starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit coder-routes: primary web URL must use WORKSPACE (= coder_app slug) as the first segment so Coder's subdomain proxy can match it. Using PROJECT_SLUG broke routing when the DDEV project name differed from the workspace name. freeform/template.tf: move 'ddev config global --router-http-port=8080' to after the Docker socket is ready — DDEV config global needs Docker running and was silently failing when called before dockerd started. Co-Authored-By: Claude Sonnet 4.6 --- freeform/template.tf | 6 ++++-- image/scripts/.ddev/commands/host/coder-routes | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/freeform/template.tf b/freeform/template.tf index 4ef73a4..c4fba64 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -235,8 +235,6 @@ resource "coder_agent" "main" { echo 'config.coder.yaml' >> "$HOME/.gitignore_global" fi mkdir -p ~/.ddev - ddev config global --instrumentation-opt-in=true > /dev/null 2>&1 || true - ddev config global --router-http-port=8080 > /dev/null 2>&1 || true if [ -n "$CODER_WORKSPACE_OWNER_NAME" ]; then git config --global user.name "$CODER_WORKSPACE_OWNER_NAME" fi @@ -311,6 +309,10 @@ EOF echo "Docker Daemon already running." fi + # Configure DDEV global settings now that Docker is up (ddev config global needs Docker) + ddev config global --instrumentation-opt-in=true > /dev/null 2>&1 || true + ddev config global --router-http-port=8080 > /dev/null 2>&1 || true + # Create .ddev commands directory mkdir -p ~/.ddev/commands/host if [ -d /home/coder-files/.ddev/commands/host ]; then diff --git a/image/scripts/.ddev/commands/host/coder-routes b/image/scripts/.ddev/commands/host/coder-routes index 0bbd6c0..181ea08 100644 --- a/image/scripts/.ddev/commands/host/coder-routes +++ b/image/scripts/.ddev/commands/host/coder-routes @@ -97,12 +97,12 @@ while IFS= read -r router; do ext_port="${entrypoints[0]#http-}" if [ "$slug" = "$PROJECT_SLUG" ]; then - # Primary web service: slug equals PROJECT_SLUG (set above when svc_name=web, port≠8025). - # Use Host() rule so Coder's coder_app subdomain proxy works. - # When PROJECT_SLUG equals WORKSPACE the URL matches the coder_app slug exactly. + # Primary web service: detected when svc_name=web and port≠8025. + # URL first segment must be WORKSPACE (= coder_app slug) so Coder's subdomain + # proxy matches it, regardless of what the DDEV project is named. # Note: detect by slug, not by ext_port — DDEV names entrypoints after the internal # port (e.g. http-80) even when the global router-http-port is 8080. - CODER_HOST="${slug}--${WORKSPACE}--${OWNER}.${DOMAIN}" + CODER_HOST="${WORKSPACE}--${WORKSPACE}--${OWNER}.${DOMAIN}" PRIMARY_URL="https://${CODER_HOST}" RULE='Host(`'"${CODER_HOST}"'`)' RULE="$RULE" SVC="$service" \ From b5d6cb1d3d69c8ca3ad2c3cbe5c2339e8910d6b9 Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 18:11:44 +0000 Subject: [PATCH 13/28] =?UTF-8?q?test:=20fix=20freeform=20verify=20?= =?UTF-8?q?=E2=80=94=20expect=20WORKSPACE-based=20URL,=20check=20Coder=20U?= =?UTF-8?q?RL=20in=20ddev=20describe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous test checked for PROJECT_SLUG as the URL first segment, which matched the (broken) coder-routes output and masked the bug. All projects in a freeform workspace share the coder_app slug (workspace name), so the expected URL is always workspace--workspace--owner.domain. Also replace the ddev describe check: was testing for {proj}.ddev.site (internal DDEV URL, always present) instead of the actual Coder URL. Co-Authored-By: Claude Sonnet 4.6 --- freeform/scripts/test-freeform-verify.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/freeform/scripts/test-freeform-verify.sh b/freeform/scripts/test-freeform-verify.sh index 8cf454b..6538795 100644 --- a/freeform/scripts/test-freeform-verify.sh +++ b/freeform/scripts/test-freeform-verify.sh @@ -31,9 +31,12 @@ if [ -z "${WORKSPACE}" ] || [ -z "${OWNER}" ] || [ -z "${DOMAIN}" ]; then exit 1 fi +# The coder_app slug is the workspace name, so all projects in this workspace +# share the same Coder subdomain URL (workspace--workspace--owner.domain). +EXPECTED_URL="https://${WORKSPACE}--${WORKSPACE}--${OWNER}.${DOMAIN}" + for N in 1 2; do PROJ="ci-site${N}-${SUFFIX}" - EXPECTED_URL="https://${PROJ}--${WORKSPACE}--${OWNER}.${DOMAIN}" echo "--- ${PROJ}: ddev launch ---" cd "/tmp/${PROJ}" @@ -48,9 +51,9 @@ for N in 1 2; do echo "--- ${PROJ}: ddev describe ---" DESCRIBE=$(ddev describe 2>&1) echo "${DESCRIBE}" - echo "${DESCRIBE}" | grep -qF "${PROJ}.ddev.site" || { - echo "ERROR: ${PROJ}.ddev.site not found in ddev describe output" >&2 + echo "${DESCRIBE}" | grep -qF "${EXPECTED_URL}" || { + echo "ERROR: Coder URL ${EXPECTED_URL} not found in ddev describe output" >&2 exit 1 } - echo " OK: ddev describe shows correct URL" + echo " OK: ddev describe shows Coder URL" done From 6082a176e39ae14a61208e0fbe9c015247c087ac Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 18:37:13 +0000 Subject: [PATCH 14/28] fix: use set -euo pipefail in startup script, add #ddev-silent-no-warn to coder-routes YAML, fix verify test - Replace set +e with set -euo pipefail; add || true on glob expansions and npm config - Prepend #ddev-silent-no-warn to Traefik YAML written by coder-routes to suppress DDEV warnings - Fix verify test: ddev describe does not show Coder URL (DDEV ignores x-ddev.describe-url-port on web service); check for running status instead; ddev launch remains the canonical Coder URL command Co-Authored-By: Claude Sonnet 4.6 --- freeform/scripts/test-freeform-verify.sh | 6 +++--- freeform/template.tf | 8 ++++---- image/scripts/.ddev/commands/host/coder-routes | 6 ++++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/freeform/scripts/test-freeform-verify.sh b/freeform/scripts/test-freeform-verify.sh index 6538795..b1a7847 100644 --- a/freeform/scripts/test-freeform-verify.sh +++ b/freeform/scripts/test-freeform-verify.sh @@ -51,9 +51,9 @@ for N in 1 2; do echo "--- ${PROJ}: ddev describe ---" DESCRIBE=$(ddev describe 2>&1) echo "${DESCRIBE}" - echo "${DESCRIBE}" | grep -qF "${EXPECTED_URL}" || { - echo "ERROR: Coder URL ${EXPECTED_URL} not found in ddev describe output" >&2 + echo "${DESCRIBE}" | grep -qiE "running|OK" || { + echo "ERROR: ddev describe did not show running status" >&2 exit 1 } - echo " OK: ddev describe shows Coder URL" + echo " OK: ddev describe shows project running" done diff --git a/freeform/template.tf b/freeform/template.tf index c4fba64..da6cde4 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -176,7 +176,7 @@ resource "coder_agent" "main" { startup_script = <<-EOT #!/bin/bash - set +e + set -euo pipefail echo "Startup script started..." @@ -316,8 +316,8 @@ EOF # Create .ddev commands directory mkdir -p ~/.ddev/commands/host if [ -d /home/coder-files/.ddev/commands/host ]; then - cp -f /home/coder-files/.ddev/commands/host/* ~/.ddev/commands/host/ - chmod 755 ~/.ddev/commands/host/* + cp -f /home/coder-files/.ddev/commands/host/* ~/.ddev/commands/host/ || true + chmod 755 ~/.ddev/commands/host/* || true echo "✓ DDEV host commands installed" fi @@ -376,7 +376,7 @@ BASHCOMP # npm global directory mkdir -p ~/.npm-global - npm config set prefix "~/.npm-global" + npm config set prefix "~/.npm-global" || true export PATH="$HOME/.npm-global/bin:$PATH" if ! grep -q "\.npm-global/bin" ~/.bashrc; then echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc diff --git a/image/scripts/.ddev/commands/host/coder-routes b/image/scripts/.ddev/commands/host/coder-routes index 181ea08..16235c1 100644 --- a/image/scripts/.ddev/commands/host/coder-routes +++ b/image/scripts/.ddev/commands/host/coder-routes @@ -146,10 +146,12 @@ while IFS= read -r router; do done < <(yq e '.http.routers | keys | .[]' "$MERGED" 2>/dev/null) -yq e '.' /tmp/coder-routes-raw.yaml > "$OUTPUT" +{ printf '#ddev-silent-no-warn\n'; yq e '.' /tmp/coder-routes-raw.yaml; } > "$OUTPUT" echo "✓ Wrote $(basename "$OUTPUT")" -# Write docker-compose.coder-describe.yaml so 'ddev describe' shows Coder URLs. +# Write docker-compose.coder-describe.yaml with Coder URLs for reference. +# Note: DDEV currently ignores x-ddev.describe-url-port on the built-in web service; +# use 'ddev launch' for the canonical Coder URL. if [ -n "${DDEV_APPROOT:-}" ] && [ -n "$PRIMARY_URL" ]; then DESCRIBE_FILE="$DDEV_APPROOT/.ddev/docker-compose.coder-describe.yaml" { From 3fb3f2209d2dd8cbeda86eca01ff039b7e1a1b8b Mon Sep 17 00:00:00 2001 From: rfay Date: Tue, 5 May 2026 18:58:18 +0000 Subject: [PATCH 15/28] fix: show Coder URLs in ddev describe via coder-url custom service Use a custom service with profiles:["coder-url"] and x-ddev.describe-url-port in docker-compose.coder-describe.yaml. DDEV ignores x-ddev on the built-in web service but renders it for custom services. The coder-url service shows as "stopped" (like xhgui) with the Coder URL in the URL/PORT column and "Use: ddev launch" in INFO. URLs appear in ddev describe after the next ddev start (one restart needed to pick up the compose file written by coder-routes in the post-start hook). Update test-freeform-verify.sh to restart before checking ddev describe and verify the Coder URL is present. Co-Authored-By: Claude Sonnet 4.6 --- freeform/scripts/test-freeform-verify.sh | 10 ++++++++++ image/scripts/.ddev/commands/host/coder-routes | 12 ++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/freeform/scripts/test-freeform-verify.sh b/freeform/scripts/test-freeform-verify.sh index b1a7847..27dd0e4 100644 --- a/freeform/scripts/test-freeform-verify.sh +++ b/freeform/scripts/test-freeform-verify.sh @@ -48,6 +48,11 @@ for N in 1 2; do } echo " OK: ddev launch shows correct URL" + # The coder-url custom service in docker-compose.coder-describe.yaml is picked up + # by DDEV on the next start after coder-routes writes it. Restart to trigger that. + echo "--- ${PROJ}: ddev restart (to load coder-url describe service) ---" + ddev restart 2>&1 | tail -3 + echo "--- ${PROJ}: ddev describe ---" DESCRIBE=$(ddev describe 2>&1) echo "${DESCRIBE}" @@ -56,4 +61,9 @@ for N in 1 2; do exit 1 } echo " OK: ddev describe shows project running" + echo "${DESCRIBE}" | grep -qF "${EXPECTED_URL}" || { + echo "ERROR: Coder URL ${EXPECTED_URL} not found in ddev describe output" >&2 + exit 1 + } + echo " OK: ddev describe shows Coder URL" done diff --git a/image/scripts/.ddev/commands/host/coder-routes b/image/scripts/.ddev/commands/host/coder-routes index 16235c1..558df80 100644 --- a/image/scripts/.ddev/commands/host/coder-routes +++ b/image/scripts/.ddev/commands/host/coder-routes @@ -149,15 +149,18 @@ done < <(yq e '.http.routers | keys | .[]' "$MERGED" 2>/dev/null) { printf '#ddev-silent-no-warn\n'; yq e '.' /tmp/coder-routes-raw.yaml; } > "$OUTPUT" echo "✓ Wrote $(basename "$OUTPUT")" -# Write docker-compose.coder-describe.yaml with Coder URLs for reference. -# Note: DDEV currently ignores x-ddev.describe-url-port on the built-in web service; -# use 'ddev launch' for the canonical Coder URL. +# Write docker-compose.coder-describe.yaml so 'ddev describe' shows Coder URLs. +# Uses a custom service with profiles so DDEV renders it without starting a container. +# The URL appears in 'ddev describe' on the next 'ddev start' after this file is written. if [ -n "${DDEV_APPROOT:-}" ] && [ -n "$PRIMARY_URL" ]; then DESCRIBE_FILE="$DDEV_APPROOT/.ddev/docker-compose.coder-describe.yaml" { + printf '#ddev-silent-no-warn\n' printf '# Auto-generated by ddev coder-routes -- do not edit.\n' printf 'services:\n' - printf ' web:\n' + printf ' coder-url:\n' + printf ' image: busybox\n' + printf ' profiles: ["coder-url"]\n' printf ' x-ddev:\n' if [ -n "$MAILPIT_URL" ]; then printf ' describe-url-port: |\n' @@ -166,6 +169,7 @@ if [ -n "${DDEV_APPROOT:-}" ] && [ -n "$PRIMARY_URL" ]; then else printf ' describe-url-port: "%s"\n' "$PRIMARY_URL" fi + printf ' describe-info: "Use: ddev launch"\n' } > "$DESCRIBE_FILE" echo "✓ Wrote docker-compose.coder-describe.yaml" fi From cbb4202925dd2812989fdc21c96b1583ca5677a8 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 16:19:53 -0600 Subject: [PATCH 16/28] feat: multi-project support in freeform template via for_each coder_app - Add project_names parameter (mutable, comma-separated, defaults to workspace name) generating one coder_app per project via for_each - Add CODER_PROJECT_NAMES env var so startup script and coder-setup can reference the registered project list - Update startup script next-steps to show required project names and the --project-name flag - Update coder-setup to warn when DDEV project name is not in the registered list, explaining how to fix it - Update coder-routes to use PROJECT_SLUG (= DDEV project name) as the URL slug rather than always using WORKSPACE; this makes the Traefik Host rule match the coder_app slug for each project - Update WELCOME.txt to show the naming contract - Add Terraform tests for single-project default, two-project, and whitespace-trimming cases - Add docs/reference/coder-url-patterns.md reference document - Document scp-based script execution technique in CLAUDE.md - Bump VERSION to v0.4 Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 20 ++ VERSION | 2 +- docs/reference/coder-url-patterns.md | 177 ++++++++++++++++++ freeform/.terraform.lock.hcl | 23 ++- freeform/README.md | 2 +- freeform/template.tf | 53 ++++-- freeform/tests/validate.tftest.hcl | 51 ++++- .../scripts/.ddev/commands/host/coder-routes | 9 +- image/scripts/.ddev/commands/host/coder-setup | 19 ++ image/scripts/WELCOME.txt | 11 +- 10 files changed, 340 insertions(+), 27 deletions(-) create mode 100644 docs/reference/coder-url-patterns.md diff --git a/CLAUDE.md b/CLAUDE.md index ea40705..0504e2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,26 @@ This project provides a Coder v2+ template for DDEV-based development environmen - Use `jq` (not `python3 -m json.tool`) for JSON pretty-printing and querying +## Working with Coder Workspaces via SSH + +After running `coder config-ssh --yes`, workspaces are available as SSH hosts named `.coder`. Use `scp` to copy files in or out, then `ssh` to execute scripts non-interactively: + +```bash +# Configure SSH (once) +coder config-ssh --yes + +# Copy a file into a workspace +scp ./local-file.sh mp1.coder:/tmp/ + +# Execute a script non-interactively (preferred — avoids PTY/pipe issues) +ssh mp1.coder bash /tmp/local-file.sh + +# One-liner for quick commands +ssh mp1.coder ddev list +``` + +When running commands via `coder ssh -- ...` or piped heredocs, the PTY allocation causes interactive prompts and pipe-stall issues. Writing a script to `/tmp/` and executing it via `ssh workspace.coder bash /tmp/script.sh` is reliable for multi-step operations. + ## Essential Commands ### Template Management diff --git a/VERSION b/VERSION index a85e614..1811f96 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.3 +v0.4 diff --git a/docs/reference/coder-url-patterns.md b/docs/reference/coder-url-patterns.md new file mode 100644 index 0000000..21d3a2b --- /dev/null +++ b/docs/reference/coder-url-patterns.md @@ -0,0 +1,177 @@ +# Coder URL and Routing Patterns + +Reference for how Coder constructs workspace URLs, what environment variables are available, and how this interacts with DDEV's Traefik router for multi-project routing. + +## URL Anatomy + +Coder routes workspace traffic in two distinct ways, each producing a different URL pattern. + +--- + +## 1. Named App URLs (`coder_app` with `subdomain = true`) + +**Official docs:** The URL construction pattern for named apps is not documented in a single canonical place. The closest references are: + +- [coder.com/docs/admin/networking/wildcard-access-url](https://coder.com/docs/admin/networking/wildcard-access-url) — shows the example `8080--main--myworkspace--john.coder.example.com` +- [registry.terraform.io — coder_app resource](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/app) — describes the `subdomain` attribute but does not spell out the URL format +- [coder/coder source: appurl.go](https://github.com/coder/coder/blob/main/coderd/workspaceapps/appurl/appurl.go) — the Go source that generates the subdomain + +```text +https://{slug}--{workspace}--{owner}.{wildcard-domain}/ +``` + +When a workspace has only one agent (the normal case), the agent name is omitted. If a workspace defines multiple agents you get: + +```text +https://{slug}--{agent}--{workspace}--{owner}.{wildcard-domain}/ +``` + +| Component | Example | Source | +| --------- | ------- | ------ | +| `slug` | `ddev-web`, `mailpit`, `adminer` | `slug` attribute on `coder_app` resource | +| `agent` | `main` | `coder_agent` resource name (only included when multiple agents exist) | +| `workspace` | `myworkspace` | `CODER_WORKSPACE_NAME` | +| `owner` | `rfay` | `CODER_WORKSPACE_OWNER` (username, not display name) | +| `wildcard-domain` | `coder.ddev.com` | `CODER_WILDCARD_ACCESS_URL` minus the leading `*.` | + +**Example** — a `coder_app` with `slug = "mailpit"` in workspace `myworkspace` owned by `rfay`: + +```text +https://mailpit--myworkspace--rfay.coder.ddev.com/ +``` + +The Coder server terminates TLS and reverse-proxies to the `url` configured on the `coder_app` (e.g. `http://localhost:8025`). The wildcard TLS certificate must cover `*.coder.ddev.com`. + +**`share` options** — controls who can open the URL: + +| Value | Access | +| ----- | ------ | +| `"owner"` | Workspace owner only (default) | +| `"authenticated"` | Any logged-in Coder user | +| `"public"` | Anyone with the URL | + +**Limits:** No documented per-workspace limit on `coder_app` resources. Use the `order` attribute (integer) and `group` attribute (string, max 64 chars) to control dashboard layout. + +--- + +## 2. Dashboard Port-Forwarding URLs (dynamic, no `coder_app` needed) + +**Official docs:** [coder.com/docs/admin/networking/port-forwarding](https://coder.com/docs/admin/networking/port-forwarding) + +Any port in the workspace can be forwarded on demand from the Coder dashboard without declaring a `coder_app`. The URL pattern always includes the agent name: + +```text +https://{port}--{agent}--{workspace}--{owner}.{wildcard-domain}/ +``` + +For HTTPS (port gets TLS passthrough from Coder): + +```text +https://{port}s--{agent}--{workspace}--{owner}.{wildcard-domain}/ +``` + +**Example** — port 3000 on agent `main` in workspace `myworkspace`: + +```text +https://3000--main--myworkspace--rfay.coder.ddev.com/ +``` + +> **Note:** Each hostname segment must not exceed 63 characters (DNS label limit). Long workspace or owner names can push past this limit, disabling dashboard port forwarding for that port. CLI port forwarding is unaffected. + +--- + +## 3. CLI Port Forwarding (localhost only) + +```bash +# Single port +coder port-forward myworkspace --tcp 8080:80 + +# Multiple ports / ranges +coder port-forward myworkspace --tcp 3000,9990-9999 + +# Agent-qualified (multi-agent workspaces) +coder port-forward myworkspace.main --tcp 8080:80 +``` + +Traffic arrives at `http://localhost:{local-port}` — no Coder domain involved. This bypasses TLS and the wildcard domain entirely. + +--- + +## 4. SSH Port Forwarding + +```bash +ssh -L 8080:localhost:8000 coder.myworkspace +``` + +Standard OpenSSH local port forwarding through Coder's SSH proxy. Also localhost-only. + +--- + +## Environment Variables in Workspaces + +### Identity + +Variables marked **[native]** are injected by the Coder agent automatically. Variables marked **[template]** are set via `env` blocks in the `coder_agent` resource and may differ between templates. + +| Variable | Example | Description | +| -------- | ------- | ----------- | +| `CODER_WORKSPACE_NAME` | `myworkspace` | Workspace name **[native]** | +| `CODER_WORKSPACE_ID` | `` | Workspace UUID **[native]** | +| `CODER_WORKSPACE_TRANSITION` | `start` | `start` or `stop` **[native]** | +| `CODER_WORKSPACE_OWNER_NAME` | `rfay` | Owner **username** (set from `data.coder_workspace_owner.me.name` in this template) **[template]** | +| `CODER_WORKSPACE_OWNER_EMAIL` | `accounts@ddev.com` | Owner email **[template]** | + +> **Important:** In the drupal-core and freeform templates, `CODER_WORKSPACE_OWNER_NAME` holds the **username** (e.g. `rfay`), not the display name. It is set from `data.coder_workspace_owner.me.name` in the `coder_agent` env block. This is what the `coder-routes` script uses to build Host-header rules. + +### Template / Build + +| Variable | Example | Description | +| -------- | ------- | ----------- | +| `CODER_WORKSPACE_TEMPLATE_NAME` | `freeform` | Template name | +| `CODER_WORKSPACE_TEMPLATE_VERSION` | `v1.2.3` | Template version | + +### Agent + +| Variable | Description | +| -------- | ----------- | +| `CODER_AGENT_TOKEN` | Agent auth token | +| `CODER_AGENT_NAME` | Agent name (e.g. `main`) | +| `CODER_AGENT_URL` | Coder server base URL — e.g. `https://coder.ddev.com` | + +### IDE/Proxy + +| Variable | Example | Description | +| -------- | ------- | ----------- | +| `VSCODE_PROXY_URI` | `https://vscode-web--myworkspace--rfay.coder.ddev.com/proxy/{{port}}/` | VS Code proxy template URI; `{{port}}` is replaced at runtime | + +### Deriving the wildcard domain at runtime + +The wildcard domain is **not** directly injected as an env var, but can be extracted from `VSCODE_PROXY_URI` or `CODER_AGENT_URL`: + +```bash +# From VSCODE_PROXY_URI (preferred — always a subdomain of the wildcard domain) +DOMAIN=$(echo "$VSCODE_PROXY_URI" | sed -E 's|https?://[^.]+\.(.+?)(/.*)?$|\1|') + +# Fallback: from CODER_AGENT_URL (the Coder server hostname, not the wildcard domain) +DOMAIN=$(echo "$CODER_AGENT_URL" | sed -E 's|https?://(.+?)(/.*)?$|\1|') +``` + +Once you have `DOMAIN`, `CODER_WORKSPACE_NAME`, and `CODER_WORKSPACE_OWNER_NAME`, you can construct any app URL: + +```bash +echo "https://${slug}--${CODER_WORKSPACE_NAME}--${CODER_WORKSPACE_OWNER_NAME}.${DOMAIN}" +``` + +--- + +## Server Configuration Requirements + +| Setting | Value | Purpose | +| ------- | ----- | ------- | +| `CODER_ACCESS_URL` | `https://coder.ddev.com` | Main Coder UI | +| `CODER_WILDCARD_ACCESS_URL` | `*.coder.ddev.com` | Workspace app subdomains | +| TLS cert SANs | `coder.ddev.com`, `*.coder.ddev.com` | Required; DNS-01 challenge for wildcard | + +The wildcard `*.coder.ddev.com` covers one level of subdomain only. Dashboard port-forwarding URLs like `8080--main--myworkspace--rfay.coder.ddev.com` are still a single-level subdomain of `coder.ddev.com`, so the wildcard cert covers them. + +**Official docs:** [coder.com/docs/admin/networking/wildcard-access-url](https://coder.com/docs/admin/networking/wildcard-access-url), [coder.com/docs/admin/setup](https://coder.com/docs/admin/setup) diff --git a/freeform/.terraform.lock.hcl b/freeform/.terraform.lock.hcl index 6b712ef..684d6df 100644 --- a/freeform/.terraform.lock.hcl +++ b/freeform/.terraform.lock.hcl @@ -3,9 +3,10 @@ provider "registry.terraform.io/coder/coder" { version = "2.13.1" - constraints = ">= 0.23.0, >= 2.5.0, >= 2.13.0" + constraints = ">= 2.5.0, >= 2.13.0" hashes = [ "h1:oo6ST/RHdch2u6x7OV3ojkQCM1EmBF3KrEuiJehT23E=", + "h1:wVR3Sg+hRjNbIiWLAPbolmoBMHqCFsNSOR71GW16YzQ=", "zh:04e38e4e37c89b78401c7689ade07a708635340138974bc12840920deed24c1b", "zh:0b32684dcc4d8f24a9535649d47c74a116e9682dc73551f429a98a774981b98f", "zh:0cb5ae4b1f1ae0e7d8a3a8c50ce516c230c1ba4853500d6b83b8cbb144e70f84", @@ -24,10 +25,30 @@ provider "registry.terraform.io/coder/coder" { ] } +provider "registry.terraform.io/hashicorp/null" { + version = "3.2.4" + hashes = [ + "h1:L5V05xwp/Gto1leRryuesxjMfgZwjb7oool4WS1UEFQ=", + "zh:59f6b52ab4ff35739647f9509ee6d93d7c032985d9f8c6237d1f8a59471bbbe2", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:795c897119ff082133150121d39ff26cb5f89a730a2c8c26f3a9c1abf81a9c43", + "zh:7b9c7b16f118fbc2b05a983817b8ce2f86df125857966ad356353baf4bff5c0a", + "zh:85e33ab43e0e1726e5f97a874b8e24820b6565ff8076523cc2922ba671492991", + "zh:9d32ac3619cfc93eb3c4f423492a8e0f79db05fec58e449dee9b2d5873d5f69f", + "zh:9e15c3c9dd8e0d1e3731841d44c34571b6c97f5b95e8296a45318b94e5287a6e", + "zh:b4c2ab35d1b7696c30b64bf2c0f3a62329107bd1a9121ce70683dec58af19615", + "zh:c43723e8cc65bcdf5e0c92581dcbbdcbdcf18b8d2037406a5f2033b1e22de442", + "zh:ceb5495d9c31bfb299d246ab333f08c7fb0d67a4f82681fbf47f2a21c3e11ab5", + "zh:e171026b3659305c558d9804062762d168f50ba02b88b231d20ec99578a6233f", + "zh:ed0fe2acdb61330b01841fa790be00ec6beaac91d41f311fb8254f74eb6a711f", + ] +} + provider "registry.terraform.io/kreuzwerker/docker" { version = "3.6.2" constraints = "~> 3.0" hashes = [ + "h1:/Oe7tViXf/xyQ4Pg8cDifMlD3RthOYkslwQiRgx7BTE=", "h1:1K3j0xUY2D0+E+DBDQc6k1u6Al9MkuNWrIC9rnvwFSM=", "zh:22b51a8fb63481d290bdad9a221bc8c9e45d66d1a0cd45beed3f3627bf1debd8", "zh:2b902eb80a1ae033af1135cc165d192668820a7f8ea15beb5472f811c18bea1f", diff --git a/freeform/README.md b/freeform/README.md index 47bd931..9bc7551 100644 --- a/freeform/README.md +++ b/freeform/README.md @@ -21,7 +21,7 @@ Mailpit: https://mailpit--{workspace}--{owner}.{coder-domain} Adminer: https://adminer--{workspace}--{owner}.{coder-domain} (if enabled) ``` -The DDEV project name does not need to match the workspace name — the routing script (`ddev coder-routes`) reads the actual DDEV project name from DDEV and maps it to the correct Coder subdomain. +The web URL always uses the workspace name as its subdomain prefix regardless of what the DDEV project is named internally. `ddev coder-routes` reads the running DDEV project from DDEV and writes a Traefik rule that maps the Coder subdomain to the correct container. ## Quick Start diff --git a/freeform/template.tf b/freeform/template.tf index da6cde4..35037f4 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -66,6 +66,16 @@ data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} # Per-workspace user parameters (shown in workspace creation UI) +data "coder_parameter" "project_names" { + name = "project_names" + display_name = "DDEV project names" + description = "Comma-separated DDEV project names. Each gets its own app button and URL. The DDEV project name must match exactly (case-sensitive). Single project: leave as default (workspace name)." + type = "string" + default = "" + mutable = true + order = 1 +} + data "coder_parameter" "vscode_extensions" { name = "vscode_extensions" display_name = "VS Code Extensions" @@ -92,6 +102,15 @@ locals { registry_without_version = replace(var.workspace_image_registry, ":${local.image_version}", "") workspace_image_registry_base = replace(local.registry_without_version, ":latest", "") + + # Parse project names from the coder_parameter. Fall back to workspace name when + # the value is empty or "[]" (the latter comes from the mock in Terraform tests). + _project_names_raw = trimspace(data.coder_parameter.project_names.value) + project_names = ( + local._project_names_raw != "" && local._project_names_raw != "[]" + ? [for s in split(",", local._project_names_raw) : trimspace(s) if trimspace(s) != ""] + : [data.coder_workspace.me.name] + ) } variable "vscode_extensions" { @@ -255,7 +274,7 @@ resource "coder_agent" "main" { # DDEV post-start hooks and interactive shells (DDEV exec-host inherits the # shell environment, which sources ~/.bashrc for login shells). # Use printenv to avoid $${!var} indirect expansion which Terraform parses. - for _var in CODER_AGENT_URL VSCODE_PROXY_URI CODER_WORKSPACE_NAME CODER_WORKSPACE_OWNER_NAME CODER_WORKSPACE_OWNER_EMAIL; do + for _var in CODER_AGENT_URL VSCODE_PROXY_URI CODER_WORKSPACE_NAME CODER_WORKSPACE_OWNER_NAME CODER_WORKSPACE_OWNER_EMAIL CODER_PROJECT_NAMES; do _val=$(printenv "$_var" 2>/dev/null || true) if [ -n "$_val" ]; then sed -i "/^export $_var=/d" ~/.bashrc || true @@ -385,12 +404,14 @@ BASHCOMP echo "" echo "=== Setup Complete ===" echo "" - echo "Next steps:" - echo " 1. Clone or create your project (any directory):" - echo " git clone " - echo " cd " - echo " 2. Configure DDEV:" - echo " ddev config --project-type=" + echo "Registered DDEV project name(s): $CODER_PROJECT_NAMES" + echo "" + echo "Next steps (repeat for each project name above):" + echo " 1. Clone or create your project directory:" + echo " git clone " + echo " cd " + echo " 2. Configure DDEV — project name MUST match a registered name:" + echo " ddev config --project-name= --project-type=" echo " 3. Install Coder routing hook (once per project):" echo " ddev coder-setup" echo " 4. Start DDEV:" @@ -405,6 +426,7 @@ BASHCOMP CODER_WORKSPACE_NAME = data.coder_workspace.me.name CODER_WORKSPACE_OWNER_NAME = data.coder_workspace_owner.me.name CODER_WORKSPACE_OWNER_EMAIL = data.coder_workspace_owner.me.email + CODER_PROJECT_NAMES = join(",", local.project_names) HOME = "/home/coder" } @@ -432,13 +454,14 @@ module "vscode-web" { extensions = local.selected_extensions } -# Slug matches the workspace name. DDEV router HTTP port is 8080 (set via ddev config global in startup). -# Coder subdomain URL: {workspace_name}--{workspace_name}--{owner}.{domain} -# Traefik rule in coder-routes.yaml matches this exact host. -resource "coder_app" "ddev-web" { +# One coder_app per project name. All route to ddev-router on port 8080. +# ddev-router dispatches by Host header: {slug}--{workspace}--{owner}.{domain} +# The DDEV project name must equal the slug for coder-routes to build the correct rule. +resource "coder_app" "ddev_web" { + for_each = toset(local.project_names) agent_id = coder_agent.main.id - slug = data.coder_workspace.me.name - display_name = "DDEV Web" + slug = each.key + display_name = each.key order = 1 url = "http://localhost:8080" icon = "https://raw.githubusercontent.com/ddev/ddev/main/docs/content/developers/logos/SVG/Logo.svg" @@ -581,8 +604,8 @@ resource "coder_metadata" "workspace_info" { value = "${docker_image.workspace_image.name} (version: ${local.image_version})" } item { - key = "ddev_project_name" - value = data.coder_workspace.me.name + key = "ddev_projects" + value = join(", ", local.project_names) } item { key = "cpu" diff --git a/freeform/tests/validate.tftest.hcl b/freeform/tests/validate.tftest.hcl index 660a3f0..0edffe8 100644 --- a/freeform/tests/validate.tftest.hcl +++ b/freeform/tests/validate.tftest.hcl @@ -11,7 +11,8 @@ mock_provider "coder" { name = "testuser" } } - # vscode_extensions.value is jsondecode()d in locals; must be valid JSON + # Default mock for coder_parameter. vscode_extensions expects "[]" (valid JSON array). + # project_names falls back to workspace name when value is "[]" (handled in locals). mock_data "coder_parameter" { defaults = { value = "[]" @@ -33,6 +34,54 @@ run "container_created_when_started" { } } +run "single_project_default" { + command = plan + assert { + condition = length(coder_app.ddev_web) == 1 + error_message = "should have exactly 1 coder_app.ddev_web with default (workspace name)" + } + assert { + condition = contains(keys(coder_app.ddev_web), "test-workspace") + error_message = "default project slug should be the workspace name" + } +} + +run "two_projects" { + command = plan + override_data { + target = data.coder_parameter.project_names + values = { + value = "drupal,wordpress" + } + } + assert { + condition = length(coder_app.ddev_web) == 2 + error_message = "should have 2 coder_app.ddev_web instances for two project names" + } + assert { + condition = contains(keys(coder_app.ddev_web), "drupal") + error_message = "coder_app.ddev_web[\"drupal\"] should exist" + } + assert { + condition = contains(keys(coder_app.ddev_web), "wordpress") + error_message = "coder_app.ddev_web[\"wordpress\"] should exist" + } +} + +run "two_projects_with_spaces" { + command = plan + override_data { + target = data.coder_parameter.project_names + values = { + value = "drupal, wordpress" + } + } + assert { + condition = length(coder_app.ddev_web) == 2 + error_message = "spaces around project names should be trimmed" + } +} + run "adminer_off_by_default" { command = plan assert { diff --git a/image/scripts/.ddev/commands/host/coder-routes b/image/scripts/.ddev/commands/host/coder-routes index 558df80..f300a2d 100644 --- a/image/scripts/.ddev/commands/host/coder-routes +++ b/image/scripts/.ddev/commands/host/coder-routes @@ -98,11 +98,10 @@ while IFS= read -r router; do if [ "$slug" = "$PROJECT_SLUG" ]; then # Primary web service: detected when svc_name=web and port≠8025. - # URL first segment must be WORKSPACE (= coder_app slug) so Coder's subdomain - # proxy matches it, regardless of what the DDEV project is named. - # Note: detect by slug, not by ext_port — DDEV names entrypoints after the internal - # port (e.g. http-80) even when the global router-http-port is 8080. - CODER_HOST="${WORKSPACE}--${WORKSPACE}--${OWNER}.${DOMAIN}" + # Slug = PROJECT_SLUG (= sanitized DDEV project name), which must equal the + # coder_app slug declared in the Terraform template. WORKSPACE is the Coder + # workspace name (the second component in the subdomain). + CODER_HOST="${PROJECT_SLUG}--${WORKSPACE}--${OWNER}.${DOMAIN}" PRIMARY_URL="https://${CODER_HOST}" RULE='Host(`'"${CODER_HOST}"'`)' RULE="$RULE" SVC="$service" \ diff --git a/image/scripts/.ddev/commands/host/coder-setup b/image/scripts/.ddev/commands/host/coder-setup index e21d2c8..8056886 100755 --- a/image/scripts/.ddev/commands/host/coder-setup +++ b/image/scripts/.ddev/commands/host/coder-setup @@ -57,6 +57,25 @@ if ! grep -qF ".ddev/docker-compose.coder-describe.yaml" ~/.config/git/ignore 2> echo "✓ Added .ddev/docker-compose.coder-describe.yaml to ~/.config/git/ignore" fi +# Warn if this project's name isn't in the registered project list. +if [ -n "${CODER_PROJECT_NAMES:-}" ] && [ -n "${DDEV_SITENAME:-}" ]; then + IFS=',' read -ra _registered <<< "$CODER_PROJECT_NAMES" + _found=false + for _name in "${_registered[@]}"; do + if [ "$(echo "$_name" | tr -d ' ')" = "$DDEV_SITENAME" ]; then + _found=true + break + fi + done + if [ "$_found" = "false" ]; then + echo "" + echo "⚠️ WARNING: DDEV project name '$DDEV_SITENAME' is not in the registered project list: $CODER_PROJECT_NAMES" + echo " Routing will not work until this project name is added." + echo " Edit the workspace and update the 'DDEV project names' parameter to include '$DDEV_SITENAME'," + echo " then restart the workspace to create the matching app button." + fi +fi + echo "" echo "Coder routing hook installed. Run 'ddev start' to activate routing." echo "The hook runs automatically after every 'ddev start'." diff --git a/image/scripts/WELCOME.txt b/image/scripts/WELCOME.txt index df08739..5785a45 100644 --- a/image/scripts/WELCOME.txt +++ b/image/scripts/WELCOME.txt @@ -4,10 +4,15 @@ This workspace is set up for web development with DDEV. -Quick start (your project can live in any directory): - git clone && cd - ddev config --project-type= +Quick start: + git clone # use a registered project name + cd + ddev config --project-name= --project-type= ddev coder-setup ddev start +⚠️ The DDEV project name must match a name registered in the workspace. + Registered names are shown in the startup log and in CODER_PROJECT_NAMES. + To add more projects, edit the workspace and update "DDEV project names". + 📚 Documentation: https://github.com/ddev/coder-ddev From b3f372657738f3ecd3cfd0bc58e3c1bc7a072fc1 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 16:30:59 -0600 Subject: [PATCH 17/28] =?UTF-8?q?test:=20fix=20freeform=20CI=20=E2=80=94?= =?UTF-8?q?=20register=20project=20names=20at=20workspace=20creation,=20us?= =?UTF-8?q?e=20per-project=20URLs=20in=20verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create freeform workspace with project_names=ci-site1-,ci-site2- via --rich-parameter-file to avoid comma-as-separator issue with --parameter - test-freeform-verify.sh: EXPECTED_URL now uses project slug (${PROJ}--${WORKSPACE}--...) instead of the old workspace-name URL shared across all projects - Cleanup step: replace blanket || true with explicit coder show existence check so real cleanup failures are visible; gracefully skip when workspace was never created Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration-test.yml | 21 ++++++++++++++++++++- freeform/scripts/test-freeform-verify.sh | 6 ++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 6637cd4..36309d9 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -122,6 +122,7 @@ jobs: ${{ matrix.extra_vars }} - name: Create workspace + if: ${{ matrix.template != 'freeform' }} run: | coder create ${{ env.WORKSPACE_NAME }} \ --template ${{ matrix.template }} \ @@ -130,6 +131,20 @@ jobs: ${{ matrix.extra_params }} \ --yes + - name: Create freeform workspace (with project names) + if: ${{ matrix.template == 'freeform' }} + run: | + cat > /tmp/freeform-params-${{ github.run_id }}.yaml << EOF + project_names: "ci-site1-${{ github.run_id }},ci-site2-${{ github.run_id }}" + vscode_extensions: "[]" + EOF + coder create ${{ env.WORKSPACE_NAME }} \ + --template ${{ matrix.template }} \ + --template-version ci-${{ github.run_id }} \ + --rich-parameter-file /tmp/freeform-params-${{ github.run_id }}.yaml \ + --use-parameter-defaults \ + --yes + - name: Verify workspace — agent connected run: coder ssh ${{ env.WORKSPACE_NAME }} --wait=yes -- echo "Agent connected" @@ -227,13 +242,17 @@ jobs: - name: Cleanup freeform PHP projects if: ${{ always() && matrix.template == 'freeform' }} run: | + coder show "${{ env.WORKSPACE_NAME }}" > /dev/null 2>&1 || { + echo "Workspace ${{ env.WORKSPACE_NAME }} not found — skipping DDEV cleanup" + exit 0 + } scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o ProxyCommand="coder ssh --stdio ${{ env.WORKSPACE_NAME }}" \ freeform/scripts/test-freeform-cleanup.sh \ coder@workspace:/tmp/test-freeform-cleanup.sh coder ssh ${{ env.WORKSPACE_NAME }} -- \ env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} \ - bash /tmp/test-freeform-cleanup.sh "${{ github.run_id }}" < /dev/null || true + bash /tmp/test-freeform-cleanup.sh "${{ github.run_id }}" < /dev/null - name: Delete workspace if: always() diff --git a/freeform/scripts/test-freeform-verify.sh b/freeform/scripts/test-freeform-verify.sh index 27dd0e4..364cd8b 100644 --- a/freeform/scripts/test-freeform-verify.sh +++ b/freeform/scripts/test-freeform-verify.sh @@ -31,12 +31,10 @@ if [ -z "${WORKSPACE}" ] || [ -z "${OWNER}" ] || [ -z "${DOMAIN}" ]; then exit 1 fi -# The coder_app slug is the workspace name, so all projects in this workspace -# share the same Coder subdomain URL (workspace--workspace--owner.domain). -EXPECTED_URL="https://${WORKSPACE}--${WORKSPACE}--${OWNER}.${DOMAIN}" - for N in 1 2; do PROJ="ci-site${N}-${SUFFIX}" + # Each project's coder_app slug matches the DDEV project name. + EXPECTED_URL="https://${PROJ}--${WORKSPACE}--${OWNER}.${DOMAIN}" echo "--- ${PROJ}: ddev launch ---" cd "/tmp/${PROJ}" From e4e16b6ff987595f08989193f37e2f674ae20b5d Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 16:43:48 -0600 Subject: [PATCH 18/28] fix: show Web URL first, Mailpit second, other services last in ddev launch output Previously yq returned routers alphabetically so xhgui appeared before Web. Collect each category into a variable and print in priority order. Co-Authored-By: Claude Sonnet 4.6 --- image/scripts/.ddev/commands/host/launch | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/image/scripts/.ddev/commands/host/launch b/image/scripts/.ddev/commands/host/launch index af9232a..ad26d79 100644 --- a/image/scripts/.ddev/commands/host/launch +++ b/image/scripts/.ddev/commands/host/launch @@ -78,6 +78,10 @@ if [ ! -f "$CODER_ROUTES" ]; then exit 0 fi +WEB_LINE="" +MAILPIT_LINE="" +OTHER_LINES="" + while IFS= read -r router; do entrypoint=$(yq e ".http.routers.\"${router}\".entrypoints[0] // \"\"" "$CODER_ROUTES" 2>/dev/null) [ -z "$entrypoint" ] || [ "$entrypoint" = "null" ] && continue @@ -90,16 +94,20 @@ while IFS= read -r router; do # Extract hostname from Host(`...`) — these use Coder subdomain proxy host=$(echo "$rule" | sed -E 's/Host\(`(.+)`\)/\1/') if [ "$router" = "$WEB_ROUTER" ]; then - echo " Web: https://${host}${PATH_SUFFIX}" + WEB_LINE=" Web: https://${host}${PATH_SUFFIX}" elif [ "$slug" = "mailpit" ]; then - echo " Mailpit: https://${host}" + MAILPIT_LINE=" Mailpit: https://${host}" else - echo " ${slug}: https://${host}" + OTHER_LINES="${OTHER_LINES} ${slug}: https://${host}\n" fi else # PathPrefix rule — dynamic add-on, accessible via Coder port-forwarding URL - echo " ${slug}: https://${ext_port}--${AGENT}--${WORKSPACE}--${OWNER}.${DOMAIN}" + OTHER_LINES="${OTHER_LINES} ${slug}: https://${ext_port}--${AGENT}--${WORKSPACE}--${OWNER}.${DOMAIN}\n" fi done < <(yq e '.http.routers | keys | .[]' "$CODER_ROUTES" 2>/dev/null) +[ -n "$WEB_LINE" ] && echo "$WEB_LINE" +[ -n "$MAILPIT_LINE" ] && echo "$MAILPIT_LINE" +[ -n "$OTHER_LINES" ] && printf "%b" "$OTHER_LINES" + echo "" From 8f1d2d99873b396bb1d63236c1be5cccbdbf1290 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 16:49:32 -0600 Subject: [PATCH 19/28] fix: simplify docker-compose.coder-describe.yaml to use web service, drop busybox No need for a separate coder-url service with a busybox image and profiles. Attaching x-ddev labels directly to the web service is sufficient. Co-Authored-By: Claude Sonnet 4.6 --- image/scripts/.ddev/commands/host/coder-routes | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/image/scripts/.ddev/commands/host/coder-routes b/image/scripts/.ddev/commands/host/coder-routes index f300a2d..4e5415d 100644 --- a/image/scripts/.ddev/commands/host/coder-routes +++ b/image/scripts/.ddev/commands/host/coder-routes @@ -149,17 +149,13 @@ done < <(yq e '.http.routers | keys | .[]' "$MERGED" 2>/dev/null) echo "✓ Wrote $(basename "$OUTPUT")" # Write docker-compose.coder-describe.yaml so 'ddev describe' shows Coder URLs. -# Uses a custom service with profiles so DDEV renders it without starting a container. -# The URL appears in 'ddev describe' on the next 'ddev start' after this file is written. if [ -n "${DDEV_APPROOT:-}" ] && [ -n "$PRIMARY_URL" ]; then DESCRIBE_FILE="$DDEV_APPROOT/.ddev/docker-compose.coder-describe.yaml" { printf '#ddev-silent-no-warn\n' printf '# Auto-generated by ddev coder-routes -- do not edit.\n' printf 'services:\n' - printf ' coder-url:\n' - printf ' image: busybox\n' - printf ' profiles: ["coder-url"]\n' + printf ' web:\n' printf ' x-ddev:\n' if [ -n "$MAILPIT_URL" ]; then printf ' describe-url-port: |\n' From fede21a9888c2d9c4d85fe38e7444642af8da7ab Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 16:53:30 -0600 Subject: [PATCH 20/28] test: verify both Web and Mailpit URLs in ddev launch and ddev describe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Check Mailpit URL in ddev launch output (was only checking Web) - Check both Web and Mailpit URLs in ddev describe output - Drop stale ddev restart step — post-start hook writes the describe file during ddev start so no restart is needed Co-Authored-By: Claude Sonnet 4.6 --- freeform/scripts/test-freeform-verify.sh | 32 +++++++++++++++--------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/freeform/scripts/test-freeform-verify.sh b/freeform/scripts/test-freeform-verify.sh index 364cd8b..79fb658 100644 --- a/freeform/scripts/test-freeform-verify.sh +++ b/freeform/scripts/test-freeform-verify.sh @@ -34,23 +34,26 @@ fi for N in 1 2; do PROJ="ci-site${N}-${SUFFIX}" # Each project's coder_app slug matches the DDEV project name. - EXPECTED_URL="https://${PROJ}--${WORKSPACE}--${OWNER}.${DOMAIN}" + WEB_URL="https://${PROJ}--${WORKSPACE}--${OWNER}.${DOMAIN}" + MAILPIT_URL="https://mailpit--${WORKSPACE}--${OWNER}.${DOMAIN}" echo "--- ${PROJ}: ddev launch ---" cd "/tmp/${PROJ}" LAUNCH=$(ddev launch 2>&1) echo "${LAUNCH}" - echo "${LAUNCH}" | grep -qF "${EXPECTED_URL}" || { - echo "ERROR: expected ${EXPECTED_URL} not found in ddev launch output" >&2 + echo "${LAUNCH}" | grep -qF "${WEB_URL}" || { + echo "ERROR: expected ${WEB_URL} not found in ddev launch output" >&2 exit 1 } - echo " OK: ddev launch shows correct URL" - - # The coder-url custom service in docker-compose.coder-describe.yaml is picked up - # by DDEV on the next start after coder-routes writes it. Restart to trigger that. - echo "--- ${PROJ}: ddev restart (to load coder-url describe service) ---" - ddev restart 2>&1 | tail -3 + echo " OK: ddev launch shows correct Web URL" + echo "${LAUNCH}" | grep -qF "${MAILPIT_URL}" || { + echo "ERROR: expected ${MAILPIT_URL} not found in ddev launch output" >&2 + exit 1 + } + echo " OK: ddev launch shows correct Mailpit URL" + # docker-compose.coder-describe.yaml is written by the post-start hook (coder-routes) + # during ddev start, so ddev describe picks it up immediately — no restart needed. echo "--- ${PROJ}: ddev describe ---" DESCRIBE=$(ddev describe 2>&1) echo "${DESCRIBE}" @@ -59,9 +62,14 @@ for N in 1 2; do exit 1 } echo " OK: ddev describe shows project running" - echo "${DESCRIBE}" | grep -qF "${EXPECTED_URL}" || { - echo "ERROR: Coder URL ${EXPECTED_URL} not found in ddev describe output" >&2 + echo "${DESCRIBE}" | grep -qF "${WEB_URL}" || { + echo "ERROR: Web URL ${WEB_URL} not found in ddev describe output" >&2 + exit 1 + } + echo " OK: ddev describe shows Web URL" + echo "${DESCRIBE}" | grep -qF "${MAILPIT_URL}" || { + echo "ERROR: Mailpit URL ${MAILPIT_URL} not found in ddev describe output" >&2 exit 1 } - echo " OK: ddev describe shows Coder URL" + echo " OK: ddev describe shows Mailpit URL" done From 4b73c7bfa0a6e04c15a92b4227c4e3158676281c Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 16:53:50 -0600 Subject: [PATCH 21/28] test: drop Mailpit URL checks, Web URL is sufficient Co-Authored-By: Claude Sonnet 4.6 --- freeform/scripts/test-freeform-verify.sh | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/freeform/scripts/test-freeform-verify.sh b/freeform/scripts/test-freeform-verify.sh index 79fb658..eb82711 100644 --- a/freeform/scripts/test-freeform-verify.sh +++ b/freeform/scripts/test-freeform-verify.sh @@ -35,7 +35,6 @@ for N in 1 2; do PROJ="ci-site${N}-${SUFFIX}" # Each project's coder_app slug matches the DDEV project name. WEB_URL="https://${PROJ}--${WORKSPACE}--${OWNER}.${DOMAIN}" - MAILPIT_URL="https://mailpit--${WORKSPACE}--${OWNER}.${DOMAIN}" echo "--- ${PROJ}: ddev launch ---" cd "/tmp/${PROJ}" @@ -46,11 +45,6 @@ for N in 1 2; do exit 1 } echo " OK: ddev launch shows correct Web URL" - echo "${LAUNCH}" | grep -qF "${MAILPIT_URL}" || { - echo "ERROR: expected ${MAILPIT_URL} not found in ddev launch output" >&2 - exit 1 - } - echo " OK: ddev launch shows correct Mailpit URL" # docker-compose.coder-describe.yaml is written by the post-start hook (coder-routes) # during ddev start, so ddev describe picks it up immediately — no restart needed. @@ -67,9 +61,4 @@ for N in 1 2; do exit 1 } echo " OK: ddev describe shows Web URL" - echo "${DESCRIBE}" | grep -qF "${MAILPIT_URL}" || { - echo "ERROR: Mailpit URL ${MAILPIT_URL} not found in ddev describe output" >&2 - exit 1 - } - echo " OK: ddev describe shows Mailpit URL" done From f87b8e566a73aadc0faaeb16aef6e07ffe85c5b3 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 17:06:27 -0600 Subject: [PATCH 22/28] fix: give each project its own mailpit slug (mailpit-{project}) to avoid sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single coder_app "mailpit" slug meant all projects in a workspace routed to mailpit--workspace--owner.domain — whichever project ran coder-routes last won. Now coder_app.mailpit is for_each on project_names with slug "mailpit-{project}", and coder-routes emits "mailpit-{PROJECT_SLUG}" as the Traefik router slug to match. Co-Authored-By: Claude Sonnet 4.6 --- freeform/template.tf | 6 ++++-- image/scripts/.ddev/commands/host/coder-routes | 8 ++++---- image/scripts/.ddev/commands/host/launch | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/freeform/template.tf b/freeform/template.tf index 35037f4..374c48e 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -477,10 +477,12 @@ resource "coder_app" "ddev_web" { # Mailpit runs inside the web container at port 8025. # DDEV service: {project}-web-8025 (from HTTP_EXPOSE=...,{mailpit_port}:8025 on the web container). +# One app per project so each gets its own subdomain: mailpit-{project}--{workspace}--{owner}.domain resource "coder_app" "mailpit" { + for_each = toset(local.project_names) agent_id = coder_agent.main.id - slug = "mailpit" - display_name = "Mailpit" + slug = "mailpit-${each.key}" + display_name = "Mailpit (${each.key})" url = "http://localhost:8025" icon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mailpit.svg" subdomain = true diff --git a/image/scripts/.ddev/commands/host/coder-routes b/image/scripts/.ddev/commands/host/coder-routes index 4e5415d..d41bc15 100644 --- a/image/scripts/.ddev/commands/host/coder-routes +++ b/image/scripts/.ddev/commands/host/coder-routes @@ -73,14 +73,14 @@ while IFS= read -r router; do # Derive Coder slug from service name: strip {ddev_project}- prefix and -{port} suffix. # Examples (DDEV_PROJECT=myproject, WORKSPACE=myworkspace): # myproject-web-8080 → svc=web port=8080 → slug=PROJECT_SLUG (primary web) - # myproject-web-8025 → svc=web port=8025 → slug=mailpit + # myproject-web-8025 → svc=web port=8025 → slug=mailpit-PROJECT_SLUG # myproject-adminer-9100 → svc=adminer → slug=adminer svc_and_port="${service#${DDEV_PROJECT}-}" port="${svc_and_port##*-}" svc_name="${svc_and_port%-*}" if [ "$svc_name" = "web" ] && [ "$port" = "8025" ]; then - slug="mailpit" + slug="mailpit-${PROJECT_SLUG}" elif [ "$svc_name" = "web" ]; then slug="$PROJECT_SLUG" else @@ -112,11 +112,11 @@ while IFS= read -r router; do .http.routers.\"${ROUTER_NAME}\".tls = false" \ /tmp/coder-routes-raw.yaml echo " + ${slug}: ${entrypoints[*]} → ${service} (https://${CODER_HOST})" - elif [ "$slug" = "mailpit" ] || [ "$slug" = "adminer" ]; then + elif [ "$slug" = "mailpit-${PROJECT_SLUG}" ] || [ "$slug" = "adminer" ]; then # Known Coder app slugs (defined as coder_app resources in the Terraform template): # use Host() rule so the Coder subdomain proxy URL routes correctly. CODER_HOST="${slug}--${WORKSPACE}--${OWNER}.${DOMAIN}" - [ "$slug" = "mailpit" ] && MAILPIT_URL="https://${CODER_HOST}" + [ "$slug" = "mailpit-${PROJECT_SLUG}" ] && MAILPIT_URL="https://${CODER_HOST}" RULE='Host(`'"${CODER_HOST}"'`)' RULE="$RULE" SVC="$service" \ yq e -i \ diff --git a/image/scripts/.ddev/commands/host/launch b/image/scripts/.ddev/commands/host/launch index ad26d79..d045336 100644 --- a/image/scripts/.ddev/commands/host/launch +++ b/image/scripts/.ddev/commands/host/launch @@ -57,7 +57,7 @@ WEB_ROUTER="${PROJECT}-coder-${PROJECT_SLUG}" if [ "${MAILPIT}" = "true" ]; then if [ -f "$CODER_ROUTES" ]; then - mailpit_rule=$(yq e ".http.routers.\"${PROJECT}-coder-mailpit\".rule // \"\"" "$CODER_ROUTES" 2>/dev/null) + mailpit_rule=$(yq e ".http.routers.\"${PROJECT}-coder-mailpit-${PROJECT_SLUG}\".rule // \"\"" "$CODER_ROUTES" 2>/dev/null) if [ -n "$mailpit_rule" ] && [ "$mailpit_rule" != "null" ]; then host=$(echo "$mailpit_rule" | sed -E 's/Host\(`(.+)`\)/\1/') echo "https://${host}" @@ -65,7 +65,7 @@ if [ "${MAILPIT}" = "true" ]; then fi fi # fallback - echo "https://mailpit--${WORKSPACE}--${OWNER}.${DOMAIN}" + echo "https://mailpit-${PROJECT_SLUG}--${WORKSPACE}--${OWNER}.${DOMAIN}" exit 0 fi From 566ae23ce115e5698c5e69d244b86826068bd365 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 17:15:40 -0600 Subject: [PATCH 23/28] fix: write docker-compose.coder-describe.yaml in coder-setup, not coder-routes post-start DDEV reads docker-compose files at start time, so the file must exist before ddev start. Writing it in the post-start hook (coder-routes) meant it was never picked up until the following start. coder-setup now writes the file with statically-computed URLs immediately after writing config.coder.yaml, before the user runs ddev start. coder-routes no longer writes or manages this file. Co-Authored-By: Claude Sonnet 4.6 --- .../scripts/.ddev/commands/host/coder-routes | 26 ----------------- image/scripts/.ddev/commands/host/coder-setup | 28 +++++++++++++++++++ 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/image/scripts/.ddev/commands/host/coder-routes b/image/scripts/.ddev/commands/host/coder-routes index d41bc15..09d5b9b 100644 --- a/image/scripts/.ddev/commands/host/coder-routes +++ b/image/scripts/.ddev/commands/host/coder-routes @@ -46,9 +46,6 @@ OUTPUT="$ROUTES_DIR/coder-routes-${DDEV_PROJECT}.yaml" # Seed the output file printf "http:\n routers: {}\n" > /tmp/coder-routes-raw.yaml -PRIMARY_URL="" -MAILPIT_URL="" - echo "Building Coder Traefik routes from ${DDEV_PROJECT}_merged.yaml:" # Iterate over every router in the merged config. @@ -102,7 +99,6 @@ while IFS= read -r router; do # coder_app slug declared in the Terraform template. WORKSPACE is the Coder # workspace name (the second component in the subdomain). CODER_HOST="${PROJECT_SLUG}--${WORKSPACE}--${OWNER}.${DOMAIN}" - PRIMARY_URL="https://${CODER_HOST}" RULE='Host(`'"${CODER_HOST}"'`)' RULE="$RULE" SVC="$service" \ yq e -i \ @@ -116,7 +112,6 @@ while IFS= read -r router; do # Known Coder app slugs (defined as coder_app resources in the Terraform template): # use Host() rule so the Coder subdomain proxy URL routes correctly. CODER_HOST="${slug}--${WORKSPACE}--${OWNER}.${DOMAIN}" - [ "$slug" = "mailpit-${PROJECT_SLUG}" ] && MAILPIT_URL="https://${CODER_HOST}" RULE='Host(`'"${CODER_HOST}"'`)' RULE="$RULE" SVC="$service" \ yq e -i \ @@ -148,27 +143,6 @@ done < <(yq e '.http.routers | keys | .[]' "$MERGED" 2>/dev/null) { printf '#ddev-silent-no-warn\n'; yq e '.' /tmp/coder-routes-raw.yaml; } > "$OUTPUT" echo "✓ Wrote $(basename "$OUTPUT")" -# Write docker-compose.coder-describe.yaml so 'ddev describe' shows Coder URLs. -if [ -n "${DDEV_APPROOT:-}" ] && [ -n "$PRIMARY_URL" ]; then - DESCRIBE_FILE="$DDEV_APPROOT/.ddev/docker-compose.coder-describe.yaml" - { - printf '#ddev-silent-no-warn\n' - printf '# Auto-generated by ddev coder-routes -- do not edit.\n' - printf 'services:\n' - printf ' web:\n' - printf ' x-ddev:\n' - if [ -n "$MAILPIT_URL" ]; then - printf ' describe-url-port: |\n' - printf ' %s\n' "$PRIMARY_URL" - printf ' Mailpit: %s\n' "$MAILPIT_URL" - else - printf ' describe-url-port: "%s"\n' "$PRIMARY_URL" - fi - printf ' describe-info: "Use: ddev launch"\n' - } > "$DESCRIBE_FILE" - echo "✓ Wrote docker-compose.coder-describe.yaml" -fi - # Push to running ddev-router; Traefik's watch:true reloads within ~1s. # The bind-mount from ~/.ddev/traefik/custom-global-config/ means writing the file # is usually sufficient, but docker cp guarantees an immediate reload. diff --git a/image/scripts/.ddev/commands/host/coder-setup b/image/scripts/.ddev/commands/host/coder-setup index 8056886..c1d2dbe 100755 --- a/image/scripts/.ddev/commands/host/coder-setup +++ b/image/scripts/.ddev/commands/host/coder-setup @@ -46,6 +46,34 @@ hooks: EOF echo "✓ Wrote .ddev/config.coder.yaml" +# Write docker-compose.coder-describe.yaml so 'ddev describe' shows the Coder web URL. +# Must exist before 'ddev start' — DDEV reads docker-compose files at start time. +_WORKSPACE="${CODER_WORKSPACE_NAME:-}" +_OWNER="${CODER_WORKSPACE_OWNER_NAME:-}" +_DOMAIN="" +if [ -n "${VSCODE_PROXY_URI:-}" ]; then + _DOMAIN=$(echo "$VSCODE_PROXY_URI" | sed -E 's|https?://[^.]+\.(.+?)(/.*)?$|\1|') +elif [ -n "${CODER_AGENT_URL:-}" ]; then + _DOMAIN=$(echo "$CODER_AGENT_URL" | sed -E 's|https?://(.+?)(/.*)?$|\1|') +fi +if [ -n "$_WORKSPACE" ] && [ -n "$_OWNER" ] && [ -n "$_DOMAIN" ] && [ -n "${DDEV_SITENAME:-}" ]; then + _SLUG=$(echo "$DDEV_SITENAME" | tr '[:upper:]' '[:lower:]' | tr '._' '--' | tr -s '-') + _WEB_URL="https://${_SLUG}--${_WORKSPACE}--${_OWNER}.${_DOMAIN}" + _MAILPIT_URL="https://mailpit-${_SLUG}--${_WORKSPACE}--${_OWNER}.${_DOMAIN}" + cat > .ddev/docker-compose.coder-describe.yaml << EOF +#ddev-silent-no-warn +# Auto-generated by ddev coder-setup — do not edit. +services: + web: + x-ddev: + describe-url-port: | + ${_WEB_URL} + Mailpit: ${_MAILPIT_URL} + describe-info: "Use: ddev launch" +EOF + echo "✓ Wrote .ddev/docker-compose.coder-describe.yaml" +fi + # Gitignore it so it doesn't end up in the project repo mkdir -p ~/.config/git if ! grep -qF ".ddev/config.coder.yaml" ~/.config/git/ignore 2>/dev/null; then From 49cb88e353e6a0338675b5f9ef00c702d8929172 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 17:18:09 -0600 Subject: [PATCH 24/28] fix: give xhgui and adminer per-project slugs to avoid shared-port conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xhgui is always present in the image; adminer is opt-in but per-project when enabled. Both use shared Traefik entrypoint ports where name-based routing means multiple projects would conflict with a single slug. - coder_app.xhgui: for_each on project_names, slug xhgui-{project} - coder_app.adminer: for_each on project_names (when enable_adminer=true), slug adminer-{project} - coder-routes: detect xhgui/adminer by svc_name and emit Host() rules with per-project slugs Unknown add-ons in multi-project workspaces still use PathPrefix/port-forwarding and will conflict on shared ports — documented limitation. Co-Authored-By: Claude Sonnet 4.6 --- freeform/template.tf | 21 ++++++++++++++----- .../scripts/.ddev/commands/host/coder-routes | 9 ++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/freeform/template.tf b/freeform/template.tf index 374c48e..997b0c4 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -495,14 +495,25 @@ resource "coder_app" "mailpit" { } } -# Adminer: database admin UI added by ddev get ddev/ddev-adminer. +# xhgui is always present in the image (not an add-on). One app per project. +resource "coder_app" "xhgui" { + for_each = toset(local.project_names) + agent_id = coder_agent.main.id + slug = "xhgui-${each.key}" + display_name = "xhgui (${each.key})" + url = "http://localhost:8143" + icon = "/icon/speedometer.svg" + subdomain = true + share = "owner" +} + +# Adminer: optional database admin UI (enable_adminer variable). One app per project. # HTTP_EXPOSE=9100:8080 → ddev-router port 9100 → adminer container port 8080. -# coder-routes post-start hook adds the Traefik router automatically. resource "coder_app" "adminer" { - count = var.enable_adminer ? 1 : 0 + for_each = var.enable_adminer ? toset(local.project_names) : toset([]) agent_id = coder_agent.main.id - slug = "adminer" - display_name = "Adminer" + slug = "adminer-${each.key}" + display_name = "Adminer (${each.key})" url = "http://localhost:9100" icon = "/icon/database.svg" subdomain = true diff --git a/image/scripts/.ddev/commands/host/coder-routes b/image/scripts/.ddev/commands/host/coder-routes index 09d5b9b..75c0416 100644 --- a/image/scripts/.ddev/commands/host/coder-routes +++ b/image/scripts/.ddev/commands/host/coder-routes @@ -71,7 +71,8 @@ while IFS= read -r router; do # Examples (DDEV_PROJECT=myproject, WORKSPACE=myworkspace): # myproject-web-8080 → svc=web port=8080 → slug=PROJECT_SLUG (primary web) # myproject-web-8025 → svc=web port=8025 → slug=mailpit-PROJECT_SLUG - # myproject-adminer-9100 → svc=adminer → slug=adminer + # myproject-xhgui-80 → svc=xhgui → slug=xhgui-PROJECT_SLUG + # myproject-adminer-9100 → svc=adminer → slug=adminer-PROJECT_SLUG svc_and_port="${service#${DDEV_PROJECT}-}" port="${svc_and_port##*-}" svc_name="${svc_and_port%-*}" @@ -80,6 +81,10 @@ while IFS= read -r router; do slug="mailpit-${PROJECT_SLUG}" elif [ "$svc_name" = "web" ]; then slug="$PROJECT_SLUG" + elif [ "$svc_name" = "xhgui" ]; then + slug="xhgui-${PROJECT_SLUG}" + elif [ "$svc_name" = "adminer" ]; then + slug="adminer-${PROJECT_SLUG}" else slug="$svc_name" fi @@ -108,7 +113,7 @@ while IFS= read -r router; do .http.routers.\"${ROUTER_NAME}\".tls = false" \ /tmp/coder-routes-raw.yaml echo " + ${slug}: ${entrypoints[*]} → ${service} (https://${CODER_HOST})" - elif [ "$slug" = "mailpit-${PROJECT_SLUG}" ] || [ "$slug" = "adminer" ]; then + elif [ "$slug" = "mailpit-${PROJECT_SLUG}" ] || [ "$slug" = "xhgui-${PROJECT_SLUG}" ] || [ "$slug" = "adminer-${PROJECT_SLUG}" ]; then # Known Coder app slugs (defined as coder_app resources in the Terraform template): # use Host() rule so the Coder subdomain proxy URL routes correctly. CODER_HOST="${slug}--${WORKSPACE}--${OWNER}.${DOMAIN}" From 7ef01a16bdb24351885f66ea2addaa28de32638e Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 17:23:33 -0600 Subject: [PATCH 25/28] run tests From 7ede5c806a9c06fe1497942096a62d332d11188e Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 17:33:21 -0600 Subject: [PATCH 26/28] ci: add GitHub Actions workflow to build and push Docker image Builds linux/amd64 on native GitHub-hosted runners, eliminating the need to cross-compile from arm64 or SSH into a remote machine to build. Triggers on push to main (image/ or VERSION changes) and workflow_dispatch. PRs build but do not push. Uses GitHub Actions cache for layer caching. Requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN repository secrets. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build-image.yml | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/build-image.yml diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 0000000..8dd330c --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,54 @@ +name: Build and Push Docker Image + +# Builds linux/amd64 on GitHub-hosted runners (native amd64) so arm64 dev +# machines don't need cross-compilation. Triggers on changes to the image or +# VERSION, and can be run manually from any branch for ad-hoc builds. +# +# Required repository secrets: +# DOCKERHUB_USERNAME - Docker Hub username +# DOCKERHUB_TOKEN - Docker Hub access token (read/write) + +on: + push: + branches: [main] + paths: + - 'image/**' + - 'VERSION' + pull_request: + paths: + - 'image/**' + - 'VERSION' + workflow_dispatch: + +jobs: + build: + name: Build image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Read version + id: version + run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: ${{ github.event_name != 'pull_request' }} + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: image + platforms: linux/amd64 + push: ${{ github.event_name != 'pull_request' }} + tags: | + ddev/coder-ddev:${{ steps.version.outputs.version }} + ddev/coder-ddev:latest + cache-from: type=gha + cache-to: type=gha,mode=max From add3b69acac6496c41f0238be7b6dd910724efea Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 17:44:32 -0600 Subject: [PATCH 27/28] ci: include run_attempt in CI tag so retries don't conflict Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration-test.yml | 37 +++++++++++++------------- .github/workflows/staging-push.yml | 2 +- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 36309d9..a0a441e 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -80,7 +80,8 @@ jobs: run: shell: bash -euo pipefail {0} env: - WORKSPACE_NAME: ci-${{ matrix.template }}-${{ github.run_id }} + CI_TAG: ${{ github.run_id }}-${{ github.run_attempt }} + WORKSPACE_NAME: ci-${{ matrix.template }}-${{ github.run_id }}-${{ github.run_attempt }} CI: "true" DDEV_NONINTERACTIVE: "true" NO_COLOR: "1" @@ -116,7 +117,7 @@ jobs: coder templates push ${{ matrix.template }} \ --directory ${{ matrix.template }} \ --activate=false \ - --name ci-${{ github.run_id }} \ + --name ci-${{ env.CI_TAG }} \ --yes \ --variable workspace_image_registry=index.docker.io/ddev/coder-ddev \ ${{ matrix.extra_vars }} @@ -126,7 +127,7 @@ jobs: run: | coder create ${{ env.WORKSPACE_NAME }} \ --template ${{ matrix.template }} \ - --template-version ci-${{ github.run_id }} \ + --template-version ci-${{ env.CI_TAG }} \ --parameter "vscode_extensions=[]" \ ${{ matrix.extra_params }} \ --yes @@ -134,14 +135,14 @@ jobs: - name: Create freeform workspace (with project names) if: ${{ matrix.template == 'freeform' }} run: | - cat > /tmp/freeform-params-${{ github.run_id }}.yaml << EOF - project_names: "ci-site1-${{ github.run_id }},ci-site2-${{ github.run_id }}" + cat > /tmp/freeform-params-${{ env.CI_TAG }}.yaml << EOF + project_names: "ci-site1-${{ env.CI_TAG }},ci-site2-${{ env.CI_TAG }}" vscode_extensions: "[]" EOF coder create ${{ env.WORKSPACE_NAME }} \ --template ${{ matrix.template }} \ - --template-version ci-${{ github.run_id }} \ - --rich-parameter-file /tmp/freeform-params-${{ github.run_id }}.yaml \ + --template-version ci-${{ env.CI_TAG }} \ + --rich-parameter-file /tmp/freeform-params-${{ env.CI_TAG }}.yaml \ --use-parameter-defaults \ --yes @@ -164,9 +165,9 @@ jobs: if: ${{ matrix.app_slug != '' }} run: | # Write start script to runner-local file so we avoid the coder ssh heredoc+PTY hang - cat > /tmp/ci-ddev-start-${{ github.run_id }}.sh << 'EOF' + cat > /tmp/ci-ddev-start-${{ env.CI_TAG }}.sh << 'EOF' set -euo pipefail - TESTDIR=/tmp/ci-ddev-${{ github.run_id }} + TESTDIR=/tmp/ci-ddev-${{ env.CI_TAG }} echo "--- Creating test project in $TESTDIR ---" mkdir -p "$TESTDIR/web" && cd "$TESTDIR" ddev config --project-type=php --docroot=web @@ -178,11 +179,11 @@ jobs: -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null \ -o ProxyCommand="coder ssh --stdio ${{ env.WORKSPACE_NAME }}" \ - /tmp/ci-ddev-start-${{ github.run_id }}.sh \ - coder@workspace:/tmp/ci-ddev-start-${{ github.run_id }}.sh + /tmp/ci-ddev-start-${{ env.CI_TAG }}.sh \ + coder@workspace:/tmp/ci-ddev-start-${{ env.CI_TAG }}.sh coder ssh ${{ env.WORKSPACE_NAME }} -- \ env CI=${{ env.CI }} DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ - bash /tmp/ci-ddev-start-${{ github.run_id }}.sh < /dev/null + bash /tmp/ci-ddev-start-${{ env.CI_TAG }}.sh < /dev/null - name: Verify workspace — DDEV web externally accessible if: ${{ matrix.app_slug != '' }} @@ -198,8 +199,8 @@ jobs: - name: Cleanup DDEV test project if: ${{ matrix.app_slug != '' }} run: | - coder ssh ${{ env.WORKSPACE_NAME }} -- ddev delete ci-ddev-${{ github.run_id }} --omit-snapshot -y < /dev/null || true - coder ssh ${{ env.WORKSPACE_NAME }} -- rm -rf /tmp/ci-ddev-${{ github.run_id }} < /dev/null || true + coder ssh ${{ env.WORKSPACE_NAME }} -- ddev delete ci-ddev-${{ env.CI_TAG }} --omit-snapshot -y < /dev/null || true + coder ssh ${{ env.WORKSPACE_NAME }} -- rm -rf /tmp/ci-ddev-${{ env.CI_TAG }} < /dev/null || true - name: Inject current-branch DDEV scripts into freeform workspace if: ${{ matrix.template == 'freeform' }} @@ -222,7 +223,7 @@ jobs: coder@workspace:/tmp/test-freeform-start.sh coder ssh ${{ env.WORKSPACE_NAME }} -- \ env CI=${{ env.CI }} DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ - bash /tmp/test-freeform-start.sh "${{ github.run_id }}" < /dev/null + bash /tmp/test-freeform-start.sh "${{ env.CI_TAG }}" < /dev/null - name: Verify freeform — ddev launch and describe per-project URLs if: ${{ matrix.template == 'freeform' }} @@ -236,7 +237,7 @@ jobs: coder ssh ${{ env.WORKSPACE_NAME }} -- \ env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ bash /tmp/test-freeform-verify.sh \ - "${{ github.run_id }}" "${{ env.WORKSPACE_NAME }}" "${OWNER}" "${CODER_DOMAIN}" \ + "${{ env.CI_TAG }}" "${{ env.WORKSPACE_NAME }}" "${OWNER}" "${CODER_DOMAIN}" \ < /dev/null - name: Cleanup freeform PHP projects @@ -252,7 +253,7 @@ jobs: coder@workspace:/tmp/test-freeform-cleanup.sh coder ssh ${{ env.WORKSPACE_NAME }} -- \ env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} \ - bash /tmp/test-freeform-cleanup.sh "${{ github.run_id }}" < /dev/null + bash /tmp/test-freeform-cleanup.sh "${{ env.CI_TAG }}" < /dev/null - name: Delete workspace if: always() @@ -274,4 +275,4 @@ jobs: - name: Archive CI template version if: always() - run: coder templates versions archive ${{ matrix.template }} ci-${{ github.run_id }} --yes || true + run: coder templates versions archive ${{ matrix.template }} ci-${{ env.CI_TAG }} --yes || true diff --git a/.github/workflows/staging-push.yml b/.github/workflows/staging-push.yml index b3c50fb..3fa3ca7 100644 --- a/.github/workflows/staging-push.yml +++ b/.github/workflows/staging-push.yml @@ -43,7 +43,7 @@ jobs: extra_vars: "" fail-fast: false env: - VERSION_NAME: ci-${{ github.run_id }} + VERSION_NAME: ci-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@v6 From fd427ed097ea43cda85a7e1ccc6a6d134d493536 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Tue, 5 May 2026 17:51:19 -0600 Subject: [PATCH 28/28] ci: shorten workspace names with ws_name matrix alias to stay under 32 chars Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration-test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index a0a441e..c7a908c 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -68,10 +68,12 @@ jobs: matrix: include: - template: user-defined-web + ws_name: udw extra_vars: "" extra_params: "" app_slug: "ddev-web" - template: freeform + ws_name: ff extra_vars: "" extra_params: "" app_slug: "" @@ -81,7 +83,7 @@ jobs: shell: bash -euo pipefail {0} env: CI_TAG: ${{ github.run_id }}-${{ github.run_attempt }} - WORKSPACE_NAME: ci-${{ matrix.template }}-${{ github.run_id }}-${{ github.run_attempt }} + WORKSPACE_NAME: ci-${{ matrix.ws_name }}-${{ github.run_id }}-${{ github.run_attempt }} CI: "true" DDEV_NONINTERACTIVE: "true" NO_COLOR: "1"