Skip to content

feat(cli): make self-test verify end-to-end routing#105

Merged
paolomainardi merged 1 commit into
mainfrom
feat/104-self-test-e2e
Jun 20, 2026
Merged

feat(cli): make self-test verify end-to-end routing#105
paolomainardi merged 1 commit into
mainfrom
feat/104-self-test-e2e

Conversation

@paolomainardi

@paolomainardi paolomainardi commented Jun 20, 2026

Copy link
Copy Markdown
Member

User description

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

Closes #104

Problem

spark-http-proxy self-test started a plain nginx with no VIRTUAL_HOST, so the proxy never managed it and routing was never exercised. The only assertion was that dig exited 0 (it didn't even check the returned IP). A passing self-test therefore did not mean a container was actually reachable.

Change

run_self_test is now a real end-to-end check:

  1. Requires the http-proxy and dns services to be running.
  2. Starts a throwaway container with VIRTUAL_HOST=<test-domain> (derived from the first configured TLD, e.g. spark-http-proxy-selftest.loc).
  3. Asserts DNS resolves that domain to the configured target IP (compares the value).
  4. Probes the proxy over HTTP and HTTPS with curl --resolve (so both the connection target and TLS SNI match the host), retrying while routes propagate, and requires 200 on both.
  5. Always removes the test container via a RETURN trap.

Exits non-zero with a per-check report on failure.

Verification

Ran against the dev stack (make dev-up):

ℹ  Running self-test (domain: spark-http-proxy-selftest.loc)...
✅ Proxy and DNS services are running
✅ DNS resolves spark-http-proxy-selftest.loc → 127.0.0.1
✅ HTTP route works (200)
✅ HTTPS route works (200)
✅ Self-test completed successfully

Test container cleaned up by the trap (0 leftovers). bash -n and shellcheck clean for the changed function.


PR Type

Enhancement


Description

  • self-test upgraded from DNS-only check to full end-to-end routing verification

  • Adds HTTP and HTTPS proxy routing checks with retry logic via curl --resolve

  • Validates DNS resolves test domain to configured target IP (value comparison)

  • Test container now uses VIRTUAL_HOST and is cleaned up via RETURN trap


Diagram Walkthrough

flowchart LR
  A["self-test starts"] -- "check services" --> B["http-proxy & dns running?"]
  B -- "yes" --> C["start nginx container\nwith VIRTUAL_HOST=test-domain"]
  C -- "DNS check" --> D["dig resolves domain\nto target IP?"]
  D -- "pass" --> E["HTTP probe\ncurl --resolve port 80"]
  E -- "200" --> F["HTTPS probe\ncurl --resolve port 443 -k"]
  F -- "200" --> G["✅ Self-test passed"]
  B -- "no" --> H["❌ Exit non-zero"]
  D -- "fail" --> I["failures++"]
  E -- "fail" --> I
  F -- "fail" --> I
  I --> J["❌ Self-test failed\nper-check report"]
Loading

File Walkthrough

Relevant files
Enhancement
spark-http-proxy
Upgrade self-test to end-to-end HTTP/HTTPS routing check 

bin/spark-http-proxy

  • Adds self_test_http_probe helper that polls HTTP/HTTPS via curl
    --resolve with retries until 200 or timeout
  • run_self_test now checks both http-proxy and dns services are running
    before proceeding
  • Test container started with VIRTUAL_HOST= (derived from first
    configured TLD)
  • DNS check now compares resolved IP value against
    HTTP_PROXY_DNS_TARGET_IP instead of just checking exit code
  • Adds RETURN trap to always clean up the test container; tracks
    per-check failures with clear report
+76/-23 
Documentation
CHANGELOG.md
Document self-test end-to-end routing change in changelog

CHANGELOG.md

+4/-0     

The previous self-test started a plain nginx with no VIRTUAL_HOST and only
checked that dig exited 0, so it never exercised routing and a pass did not mean
a container was reachable.

self-test now: requires the http-proxy and dns services to be running; starts a
throwaway container with VIRTUAL_HOST set to a test domain derived from the first
configured TLD; asserts DNS resolves that domain to the configured target IP;
probes the proxy over HTTP and HTTPS (via curl --resolve, retrying while routes
propagate) and requires HTTP 200 on both; and always removes the test container
via a RETURN trap. It exits non-zero with a per-check report when any check
fails.

Verified against the dev stack: DNS, HTTP, and HTTPS checks pass and the test
container is cleaned up.

Closes: #104
Assisted-by: claude-code/claude-opus-4-8
@paolomainardi
paolomainardi merged commit 188de8f into main Jun 20, 2026
14 checks passed
@sparkfabrik-ai-bot

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis ✅

104 - PR Code Verified

Compliant requirements:

  • Verify http-proxy and dns services are running
  • Start throwaway container with VIRTUAL_HOST=<test-domain> derived from first configured TLD
  • Assert DNS resolves test domain to configured target IP (value comparison)
  • Assert HTTP returns 200 with retries (up to 15 attempts, 2s sleep)
  • Assert HTTPS returns 200 with -k flag and retries
  • Always clean up via RETURN trap
  • Exit non-zero with per-check failure count report

Requires further human verification:

  • Actual end-to-end behavior in a real dev stack (DNS propagation timing, container network attachment to the proxy network)
  • Whether the throwaway nginx container is automatically attached to the proxy's Docker network (no --network flag is specified in docker run)
⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Missing Network

The test container is started with docker run -d --name "${container_name}" -e "VIRTUAL_HOST=${test_domain}" nginx:latest but no --network flag is specified. The nginx-proxy/jwilder-style proxy discovers containers only on shared networks. If the proxy runs on a non-default network, the test container will never be picked up, DNS will not resolve, and HTTP/HTTPS checks will always fail — even though the proxy itself is healthy.

if ! docker run -d --name "${container_name}" -e "VIRTUAL_HOST=${test_domain}" nginx:latest >/dev/null 2>&1; then
  log_error "Failed to start test container"
  return 1
fi
Unquoted Variable

In self_test_http_probe, ${extra} is passed unquoted to curl (line 521). When extra is empty this is harmless, but when it contains -k the word-splitting is intentional. However, if extra ever contains a value with spaces or glob characters, it will break. A safer pattern is to pass extra options as an array. This is a minor shellcheck concern but worth noting for robustness.

code="$(curl ${extra} -s -o /dev/null -w '%{http_code}' \

@sparkfabrik-ai-bot

Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Guard against empty TLD value

If HTTP_PROXY_DNS_TLDS is set to an empty string, first_tld will become an empty
string after trimming, resulting in a test domain like spark-http-proxy-selftest.
which is invalid. Add a guard to fall back to loc if the extracted TLD is empty.

bin/spark-http-proxy [540-541]

 first_tld="${HTTP_PROXY_DNS_TLDS:-loc}"
 first_tld="$(echo "${first_tld%%,*}" | tr -d '[:space:]')"
+first_tld="${first_tld:-loc}"
Suggestion importance[1-10]: 5

__

Why: This is a valid edge case: if HTTP_PROXY_DNS_TLDS is set to an empty string, the default :-loc won't trigger and the TLD could become empty. Adding first_tld="${first_tld:-loc}" as a fallback is a simple and correct fix.

Low
Unreliable trap usage inside function

Using trap ... RETURN inside a function is not portable across all bash versions and
may not behave as expected in all environments. A more reliable approach is to use
trap ... EXIT or explicitly call cleanup at all exit points. Consider using a
dedicated cleanup function called at the end of the function or via trap ... EXIT
(though that affects the whole shell), or simply ensure cleanup is called before
every return statement.

bin/spark-http-proxy [561]

-trap 'docker rm -f "'"${container_name}"'" >/dev/null 2>&1 || true' RETURN
+# Cleanup is handled explicitly before each return and at the end of the function
+# (remove the trap line and add: docker rm -f "${container_name}" >/dev/null 2>&1 || true
+# before the final return statements)
Suggestion importance[1-10]: 3

__

Why: trap ... RETURN is actually well-supported in bash for cleanup within functions, so the concern about portability is somewhat overstated. The suggestion's improved_code doesn't provide a concrete replacement, making it hard to evaluate the actual improvement.

Low
Possible issue
Hardcoded IP ignores configured target address

The ${extra} variable is unquoted, which can cause word-splitting issues if it
contains spaces or special characters (e.g., -k works fine, but more complex values
would break). Additionally, the --resolve flag hardcodes 127.0.0.1 instead of using
the target_ip variable, meaning the probe always connects to localhost regardless of
the configured target IP. The target_ip should be passed as a parameter or the
hardcoded value should be consistent with the configuration.

bin/spark-http-proxy [520-523]

 for _ in $(seq 1 15); do
-code="$(curl ${extra} -s -o /dev/null -w '%{http_code}' \
-  --resolve "${host}:${port}:127.0.0.1" --max-time 5 \
-  "${scheme}://${host}" 2>/dev/null || true)"
+  code="$(curl "${extra}" -s -o /dev/null -w '%{http_code}' \
+    --resolve "${host}:${port}:${5:-127.0.0.1}" --max-time 5 \
+    "${scheme}://${host}" 2>/dev/null || true)"
Suggestion importance[1-10]: 5

__

Why: The --resolve flag hardcodes 127.0.0.1 instead of using target_ip, which is a valid concern since the configured target_ip could differ. The unquoted ${extra} is also a real word-splitting risk. However, the improved_code uses ${5:-127.0.0.1} which changes the function signature in a non-obvious way.

Low

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.

self-test only checks DNS liveness, not routing

1 participant