fix(proxy): deterministic routing, service hardening, unit tests, and CI go-checks#102
Conversation
Select the backend network IP and default port deterministically instead of relying on Go map iteration order, which could route a VIRTUAL_HOST container to a different network IP or port across restarts when it was attached to multiple networks or exposed multiple ports. Lower the generated DNS A-record TTL from 3600s to 60s so a changed target IP propagates quickly in local development rather than being cached for an hour by the OS stub resolver. Guard against a nil-pointer panic in join-networks when a container reports no network settings, give signal handling a single owner in the shared service framework, and abort the event-stream reconnect backoff promptly on shutdown. Remove unused code (GetEnvOrDefaultInt, SliceToSet, the TraefikLabels and NetworkInfo structs) and extract a shared HasTraefikLabel helper. Refs: #101 Assisted-by: claude-code/claude-opus-4-8
Cover parseVirtualHosts, convertWildcardToRegex, generateServiceName, getEffectivePort, getContainerIP/getDefaultPort determinism, generateTraefikConfig, isDomainHandled, and the config/utils helpers. Refs: #101 Assisted-by: claude-code/claude-opus-4-8
Run gofmt, go vet, and go test -race on every non-main branch so the new unit tests and formatting are enforced in CI. Closes: #101 Assisted-by: claude-code/claude-opus-4-8
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Explore these optional code suggestions:
|
There was a problem hiding this comment.
Pull request overview
This PR improves the Spark HTTP Proxy’s Go sidecars by making routing decisions
deterministic, hardening event-loop shutdown behavior, removing confirmed dead
code, and adding the project’s first Go unit tests plus a CI gate to run them.
Changes:
- Make
dinghy-layerbackend IP/port selection deterministic (sorted selection
rather than randomized map iteration). - Improve service framework behavior (signal ownership + reconnect backoff) and
hardenjoin-networksagainst nil network settings. - Add Go unit tests + a CI
go-checksjob (gofmt,go vet,go test -race),
and update the changelog.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pkg/utils/utils.go | Adds HasTraefikLabel helper and removes unused SliceToSet. |
| pkg/utils/utils_test.go | Adds unit tests for env parsing, label detection, and log level validation. |
| pkg/service/docker_event_service.go | Refactors signal handling ownership and reconnect logic. |
| pkg/config/config.go | Removes unused GetEnvOrDefaultInt. |
| pkg/config/config_test.go | Adds unit tests for env helpers. |
| cmd/join-networks/main.go | Removes unused network struct and adds nil guard for network settings. |
| cmd/dns-server/main.go | Lowers A-record TTL, avoids string-parsing RR creation, and logs write errors. |
| cmd/dns-server/main_test.go | Adds unit tests for domain-matching logic. |
| cmd/dinghy-layer/main.go | Sorts networks/ports for deterministic routing; uses shared Traefik-label helper. |
| cmd/dinghy-layer/main_test.go | Adds unit tests for parsing, determinism fixes, and config generation. |
| CHANGELOG.md | Documents the new tests/CI job and correctness fixes. |
| .github/workflows/ci.yml | Adds go-checks job for formatting/vet/unit-test gating. |
Comments suppressed due to low confidence (1)
pkg/service/docker_event_service.go:129
- The Docker events loop reads from
eventsChan/errChanwithout checking whether either channel has been closed. In Go, receiving from a closed channel returns the zero value immediately, so if Docker closes either channel (e.g. daemon restart / connection drop) this loop can become a tight spin (high CPU) and never reach the reconnect/backoff path.
for {
select {
case <-ctx.Done():
return nil
case event := <-eventsChan:
…uplication Reject a non-IPv4 HTTP_PROXY_DNS_TARGET_IP at startup. The server answers only A records, so an IPv6 target previously passed validation and was silently truncated into a 4-byte A record. Drop the redundant NetworkSet copy in performInitialNetworkJoin now that ContainerInfo.Networks is already a NetworkSet, and fold the duplicated sort-and-format tail of getDefaultPort into a lowestTCPPort helper. Refs: #101 Assisted-by: claude-code/claude-opus-4-8
Fresh-eyes review (self-review with subagents)Ran a high-recall review over the diff. One real regression and two simplifications were found and fixed in Correctness regression (fixed)The rewritten Simplifications (applied)
Checked and confirmed correctThe signal-handling refactor has a single owner with no double-close or missing close (both binaries call Verification after the fixes: |
The event loop received from the events and error channels without checking for closure. When the Docker daemon closes the stream (for example on a daemon restart), a closed channel returns the zero value immediately, so the loop would busy-spin instead of reconnecting. Detect closure with the comma-ok form and reconnect after a backoff, sharing the backoff with the existing error path. Refs: #101 Assisted-by: claude-code/claude-opus-4-8
Add a test seam to Service: a subscribe func field (defaulting to the Docker client's Events) and a configurable reconnectDelay. This lets the event loop be driven without a Docker daemon. New tests assert that runEventLoop reconnects when the events channel is closed and when an error is delivered, and that it returns the initial-scan error. Refs: #101 Assisted-by: claude-code/claude-opus-4-8
User description
Closes #101
Summary
This addresses the code-quality findings from a full read of the Go codebase. It fixes two latent routing bugs, hardens the event-driven services, removes dead code, and adds the project's first unit tests plus a CI gate to run them.
Correctness fixes
cmd/dinghy-layer):getContainerIPranged over the container's networks map and returned the first non-empty IP. Go randomizes map iteration, so a container attached to multiple networks could be routed to a different network IP on each restart or event. Network names are now sorted before selection.cmd/dinghy-layer):getDefaultPorthad the same map-iteration problem over exposed ports. It now picks the lowest exposed TCP port (then the lowest bound TCP port), so the choice is stable.cmd/dns-server): the old value was thedns.NewRRdefault, not a deliberate choice. On a local development resolver a one-hour TTL means a changedHTTP_PROXY_DNS_TARGET_IPstays cached by the OS stub resolver. The record is now built directly as a*dns.Ainstead of being parsed from a string on every query.cmd/join-networks):getContainerInfodereferencedNetworkSettingswithout a nil check, unlike the equivalent code indinghy-layer.pkg/service):RunandRunWithSignalHandlingeach installed their ownsignal.Notifyfor the same signals, so two goroutines raced for each signal. Signal handling now has a single owner, and the event-stream reconnect backoff exits promptly on shutdown instead of always sleeping five seconds.Cleanup
GetEnvOrDefaultInt,SliceToSet, theTraefikLabelsstruct (its own comment noted it was unused), and the write-onlyNetworkInfostruct.HasTraefikLabelhelper used by bothdinghy-layerandutils.ShouldManageContainer.Tests and CI
HostRegexpbranch, the ReDoS guard, and the two determinism fixes above. These need no Docker, unlike the existing integration suite.go-checksCI job runninggofmt,go vet, andgo test -raceon every non-mainbranch. CI previously ran no Go-level checks.Verification
go test -race ./...: 61 passing.gofmt -landgo vet ./...: clean.make test(full integration suite, as CI runs it): all 6 suites pass (HTTP 5/5, HSTS 5/5, DNS 15/15, upstream DNS 3/3, forwarding 2/2, DNS config 3/3) against images rebuilt from this branch.Notes
golangci-lintwas intentionally left out of the CI job. Its defaultstaticcheckwould fail immediately on the pre-existingtypes.ContainerJSON/types.Containerdeprecations across the codebase, which belong to a separate Docker SDK migration rather than this change.PR Type
Bug fix, Tests, Enhancement
Description
Fix nondeterministic backend IP and port selection by sorting map keys before iteration
Remove dead code:
TraefikLabels,NetworkInfo,GetEnvOrDefaultInt,SliceToSet; extract sharedHasTraefikLabelhelperAdd first unit tests for pure parsing/config helpers across
dinghy-layer,dns-server,config, andutilsAdd CI
go-checksjob enforcinggofmt,go vet, andgo test -race; harden signal handling and DNS A-record constructionDiagram Walkthrough
File Walkthrough
4 files
Deterministic IP/port selection, remove dead struct, extract helperLower A-record TTL, build record directly, log write errorsGuard nil NetworkSettings, remove unused NetworkInfo structSingle signal owner, extract filter helper, prompt reconnect backoff4 files
Add unit tests for parsing, routing, and config helpersAdd unit tests for isDomainHandledAdd unit tests for config env helpersAdd unit tests for utils helpers1 files
Remove unused GetEnvOrDefaultInt function1 files
Extract HasTraefikLabel helper, remove SliceToSet dead code1 files
Add go-checks CI job for fmt, vet, and unit tests1 files
Document all fixes and additions for this PR