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 diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 56dc1f1..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: "" @@ -80,7 +82,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.ws_name }}-${{ github.run_id }}-${{ github.run_attempt }} CI: "true" DDEV_NONINTERACTIVE: "true" NO_COLOR: "1" @@ -116,20 +119,35 @@ 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 }} - name: Create workspace + if: ${{ matrix.template != 'freeform' }} 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 + - name: Create freeform workspace (with project names) + if: ${{ matrix.template == 'freeform' }} + run: | + 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-${{ env.CI_TAG }} \ + --rich-parameter-file /tmp/freeform-params-${{ env.CI_TAG }}.yaml \ + --use-parameter-defaults \ + --yes + - name: Verify workspace — agent connected run: coder ssh ${{ env.WORKSPACE_NAME }} --wait=yes -- echo "Agent connected" @@ -149,9 +167,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 @@ -163,11 +181,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 != '' }} @@ -183,8 +201,61 @@ 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' }} + 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: | + scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o ProxyCommand="coder ssh --stdio ${{ env.WORKSPACE_NAME }}" \ + 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/test-freeform-start.sh "${{ env.CI_TAG }}" < /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://}" + scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o ProxyCommand="coder ssh --stdio ${{ env.WORKSPACE_NAME }}" \ + freeform/scripts/test-freeform-verify.sh \ + coder@workspace:/tmp/test-freeform-verify.sh + coder ssh ${{ env.WORKSPACE_NAME }} -- \ + env DDEV_NONINTERACTIVE=${{ env.DDEV_NONINTERACTIVE }} NO_COLOR=${{ env.NO_COLOR }} \ + bash /tmp/test-freeform-verify.sh \ + "${{ env.CI_TAG }}" "${{ env.WORKSPACE_NAME }}" "${OWNER}" "${CODER_DOMAIN}" \ + < /dev/null + + - 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 "${{ env.CI_TAG }}" < /dev/null - name: Delete workspace if: always() @@ -206,4 +277,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 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/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/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 8ee86c6..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 @@ -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/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..eb82711 --- /dev/null +++ b/freeform/scripts/test-freeform-verify.sh @@ -0,0 +1,64 @@ +#!/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}" + # Each project's coder_app slug matches the DDEV project name. + WEB_URL="https://${PROJ}--${WORKSPACE}--${OWNER}.${DOMAIN}" + + echo "--- ${PROJ}: ddev launch ---" + cd "/tmp/${PROJ}" + LAUNCH=$(ddev launch 2>&1) + echo "${LAUNCH}" + 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 Web 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}" + echo "${DESCRIBE}" | grep -qiE "running|OK" || { + echo "ERROR: ddev describe did not show running status" >&2 + exit 1 + } + echo " OK: ddev describe shows project running" + 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" +done diff --git a/freeform/template.tf b/freeform/template.tf index a16b60d..997b0c4 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" { @@ -176,7 +195,7 @@ resource "coder_agent" "main" { startup_script = <<-EOT #!/bin/bash - set +e + set -euo pipefail echo "Startup script started..." @@ -235,7 +254,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 if [ -n "$CODER_WORKSPACE_OWNER_NAME" ]; then git config --global user.name "$CODER_WORKSPACE_OWNER_NAME" fi @@ -256,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 @@ -310,11 +328,15 @@ 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 - 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 @@ -373,7 +395,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 @@ -382,12 +404,14 @@ BASHCOMP echo "" echo "=== Setup Complete ===" echo "" - echo "Next steps:" - echo " 1. Clone or create your project:" - echo " git clone ~/myproject" - echo " cd ~/myproject" - 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:" @@ -402,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" } @@ -429,21 +454,22 @@ module "vscode-web" { extensions = local.selected_extensions } -# Slug matches the workspace name, which is also the DDEV project name. -# 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: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 } @@ -451,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 @@ -467,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 @@ -578,8 +617,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 d54c1cc..75c0416 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 @@ -22,6 +23,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 +37,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 @@ -49,23 +59,32 @@ 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 # 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-8025 → svc=web port=8025 → slug=mailpit - # myproject-adminer-8080 → svc=adminer → slug=adminer + # myproject-web-8080 → svc=web port=8080 → slug=PROJECT_SLUG (primary web) + # myproject-web-8025 → svc=web port=8025 → slug=mailpit-PROJECT_SLUG + # 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%-*}" if [ "$svc_name" = "web" ] && [ "$port" = "8025" ]; then - slug="mailpit" + slug="mailpit-${PROJECT_SLUG}" elif [ "$svc_name" = "web" ]; then - slug="$WORKSPACE" + 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 @@ -76,15 +95,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. - # 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). + # Used for PathPrefix (port-forwarding) URLs on dynamic add-on services. ext_port="${entrypoints[0]#http-}" - if [ "$ext_port" = "80" ]; 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. - CODER_HOST="${slug}--${WORKSPACE}--${OWNER}.${DOMAIN}" + if [ "$slug" = "$PROJECT_SLUG" ]; then + # Primary web service: detected when svc_name=web and port≠8025. + # 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}" RULE='Host(`'"${CODER_HOST}"'`)' RULE="$RULE" SVC="$service" \ yq e -i \ @@ -94,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" ] || [ "$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}" @@ -111,6 +130,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 +140,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" +{ printf '#ddev-silent-no-warn\n'; 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..c1d2dbe 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 @@ -39,15 +41,68 @@ 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" +# 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 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 + +# 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." diff --git a/image/scripts/.ddev/commands/host/launch b/image/scripts/.ddev/commands/host/launch index 5d0ccaa..d045336 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] @@ -21,8 +22,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 +44,70 @@ 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 - echo "https://mailpit--${WORKSPACE}--${OWNER}.${DOMAIN}" + if [ -f "$CODER_ROUTES" ]; then + 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}" + exit 0 + fi + fi + # fallback + echo "https://mailpit-${PROJECT_SLUG}--${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 + +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 + + 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 + WEB_LINE=" Web: https://${host}${PATH_SUFFIX}" + elif [ "$slug" = "mailpit" ]; then + MAILPIT_LINE=" Mailpit: https://${host}" else - # Dynamic add-on (PathPrefix rule): use Coder port-forwarding URL - echo " ${slug}: https://${ext_port}--${AGENT}--${WORKSPACE}--${OWNER}.${DOMAIN}" + OTHER_LINES="${OTHER_LINES} ${slug}: https://${host}\n" 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 + 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 "" diff --git a/image/scripts/WELCOME.txt b/image/scripts/WELCOME.txt index 9966dfc..5785a45 100644 --- a/image/scripts/WELCOME.txt +++ b/image/scripts/WELCOME.txt @@ -4,12 +4,15 @@ This workspace is set up for web development with DDEV. -🌐 PUBLISH YOUR PROJECT - Go to the coder project page and configure public access. +Quick start: + git clone # use a registered project name + cd + ddev config --project-name= --project-type= + ddev coder-setup + ddev start -📚 MORE INFORMATION - - Projects: ~/projects/README.md - - ddev Documentation: https://docs.ddev.com/ - -Good luck with your project! +⚠️ 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