feat(service): add a deprecation signal on the legacy /setup and /mgmt paths (refs #451)

So the eventual 1.x removal of the legacy admin paths can be data-driven (cut a
route only once it has gone quiet across real deployments), record usage of the
pre-/api paths without changing their behavior.

- New DeprecatedRouteMiddleware: after serving, counts the hit keyed by
  "METHOD <route-pattern>" and logs a one-time warning per route pointing at the
  /api equivalent. Wired onto the legacy /setup and /mgmt mounts only — NOT the
  /api/* twins, NOT the externally-pinned OAuth callbacks, NOT the Stockholm
  setup-wizard catch-all.
- Counts are exposed in the diagnostic export (deprecated_route_hits), so the
  shared bundles show whether the old paths are still in use.

Legacy paths keep working unchanged. make test-http-client: 95 requests, 0
failed (the suite still exercises /mgmt directly and now emits the one-time
warnings). go test + golangci-lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-06 21:36:49 +02:00
co-authored by Claude Opus 4.8
parent 3d5add3a07
commit 30c7599210
5 changed files with 158 additions and 8 deletions
@@ -0,0 +1,47 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
)
// TestDeprecatedRouteSignal verifies the legacy admin paths are counted (and the
// new /api/* twins are not), so the diagnostic export can show whether the old
// paths are still in use before they are removed in a future major release.
func TestDeprecatedRouteSignal(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
_ = ds.Initialize()
server := handlers.NewServer(ds, nil, "http://localhost:8000", true, false, false)
r := setupRouter(server, nil)
ts := httptest.NewServer(r)
defer ts.Close()
hit := func(path string) {
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
_ = resp.Body.Close()
}
hit("/setup/version") // legacy — counted
hit("/setup/version") // legacy again — count increments
hit("/api/setup/version") // new canonical — must NOT be counted
hits := server.DeprecatedRouteHits()
if got := hits["GET /setup/version"]; got != 2 {
t.Errorf("legacy GET /setup/version hits = %d, want 2", got)
}
if _, tracked := hits["GET /api/setup/version"]; tracked {
t.Errorf("/api/setup/version must not be tracked as deprecated; hits=%v", hits)
}
}
+11 -2
View File
@@ -1375,9 +1375,12 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
r.Get("/amazon/callback", server.HandleMgmtAmazonCallback)
// All other management endpoints require Basic Auth.
// All other management endpoints require Basic Auth. On the legacy mount
// they also carry the deprecation signal (counts + one-time warning); the
// callbacks above are excluded (externally-pinned, not deprecated).
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
r.Use(server.DeprecatedRouteMiddleware)
mountMgmtAuthed(r)
})
})
@@ -1454,7 +1457,13 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
}
r.Route("/setup", func(r chi.Router) {
mountSetupAPI(r)
// Legacy admin API: same handlers as /api/setup, plus the deprecation
// signal (counts + one-time warning). Scoped to the API routes only — the
// Stockholm wizard catch-all below is frontend, not a deprecated API path.
r.Group(func(r chi.Router) {
r.Use(server.DeprecatedRouteMiddleware)
mountSetupAPI(r)
})
// Serve Stockholm setup wizard pages for paths not matched by the
// management API. The Stockholm frontend has a setup/ directory that must
+90
View File
@@ -0,0 +1,90 @@
package handlers
import (
"log"
"net/http"
"sync"
"github.com/go-chi/chi/v5"
)
// deprecatedRouteTracker counts hits on legacy admin routes (the /setup and
// /mgmt paths that now also live under /api/*). The counts let the eventual 1.x
// removal be data-driven — a route is only cut once it has gone quiet across
// real deployments — and are surfaced in the diagnostic export.
type deprecatedRouteTracker struct {
mu sync.Mutex
counts map[string]int64
logged map[string]bool
}
func newDeprecatedRouteTracker() *deprecatedRouteTracker {
return &deprecatedRouteTracker{
counts: map[string]int64{},
logged: map[string]bool{},
}
}
// record increments the counter for key ("METHOD pattern") and reports whether
// this is the first time the key was seen, so the caller can log once per route
// per process rather than on every hit.
func (t *deprecatedRouteTracker) record(key string) (firstHit bool) {
t.mu.Lock()
defer t.mu.Unlock()
t.counts[key]++
if !t.logged[key] {
t.logged[key] = true
return true
}
return false
}
func (t *deprecatedRouteTracker) snapshot() map[string]int64 {
t.mu.Lock()
defer t.mu.Unlock()
out := make(map[string]int64, len(t.counts))
for k, v := range t.counts {
out[k] = v
}
return out
}
// DeprecatedRouteMiddleware records a hit on a legacy admin route and logs a
// one-time warning pointing at the /api equivalent. It never alters the
// response — the legacy paths keep working; this only produces the observability
// the 1.x removal needs. Wire it onto the legacy /setup and /mgmt mounts only,
// not their /api/* twins.
func (s *Server) DeprecatedRouteMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
rctx := chi.RouteContext(r.Context())
if rctx == nil {
return
}
pattern := rctx.RoutePattern()
if pattern == "" {
return
}
key := r.Method + " " + pattern
if s.deprecatedRoutes.record(key) {
log.Printf("[deprecated-route] %s used by client=%s — use /api%s instead; "+
"the legacy path still works but is slated for removal in a future major release",
sanitizeLog(key), sanitizeLog(clientHostFromRemoteAddr(r.RemoteAddr)), sanitizeLog(pattern))
}
})
}
// DeprecatedRouteHits returns a snapshot of legacy-route hit counts keyed by
// "METHOD pattern", for the diagnostic export.
func (s *Server) DeprecatedRouteHits() map[string]int64 {
return s.deprecatedRoutes.snapshot()
}
+8 -6
View File
@@ -31,10 +31,11 @@ import (
// alongside it so the maintainer can compare on-disk state with what the
// service serves.
type diagnosticReport struct {
GeneratedAt string `json:"generated_at"`
ServiceVersion map[string]string `json:"service_version"`
HealthChecks []health.CheckResult `json:"health_checks"`
Devices []deviceDiagnostic `json:"devices"`
GeneratedAt string `json:"generated_at"`
ServiceVersion map[string]string `json:"service_version"`
HealthChecks []health.CheckResult `json:"health_checks"`
Devices []deviceDiagnostic `json:"devices"`
DeprecatedRouteHits map[string]int64 `json:"deprecated_route_hits,omitempty"`
}
type deviceDiagnostic struct {
@@ -768,8 +769,9 @@ func addTarBytes(tw *tar.Writer, name string, data []byte) error {
func (s *Server) buildDiagnosticReport(redirectCfgs map[string]*redirectConfig) diagnosticReport {
report := diagnosticReport{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
ServiceVersion: buildVersionInfo(),
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
ServiceVersion: buildVersionInfo(),
DeprecatedRouteHits: s.DeprecatedRouteHits(),
}
if s.healthRegistry != nil {
+2
View File
@@ -55,6 +55,7 @@ type Server struct {
dnsDiscovery *discovery.DNSDiscovery
authProbes *authProbeRegistry
authProbeTimeoutOverride time.Duration // zero means use defaultAuthProbeTimeout; injectable for tests
deprecatedRoutes *deprecatedRouteTracker
Version string
Commit string
Date string
@@ -133,6 +134,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
peerObserver: newPeerObserver(),
healthRegistry: health.NewRegistry(),
authProbes: newAuthProbeRegistry(defaultAuthProbeTTL),
deprecatedRoutes: newDeprecatedRouteTracker(),
}
health.RegisterSourcesXMLPresent(s.healthRegistry, ds)