diff --git a/internal/app/integrations.go b/internal/app/integrations.go index e0f6d12..8d3302a 100644 --- a/internal/app/integrations.go +++ b/internal/app/integrations.go @@ -67,6 +67,53 @@ func (a *App) ensureCaddyRoute(ctx context.Context, p Project) error { "match": []any{map[string]any{"host": []string{p.RouteHost}}}, "handle": []any{map[string]any{"handler": "reverse_proxy", "upstreams": []any{map[string]any{"dial": "unix/" + socketPath}}}}, } + + cfg, err := a.fetchCaddyConfig(ctx) + if err != nil { + return err + } + indexes := findCaddyRouteIndexesByID(cfg, a.cfg.CaddyServerID, route["@id"].(string)) + if len(indexes) > 0 { + // Update first matching route in place. + if err := a.putCaddyRouteAtIndex(ctx, indexes[0], route); err != nil { + return err + } + // Remove stale duplicates from highest index to lowest. + for i := len(indexes) - 1; i >= 1; i-- { + if err := a.deleteCaddyRouteAtIndex(ctx, indexes[i]); err != nil { + return err + } + } + return nil + } + + if err := a.appendCaddyRoute(ctx, route); err != nil { + // If duplicate race happened, retry once through dedupe/update path. + if strings.Contains(err.Error(), "duplicate ID") { + cfg2, ferr := a.fetchCaddyConfig(ctx) + if ferr != nil { + return err + } + indexes2 := findCaddyRouteIndexesByID(cfg2, a.cfg.CaddyServerID, route["@id"].(string)) + if len(indexes2) == 0 { + return err + } + if uerr := a.putCaddyRouteAtIndex(ctx, indexes2[0], route); uerr != nil { + return uerr + } + for i := len(indexes2) - 1; i >= 1; i-- { + if derr := a.deleteCaddyRouteAtIndex(ctx, indexes2[i]); derr != nil { + return derr + } + } + return nil + } + return err + } + return nil +} + +func (a *App) appendCaddyRoute(ctx context.Context, route map[string]any) error { body, _ := json.Marshal(route) url := fmt.Sprintf("%s/config/apps/http/servers/%s/routes", a.cfg.CaddyAdminURL, a.cfg.CaddyServerID) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body))) @@ -86,6 +133,78 @@ func (a *App) ensureCaddyRoute(ctx context.Context, p Project) error { return nil } +func (a *App) putCaddyRouteAtIndex(ctx context.Context, idx int, route map[string]any) error { + body, _ := json.Marshal(route) + url := fmt.Sprintf("%s/config/apps/http/servers/%s/routes/%d", a.cfg.CaddyAdminURL, a.cfg.CaddyServerID, idx) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, strings.NewReader(string(body))) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("caddy update request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("caddy update error: status=%d body=%s", resp.StatusCode, string(respBody)) + } + return nil +} + +func (a *App) deleteCaddyRouteAtIndex(ctx context.Context, idx int) error { + url := fmt.Sprintf("%s/config/apps/http/servers/%s/routes/%d", a.cfg.CaddyAdminURL, a.cfg.CaddyServerID, idx) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("caddy delete request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("caddy delete error: status=%d body=%s", resp.StatusCode, string(respBody)) + } + return nil +} + +func findCaddyRouteIndexesByID(cfg map[string]any, serverID, routeID string) []int { + var out []int + apps, ok := cfg["apps"].(map[string]any) + if !ok { + return out + } + httpApp, ok := apps["http"].(map[string]any) + if !ok { + return out + } + servers, ok := httpApp["servers"].(map[string]any) + if !ok { + return out + } + server, ok := servers[serverID].(map[string]any) + if !ok { + return out + } + routes, ok := server["routes"].([]any) + if !ok { + return out + } + for i, r := range routes { + rm, ok := r.(map[string]any) + if !ok { + continue + } + if rid, ok := rm["@id"].(string); ok && rid == routeID { + out = append(out, i) + } + } + return out +} + func (a *App) fetchCaddyConfig(ctx context.Context) (map[string]any, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.CaddyAdminURL+"/config/", nil) if err != nil {