refactor: extract ProxyProvider interface with None and NPM implementations

Replace direct npm.Client usage throughout the codebase with the
proxy.Provider interface, enabling pluggable proxy backends. The
deployer, API layer, and proxy manager now use provider-agnostic
route management (ConfigureRoute/DeleteRoute) instead of NPM-specific
API calls. Adds ProxyRouteID (string) to Instance model and
ProxyProvider setting to Settings, with SQLite migrations for
backward compatibility.
This commit is contained in:
2026-04-04 19:39:08 +03:00
parent 6667abf03c
commit 7d6719da12
17 changed files with 365 additions and 249 deletions
+6 -5
View File
@@ -38,12 +38,13 @@ func (s *Server) getHealth(w http.ResponseWriter, r *http.Request) {
result["docker"] = map[string]any{"connected": true}
}
// Check NPM connectivity if configured.
if s.npm != nil {
if err := s.npm.Ping(ctx); err != nil {
result["npm"] = map[string]any{"connected": false, "error": "NPM unreachable"}
// Check proxy provider connectivity.
if s.proxyProvider != nil {
providerName := s.proxyProvider.Name()
if err := s.proxyProvider.Ping(ctx); err != nil {
result["proxy"] = map[string]any{"provider": providerName, "connected": false, "error": providerName + " unreachable"}
} else {
result["npm"] = map[string]any{"connected": true}
result["proxy"] = map[string]any{"provider": providerName, "connected": true}
}
}
+4 -13
View File
@@ -9,7 +9,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/store"
)
@@ -116,18 +115,10 @@ func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) {
}
}
// Delete NPM proxy host if it has one.
if inst.NpmProxyID > 0 {
settings, err := s.store.GetSettings()
if err == nil {
npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword)
if err == nil {
if authErr := s.npm.Authenticate(r.Context(), settings.NpmEmail, npmPassword); authErr == nil {
if delErr := s.npm.DeleteProxyHost(r.Context(), inst.NpmProxyID); delErr != nil {
slog.Warn("delete proxy host on instance removal", "proxy_id", inst.NpmProxyID, "error", delErr)
}
}
}
// Delete proxy route if it has one.
if inst.ProxyRouteID != "" {
if err := s.proxyProvider.DeleteRoute(r.Context(), inst.ProxyRouteID); err != nil {
slog.Warn("delete proxy route on instance removal", "route_id", inst.ProxyRouteID, "error", err)
}
}
+15 -12
View File
@@ -26,10 +26,11 @@ type DNSProviderChangedFunc func(provider dns.Provider)
// Server holds all dependencies for the API layer.
type Server struct {
store *store.Store
docker *docker.Client
npm *npm.Client
deployer DeployTriggerer
store *store.Store
docker *docker.Client
npm *npm.Client // optional: only for NPM-specific endpoints (certificates)
proxyProvider proxy.Provider
deployer DeployTriggerer
webhook *webhook.Handler
eventBus *events.Bus
encKey [32]byte
@@ -53,6 +54,7 @@ func NewServer(
st *store.Store,
dockerClient *docker.Client,
npmClient *npm.Client,
proxyProvider proxy.Provider,
deployer DeployTriggerer,
webhookHandler *webhook.Handler,
eventBus *events.Bus,
@@ -61,14 +63,15 @@ func NewServer(
localAuth := auth.NewLocalAuth(encKey)
s := &Server{
store: st,
docker: dockerClient,
npm: npmClient,
deployer: deployer,
webhook: webhookHandler,
eventBus: eventBus,
encKey: encKey,
localAuth: localAuth,
store: st,
docker: dockerClient,
npm: npmClient,
proxyProvider: proxyProvider,
deployer: deployer,
webhook: webhookHandler,
eventBus: eventBus,
encKey: encKey,
localAuth: localAuth,
}
// Try to initialize OIDC provider from stored settings.
+12
View File
@@ -34,6 +34,7 @@ type settingsRequest struct {
DNSProvider *string `json:"dns_provider,omitempty"`
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"`
ProxyProvider *string `json:"proxy_provider,omitempty"`
BackupEnabled *bool `json:"backup_enabled,omitempty"`
BackupIntervalHours *int `json:"backup_interval_hours,omitempty"`
BackupRetentionCount *int `json:"backup_retention_count,omitempty"`
@@ -65,6 +66,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
"dns_provider": settings.DNSProvider,
"has_cloudflare_api_token": settings.CloudflareAPIToken != "",
"cloudflare_zone_id": settings.CloudflareZoneID,
"proxy_provider": settings.ProxyProvider,
"backup_enabled": settings.BackupEnabled,
"backup_interval_hours": settings.BackupIntervalHours,
"backup_retention_count": settings.BackupRetentionCount,
@@ -166,6 +168,16 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
updated.CloudflareZoneID = *req.CloudflareZoneID
}
// Proxy provider setting.
if req.ProxyProvider != nil {
prov := *req.ProxyProvider
if prov != "" && prov != "none" && prov != "npm" {
respondError(w, http.StatusBadRequest, "proxy_provider must be 'none' or 'npm'")
return
}
updated.ProxyProvider = prov
}
// Backup settings.
if req.BackupEnabled != nil {
updated.BackupEnabled = *req.BackupEnabled
+4 -13
View File
@@ -7,7 +7,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/events"
"github.com/alexei/docker-watcher/internal/stale"
"github.com/alexei/docker-watcher/internal/store"
@@ -121,18 +120,10 @@ func (s *Server) cleanupInstance(r *http.Request, inst store.Instance) error {
}
}
// Delete NPM proxy host if present.
if inst.NpmProxyID > 0 {
settings, err := s.store.GetSettings()
if err == nil {
npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword)
if err == nil {
if authErr := s.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); authErr == nil {
if delErr := s.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil {
slog.Warn("stale cleanup: delete proxy host", "proxy_id", inst.NpmProxyID, "error", delErr)
}
}
}
// Delete proxy route if present.
if inst.ProxyRouteID != "" {
if err := s.proxyProvider.DeleteRoute(ctx, inst.ProxyRouteID); err != nil {
slog.Warn("stale cleanup: delete proxy route", "route_id", inst.ProxyRouteID, "error", err)
}
}