Files
tiny-forge/internal/proxy/manager.go
T
alexei.dolgolyov 7d6719da12 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.
2026-04-04 19:39:08 +03:00

371 lines
11 KiB
Go

package proxy
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"github.com/alexei/docker-watcher/internal/dns"
"github.com/alexei/docker-watcher/internal/store"
)
// Manager handles the lifecycle of standalone proxy hosts.
type Manager struct {
store *store.Store
provider Provider
dnsMu sync.RWMutex
dns dns.Provider // nil when wildcard DNS is active
}
// NewManager creates a new proxy manager.
func NewManager(st *store.Store, provider Provider) *Manager {
return &Manager{
store: st,
provider: provider,
}
}
// SetDNSProvider sets the DNS provider for managing DNS records.
func (m *Manager) SetDNSProvider(provider dns.Provider) {
m.dnsMu.Lock()
defer m.dnsMu.Unlock()
m.dns = provider
}
// getDNS returns the current DNS provider under read lock.
func (m *Manager) getDNS() dns.Provider {
m.dnsMu.RLock()
defer m.dnsMu.RUnlock()
return m.dns
}
// CreateProxyRequest is the input for creating a standalone proxy.
type CreateProxyRequest struct {
Domain string `json:"domain"`
DestinationURL string `json:"destination_url"`
DestinationPort int `json:"destination_port"`
}
// UpdateProxyRequest is the input for updating a standalone proxy.
type UpdateProxyRequest struct {
Domain string `json:"domain"`
DestinationURL string `json:"destination_url"`
DestinationPort int `json:"destination_port"`
}
// ProxyView is a unified view of both standalone and deploy-managed proxies.
type ProxyView struct {
ID string `json:"id"`
Domain string `json:"domain"`
Destination string `json:"destination"`
Type string `json:"type"` // "standalone" or "managed"
ProjectName string `json:"project_name,omitempty"`
StageName string `json:"stage_name,omitempty"`
HealthStatus string `json:"health_status"`
SSLEnabled bool `json:"ssl_enabled"`
NpmProxyID int `json:"npm_proxy_id"`
CreatedAt string `json:"created_at"`
}
// CreateProxy validates the destination, creates a proxy route via the provider, and saves to the store.
func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (store.StandaloneProxy, error) {
// Validate destination.
result := ValidateDestination(ctx, req.DestinationURL, req.DestinationPort)
if !result.Valid {
return store.StandaloneProxy{}, fmt.Errorf("destination validation failed: %s", lastFailedStep(result))
}
// Load settings for SSL certificate and domain.
settings, err := m.store.GetSettings()
if err != nil {
return store.StandaloneProxy{}, fmt.Errorf("get settings: %w", err)
}
// Create proxy route via provider.
routeID, err := m.provider.ConfigureRoute(ctx, req.Domain, req.DestinationURL, req.DestinationPort, RouteOptions{
SSLCertificateID: settings.SSLCertificateID,
})
if err != nil {
return store.StandaloneProxy{}, fmt.Errorf("create proxy route: %w", err)
}
slog.Info("created proxy route for standalone proxy",
"domain", req.Domain, "route_id", routeID, "provider", m.provider.Name())
// Save to store.
proxy, err := m.store.CreateStandaloneProxy(store.StandaloneProxy{
Domain: req.Domain,
DestinationURL: req.DestinationURL,
DestinationPort: req.DestinationPort,
SSLCertificateID: settings.SSLCertificateID,
HealthStatus: "unknown",
})
if err != nil {
// Best effort: clean up the proxy route if store insert fails.
if delErr := m.provider.DeleteRoute(ctx, routeID); delErr != nil {
slog.Error("failed to clean up proxy route after store error",
"route_id", routeID, "error", delErr)
}
return store.StandaloneProxy{}, fmt.Errorf("save standalone proxy: %w", err)
}
// Create DNS record after successful store save.
m.ensureDNS(ctx, req.Domain, proxy.ID)
return proxy, nil
}
// UpdateProxy re-validates the destination, updates the proxy route via the provider, and updates the store.
func (m *Manager) UpdateProxy(ctx context.Context, id string, req UpdateProxyRequest) (store.StandaloneProxy, error) {
existing, err := m.store.GetStandaloneProxy(id)
if err != nil {
return store.StandaloneProxy{}, fmt.Errorf("get proxy: %w", err)
}
// Validate new destination.
result := ValidateDestination(ctx, req.DestinationURL, req.DestinationPort)
if !result.Valid {
return store.StandaloneProxy{}, fmt.Errorf("destination validation failed: %s", lastFailedStep(result))
}
// Load settings for SSL certificate.
settings, err := m.store.GetSettings()
if err != nil {
return store.StandaloneProxy{}, fmt.Errorf("get settings: %w", err)
}
// Update proxy route via provider (ConfigureRoute handles create-or-update).
if _, err := m.provider.ConfigureRoute(ctx, req.Domain, req.DestinationURL, req.DestinationPort, RouteOptions{
SSLCertificateID: settings.SSLCertificateID,
}); err != nil {
return store.StandaloneProxy{}, fmt.Errorf("update proxy route: %w", err)
}
// Update store.
updated := existing
updated.Domain = req.Domain
updated.DestinationURL = req.DestinationURL
updated.DestinationPort = req.DestinationPort
updated.SSLCertificateID = settings.SSLCertificateID
if err := m.store.UpdateStandaloneProxy(updated); err != nil {
return store.StandaloneProxy{}, fmt.Errorf("update standalone proxy: %w", err)
}
// Update DNS records if domain changed.
if existing.Domain != req.Domain {
m.removeDNS(ctx, existing.Domain)
m.ensureDNS(ctx, req.Domain, id)
}
// Re-read from store to get updated timestamps.
return m.store.GetStandaloneProxy(id)
}
// DeleteProxy removes the proxy route via the provider and deletes from the store.
func (m *Manager) DeleteProxy(ctx context.Context, id string) error {
p, err := m.store.GetStandaloneProxy(id)
if err != nil {
return fmt.Errorf("get proxy: %w", err)
}
// Delete proxy route via provider using the NpmProxyID as a string route ID.
if p.NpmProxyID > 0 {
routeID := fmt.Sprintf("%d", p.NpmProxyID)
if err := m.provider.DeleteRoute(ctx, routeID); err != nil {
slog.Warn("failed to delete proxy route (continuing with store deletion)",
"route_id", routeID, "error", err)
}
}
// Remove DNS record.
m.removeDNS(ctx, p.Domain)
if err := m.store.DeleteStandaloneProxy(id); err != nil {
return fmt.Errorf("delete standalone proxy: %w", err)
}
return nil
}
// GetProxy returns a single standalone proxy by ID.
func (m *Manager) GetProxy(id string) (store.StandaloneProxy, error) {
proxy, err := m.store.GetStandaloneProxy(id)
if err != nil {
return store.StandaloneProxy{}, fmt.Errorf("get proxy: %w", err)
}
return proxy, nil
}
// ListProxies returns all standalone proxies.
func (m *Manager) ListProxies() ([]store.StandaloneProxy, error) {
proxies, err := m.store.ListStandaloneProxies()
if err != nil {
return nil, fmt.Errorf("list proxies: %w", err)
}
return proxies, nil
}
// ListAllProxies returns a merged view of standalone and deploy-managed proxies.
func (m *Manager) ListAllProxies() ([]ProxyView, error) {
views := []ProxyView{}
// Standalone proxies.
standalones, err := m.store.ListStandaloneProxies()
if err != nil {
return nil, fmt.Errorf("list standalone proxies: %w", err)
}
for _, p := range standalones {
views = append(views, ProxyView{
ID: p.ID,
Domain: p.Domain,
Destination: fmt.Sprintf("%s:%d", p.DestinationURL, p.DestinationPort),
Type: "standalone",
HealthStatus: p.HealthStatus,
SSLEnabled: p.SSLCertificateID > 0,
NpmProxyID: p.NpmProxyID,
CreatedAt: p.CreatedAt,
})
}
// Deploy-managed proxies: instances with a proxy route configured.
instances, err := m.store.ListAllInstances()
if err != nil {
return nil, fmt.Errorf("list instances: %w", err)
}
// Pre-load project and stage names to avoid N+1 queries.
allProjects, _ := m.store.GetAllProjects()
projectNames := make(map[string]string, len(allProjects))
for _, p := range allProjects {
projectNames[p.ID] = p.Name
}
stageNames := make(map[string]string)
for _, p := range allProjects {
stages, _ := m.store.GetStagesByProjectID(p.ID)
for _, s := range stages {
stageNames[s.ID] = s.Name
}
}
for _, inst := range instances {
if inst.ProxyRouteID == "" && inst.NpmProxyID <= 0 {
continue
}
projectName := projectNames[inst.ProjectID]
if projectName == "" {
projectName = inst.ProjectID
}
stageName := stageNames[inst.StageID]
if stageName == "" {
stageName = inst.StageID
}
cid := inst.ContainerID
if len(cid) > 12 {
cid = cid[:12]
}
destination := fmt.Sprintf("%s:%d", cid, inst.Port)
if inst.Subdomain != "" {
destination = fmt.Sprintf("%s:%d", inst.Subdomain, inst.Port)
}
healthStatus := "unknown"
if inst.Status == "running" {
healthStatus = "healthy"
} else if inst.Status == "stopped" || inst.Status == "failed" {
healthStatus = "unhealthy"
}
views = append(views, ProxyView{
ID: inst.ID,
Domain: inst.Subdomain,
Destination: destination,
Type: "managed",
ProjectName: projectName,
StageName: stageName,
HealthStatus: healthStatus,
SSLEnabled: true, // managed proxies always get SSL from settings
NpmProxyID: inst.NpmProxyID,
CreatedAt: inst.CreatedAt,
})
}
return views, nil
}
// ensureDNS creates or updates a DNS record for a standalone proxy domain. Best-effort.
func (m *Manager) ensureDNS(ctx context.Context, domain, proxyID string) {
dnsProvider := m.getDNS()
if dnsProvider == nil {
return
}
settings, err := m.store.GetSettings()
if err != nil {
slog.Warn("dns: get settings for server IP", "error", err)
return
}
if settings.ServerIP == "" {
slog.Warn("dns: server IP not configured, skipping DNS record creation", "domain", domain)
return
}
recordID, err := dnsProvider.EnsureRecord(ctx, domain, settings.ServerIP)
if err != nil {
slog.Warn("dns: failed to create/update record for standalone proxy", "domain", domain, "error", err)
return
}
if _, err := m.store.CreateDNSRecord(store.DNSRecord{
FQDN: domain,
ProviderRecordID: recordID,
ConsumerType: "standalone",
ConsumerID: proxyID,
}); err != nil {
// May already exist — try updating.
if updateErr := m.store.UpdateDNSRecordProviderID(domain, recordID); updateErr != nil {
slog.Warn("dns: failed to track record", "domain", domain, "error", updateErr)
}
}
slog.Info("dns: record ensured for standalone proxy", "domain", domain)
}
// removeDNS deletes a DNS record for a standalone proxy domain. Best-effort.
func (m *Manager) removeDNS(ctx context.Context, domain string) {
dnsProvider := m.getDNS()
if dnsProvider == nil {
return
}
if err := dnsProvider.DeleteRecord(ctx, domain); err != nil {
slog.Warn("dns: failed to delete record for standalone proxy", "domain", domain, "error", err)
return
}
if err := m.store.DeleteDNSRecord(domain); err != nil {
slog.Warn("dns: failed to remove tracking record", "domain", domain, "error", err)
}
slog.Info("dns: record deleted for standalone proxy", "domain", domain)
}
// lastFailedStep returns the message of the last failed validation step.
func lastFailedStep(result ValidationResult) string {
for _, step := range result.Steps {
if !step.Passed {
msg := step.Message
if step.Hint != "" {
msg += " — " + step.Hint
}
return msg
}
}
return "unknown validation failure"
}
// IsNotFound checks if an error wraps store.ErrNotFound.
func IsNotFound(err error) bool {
return errors.Is(err, store.ErrNotFound)
}