Skip to content

Fix Caddy route creation when routes array is null#25

Merged
jfox85 merged 2 commits intomainfrom
jf-fix-routes
Feb 12, 2026
Merged

Fix Caddy route creation when routes array is null#25
jfox85 merged 2 commits intomainfrom
jf-fix-routes

Conversation

@jfox85
Copy link
Owner

@jfox85 jfox85 commented Feb 12, 2026

Summary

  • Dynamic server discovery: Discovers the port-80 HTTP server instead of hardcoding srv1, with graceful fallback
  • Null routes fix: Adds EnsureRoutesArray() that initializes the server's routes array via PATCH when it's null/missing, preventing Caddy 500 errors on route append
  • Resilient GetAllRoutes: Handles null response bodies and 404 status codes by returning empty slices instead of errors

Test plan

  • All existing tests pass (go test -v -race ./...)
  • New unit tests with httptest mocks cover: server discovery (4 cases), EnsureRoutesArray (3 cases), GetAllRoutes null handling (3 cases), server path integration
  • Manual: devx caddy check --fix with fresh Caddy config (null routes) succeeds
  • Manual: devx session create provisions routes correctly with non-srv1 server names

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Ensure route storage is initialized when missing, avoiding provisioning failures.
    • Use dynamic server discovery so route operations target the correct server instead of a fixed name.
    • Handle missing, null or 404 route responses gracefully to prevent unexpected errors.
  • Tests

    • Added extensive tests covering server discovery, route initialization, and robust route retrieval/operations.

Caddy returns a 500 error when appending a route to a server whose
`routes` field is null (rather than an empty array). This fixes
`devx caddy check --fix` by:

- Discovering the port-80 server dynamically instead of hardcoding srv1
- Adding EnsureRoutesArray() to initialize null/missing routes before
  route creation batches
- Making GetAllRoutes resilient to null bodies and 404 responses
- Adding comprehensive unit tests with httptest mocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Feb 12, 2026

📝 Walkthrough

Walkthrough

Introduces dynamic HTTP server discovery in the Caddy client, replaces hard-coded /srv1 paths with server-specific paths, adds EnsureRoutesArray to initialize missing routes arrays, and integrates that initialization into health and provisioning flows. Extensive tests cover discovery, initialization, and null/404 handling.

Changes

Cohort / File(s) Summary
Server discovery & route core
caddy/routes.go
Adds serverName field, discoverServerName() and serverPath() helpers; replaces hard-coded /srv1 paths with dynamic server paths; adds EnsureRoutesArray(); makes GetAllRoutes/ReplaceAllRoutes robust to missing/null routes; discovery occurs during client init.
API surface adjustment
caddy/routes.go
Updates CreateRouteWithProject signature to include projectAlias.
Initialization integration
caddy/health.go, caddy/provisioning.go
Calls EnsureRoutesArray() early in RepairRoutes and ProvisionSessionRoutesWithProject, returning errors when array initialization fails.
Tests
caddy/routes_test.go
Adds extensive tests and test helpers: server discovery scenarios, EnsureRoutesArray behaviors, null/404 handling for GetAllRoutes, serverPath verification, and an integration-style test asserting request paths use discovered server names.

Sequence Diagram(s)

sequenceDiagram
    participant Client as CaddyClient
    participant Admin as Caddy Admin API
    participant ServerConfig as Server Config

    Client->>Admin: GET /config/apps/http/servers
    Admin-->>Client: JSON list of servers
    Client->>Client: discoverServerName() -> pick serverName (port 80 or fallback)
    Client->>ServerConfig: PATCH /config/apps/http/servers/{serverName}/routes  {"routes":[]}
    ServerConfig-->>Client: 200 OK (routes initialized) / 404 or error
    Client->>ServerConfig: GET /config/apps/http/servers/{serverName}/routes
    ServerConfig-->>Client: routes (or null/404 handled)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I sniffed the admin lanes and found a name anew,
From srv1 to a server on port eighty's view.
I planted empty routes where emptiness had grown,
Ran tests beneath the moonlight so no bug would roam.
Hop, patch, and flourish — the config sings, hooray!

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely summarizes the main change: fixing Caddy route creation when the routes array is null, which is the core issue addressed by adding EnsureRoutesArray() initialization.
Docstring Coverage ✅ Passed Docstring coverage is 81.82% which is sufficient. The required threshold is 80.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch jf-fix-routes

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
caddy/routes_test.go (1)

243-265: Consider synchronizing patched for race-detector safety.

The patched bool is written inside the HTTP handler (which runs in a separate goroutine) and read in the test goroutine. While the HTTP round-trip provides an implicit happens-before, using sync/atomic or a mutex would make this explicit and guard against race-detector false positives if test internals ever change.

The same pattern applies to the other two subtests at lines 267-311.

♻️ Optional: use atomic bool
 	t.Run("routes already exists — no PATCH", func(t *testing.T) {
-		patched := false
+		var patched atomic.Bool
 		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 			if r.Method == http.MethodGet {
 				_, _ = w.Write([]byte(`{"listen":[":80"],"routes":[]}`))
 				return
 			}
 			if r.Method == http.MethodPatch {
-				patched = true
+				patched.Store(true)
 			}
 			w.WriteHeader(http.StatusOK)
 		}))
 		defer ts.Close()

 		c := newTestClient(ts, "srv1")
 		if err := c.EnsureRoutesArray(); err != nil {
 			t.Fatalf("unexpected error: %v", err)
 		}
-		if patched {
+		if patched.Load() {
 			t.Error("expected no PATCH when routes already exists")
 		}
 	})

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@caddy/routes_test.go`:
- Around line 180-238: Handlers in the tests use json.NewEncoder(w).Encode(...)
and w.Write(...) with unchecked error returns which fails errcheck; update each
test handler (the http.HandlerFunc closures in the t.Run cases) to explicitly
handle or discard the errors by assigning the returns (e.g. _, _ =
json.NewEncoder(w).Encode(...)) or by checking the error and t.Fatalf if
non-nil; specifically address the Encode calls inside the first three handlers
and the w.Write calls in the last two handlers so json.NewEncoder(w).Encode and
w.Write return values are not left unchecked.

In `@caddy/routes.go`:
- Around line 70-97: The port-matching in discoverServerName uses
strings.Contains(addr, ":80") which false-positives on ports like :8080; update
the loop that iterates srv.Listen (the addr variable) to reliably extract the
port (use net.SplitHostPort or handle leading ":" cases) and compare the port
string exactly to "80" before setting c.serverName; ensure IPv6 forms like
"[::]:80" and short forms like ":80" are handled and skip entries that don’t
parse to a host:port pair.

Comment on lines +70 to +97
func (c *CaddyClient) discoverServerName() {
c.serverName = "srv1" // default fallback

resp, err := c.client.R().Get(c.baseURL + "/config/apps/http/servers")
if err != nil || resp.StatusCode() != http.StatusOK {
return
}

var servers map[string]json.RawMessage
if err := json.Unmarshal(resp.Body(), &servers); err != nil {
return
}

for name, raw := range servers {
var srv struct {
Listen []string `json:"listen"`
}
if err := json.Unmarshal(raw, &srv); err != nil {
continue
}
for _, addr := range srv.Listen {
if strings.Contains(addr, ":80") {
c.serverName = name
return
}
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Port-matching logic can produce false positives for ports like :8080, :800, :8000.

strings.Contains(addr, ":80") matches any address containing the substring :80, not just port 80. A server listening on :8080 or :8000 would be incorrectly selected.

🐛 Proposed fix: use a stricter match
 		for _, addr := range srv.Listen {
-			if strings.Contains(addr, ":80") {
+			if addr == ":80" || strings.HasSuffix(addr, ":80") {
 				c.serverName = name
 				return
 			}

This covers both :80 and 0.0.0.0:80 / [::]:80 while avoiding false matches on :8080, :800, etc.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (c *CaddyClient) discoverServerName() {
c.serverName = "srv1" // default fallback
resp, err := c.client.R().Get(c.baseURL + "/config/apps/http/servers")
if err != nil || resp.StatusCode() != http.StatusOK {
return
}
var servers map[string]json.RawMessage
if err := json.Unmarshal(resp.Body(), &servers); err != nil {
return
}
for name, raw := range servers {
var srv struct {
Listen []string `json:"listen"`
}
if err := json.Unmarshal(raw, &srv); err != nil {
continue
}
for _, addr := range srv.Listen {
if strings.Contains(addr, ":80") {
c.serverName = name
return
}
}
}
}
func (c *CaddyClient) discoverServerName() {
c.serverName = "srv1" // default fallback
resp, err := c.client.R().Get(c.baseURL + "/config/apps/http/servers")
if err != nil || resp.StatusCode() != http.StatusOK {
return
}
var servers map[string]json.RawMessage
if err := json.Unmarshal(resp.Body(), &servers); err != nil {
return
}
for name, raw := range servers {
var srv struct {
Listen []string `json:"listen"`
}
if err := json.Unmarshal(raw, &srv); err != nil {
continue
}
for _, addr := range srv.Listen {
if addr == ":80" || strings.HasSuffix(addr, ":80") {
c.serverName = name
return
}
}
}
}
🤖 Prompt for AI Agents
In `@caddy/routes.go` around lines 70 - 97, The port-matching in
discoverServerName uses strings.Contains(addr, ":80") which false-positives on
ports like :8080; update the loop that iterates srv.Listen (the addr variable)
to reliably extract the port (use net.SplitHostPort or handle leading ":" cases)
and compare the port string exactly to "80" before setting c.serverName; ensure
IPv6 forms like "[::]:80" and short forms like ":80" are handled and skip
entries that don’t parse to a host:port pair.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jfox85 jfox85 merged commit 847510d into main Feb 12, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant