Skip to content

feat(dinghy-layer): support VIRTUAL_PATH to share one hostname - #115

Merged
paolomainardi merged 3 commits into
mainfrom
feat/113-virtual-path
Aug 13, 2026
Merged

feat(dinghy-layer): support VIRTUAL_PATH to share one hostname#115
paolomainardi merged 3 commits into
mainfrom
feat/113-virtual-path

Conversation

@paolomainardi

@paolomainardi paolomainardi commented Aug 12, 2026

Copy link
Copy Markdown
Member

User description

🤖 This was written by an AI agent on behalf of @paolomainardi.

Closes #113. Implements the specification merged in #114.

What it does

A container setting VIRTUAL_PATH alongside VIRTUAL_HOST is mounted under that path of that hostname, so two containers can share one domain:

frontend:
  environment:
    - VIRTUAL_HOST=myapp.local
    - VIRTUAL_PORT=5173

api:
  environment:
    - VIRTUAL_HOST=myapp.local # the same domain
    - VIRTUAL_PATH=/api # mounted under it
    - VIRTUAL_PORT=3000

myapp.local/ reaches the frontend, myapp.local/api/... reaches the API, and the page can call /api/... with no host in front of it. No CORS, no preflight, one certificate.

VIRTUAL_PATH is nginx-proxy's variable for this, which is why it was chosen over inventing syntax inside VIRTUAL_HOST: VIRTUAL_HOST parsing is untouched, each container keeps its own VIRTUAL_PORT, and the ~-regex form cannot have a slash reinterpreted.

Two routing decisions worth reviewing

Matching is by segment. Traefik's PathPrefix is a raw string prefix, so PathPrefix(/api) also matches /api-docs. An ingress pathType: Prefix splits on separators and does not. Since the point is that one relative call behaves the same locally and once deployed, the emitted rule is:

Host(`myapp.local`) && (PathPrefix(`/api/`) || Path(`/api`))

Priority is set on mounted routers only. Traefik ranks routers by rule length when priority is unset, in one table per entrypoint, across providers. Setting a priority on the host routers this layer already emits would re-rank them against routers built from users' own traefik.* labels, and would replace today's stable exact-versus-wildcard ordering with an undefined tie. So host routers are emitted byte-identically to before, and only mounted routers carry a value. That value is non-zero deliberately: with omitempty a zero would be dropped and silently restore inherited ordering, which a test pins.

Three silent failures that now warn

None are caused by this feature; the first two are simply easier to hit with it.

  • A container carrying any traefik. label is skipped whole, so its VIRTUAL_HOST never routes. This is intentional precedence, but a middleware label alone triggers it. It was debug-level, so invisible by default.
  • VIRTUAL_PATH with no VIRTUAL_HOST exposes nothing at all.
  • Two containers claiming the same host and path. Tracked across container events rather than only at startup, since the proxy is normally already running when a stack starts.

Also fixed

Example 7 in examples/applications.yml could never have worked: its CORS middleware label made the layer skip the container, so VIRTUAL_HOST=api.loc produced no route and the routers it referenced did not exist. It now declares its own routers.

AGENTS.md said the repository has no unit tests and that make test is the verification step. There are five _test.go files, and make test runs only the integration suite.

Tests

Unit (cmd/dinghy-layer/main_test.go): every accepted and rejected path form, the rule asserted whole, the emitted YAML asserted for both the presence and the absence of priority, and the ordering against host and wildcard rules. I mutation-checked the three that matter: replacing the matcher with a naive PathPrefix fails 5 assertions, dropping the priority fails 6, removing the path validation fails 8.

Integration (test/test.sh): two containers sharing a hostname with distinct bodies, since status codes cannot distinguish a working mount from a fall-through. Covers the root, the mount, a nested path, /api-docs not being captured, both schemes, a wildcard not outranking the mount, and the documented fall-through when the mounted container stops.

Diagnostic: self-test starts a mounted container and checks it over both schemes by body.

Security note

The rule is assembled by string formatting, so a path containing a backtick could close the matcher and append arbitrary routing syntax. Validation rejects anything that is not a single plain path, and there is a test for exactly that input.


PR Type

Enhancement, Tests, Documentation, Bug fix


Description

  • Add VIRTUAL_PATH shared-host path routing

  • Validate paths and prioritize mounted routers

  • Warn about ignored and duplicate routes

  • Cover routing, diagnostics, docs, and examples


Diagram Walkthrough

flowchart LR
  Container["Container with VIRTUAL_HOST and VIRTUAL_PATH"]
  Layer["dinghy layer"]
  Router["Prioritized segment-aware path router"]
  Backend["Mounted container backend"]
  Container -- "declares host and path" --> Layer
  Layer -- "writes Traefik route" --> Router
  Router -- "forwards unchanged request path" --> Backend
Loading

File Walkthrough

Relevant files
Enhancement
3 files
main.go
Generate validated prioritized virtual path routes             
+191/-2 
traefik.go
Add router priority YAML configuration support                     
+9/-2     
spark-http-proxy
Validate certificate domains and path self-test                   
+93/-4   
Tests
2 files
main_test.go
Test path parsing routing priorities and claims                   
+263/-0 
test.sh
Exercise virtual path routing end to end                                 
+196/-1 
Documentation
3 files
AGENTS.md
Document virtual paths and test suite guidance                     
+26/-11 
CHANGELOG.md
Record virtual path routing release changes                           
+8/-0     
README.md
Document shared-domain `VIRTUAL_PATH` configuration           
+71/-0   
Bug fix
1 files
applications.yml
Fix CORS example and demonstrate path routing                       
+34/-4   


Assisted-by: pr-agent/gpt-5.6-terra

A container setting VIRTUAL_PATH alongside VIRTUAL_HOST is mounted under
that path of that hostname, so a browser-served frontend and its API can share
one origin locally: no CORS, no preflight, one certificate. VIRTUAL_PATH is the
variable nginx-proxy uses for this, and this layer exists to be compatible with
that style of container.

Matching is by path segment. Traefik's PathPrefix is a raw string prefix, so
PathPrefix(/api) would also match /api-docs, while an ingress path prefix splits
on separators and does not. Pairing PathPrefix with Path keeps a mount from
capturing a sibling that merely starts with the same characters, which is what
lets one relative call behave the same locally and once deployed.

Priority is set on mounted routers only. Traefik ranks routers by rule length
when priority is unset, in one table per entrypoint across providers, so giving
host routers an explicit value would re-rank them against routers built from
users' own labels and would replace today's stable exact-versus-wildcard
ordering with an undefined tie. Host routers are therefore emitted exactly as
before, and the field is non-zero where it is set because omitempty would
otherwise drop it and silently restore inherited ordering.

Nothing is stripped, matching both an ingress and nginx-proxy's default;
VIRTUAL_DEST is not supported.

Two failures that were previously silent at debug level now warn: a container
carrying any traefik. label, which is skipped whole so its VIRTUAL_HOST never
routes, and VIRTUAL_PATH with no VIRTUAL_HOST. A third warns when two containers
claim the same host and path, tracked across events rather than only at startup,
because the proxy is normally running before a stack starts.

The certificate commands reject an argument containing a path, since a
certificate covers a hostname and the filename helper would otherwise fold the
path in and half succeed. self-test now exercises a mounted path over both
schemes, comparing the response body: without the path route the request falls
through to the hostname's container, which answers 200 and would hide it.

Also fixes the CORS example, which could never have worked because its
middleware label made the layer skip the container.

Refs: #113
Assisted-by: claude-code/claude-opus-5
@sparkfabrik-ai-bot

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

113 - Partially compliant

Compliant requirements:

  • Add VIRTUAL_PATH support alongside unchanged VIRTUAL_HOST and per-container VIRTUAL_PORT.
  • Route mounted paths using segment-aware matching, without stripping the prefix.
  • Support HTTP and HTTPS routing for shared-hostname containers.
  • Validate path declarations and warn about invalid declarations, ignored routing variables, and duplicate route claims.
  • Cover routing, fallback behavior, wildcard precedence, and diagnostics with unit, integration, and self-test coverage.
  • Document the feature and update affected examples and certificate command behavior.

Non-compliant requirements:

  • Give mounted path routers explicit precedence over host and wildcard routes for all valid VIRTUAL_HOST declarations.

Requires further human verification:

  • Run the Docker integration suite and spark-http-proxy self-test against a live Traefik installation.

114 - PR Code Verified

Compliant requirements:

  • The implementation follows the documented VIRTUAL_PATH routing model and includes corresponding documentation and tests.

Requires further human verification:

  • The OpenSpec files are not present in this diff, so their continued presence and exact specification content cannot be verified here.
⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Priority ceiling

10000 + len(path) is not guaranteed to exceed Traefik's default rule-length priority. VIRTUAL_HOST preserves the raw ~ HostRegexp form and has no visible length limit, so a valid wildcard regex longer than 10,000 characters can produce an unprioritized router that outranks a mounted path. This recreates the ambiguous/wrong wildcard precedence the explicit priority is intended to prevent. Use a priority near Traefik's supported maximum (with its required safety margin), rather than a plausibility-based fixed base.

// pathPriorityBase lifts every mounted-path router above the rule-length
// ordering Traefik applies when no priority is set. Rule length is bounded by
// how long a hostname and a path can plausibly be, so a base well past that
// keeps a mounted path ahead of a bare host and of a wildcard that happens to
// produce a long regex.
const pathPriorityBase = 10000

Assisted-by: pr-agent/gpt-5.6-terra

@sparkfabrik-ai-bot

Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Follow mounted-path redirects

Follow redirects when checking response bodies. nginx redirects a request for a
directory such as /api to /api/, so the current probe receives a 301 with an empty
body even when Traefik correctly selected the mounted router.

test/test.sh [707]

-body="$(curl -s -H "Host: ${hostname}" "http://localhost:${HTTP_PORT}${path}" 2>/dev/null || true)"
+body="$(curl -sL -H "Host: ${hostname}" "http://localhost:${HTTP_PORT}${path}" 2>/dev/null || true)"
Suggestion importance[1-10]: 7

__

Why: Requesting /api from nginx redirects to /api/, so the current body-based integration assertion can fail even when Traefik selected the mounted router correctly. Adding -L makes the test validate the final backend response.

Medium
Follow self-test path redirects

Add redirect following to the body probe. The self-test's nginx backend serves
/self-test as a directory and redirects it to /self-test/, causing a healthy
VIRTUAL_PATH route to fail the body assertion.

bin/spark-http-proxy [681-683]

-body="$(curl ${extra} -s \
+body="$(curl ${extra} -sL \
   --resolve "${host}:${port}:127.0.0.1" --max-time 5 \
   "${scheme}://${host}${path}" 2>/dev/null || true)"
Suggestion importance[1-10]: 7

__

Why: The self-test's mounted backend serves /self-test as a directory, for which nginx redirects to /self-test/; without -L, the probe sees no expected marker despite correct routing. The change correctly makes the body probe follow that redirect.

Medium

Assisted-by: pr-agent/gpt-5.6-terra

The path probe compared the body of whatever the bare path returned, but a
backend serving a directory answers that with a redirect to the trailing-slash
form, and the redirect page is identical whichever container produced it. The
probe now follows redirects, so it compares the content of the container that
actually owns the path.

Both probes also returned non-zero on failure from inside the caller's command
substitution, which under set -e ended the script before it could report the
failed check or run the trap that removes the test containers. They now always
succeed and let the caller compare, which is what makes a failure visible.

Verified by removing VIRTUAL_PATH from the mounted container: the hostname
checks still pass with 200 while the path checks fail, which is the case a
status-code probe could not have caught.

Refs: #113
Assisted-by: claude-code/claude-opus-5
The HTTPS checks requested the bare path, which a backend serving a
directory answers with a redirect to the trailing-slash form. That redirect
names the http scheme, because the backend does not know the request arrived
over TLS, so following it either failed to resolve or silently completed the
assertion over plain HTTP.

The HTTPS assertions now request the trailing-slash form, so no redirect is
involved and the exchange stays on TLS. Both ports are mapped in the probes
regardless, so a redirect between schemes can still be followed rather than
producing an empty body.

This passed locally before because the machine resolves the test domain through
the proxy's own DNS, so curl did not need the mapping. CI has no such resolver,
which is why it only failed there.

Refs: #113
Assisted-by: claude-code/claude-opus-5
@paolomainardi
paolomainardi merged commit cc64c0e into main Aug 13, 2026
14 checks passed
@paolomainardi
paolomainardi deleted the feat/113-virtual-path branch August 13, 2026 00:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support VIRTUAL_PATH so containers can share one hostname

1 participant