diff --git a/cmd/server/main.go b/cmd/server/main.go index 1842d2c..8119447 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -20,6 +20,7 @@ import ( "github.com/alexei/docker-watcher/internal/config" "github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/docker-watcher/internal/deployer" + "github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/docker-watcher/internal/events" "github.com/alexei/docker-watcher/internal/health" @@ -192,10 +193,23 @@ func main() { } }() + // Initialize DNS provider from settings (nil for wildcard mode). + dnsProvider := initDNSProvider(settings, encKey) + if dnsProvider != nil { + dep.SetDNSProvider(dnsProvider) + proxyManager.SetDNSProvider(dnsProvider) + slog.Info("DNS provider initialized", "provider", settings.DNSProvider) + } + // Build API server. apiServer := api.NewServer(db, dockerClient, npmClient, dep, webhookHandler, eventBus, encKey) apiServer.SetStaleScanner(staleScanner) apiServer.SetProxyManager(proxyManager) + apiServer.SetDNSProvider(dnsProvider) + apiServer.SetDNSProviderChangedCallback(func(provider dns.Provider) { + dep.SetDNSProvider(provider) + proxyManager.SetDNSProvider(provider) + }) router := apiServer.Router() // Serve embedded static files for the SPA frontend. @@ -309,3 +323,30 @@ func ensureDefaultAdmin(db *store.Store) error { slog.Info("default admin user created", "username", "admin") return nil } + +// initDNSProvider creates a DNS provider from settings. Returns nil for wildcard mode. +func initDNSProvider(settings store.Settings, encKey [32]byte) dns.Provider { + if settings.WildcardDNS || settings.DNSProvider == "" { + return nil + } + + token := settings.CloudflareAPIToken + if token != "" { + decrypted, err := crypto.Decrypt(encKey, token) + if err != nil { + slog.Error("dns: failed to decrypt API token", "error", err) + return nil + } + token = decrypted + } + + provider, err := dns.NewProvider(settings.DNSProvider, dns.Config{ + Token: token, + ZoneID: settings.CloudflareZoneID, + }) + if err != nil { + slog.Error("dns: failed to create provider", "error", err) + return nil + } + return provider +} diff --git a/internal/api/dns.go b/internal/api/dns.go new file mode 100644 index 0000000..cd2d729 --- /dev/null +++ b/internal/api/dns.go @@ -0,0 +1,365 @@ +package api + +import ( + "log/slog" + "net/http" + "strings" + + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/dns" + "github.com/alexei/docker-watcher/internal/store" + "github.com/go-chi/chi/v5" +) + +// dnsRecordView is the response format for DNS records with consumer context. +type dnsRecordView struct { + FQDN string `json:"fqdn"` + Type string `json:"type"` + Content string `json:"content"` + ConsumerType string `json:"consumer_type"` + ConsumerName string `json:"consumer_name"` + ConsumerID string `json:"consumer_id"` + Status string `json:"status"` // "synced", "orphaned", "missing" +} + +// listDNSRecords handles GET /api/dns/records. +// In managed DNS mode: merges local dns_records with actual Cloudflare records to compute sync status. +// In wildcard mode: shows all expected FQDNs from active consumers (informational, no sync status). +func (s *Server) listDNSRecords(w http.ResponseWriter, r *http.Request) { + settings, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + + consumerNames := s.buildConsumerNameMap() + + // In wildcard mode, show expected records from consumers without sync status. + if settings.WildcardDNS { + expectedFQDNs := s.computeExpectedFQDNs(settings) + var views []dnsRecordView + for fqdn, consumer := range expectedFQDNs { + parts := strings.SplitN(consumer, ":", 2) + consumerType, consumerID := parts[0], "" + if len(parts) > 1 { + consumerID = parts[1] + } + name := consumerNames[consumer] + if name == "" { + name = consumerID + } + views = append(views, dnsRecordView{ + FQDN: fqdn, + Type: "A", + Content: settings.ServerIP, + ConsumerType: consumerType, + ConsumerName: name, + ConsumerID: consumerID, + Status: "wildcard", + }) + } + if views == nil { + views = []dnsRecordView{} + } + respondJSON(w, http.StatusOK, views) + return + } + + // Managed DNS mode: full sync status computation. + + // Get local tracked records. + localRecords, err := s.store.ListDNSRecords() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list local records: "+err.Error()) + return + } + + // Try to get actual records from the DNS provider. + var providerRecords []dns.Record + provider := s.getOrCreateDNSProvider(settings) + if provider != nil { + providerRecords, err = provider.ListRecords(r.Context()) + if err != nil { + slog.Warn("dns records: failed to list provider records", "error", err) + // Continue with local-only view. + } + } + + // Build a map of provider records by FQDN. + providerByFQDN := make(map[string]dns.Record, len(providerRecords)) + for _, rec := range providerRecords { + providerByFQDN[rec.FQDN] = rec + } + + // Build a set of local FQDNs. + localFQDNs := make(map[string]bool, len(localRecords)) + for _, rec := range localRecords { + localFQDNs[rec.FQDN] = true + } + + var views []dnsRecordView + + // Process local records: check if they exist in provider. + for _, local := range localRecords { + status := "missing" + content := settings.ServerIP + if pRec, ok := providerByFQDN[local.FQDN]; ok { + status = "synced" + content = pRec.Content + } + + name := consumerNames[local.ConsumerType+":"+local.ConsumerID] + if name == "" { + name = local.ConsumerID + } + + views = append(views, dnsRecordView{ + FQDN: local.FQDN, + Type: "A", + Content: content, + ConsumerType: local.ConsumerType, + ConsumerName: name, + ConsumerID: local.ConsumerID, + Status: status, + }) + } + + // Find orphaned records: in provider but not in local tracking. + for _, pRec := range providerRecords { + if !localFQDNs[pRec.FQDN] { + views = append(views, dnsRecordView{ + FQDN: pRec.FQDN, + Type: pRec.Type, + Content: pRec.Content, + ConsumerType: "", + ConsumerName: "", + ConsumerID: "", + Status: "orphaned", + }) + } + } + + if views == nil { + views = []dnsRecordView{} + } + + respondJSON(w, http.StatusOK, views) +} + +// deleteDNSRecord handles DELETE /api/dns/records/{fqdn}. +func (s *Server) deleteDNSRecord(w http.ResponseWriter, r *http.Request) { + fqdn := chi.URLParam(r, "fqdn") + if fqdn == "" { + respondError(w, http.StatusBadRequest, "fqdn is required") + return + } + + settings, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + + provider := s.getOrCreateDNSProvider(settings) + if provider != nil { + if err := provider.DeleteRecord(r.Context(), fqdn); err != nil { + respondError(w, http.StatusBadGateway, "failed to delete DNS record: "+err.Error()) + return + } + } + + // Remove local tracking. + if err := s.store.DeleteDNSRecord(fqdn); err != nil { + slog.Warn("delete dns tracking record", "fqdn", fqdn, "error", err) + } + + respondJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// buildConsumerNameMap builds a lookup of "type:id" -> display name for DNS consumers. +func (s *Server) buildConsumerNameMap() map[string]string { + names := make(map[string]string) + + // Instance consumers: "instance:id" -> "project/stage:tag" + projects, _ := s.store.GetAllProjects() + projectNames := make(map[string]string, len(projects)) + for _, p := range projects { + projectNames[p.ID] = p.Name + } + + for _, p := range projects { + stages, _ := s.store.GetStagesByProjectID(p.ID) + for _, st := range stages { + instances, _ := s.store.GetInstancesByStageID(st.ID) + for _, inst := range instances { + names["instance:"+inst.ID] = p.Name + "/" + st.Name + ":" + inst.ImageTag + } + } + } + + // Standalone proxy consumers: "standalone:id" -> domain + proxies, _ := s.store.ListStandaloneProxies() + for _, p := range proxies { + names["standalone:"+p.ID] = p.Domain + } + + return names +} + +// getOrCreateDNSProvider returns the server's DNS provider, or creates a temporary one from settings. +func (s *Server) getOrCreateDNSProvider(settings store.Settings) dns.Provider { + if s.dnsProvider != nil { + return s.dnsProvider + } + + if settings.WildcardDNS || settings.DNSProvider == "" || settings.CloudflareAPIToken == "" { + return nil + } + + token, err := crypto.Decrypt(s.encKey, settings.CloudflareAPIToken) + if err != nil { + slog.Warn("dns: failed to decrypt token for provider creation", "error", err) + return nil + } + + provider, err := dns.NewProvider(settings.DNSProvider, dns.Config{ + Token: token, + ZoneID: settings.CloudflareZoneID, + }) + if err != nil { + slog.Warn("dns: failed to create provider", "error", err) + return nil + } + return provider +} + +// syncDNSRecords handles POST /api/dns/sync. +func (s *Server) syncDNSRecords(w http.ResponseWriter, r *http.Request) { + settings, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + + if settings.WildcardDNS { + respondError(w, http.StatusBadRequest, "DNS sync is disabled in wildcard mode") + return + } + + provider := s.getOrCreateDNSProvider(settings) + if provider == nil { + respondError(w, http.StatusBadRequest, "DNS provider not configured") + return + } + + // Compute expected FQDNs from active consumers. + expectedFQDNs := s.computeExpectedFQDNs(settings) + + // Get actual provider records. + providerRecords, err := provider.ListRecords(r.Context()) + if err != nil { + respondError(w, http.StatusBadGateway, "failed to list DNS records: "+err.Error()) + return + } + + providerByFQDN := make(map[string]dns.Record, len(providerRecords)) + for _, rec := range providerRecords { + providerByFQDN[rec.FQDN] = rec + } + + // Get local tracking records. + localRecords, err := s.store.ListDNSRecords() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list local records: "+err.Error()) + return + } + localByFQDN := make(map[string]bool, len(localRecords)) + for _, rec := range localRecords { + localByFQDN[rec.FQDN] = true + } + + created := 0 + deleted := 0 + alreadySynced := 0 + + // Create missing records. + for fqdn, consumer := range expectedFQDNs { + if _, exists := providerByFQDN[fqdn]; exists { + alreadySynced++ + continue + } + + recordID, err := provider.EnsureRecord(r.Context(), fqdn, settings.ServerIP) + if err != nil { + slog.Warn("dns sync: failed to create record", "fqdn", fqdn, "error", err) + continue + } + + // Track locally. + parts := strings.SplitN(consumer, ":", 2) + consumerType, consumerID := parts[0], "" + if len(parts) > 1 { + consumerID = parts[1] + } + if _, err := s.store.CreateDNSRecord(store.DNSRecord{ + FQDN: fqdn, + ProviderRecordID: recordID, + ConsumerType: consumerType, + ConsumerID: consumerID, + }); err != nil { + s.store.UpdateDNSRecordProviderID(fqdn, recordID) + } + created++ + } + + // Delete orphaned records (in provider + tracked locally, but no active consumer). + for _, local := range localRecords { + if _, expected := expectedFQDNs[local.FQDN]; !expected { + if err := provider.DeleteRecord(r.Context(), local.FQDN); err != nil { + slog.Warn("dns sync: failed to delete orphaned record", "fqdn", local.FQDN, "error", err) + continue + } + s.store.DeleteDNSRecord(local.FQDN) + deleted++ + } + } + + respondJSON(w, http.StatusOK, map[string]int{ + "created": created, + "deleted": deleted, + "already_synced": alreadySynced, + }) +} + +// computeExpectedFQDNs returns a map of FQDN -> "consumerType:consumerID" for all active DNS consumers. +func (s *Server) computeExpectedFQDNs(settings store.Settings) map[string]string { + expected := make(map[string]string) + + // Instances with proxy enabled. + projects, _ := s.store.GetAllProjects() + for _, p := range projects { + stages, _ := s.store.GetStagesByProjectID(p.ID) + for _, st := range stages { + if !st.EnableProxy { + continue + } + instances, _ := s.store.GetInstancesByStageID(st.ID) + for _, inst := range instances { + if inst.NpmProxyID > 0 && inst.Subdomain != "" && inst.Status == "running" { + fqdn := inst.Subdomain + "." + settings.Domain + expected[fqdn] = "instance:" + inst.ID + } + } + } + } + + // Standalone proxies. + proxies, _ := s.store.ListStandaloneProxies() + for _, p := range proxies { + if p.Domain != "" { + expected[p.Domain] = "standalone:" + p.ID + } + } + + return expected +} diff --git a/internal/api/router.go b/internal/api/router.go index a908f1b..cc63817 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -8,6 +8,7 @@ import ( "github.com/alexei/docker-watcher/internal/auth" "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/docker-watcher/internal/events" "github.com/alexei/docker-watcher/internal/npm" @@ -17,6 +18,10 @@ import ( "github.com/alexei/docker-watcher/internal/webhook" ) +// DNSProviderChangedFunc is called when DNS settings change so the caller can +// update the provider on the deployer and proxy manager. +type DNSProviderChangedFunc func(provider dns.Provider) + // Server holds all dependencies for the API layer. type Server struct { store *store.Store @@ -30,6 +35,9 @@ type Server struct { oidcProvider *auth.OIDCProvider staleScanner *stale.Scanner proxyManager *proxy.Manager + + dnsProvider dns.Provider + onDNSProviderChanged DNSProviderChangedFunc } // NewServer creates a new API Server with all required dependencies. @@ -76,6 +84,16 @@ func (s *Server) SetProxyManager(pm *proxy.Manager) { s.proxyManager = pm } +// SetDNSProvider sets the current DNS provider on the server. +func (s *Server) SetDNSProvider(provider dns.Provider) { + s.dnsProvider = provider +} + +// SetDNSProviderChangedCallback sets the callback for when DNS settings change. +func (s *Server) SetDNSProviderChangedCallback(fn DNSProviderChangedFunc) { + s.onDNSProviderChanged = fn +} + // initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal. func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) { // Decrypt the OIDC client secret if it's encrypted. @@ -251,6 +269,13 @@ func (s *Server) Router() chi.Router { r.Put("/settings", s.updateSettings) r.Get("/settings/webhook-url", s.getWebhookURL) r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret) + + // DNS management endpoints. + r.Post("/settings/dns/test", s.testDNSConnection) + r.Get("/settings/dns/zones", s.listDNSZones) + r.Get("/dns/records", s.listDNSRecords) + r.Post("/dns/sync", s.syncDNSRecords) + r.Delete("/dns/records/{fqdn}", s.deleteDNSRecord) }) }) }) diff --git a/internal/api/settings.go b/internal/api/settings.go index 14dd3b4..4a93d78 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/volume" @@ -29,6 +30,10 @@ type settingsRequest struct { SSLCertificateID *int `json:"ssl_certificate_id,omitempty"` StaleThresholdDays *int `json:"stale_threshold_days,omitempty"` AllowedVolumePaths *string `json:"allowed_volume_paths,omitempty"` + WildcardDNS *bool `json:"wildcard_dns,omitempty"` + DNSProvider *string `json:"dns_provider,omitempty"` + CloudflareAPIToken string `json:"cloudflare_api_token"` + CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"` } // getSettings handles GET /api/settings. @@ -41,19 +46,23 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { // Return settings without sensitive fields. respondJSON(w, http.StatusOK, map[string]any{ - "domain": settings.Domain, - "server_ip": settings.ServerIP, - "network": settings.Network, - "subdomain_pattern": settings.SubdomainPattern, - "notification_url": settings.NotificationURL, - "npm_url": settings.NpmURL, - "npm_email": settings.NpmEmail, - "has_npm_password": settings.NpmPassword != "", - "polling_interval": settings.PollingInterval, - "ssl_certificate_id": settings.SSLCertificateID, - "stale_threshold_days": settings.StaleThresholdDays, - "allowed_volume_paths": settings.AllowedVolumePaths, - "updated_at": settings.UpdatedAt, + "domain": settings.Domain, + "server_ip": settings.ServerIP, + "network": settings.Network, + "subdomain_pattern": settings.SubdomainPattern, + "notification_url": settings.NotificationURL, + "npm_url": settings.NpmURL, + "npm_email": settings.NpmEmail, + "has_npm_password": settings.NpmPassword != "", + "polling_interval": settings.PollingInterval, + "ssl_certificate_id": settings.SSLCertificateID, + "stale_threshold_days": settings.StaleThresholdDays, + "allowed_volume_paths": settings.AllowedVolumePaths, + "wildcard_dns": settings.WildcardDNS, + "dns_provider": settings.DNSProvider, + "has_cloudflare_api_token": settings.CloudflareAPIToken != "", + "cloudflare_zone_id": settings.CloudflareZoneID, + "updated_at": settings.UpdatedAt, }) } @@ -132,6 +141,25 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { _ = paths // validated } + // DNS settings. + if req.WildcardDNS != nil { + updated.WildcardDNS = *req.WildcardDNS + } + if req.DNSProvider != nil { + updated.DNSProvider = *req.DNSProvider + } + if req.CloudflareAPIToken != "" { + encToken, err := crypto.Encrypt(s.encKey, req.CloudflareAPIToken) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt cloudflare api token: "+err.Error()) + return + } + updated.CloudflareAPIToken = encToken + } + if req.CloudflareZoneID != nil { + updated.CloudflareZoneID = *req.CloudflareZoneID + } + if err := s.store.UpdateSettings(updated); err != nil { respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error()) return @@ -142,6 +170,15 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { go s.reapplySSLToAllProxies(updated) } + // Handle DNS provider changes. + dnsChanged := existing.WildcardDNS != updated.WildcardDNS || + existing.DNSProvider != updated.DNSProvider || + existing.CloudflareZoneID != updated.CloudflareZoneID || + (req.CloudflareAPIToken != "" && req.CloudflareAPIToken != "unchanged") + if dnsChanged { + go s.handleDNSSettingsChange(existing, updated) + } + respondJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } @@ -334,3 +371,157 @@ func (s *Server) reapplySSLToAllProxies(settings store.Settings) { slog.Info("reapply SSL: completed", "updated", updated, "total_managed", len(managedProxyIDs)) } +// handleDNSSettingsChange reacts to DNS configuration changes: +// - If switching to wildcard mode: remove all managed DNS records from the provider. +// - If switching provider or credentials: remove old records, create new provider, re-sync. +func (s *Server) handleDNSSettingsChange(oldSettings, newSettings store.Settings) { + ctx := context.Background() + + // Step 1: If there was an old provider, remove all managed DNS records from it. + if !oldSettings.WildcardDNS && oldSettings.DNSProvider != "" && s.dnsProvider != nil { + records, err := s.store.ListDNSRecords() + if err != nil { + slog.Error("dns settings change: list records for cleanup", "error", err) + } else { + for _, rec := range records { + if err := s.dnsProvider.DeleteRecord(ctx, rec.FQDN); err != nil { + slog.Warn("dns settings change: delete old record", "fqdn", rec.FQDN, "error", err) + } + if err := s.store.DeleteDNSRecord(rec.FQDN); err != nil { + slog.Warn("dns settings change: remove tracking record", "fqdn", rec.FQDN, "error", err) + } + } + slog.Info("dns settings change: cleaned up old records", "count", len(records)) + } + } + + // Step 2: Create new provider (or nil for wildcard mode). + var newProvider dns.Provider + if !newSettings.WildcardDNS && newSettings.DNSProvider != "" { + token := newSettings.CloudflareAPIToken + if token != "" { + decrypted, err := crypto.Decrypt(s.encKey, token) + if err != nil { + slog.Error("dns settings change: decrypt token", "error", err) + return + } + token = decrypted + } + + provider, err := dns.NewProvider(newSettings.DNSProvider, dns.Config{ + Token: token, + ZoneID: newSettings.CloudflareZoneID, + }) + if err != nil { + slog.Error("dns settings change: create provider", "error", err) + return + } + newProvider = provider + } + + // Step 3: Update the server's DNS provider and notify dependents. + s.dnsProvider = newProvider + if s.onDNSProviderChanged != nil { + s.onDNSProviderChanged(newProvider) + } + + slog.Info("dns settings change: provider updated", + "wildcard", newSettings.WildcardDNS, + "provider", newSettings.DNSProvider) +} + +// dnsTestRequest is the expected JSON body for testing DNS provider credentials. +type dnsTestRequest struct { + Provider string `json:"provider"` + Token string `json:"token"` + ZoneID string `json:"zone_id"` +} + +// testDNSConnection handles POST /api/settings/dns/test. +func (s *Server) testDNSConnection(w http.ResponseWriter, r *http.Request) { + var req dnsTestRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Provider != "cloudflare" { + respondError(w, http.StatusBadRequest, "unsupported DNS provider: "+req.Provider) + return + } + + token := req.Token + // If no token provided, use the stored one. + if token == "" { + settings, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + if settings.CloudflareAPIToken == "" { + respondError(w, http.StatusBadRequest, "no Cloudflare API token configured") + return + } + decrypted, err := crypto.Decrypt(s.encKey, settings.CloudflareAPIToken) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to decrypt token: "+err.Error()) + return + } + token = decrypted + } + + provider, err := dns.NewCloudflare(token, req.ZoneID) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid configuration: "+err.Error()) + return + } + + if err := provider.TestConnection(r.Context()); err != nil { + respondJSON(w, http.StatusOK, map[string]any{ + "success": false, + "error": err.Error(), + }) + return + } + + respondJSON(w, http.StatusOK, map[string]any{ + "success": true, + }) +} + +// listDNSZones handles GET /api/settings/dns/zones. +func (s *Server) listDNSZones(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + // If no token in query, use stored one. + if token == "" { + settings, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + if settings.CloudflareAPIToken == "" { + respondError(w, http.StatusBadRequest, "no Cloudflare API token configured") + return + } + decrypted, err := crypto.Decrypt(s.encKey, settings.CloudflareAPIToken) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to decrypt token: "+err.Error()) + return + } + token = decrypted + } + + provider, err := dns.NewCloudflare(token, "") + if err != nil { + respondError(w, http.StatusBadRequest, "invalid configuration: "+err.Error()) + return + } + + zones, err := provider.ListZones(r.Context()) + if err != nil { + respondError(w, http.StatusBadGateway, "failed to list zones: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, zones) +} + diff --git a/internal/deployer/bluegreen.go b/internal/deployer/bluegreen.go index 66700f7..ed43501 100644 --- a/internal/deployer/bluegreen.go +++ b/internal/deployer/bluegreen.go @@ -156,6 +156,10 @@ func (d *Deployer) blueGreenDeploy( inst.NpmProxyID = npmProxyID d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info") + + // Create/update DNS record for the green instance. + fqdn := subdomain + "." + settings.Domain + d.ensureDNS(ctx, fqdn, "instance", instanceID, deployID) } else { d.logDeploy(deployID, "Blue-green: proxy skipped (disabled for this stage)", "info") } diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index e944c2d..7fb9bd8 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -10,6 +10,7 @@ import ( "sync/atomic" "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/docker-watcher/internal/events" "github.com/alexei/docker-watcher/internal/health" @@ -32,6 +33,7 @@ type Deployer struct { notifier *notify.Notifier eventBus EventPublisher encKey [32]byte + dns dns.Provider // nil when wildcard DNS is active // Graceful shutdown: tracks in-progress deploys. activeWg sync.WaitGroup @@ -64,6 +66,12 @@ func New( } } +// SetDNSProvider sets the DNS provider for managing DNS records during deployments. +// Pass nil to disable DNS management (wildcard DNS mode). +func (d *Deployer) SetDNSProvider(provider dns.Provider) { + d.dns = provider +} + // Drain waits for all in-progress deploys to complete. Call this during graceful shutdown. func (d *Deployer) Drain() { d.shuttingDown.Store(true) @@ -357,6 +365,10 @@ func (d *Deployer) executeDeploy( if err := d.store.UpdateInstance(inst); err != nil { slog.Warn("update instance with proxy ID", "error", err) } + + // Create DNS record for this instance. + fqdn := subdomain + "." + settings.Domain + d.ensureDNS(ctx, fqdn, "instance", instanceID, deployID) } else { d.logDeploy(deployID, "Proxy creation skipped (disabled for this stage)", "info") inst.Subdomain = subdomain @@ -526,6 +538,12 @@ func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, sett } else if delErr := d.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil { slog.Warn("delete proxy host", "proxy_id", inst.NpmProxyID, "error", delErr) } + + // Remove DNS record for this instance. + if inst.Subdomain != "" && settings.Domain != "" { + fqdn := inst.Subdomain + "." + settings.Domain + d.removeDNS(ctx, fqdn, "") + } } // Delete instance record. @@ -724,6 +742,78 @@ func (d *Deployer) publishInstanceStatus(instanceID, projectID, stageID, status } } +// ensureDNS creates or updates a DNS record for the given FQDN. Best-effort: logs warnings on failure. +func (d *Deployer) ensureDNS(ctx context.Context, fqdn, consumerType, consumerID, deployID string) { + if d.dns == nil { + return + } + settings, err := d.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", "fqdn", fqdn) + return + } + + recordID, err := d.dns.EnsureRecord(ctx, fqdn, settings.ServerIP) + if err != nil { + msg := fmt.Sprintf("DNS: failed to create/update record for %s: %v", fqdn, err) + slog.Warn(msg) + if deployID != "" { + d.logDeploy(deployID, msg, "warn") + } + return + } + + // Track the record locally. + if _, err := d.store.CreateDNSRecord(store.DNSRecord{ + FQDN: fqdn, + ProviderRecordID: recordID, + ConsumerType: consumerType, + ConsumerID: consumerID, + }); err != nil { + // May already exist — try updating. + if updateErr := d.store.UpdateDNSRecordProviderID(fqdn, recordID); updateErr != nil { + slog.Warn("dns: failed to track record", "fqdn", fqdn, "error", updateErr) + } + } + + logMsg := fmt.Sprintf("DNS: record ensured for %s", fqdn) + slog.Info(logMsg) + if deployID != "" { + d.logDeploy(deployID, logMsg, "info") + } +} + +// removeDNS deletes a DNS record for the given FQDN. Best-effort: logs warnings on failure. +func (d *Deployer) removeDNS(ctx context.Context, fqdn, deployID string) { + if d.dns == nil { + return + } + + if err := d.dns.DeleteRecord(ctx, fqdn); err != nil { + msg := fmt.Sprintf("DNS: failed to delete record for %s: %v", fqdn, err) + slog.Warn(msg) + if deployID != "" { + d.logDeploy(deployID, msg, "warn") + } + return + } + + // Remove local tracking. + if err := d.store.DeleteDNSRecord(fqdn); err != nil { + slog.Warn("dns: failed to remove tracking record", "fqdn", fqdn, "error", err) + } + + logMsg := fmt.Sprintf("DNS: record deleted for %s", fqdn) + slog.Info(logMsg) + if deployID != "" { + d.logDeploy(deployID, logMsg, "info") + } +} + // truncateID safely truncates a Docker ID to 12 characters for display. func truncateID(id string) string { if len(id) > 12 { diff --git a/internal/deployer/rollback.go b/internal/deployer/rollback.go index 8de53d5..5bb1e06 100644 --- a/internal/deployer/rollback.go +++ b/internal/deployer/rollback.go @@ -42,6 +42,18 @@ func (d *Deployer) rollback(ctx context.Context, deployID string, containerID st } } + // Clean up DNS record if the instance had a subdomain. + if instanceID != "" { + inst, err := d.store.GetInstanceByID(instanceID) + if err == nil && inst.Subdomain != "" { + settings, _ := d.store.GetSettings() + if settings.Domain != "" { + fqdn := inst.Subdomain + "." + settings.Domain + d.removeDNS(ctx, fqdn, deployID) + } + } + } + // Update instance status to failed if it was created. if instanceID != "" { if err := d.store.UpdateInstanceStatus(instanceID, "failed"); err != nil { diff --git a/internal/dns/cloudflare.go b/internal/dns/cloudflare.go new file mode 100644 index 0000000..2bf7a49 --- /dev/null +++ b/internal/dns/cloudflare.go @@ -0,0 +1,367 @@ +package dns + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const cfBaseURL = "https://api.cloudflare.com/client/v4" + +// Cloudflare implements the Provider interface using the Cloudflare API v4. +type Cloudflare struct { + token string + zoneID string + client *http.Client +} + +// NewCloudflare creates a new Cloudflare DNS provider. +// token is required. zoneID can be empty for ListZones/TestConnection calls. +func NewCloudflare(token, zoneID string) (*Cloudflare, error) { + if token == "" { + return nil, fmt.Errorf("cloudflare API token is required") + } + return &Cloudflare{ + token: token, + zoneID: zoneID, + client: &http.Client{Timeout: 30 * time.Second}, + }, nil +} + +// --- Provider interface --- + +// EnsureRecord creates or updates an A record for the given FQDN. +func (c *Cloudflare) EnsureRecord(ctx context.Context, fqdn, ip string) (string, error) { + if c.zoneID == "" { + return "", fmt.Errorf("zone ID is required for DNS operations") + } + + // Check if a record already exists. + existing, err := c.findRecord(ctx, fqdn) + if err != nil { + return "", fmt.Errorf("find existing record: %w", err) + } + + if existing != nil { + // Record exists — update if IP differs. + if existing.Content == ip { + return existing.ID, nil // already correct, no-op + } + updated, err := c.updateRecord(ctx, existing.ID, fqdn, ip) + if err != nil { + return "", fmt.Errorf("update record: %w", err) + } + return updated.ID, nil + } + + // Record doesn't exist — create it. + created, err := c.createRecord(ctx, fqdn, ip) + if err != nil { + return "", fmt.Errorf("create record: %w", err) + } + return created.ID, nil +} + +// DeleteRecord removes an A record by FQDN. Returns nil if not found. +func (c *Cloudflare) DeleteRecord(ctx context.Context, fqdn string) error { + if c.zoneID == "" { + return fmt.Errorf("zone ID is required for DNS operations") + } + + existing, err := c.findRecord(ctx, fqdn) + if err != nil { + return fmt.Errorf("find record: %w", err) + } + if existing == nil { + return nil // doesn't exist, nothing to delete + } + + endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfBaseURL, c.zoneID, existing.ID) + if _, err := c.doRequest(ctx, http.MethodDelete, endpoint, nil); err != nil { + return fmt.Errorf("delete record: %w", err) + } + return nil +} + +// ListRecords returns all A records in the zone. +func (c *Cloudflare) ListRecords(ctx context.Context) ([]Record, error) { + if c.zoneID == "" { + return nil, fmt.Errorf("zone ID is required for DNS operations") + } + + var allRecords []Record + page := 1 + + for { + endpoint := fmt.Sprintf("%s/zones/%s/dns_records?type=A&page=%d&per_page=100", cfBaseURL, c.zoneID, page) + body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("list records: %w", err) + } + + var resp cfListResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decode list response: %w", err) + } + + for _, r := range resp.Result { + allRecords = append(allRecords, Record{ + ID: r.ID, + FQDN: r.Name, + Type: r.Type, + Content: r.Content, + TTL: r.TTL, + Proxied: r.Proxied, + }) + } + + if page >= resp.ResultInfo.TotalPages { + break + } + page++ + } + + return allRecords, nil +} + +// TestConnection verifies the API token is valid. +func (c *Cloudflare) TestConnection(ctx context.Context) error { + endpoint := cfBaseURL + "/user/tokens/verify" + body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return fmt.Errorf("verify token: %w", err) + } + + var resp cfBaseResponse + if err := json.Unmarshal(body, &resp); err != nil { + return fmt.Errorf("decode verify response: %w", err) + } + if !resp.Success { + return fmt.Errorf("token verification failed: %s", formatErrors(resp.Errors)) + } + return nil +} + +// --- Additional methods (not part of Provider interface) --- + +// ListZones returns all zones accessible by the token. +func (c *Cloudflare) ListZones(ctx context.Context) ([]Zone, error) { + var allZones []Zone + page := 1 + + for { + endpoint := fmt.Sprintf("%s/zones?page=%d&per_page=50&status=active", cfBaseURL, page) + body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("list zones: %w", err) + } + + var resp cfZonesResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decode zones response: %w", err) + } + + for _, z := range resp.Result { + allZones = append(allZones, Zone{ + ID: z.ID, + Name: z.Name, + }) + } + + if page >= resp.ResultInfo.TotalPages { + break + } + page++ + } + + return allZones, nil +} + +// --- Internal helpers --- + +func (c *Cloudflare) findRecord(ctx context.Context, fqdn string) (*cfDNSRecord, error) { + endpoint := fmt.Sprintf("%s/zones/%s/dns_records?type=A&name=%s", + cfBaseURL, c.zoneID, url.QueryEscape(fqdn)) + + body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var resp cfListResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decode find response: %w", err) + } + + if len(resp.Result) == 0 { + return nil, nil + } + return &resp.Result[0], nil +} + +func (c *Cloudflare) createRecord(ctx context.Context, fqdn, ip string) (*cfDNSRecord, error) { + payload := cfDNSRecordRequest{ + Type: "A", + Name: fqdn, + Content: ip, + TTL: 1, // auto + Proxied: false, + } + + data, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal create payload: %w", err) + } + + endpoint := fmt.Sprintf("%s/zones/%s/dns_records", cfBaseURL, c.zoneID) + body, err := c.doRequest(ctx, http.MethodPost, endpoint, data) + if err != nil { + return nil, err + } + + var resp cfSingleResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decode create response: %w", err) + } + if !resp.Success { + return nil, fmt.Errorf("create failed: %s", formatErrors(resp.Errors)) + } + return &resp.Result, nil +} + +func (c *Cloudflare) updateRecord(ctx context.Context, recordID, fqdn, ip string) (*cfDNSRecord, error) { + payload := cfDNSRecordRequest{ + Type: "A", + Name: fqdn, + Content: ip, + TTL: 1, + Proxied: false, + } + + data, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal update payload: %w", err) + } + + endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfBaseURL, c.zoneID, recordID) + body, err := c.doRequest(ctx, http.MethodPut, endpoint, data) + if err != nil { + return nil, err + } + + var resp cfSingleResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decode update response: %w", err) + } + if !resp.Success { + return nil, fmt.Errorf("update failed: %s", formatErrors(resp.Errors)) + } + return &resp.Result, nil +} + +func (c *Cloudflare) doRequest(ctx context.Context, method, endpoint string, payload []byte) ([]byte, error) { + var bodyReader io.Reader + if payload != nil { + bodyReader = bytes.NewReader(payload) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 400 { + var errResp cfBaseResponse + if json.Unmarshal(body, &errResp) == nil && len(errResp.Errors) > 0 { + return nil, fmt.Errorf("cloudflare API error (%d): %s", resp.StatusCode, formatErrors(errResp.Errors)) + } + return nil, fmt.Errorf("cloudflare API error (%d): %s", resp.StatusCode, string(body)) + } + + return body, nil +} + +// --- Cloudflare API response types --- + +type cfBaseResponse struct { + Success bool `json:"success"` + Errors []cfError `json:"errors"` +} + +type cfError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type cfDNSRecord struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + TTL int `json:"ttl"` + Proxied bool `json:"proxied"` +} + +type cfDNSRecordRequest struct { + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + TTL int `json:"ttl"` + Proxied bool `json:"proxied"` +} + +type cfResultInfo struct { + TotalPages int `json:"total_pages"` +} + +type cfListResponse struct { + cfBaseResponse + Result []cfDNSRecord `json:"result"` + ResultInfo cfResultInfo `json:"result_info"` +} + +type cfSingleResponse struct { + cfBaseResponse + Result cfDNSRecord `json:"result"` +} + +type cfZone struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type cfZonesResponse struct { + cfBaseResponse + Result []cfZone `json:"result"` + ResultInfo cfResultInfo `json:"result_info"` +} + +func formatErrors(errs []cfError) string { + if len(errs) == 0 { + return "unknown error" + } + msg := errs[0].Message + for _, e := range errs[1:] { + msg += "; " + e.Message + } + return msg +} diff --git a/internal/dns/dns.go b/internal/dns/dns.go new file mode 100644 index 0000000..3129bad --- /dev/null +++ b/internal/dns/dns.go @@ -0,0 +1,22 @@ +package dns + +import "fmt" + +// Config holds configuration for creating a DNS provider. +type Config struct { + Token string + ZoneID string +} + +// NewProvider creates a DNS provider by name. +// Returns nil, nil when providerName is empty (wildcard DNS mode). +func NewProvider(providerName string, cfg Config) (Provider, error) { + switch providerName { + case "": + return nil, nil + case "cloudflare": + return NewCloudflare(cfg.Token, cfg.ZoneID) + default: + return nil, fmt.Errorf("unsupported DNS provider: %s", providerName) + } +} diff --git a/internal/dns/provider.go b/internal/dns/provider.go new file mode 100644 index 0000000..5e81629 --- /dev/null +++ b/internal/dns/provider.go @@ -0,0 +1,34 @@ +package dns + +import "context" + +// Record represents a DNS record from a provider. +type Record struct { + ID string `json:"id"` + FQDN string `json:"fqdn"` + Type string `json:"type"` + Content string `json:"content"` // IP address for A records + TTL int `json:"ttl"` + Proxied bool `json:"proxied"` +} + +// Zone represents a DNS zone from a provider. +type Zone struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Provider is the interface for DNS record management. +type Provider interface { + // EnsureRecord creates an A record if it doesn't exist, or updates it if the IP differs. + EnsureRecord(ctx context.Context, fqdn, ip string) (recordID string, err error) + + // DeleteRecord removes an A record by FQDN. No error if it doesn't exist. + DeleteRecord(ctx context.Context, fqdn string) error + + // ListRecords returns all A records in the zone. + ListRecords(ctx context.Context) ([]Record, error) + + // TestConnection verifies that the provider credentials are valid. + TestConnection(ctx context.Context) error +} diff --git a/internal/proxy/manager.go b/internal/proxy/manager.go index 05189ef..e8c5ff6 100644 --- a/internal/proxy/manager.go +++ b/internal/proxy/manager.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" + "github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/docker-watcher/internal/store" ) @@ -14,6 +15,7 @@ import ( type Manager struct { store *store.Store npm *npm.Client + dns dns.Provider // nil when wildcard DNS is active } // NewManager creates a new proxy manager. @@ -24,6 +26,11 @@ func NewManager(st *store.Store, npmClient *npm.Client) *Manager { } } +// SetDNSProvider sets the DNS provider for managing DNS records. +func (m *Manager) SetDNSProvider(provider dns.Provider) { + m.dns = provider +} + // CreateProxyRequest is the input for creating a standalone proxy. type CreateProxyRequest struct { Domain string `json:"domain"` @@ -108,6 +115,9 @@ func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (stor 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 } @@ -160,6 +170,12 @@ func (m *Manager) UpdateProxy(ctx context.Context, id string, req UpdateProxyReq 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) } @@ -179,6 +195,9 @@ func (m *Manager) DeleteProxy(ctx context.Context, id string) error { } } + // Remove DNS record. + m.removeDNS(ctx, proxy.Domain) + if err := m.store.DeleteStandaloneProxy(id); err != nil { return fmt.Errorf("delete standalone proxy: %w", err) } @@ -294,6 +313,56 @@ func (m *Manager) ListAllProxies() ([]ProxyView, error) { 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) { + if m.dns == 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 := m.dns.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) { + if m.dns == nil { + return + } + if err := m.dns.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 { diff --git a/internal/store/dns_records.go b/internal/store/dns_records.go new file mode 100644 index 0000000..d5c419b --- /dev/null +++ b/internal/store/dns_records.go @@ -0,0 +1,123 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// CreateDNSRecord inserts a new DNS record tracking entry. +func (s *Store) CreateDNSRecord(rec DNSRecord) (DNSRecord, error) { + if rec.ID == "" { + rec.ID = uuid.New().String() + } + now := Now() + rec.CreatedAt = now + rec.UpdatedAt = now + + _, err := s.db.Exec( + `INSERT INTO dns_records (id, fqdn, provider_record_id, consumer_type, consumer_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + rec.ID, rec.FQDN, rec.ProviderRecordID, rec.ConsumerType, rec.ConsumerID, rec.CreatedAt, rec.UpdatedAt, + ) + if err != nil { + return DNSRecord{}, fmt.Errorf("insert dns_record: %w", err) + } + return rec, nil +} + +// GetDNSRecordByFQDN returns a DNS record by its FQDN. +func (s *Store) GetDNSRecordByFQDN(fqdn string) (DNSRecord, error) { + var rec DNSRecord + err := s.db.QueryRow( + `SELECT id, fqdn, provider_record_id, consumer_type, consumer_id, created_at, updated_at + FROM dns_records WHERE fqdn = ?`, fqdn, + ).Scan(&rec.ID, &rec.FQDN, &rec.ProviderRecordID, &rec.ConsumerType, &rec.ConsumerID, &rec.CreatedAt, &rec.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return DNSRecord{}, fmt.Errorf("dns record %s: %w", fqdn, ErrNotFound) + } + if err != nil { + return DNSRecord{}, fmt.Errorf("query dns_record: %w", err) + } + return rec, nil +} + +// ListDNSRecords returns all tracked DNS records. +func (s *Store) ListDNSRecords() ([]DNSRecord, error) { + rows, err := s.db.Query( + `SELECT id, fqdn, provider_record_id, consumer_type, consumer_id, created_at, updated_at + FROM dns_records ORDER BY fqdn`, + ) + if err != nil { + return nil, fmt.Errorf("query dns_records: %w", err) + } + defer rows.Close() + + var records []DNSRecord + for rows.Next() { + var rec DNSRecord + if err := rows.Scan(&rec.ID, &rec.FQDN, &rec.ProviderRecordID, &rec.ConsumerType, &rec.ConsumerID, &rec.CreatedAt, &rec.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan dns_record: %w", err) + } + records = append(records, rec) + } + return records, rows.Err() +} + +// GetDNSRecordsByConsumer returns all DNS records for a specific consumer. +func (s *Store) GetDNSRecordsByConsumer(consumerType, consumerID string) ([]DNSRecord, error) { + rows, err := s.db.Query( + `SELECT id, fqdn, provider_record_id, consumer_type, consumer_id, created_at, updated_at + FROM dns_records WHERE consumer_type = ? AND consumer_id = ? ORDER BY fqdn`, + consumerType, consumerID, + ) + if err != nil { + return nil, fmt.Errorf("query dns_records by consumer: %w", err) + } + defer rows.Close() + + var records []DNSRecord + for rows.Next() { + var rec DNSRecord + if err := rows.Scan(&rec.ID, &rec.FQDN, &rec.ProviderRecordID, &rec.ConsumerType, &rec.ConsumerID, &rec.CreatedAt, &rec.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan dns_record: %w", err) + } + records = append(records, rec) + } + return records, rows.Err() +} + +// UpdateDNSRecordProviderID updates the provider record ID for an existing DNS record. +func (s *Store) UpdateDNSRecordProviderID(fqdn, providerRecordID string) error { + _, err := s.db.Exec( + `UPDATE dns_records SET provider_record_id = ?, updated_at = ? WHERE fqdn = ?`, + providerRecordID, Now(), fqdn, + ) + if err != nil { + return fmt.Errorf("update dns_record provider_id: %w", err) + } + return nil +} + +// DeleteDNSRecord removes a DNS record by FQDN. +func (s *Store) DeleteDNSRecord(fqdn string) error { + _, err := s.db.Exec(`DELETE FROM dns_records WHERE fqdn = ?`, fqdn) + if err != nil { + return fmt.Errorf("delete dns_record: %w", err) + } + return nil +} + +// DeleteDNSRecordsByConsumer removes all DNS records for a specific consumer. +func (s *Store) DeleteDNSRecordsByConsumer(consumerType, consumerID string) error { + _, err := s.db.Exec( + `DELETE FROM dns_records WHERE consumer_type = ? AND consumer_id = ?`, + consumerType, consumerID, + ) + if err != nil { + return fmt.Errorf("delete dns_records by consumer: %w", err) + } + return nil +} diff --git a/internal/store/models.go b/internal/store/models.go index d98bf3f..13101c5 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -58,9 +58,24 @@ type Settings struct { SSLCertificateID int `json:"ssl_certificate_id"` StaleThresholdDays int `json:"stale_threshold_days"` AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths + WildcardDNS bool `json:"wildcard_dns"` + DNSProvider string `json:"dns_provider"` + CloudflareAPIToken string `json:"cloudflare_api_token"` + CloudflareZoneID string `json:"cloudflare_zone_id"` UpdatedAt string `json:"updated_at"` } +// DNSRecord tracks a DNS record managed by the application. +type DNSRecord struct { + ID string `json:"id"` + FQDN string `json:"fqdn"` + ProviderRecordID string `json:"provider_record_id"` + ConsumerType string `json:"consumer_type"` // "instance" or "standalone" + ConsumerID string `json:"consumer_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + // Instance represents a running (or stopped) container for a project stage. type Instance struct { ID string `json:"id"` diff --git a/internal/store/settings.go b/internal/store/settings.go index 324df99..3e6dde2 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -7,36 +7,46 @@ import ( // GetSettings returns the global settings (single-row pattern, always row id=1). func (s *Store) GetSettings() (Settings, error) { var st Settings + var wildcardDNS int err := s.db.QueryRow( `SELECT domain, server_ip, network, subdomain_pattern, notification_url, npm_url, npm_email, npm_password, webhook_secret, polling_interval, base_volume_path, ssl_certificate_id, stale_threshold_days, - allowed_volume_paths, updated_at + allowed_volume_paths, wildcard_dns, dns_provider, + cloudflare_api_token, cloudflare_zone_id, updated_at FROM settings WHERE id = 1`, ).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL, &st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval, &st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays, - &st.AllowedVolumePaths, &st.UpdatedAt) + &st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider, + &st.CloudflareAPIToken, &st.CloudflareZoneID, &st.UpdatedAt) if err != nil { return Settings{}, fmt.Errorf("query settings: %w", err) } + st.WildcardDNS = wildcardDNS != 0 return st, nil } // UpdateSettings upserts the global settings row. func (s *Store) UpdateSettings(st Settings) error { st.UpdatedAt = Now() + wildcardDNS := 0 + if st.WildcardDNS { + wildcardDNS = 1 + } _, err := s.db.Exec( `UPDATE settings SET domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?, npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?, base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?, - allowed_volume_paths=?, updated_at=? + allowed_volume_paths=?, wildcard_dns=?, dns_provider=?, + cloudflare_api_token=?, cloudflare_zone_id=?, updated_at=? WHERE id = 1`, st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL, st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval, st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays, - st.AllowedVolumePaths, st.UpdatedAt, + st.AllowedVolumePaths, wildcardDNS, st.DNSProvider, + st.CloudflareAPIToken, st.CloudflareZoneID, st.UpdatedAt, ) if err != nil { return fmt.Errorf("update settings: %w", err) diff --git a/internal/store/store.go b/internal/store/store.go index e3e843d..4b803ef 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -90,6 +90,11 @@ func (s *Store) runMigrations() error { `ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`, // Add allowed_volume_paths to settings for absolute volume scope allowlist (2026-04-01). `ALTER TABLE settings ADD COLUMN allowed_volume_paths TEXT NOT NULL DEFAULT '[]'`, + // Add DNS management fields to settings (2026-04-02). + `ALTER TABLE settings ADD COLUMN wildcard_dns INTEGER NOT NULL DEFAULT 1`, + `ALTER TABLE settings ADD COLUMN dns_provider TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE settings ADD COLUMN cloudflare_api_token TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE settings ADD COLUMN cloudflare_zone_id TEXT NOT NULL DEFAULT ''`, } for _, m := range migrations { @@ -110,6 +115,7 @@ func (s *Store) runMigrations() error { `CREATE INDEX IF NOT EXISTS idx_event_log_severity ON event_log(severity)`, `CREATE INDEX IF NOT EXISTS idx_event_log_source ON event_log(source)`, `CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON event_log(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_dns_records_consumer ON dns_records(consumer_type, consumer_id)`, } for _, idx := range indexes { if _, err := s.db.Exec(idx); err != nil { @@ -297,6 +303,16 @@ CREATE TABLE IF NOT EXISTS standalone_proxies ( created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + +CREATE TABLE IF NOT EXISTS dns_records ( + id TEXT PRIMARY KEY, + fqdn TEXT NOT NULL UNIQUE, + provider_record_id TEXT NOT NULL DEFAULT '', + consumer_type TEXT NOT NULL DEFAULT '', + consumer_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); ` // Now returns the current time formatted for SQLite storage. diff --git a/plans/cloudflare-dns-management/CONTEXT.md b/plans/cloudflare-dns-management/CONTEXT.md new file mode 100644 index 0000000..1e5563b --- /dev/null +++ b/plans/cloudflare-dns-management/CONTEXT.md @@ -0,0 +1,33 @@ +# Feature Context: Cloudflare DNS Management + +## Configuration +- **Development mode:** Automated +- **Execution mode:** Direct +- **Strategy:** Big Bang +- **Build (Go):** `go build ./cmd/server` +- **Build (Frontend):** `cd web && npm run build` +- **Check (Frontend):** `cd web && npm run check` +- **Test:** `go test ./...` +- **Dev server:** `./scripts/dev-server.sh` (port 8090) + +## Current State +Starting fresh — no implementation yet. + +## Cross-Phase Dependencies +- Phase 2 depends on Phase 1 (settings fields for Cloudflare credentials) +- Phase 3 depends on Phase 2 (dns.Provider interface) +- Phase 4 depends on Phase 1 (API endpoints for settings) +- Phase 5 depends on Phase 2 + Phase 6 (client + sync logic) +- Phase 6 depends on Phase 2 (Cloudflare client) + Phase 3 (dns_records table) + +## Key Architecture Decisions +- DNS provider abstraction via `internal/dns.Provider` interface +- Cloudflare API v4 via direct HTTP (no SDK) — keeps dependencies minimal +- Local `dns_records` table tracks managed records for reconciliation +- DNS operations are best-effort (log warnings, don't block deploys) +- A records only, pointing to `ServerIP` from settings + +## Environment & Runtime Notes +- Encryption key from `ENCRYPTION_KEY` env var (AES-256-GCM) +- SQLite with WAL mode, auto-migration on startup +- Frontend is SvelteKit 2 + Svelte 5 + Tailwind CSS 4 diff --git a/plans/cloudflare-dns-management/PLAN.md b/plans/cloudflare-dns-management/PLAN.md new file mode 100644 index 0000000..7717a46 --- /dev/null +++ b/plans/cloudflare-dns-management/PLAN.md @@ -0,0 +1,50 @@ +# Feature: Cloudflare DNS Management + +**Branch:** `feature/cloudflare-dns-management` +**Base branch:** `main` +**Created:** 2026-04-02 +**Status:** 🟡 In Progress +**Strategy:** Big Bang +**Mode:** Automated +**Execution:** Direct + +## Summary + +Introduce flexible DNS management. By default, wildcard DNS is assumed (current behavior). +When disabled, the user selects a DNS provider (Cloudflare initially) and provides API +credentials. DNS A records are then automatically kept in sync with proxy consumers +(deployed instances and standalone proxies). A dedicated DNS Records page provides +visibility, filtering, and manual sync/reconciliation. + +## Build & Test Commands +- **Build (Go):** `go build ./cmd/server` +- **Build (Frontend):** `cd web && npm run build` +- **Check (Frontend):** `cd web && npm run check` +- **Test (Go):** `go test ./...` +- **Dev server:** `./scripts/dev-server.sh` + +## Phases + +- [ ] Phase 1: Settings model & API [domain: backend] → [subplan](./phase-1-settings-model.md) +- [ ] Phase 2: Cloudflare DNS client [domain: backend] → [subplan](./phase-2-cloudflare-client.md) +- [ ] Phase 3: DNS lifecycle hooks [domain: backend] → [subplan](./phase-3-dns-hooks.md) +- [ ] Phase 4: Settings UI — DNS configuration [domain: frontend] → [subplan](./phase-4-settings-ui.md) +- [ ] Phase 5: DNS Records page [domain: fullstack] → [subplan](./phase-5-dns-records-page.md) +- [ ] Phase 6: DNS sync & reconciliation [domain: backend] → [subplan](./phase-6-dns-sync.md) + +## Phase Progress Log + +| Phase | Domain | Status | Review | Build | Committed | +|-------|--------|--------|--------|-------|-----------| +| Phase 1: Settings model & API | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 2: Cloudflare DNS client | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 3: DNS lifecycle hooks | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 4: Settings UI — DNS config | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 5: DNS Records page | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 6: DNS sync & reconciliation | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | + +## Final Review +- [ ] Comprehensive code review +- [ ] Full build passes +- [ ] Full test suite passes +- [ ] Merged to `main` diff --git a/plans/cloudflare-dns-management/phase-1-settings-model.md b/plans/cloudflare-dns-management/phase-1-settings-model.md new file mode 100644 index 0000000..8ee842c --- /dev/null +++ b/plans/cloudflare-dns-management/phase-1-settings-model.md @@ -0,0 +1,59 @@ +# Phase 1: Settings Model & API + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Extend the Settings model and API to support DNS provider configuration. + +## Tasks + +- [ ] Task 1: Add new fields to `Settings` struct in `internal/store/models.go` + - `WildcardDNS` (bool, default true) + - `DNSProvider` (string, default "") + - `CloudflareAPIToken` (string, encrypted) + - `CloudflareZoneID` (string) +- [ ] Task 2: Add migration columns in `internal/store/store.go` + - `wildcard_dns` INTEGER DEFAULT 1 + - `dns_provider` TEXT DEFAULT '' + - `cloudflare_api_token` TEXT DEFAULT '' + - `cloudflare_zone_id` TEXT DEFAULT '' +- [ ] Task 3: Update `GetSettings()` and `UpdateSettings()` in `internal/store/settings.go` + - Read/write new fields + - Encrypt/decrypt `cloudflare_api_token` +- [ ] Task 4: Update `GET /api/settings` handler to include new fields (mask token) +- [ ] Task 5: Update `PUT /api/settings` handler to accept new fields +- [ ] Task 6: Add `POST /api/settings/dns/test` endpoint — validate Cloudflare token + zone +- [ ] Task 7: Add `GET /api/settings/dns/zones` endpoint — list Cloudflare zones for picker +- [ ] Task 8: Register new routes in `internal/api/router.go` + +## Files to Modify/Create +- `internal/store/models.go` — add fields to Settings struct +- `internal/store/store.go` — add migration columns +- `internal/store/settings.go` — update read/write queries +- `internal/api/settings.go` — update handlers, add new endpoints +- `internal/api/router.go` — register new routes + +## Acceptance Criteria +- New settings fields are persisted and retrievable +- Cloudflare API token is encrypted at rest +- GET /api/settings returns new fields (token masked) +- PUT /api/settings accepts and stores new fields +- DNS test and zones endpoints registered (can return placeholder until Phase 2) + +## Notes +- Token encryption uses existing `crypto.Encrypt/Decrypt` +- `has_cloudflare_api_token` bool in GET response (same pattern as npm_password) +- DNS test/zones endpoints will make real Cloudflare API calls — Phase 2 client needed + for full implementation, but can use inline HTTP calls for these two endpoints + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/cloudflare-dns-management/phase-2-cloudflare-client.md b/plans/cloudflare-dns-management/phase-2-cloudflare-client.md new file mode 100644 index 0000000..e9f6ac5 --- /dev/null +++ b/plans/cloudflare-dns-management/phase-2-cloudflare-client.md @@ -0,0 +1,61 @@ +# Phase 2: Cloudflare DNS Client + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Create an `internal/dns` package with a `Provider` interface and a Cloudflare implementation +using the Cloudflare API v4 (direct HTTP, no SDK). + +## Tasks + +- [ ] Task 1: Define `Provider` interface in `internal/dns/provider.go` + - `EnsureRecord(ctx, fqdn, ip) error` — create or update A record + - `DeleteRecord(ctx, fqdn) error` — delete A record if exists + - `ListRecords(ctx) ([]Record, error)` — list all A records in the zone + - `Record` struct: ID, FQDN, Type, Content (IP), Proxied, TTL +- [ ] Task 2: Create `internal/dns/cloudflare.go` — Cloudflare implementation + - HTTP client with `Authorization: Bearer ` header + - Base URL: `https://api.cloudflare.com/client/v4` + - `EnsureRecord`: GET records by name, create if missing, update if IP differs + - `DeleteRecord`: GET record by name, DELETE if found + - `ListRecords`: GET all A records in zone + - `ListZones`: GET zones for the token (for zone picker) + - `TestConnection`: verify token works (GET /user/tokens/verify) +- [ ] Task 3: Create `internal/dns/dns.go` — factory function + - `NewProvider(providerName, config) (Provider, error)` + - Config struct with token, zoneID + - Returns `nil, nil` when providerName is empty (wildcard mode) +- [ ] Task 4: Wire DNS test/zones endpoints in `internal/api/settings.go` + - `POST /api/settings/dns/test` — create temp Cloudflare client, call TestConnection + - `GET /api/settings/dns/zones` — create temp client, call ListZones + +## Files to Modify/Create +- `internal/dns/provider.go` — interface + Record type +- `internal/dns/cloudflare.go` — Cloudflare implementation +- `internal/dns/dns.go` — factory function +- `internal/api/settings.go` — wire test/zones endpoints to real client + +## Acceptance Criteria +- Provider interface defined with EnsureRecord, DeleteRecord, ListRecords +- Cloudflare client makes correct API calls with proper auth headers +- EnsureRecord is idempotent (create if missing, update if changed, no-op if same) +- DeleteRecord is idempotent (no error if record doesn't exist) +- ListZones returns zone ID + name pairs +- TestConnection returns success/failure + +## Notes +- Cloudflare API v4 docs: zones endpoint, dns_records endpoint +- Use `context.Context` for timeout control on all HTTP calls +- A records only (type "A"), TTL=1 (auto), proxied=false (DNS only, not CF proxy) + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/cloudflare-dns-management/phase-3-dns-hooks.md b/plans/cloudflare-dns-management/phase-3-dns-hooks.md new file mode 100644 index 0000000..b347d0a --- /dev/null +++ b/plans/cloudflare-dns-management/phase-3-dns-hooks.md @@ -0,0 +1,67 @@ +# Phase 3: DNS Lifecycle Hooks + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Hook DNS record management into the deployer and standalone proxy manager so that DNS +records are automatically created/updated/deleted in sync with proxy consumers. + +## Tasks + +- [ ] Task 1: Create `dns_records` table for tracking managed records + - Columns: id, fqdn, provider_record_id, consumer_type (instance/standalone), consumer_id, created_at, updated_at + - Store queries: CreateDNSRecord, DeleteDNSRecord, GetDNSRecordByFQDN, ListDNSRecords, GetDNSRecordsByConsumer +- [ ] Task 2: Add DNS provider to `Deployer` struct + - Accept `dns.Provider` in constructor (can be nil for wildcard mode) + - Helper: `ensureDNS(ctx, fqdn, deployID)` — calls provider.EnsureRecord + saves to dns_records + - Helper: `removeDNS(ctx, fqdn, deployID)` — calls provider.DeleteRecord + removes from dns_records +- [ ] Task 3: Hook into deployer — instance creation + - After `configureProxy` succeeds in `deployer.go` and `bluegreen.go` → call `ensureDNS` + - FQDN = `subdomain + "." + settings.Domain` +- [ ] Task 4: Hook into deployer — instance removal + - In `removeInstance` after NPM proxy deletion → call `removeDNS` + - In `rollback` after NPM proxy deletion → call `removeDNS` +- [ ] Task 5: Hook into standalone proxy manager + - `CreateProxy` → after NPM host created, call `ensureDNS` + - `UpdateProxy` → if domain changed, `removeDNS(old)` + `ensureDNS(new)` + - `DeleteProxy` → call `removeDNS` +- [ ] Task 6: Wire DNS provider into main.go + - Read settings on startup, create provider if non-wildcard + - Pass provider to Deployer and proxy Manager constructors + - Handle provider being nil (wildcard mode = no DNS ops) +- [ ] Task 7: Add `DNSRecord` model to `internal/store/models.go` + +## Files to Modify/Create +- `internal/store/models.go` — add DNSRecord struct +- `internal/store/store.go` — add dns_records table migration +- `internal/store/dns_records.go` — CRUD queries +- `internal/deployer/deployer.go` — add DNS hooks +- `internal/deployer/bluegreen.go` — add DNS hooks +- `internal/deployer/rollback.go` — add DNS cleanup +- `internal/proxy/manager.go` — add DNS hooks +- `cmd/server/main.go` — wire DNS provider + +## Acceptance Criteria +- DNS records created when proxy consumers are created (if non-wildcard mode) +- DNS records deleted when proxy consumers are removed +- DNS records updated when standalone proxy domain changes +- All DNS operations are best-effort (log warning on failure, don't block) +- dns_records table tracks all managed records +- Wildcard mode (default) skips all DNS operations + +## Notes +- DNS operations must be wrapped in error handling that logs but doesn't fail the deploy +- The dns_records table is the local source of truth for reconciliation (Phase 6) +- Provider can be nil — all hooks must check for nil before calling + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/cloudflare-dns-management/phase-4-settings-ui.md b/plans/cloudflare-dns-management/phase-4-settings-ui.md new file mode 100644 index 0000000..7254f41 --- /dev/null +++ b/plans/cloudflare-dns-management/phase-4-settings-ui.md @@ -0,0 +1,55 @@ +# Phase 4: Settings UI — DNS Configuration + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Add a "DNS Configuration" section to the Settings page with wildcard toggle, provider +selection, Cloudflare credential fields, and connection test. + +## Tasks + +- [ ] Task 1: Add new API functions in `web/src/lib/api.ts` + - `testDnsConnection(token, zoneId)` → POST /api/settings/dns/test + - `listDnsZones(token)` → GET /api/settings/dns/zones +- [ ] Task 2: Add i18n keys for DNS settings in locale files +- [ ] Task 3: Add DNS Configuration section to `web/src/routes/settings/+page.svelte` + - Toggle: "Wildcard DNS is configured" (checkbox/switch) + - When unchecked, show: + - DNS Provider dropdown (only "Cloudflare" option) + - API Token field (password type, show `has_cloudflare_api_token` indicator) + - Zone picker (loaded from API after token provided) + - "Test Connection" button with success/error feedback + - All DNS fields hidden when wildcard is checked +- [ ] Task 4: Wire save logic — include new fields in `handleSave` +- [ ] Task 5: Wire load logic — populate DNS fields from settings response + +## Files to Modify/Create +- `web/src/lib/api.ts` — add DNS API functions +- `web/src/routes/settings/+page.svelte` — add DNS config section +- `web/src/lib/i18n/en.ts` (or equivalent locale file) — add DNS translation keys + +## Acceptance Criteria +- Wildcard toggle visible and functional (default: checked) +- Unchecking reveals Cloudflare configuration form +- API token field uses password masking +- Zone picker loads zones from Cloudflare API +- Test Connection button shows success/failure +- Settings save includes DNS fields +- Settings load populates DNS fields + +## Notes +- Follow existing settings page patterns (FormField, EntityPicker for zones) +- Zone picker similar to SSL certificate picker pattern +- Token field similar to NPM password field (has_token indicator) + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/cloudflare-dns-management/phase-5-dns-records-page.md b/plans/cloudflare-dns-management/phase-5-dns-records-page.md new file mode 100644 index 0000000..acce225 --- /dev/null +++ b/plans/cloudflare-dns-management/phase-5-dns-records-page.md @@ -0,0 +1,65 @@ +# Phase 5: DNS Records Page + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Create a dedicated DNS Records page that lists all managed DNS records with filtering, +consumer mapping, and sync status visibility. + +## Tasks + +- [ ] Task 1: Add backend endpoint `GET /api/dns/records` + - Returns merged view: local dns_records + Cloudflare actual records + - Each record: fqdn, type, value (IP), consumer_type, consumer_name, status (synced/orphaned/missing) + - Orphaned = exists in Cloudflare but no local consumer + - Missing = local consumer exists but no Cloudflare record +- [ ] Task 2: Add API handler in `internal/api/dns.go` + - New handler file for DNS-related endpoints + - Register routes in router.go +- [ ] Task 3: Add frontend API function `getDnsRecords()` in `api.ts` +- [ ] Task 4: Create DNS Records page at `web/src/routes/dns/+page.svelte` + - Table with columns: FQDN, Type, Value, Consumer, Status + - Consumer column shows: instance name (project/stage) or standalone proxy name + - Status badges: synced (green), orphaned (yellow), missing (red) + - Search filter (by FQDN substring) + - Filter by consumer type: all / managed / standalone + - Filter by status: all / synced / orphaned / missing + - Manual sync button (calls POST /api/dns/sync — Phase 6) + - Refresh button to re-fetch from Cloudflare +- [ ] Task 5: Add navigation link to DNS page + - Only visible when wildcard DNS is disabled + - Add to sidebar/nav under Settings or as top-level +- [ ] Task 6: Add i18n keys for DNS records page + +## Files to Modify/Create +- `internal/api/dns.go` — new handler file +- `internal/api/router.go` — register DNS routes +- `web/src/lib/api.ts` — add DNS records API function +- `web/src/routes/dns/+page.svelte` — new page +- `web/src/routes/dns/+page.ts` — optional load function +- Navigation component — add DNS link +- Locale files — add i18n keys + +## Acceptance Criteria +- DNS Records page accessible at /dns +- Table shows all records with correct status +- Filtering works: search text, consumer type, sync status +- Only accessible/visible when wildcard DNS is disabled +- Consumer names resolve correctly (project/stage for managed, proxy name for standalone) + +## Notes +- Status computation: compare local dns_records table with Cloudflare ListRecords response +- Cache Cloudflare response for a few seconds to avoid rate limiting on page load +- Navigation link visibility tied to settings (may need a store or settings check) + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/cloudflare-dns-management/phase-6-dns-sync.md b/plans/cloudflare-dns-management/phase-6-dns-sync.md new file mode 100644 index 0000000..285f04a --- /dev/null +++ b/plans/cloudflare-dns-management/phase-6-dns-sync.md @@ -0,0 +1,62 @@ +# Phase 6: DNS Sync & Reconciliation + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Implement reconciliation logic that compares expected DNS records (from active consumers) +with actual Cloudflare records, and provides a sync endpoint to fix discrepancies. + +## Tasks + +- [ ] Task 1: Add `POST /api/dns/sync` endpoint + - Computes expected records from: active instances with proxy + standalone proxies + - Fetches actual records from Cloudflare via ListRecords + - Creates missing records (consumer exists, no CF record) + - Deletes orphaned records (CF record exists, no consumer) — only for records in dns_records table + - Updates dns_records table to reflect current state + - Returns sync report: created N, deleted N, already_synced N +- [ ] Task 2: Add helper to compute expected records + - Query all instances where npm_proxy_id > 0 and status = "running" → extract FQDN + - Query all standalone proxies → extract domain + - Return list of expected FQDNs +- [ ] Task 3: Add `DELETE /api/dns/records/{fqdn}` endpoint + - Manual deletion of a specific DNS record (for orphan cleanup) + - Calls provider.DeleteRecord + removes from dns_records +- [ ] Task 4: Wire sync endpoint in `internal/api/dns.go` and router +- [ ] Task 5: Add frontend sync button handler in DNS Records page + - Call POST /api/dns/sync + - Show sync report (toast or inline) + - Refresh records list after sync + +## Files to Modify/Create +- `internal/api/dns.go` — add sync + delete endpoints +- `internal/api/router.go` — register new routes +- `internal/store/dns_records.go` — add helper queries (list consumers with FQDNs) +- `web/src/lib/api.ts` — add syncDnsRecords(), deleteDnsRecord() functions +- `web/src/routes/dns/+page.svelte` — wire sync button + +## Acceptance Criteria +- POST /api/dns/sync creates missing and removes orphaned records +- Sync report returned with counts +- Manual delete endpoint works for individual records +- Frontend sync button triggers reconciliation and refreshes view +- Only records tracked in dns_records table are candidates for orphan deletion + (don't delete unrelated Cloudflare records) + +## Notes +- Safety: only delete Cloudflare records that are tracked in our dns_records table + (never touch records we didn't create) +- Rate limiting: Cloudflare API has rate limits, batch operations where possible +- Expected records query needs to join instances + standalone_proxies with settings.domain + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows project conventions +- [ ] No unintended side effects +- [ ] Build passes +- [ ] Tests pass (new + existing) + +## Handoff to Next Phase + diff --git a/plans/docker-watcher-core/CONTEXT.md b/plans/docker-watcher-core/CONTEXT.md deleted file mode 100644 index 581c2fc..0000000 --- a/plans/docker-watcher-core/CONTEXT.md +++ /dev/null @@ -1,53 +0,0 @@ -# Feature Context: Docker Watcher Core - -## Configuration -- **Development mode:** Automated -- **Execution mode:** Orchestrator -- **Strategy:** Big Bang (with per-phase code quality reviews) -- **Build (Go):** `go build ./cmd/server/` -- **Test (Go):** `go test ./...` -- **Lint (Go):** `golangci-lint run` -- **Build (Frontend):** `cd web && npm run build` -- **Test (Frontend):** `cd web && npm test` -- **Dev server:** `go run ./cmd/server/` - -## Current State -Greenfield project. Only PLAN.md exists with the architecture document. - -## Temporary Workarounds -None yet. - -## Cross-Phase Dependencies -- Phase 2 depends on Phase 1 (store CRUD for seed import) -- Phases 3 and 4 are independent of each other (can run in parallel) -- Phase 5 depends on Phase 1 (store for poll state) -- Phase 6 depends on Phase 3 (Docker inspect for auto-creation) and Phase 1 (store) -- Phase 7 depends on Phases 3, 4, 5 (Docker, NPM, registry clients) -- Phase 8 depends on Phases 1-7 (wires everything to HTTP) -- Phases 9 and 10 are independent of each other (can run in parallel) -- Phase 11 depends on Phases 8, 9, 10 (embeds frontend, SSE wires to API) -- Phase 12 depends on all prior phases - -## Deferred Work -None yet. - -## Failed Approaches -None yet. - -## Review Findings Log -None yet. - -## Phase Execution Log -| Phase | Agent Used | Test Writer | Parallel | Notes | -|-------|-----------|-------------|----------|-------| -| — | — | — | — | No phases executed yet | - -## Environment & Runtime Notes -- Platform: Windows 10 (development), Linux (deployment target) -- Docker socket: `/var/run/docker.sock` (Linux) — development may need Docker Desktop -- Go version: TBD (will be determined in Phase 1) - -## Implementation Notes -- Big Bang strategy: intermediate phases skip build/tests, code quality reviews after every phase -- Final phase (12) is the only phase where build + full test suite must pass -- Phases 3+4 and 9+10 identified for parallel execution diff --git a/plans/docker-watcher-core/PLAN.md b/plans/docker-watcher-core/PLAN.md deleted file mode 100644 index 4dcbd72..0000000 --- a/plans/docker-watcher-core/PLAN.md +++ /dev/null @@ -1,108 +0,0 @@ -# Feature: Docker Watcher Core - -**Branch:** `feature/docker-watcher-core` -**Base branch:** `main` -**Created:** 2026-03-27 -**Status:** 🟡 In Progress -**Strategy:** Big Bang (with per-phase code quality reviews) -**Mode:** Automated -**Execution:** Orchestrator - -## Summary - -A self-hosted tool that automates Docker container deployment with Nginx Proxy Manager integration. Detects new images from Gitea/GitHub registries, deploys containers, and configures reverse proxy routing — all from a web dashboard. Supports multiple simultaneous versions of the same project. - -## Build & Test Commands - -- **Build (Go):** `go build ./cmd/server/` -- **Test (Go):** `go test ./...` -- **Lint (Go):** `golangci-lint run` -- **Build (Frontend):** `cd web && npm run build` -- **Test (Frontend):** `cd web && npm test` -- **Dev server:** `go run ./cmd/server/` - -## Phases - -- [x] Phase 1: Project Scaffold & SQLite Store [domain: backend] → [subplan](./phase-1-scaffold-store.md) -- [x] Phase 2: Crypto & Config Seed Loader [domain: backend] → [subplan](./phase-2-crypto-config.md) -- [x] Phase 3: Docker Client [domain: backend] → [subplan](./phase-3-docker-client.md) -- [x] Phase 4: NPM Client [domain: backend] → [subplan](./phase-4-npm-client.md) -- [x] Phase 5: Registry Client & Poller [domain: backend] → [subplan](./phase-5-registry-poller.md) -- [x] Phase 6: Webhook Handler [domain: backend] → [subplan](./phase-6-webhook-handler.md) -- [x] Phase 7: Deployer & Health Checker [domain: backend] → [subplan](./phase-7-deployer.md) -- [x] Phase 8: REST API Layer [domain: backend] → [subplan](./phase-8-api-layer.md) -- [x] Phase 9: SvelteKit Dashboard & Project Views [domain: frontend] → [subplan](./phase-9-dashboard.md) -- [x] Phase 10: Quick Deploy & Settings Pages [domain: frontend] → [subplan](./phase-10-settings-deploy.md) -- [x] Phase 11: Frontend Embed & Real-Time Updates [domain: fullstack] → [subplan](./phase-11-embed-sse.md) -- [x] Phase 12: Hardening [domain: backend] → [subplan](./phase-12-hardening.md) -- [x] Phase 13: Volumes & Environment [domain: fullstack] → [subplan](./phase-14-volumes-env.md) -- [x] Phase 14: Frontend Polish & Modern UI [domain: frontend] → [subplan](./phase-13-ui-polish.md) - -### Parallel Execution Notes - -- Phases 3 and 4 are independent (Docker client vs NPM client) — can run in parallel -- Phases 9 and 10 are independent (dashboard vs settings pages) — can run in parallel - -## Phase Progress Log - -| Phase | Domain | Status | Review | Build | Committed | -| ----- | ------ | ------ | ------ | ----- | --------- | -| Phase 1: Scaffold & Store | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | -| Phase 2: Crypto & Config | backend | ✅ Complete | ✅ Pass w/ notes | ⏭️ Skip (Big Bang) | ✅ | -| Phase 3: Docker Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | -| Phase 4: NPM Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | -| Phase 5: Registry & Poller | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | -| Phase 6: Webhook Handler | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | -| Phase 7: Deployer & Health | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | -| Phase 8: API Layer | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | -| Phase 9: Dashboard | frontend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ | -| Phase 10: Settings & Deploy | frontend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ | -| Phase 11: Embed & SSE | fullstack | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ | -| Phase 12: Hardening | backend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ | -| Phase 13: Volumes & Env | fullstack | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ | -| Phase 14: UI Polish | frontend | ✅ Complete | ⬜ Pending | ✅ Required (Final) | ⬜ | - -## Amendment Log - -### Amendment 1 — 2026-03-27 - -**Type:** Added phase -**What changed:** Added Phase 13: Frontend Polish & Modern UI after Phase 12 -**Why:** User wants modern look & feel with SVG icons and polished frontend -**Impact on existing phases:** None — Phase 13 runs after all functionality is complete. Build/tests now required on Phase 13 (final) instead of Phase 12. - -### Amendment 2 — 2026-03-27 - -**Type:** Modified phase -**What changed:** Added Task 13 (EN/RU localization) to Phase 13: Frontend Polish & Modern UI -**Why:** User wants bilingual support (English and Russian) in the dashboard -**Impact on existing phases:** None — contained within Phase 13 - -### Amendment 3 — 2026-03-27 - -**Type:** Added phase -**What changed:** Added Phase 14: Volumes & Environment — per-project env vars with per-stage overrides, volume mounts with shared/isolated modes, encryption for sensitive values, UI editor -**Why:** Missing from feature planner phases but present in root PLAN.md Phase 4 -**Impact on existing phases:** Phase 14 becomes the final phase (build/tests required). Phase 13 (UI Polish) remains but no longer the final phase for build enforcement. - -### Amendment 4 — 2026-03-27 - -**Type:** Modified phase -**What changed:** Updated Phase 12 (Hardening) auth tasks to support two modes: Local auth (username/password in SQLite with bcrypt) and OAuth2/OIDC (Authentik or any OIDC provider with configurable discovery URL). Added auth settings UI, user management, OIDC callback flow. -**Why:** Root PLAN.md was updated to require OAuth2/OIDC support alongside local auth -**Impact on existing phases:** Phase 12 task count increased from 10 to 12. Added new files for auth module and login page. - -### Amendment 5 — 2026-03-27 - -**Type:** Reordered phases -**What changed:** Swapped Phase 13 (UI Polish) and Phase 14 (Volumes & Env). Volumes & Env is now Phase 13, UI Polish is now Phase 14 (final). -**Why:** Volumes & Env adds new UI pages that need the polish pass. UI Polish must run last to cover all pages including auth (Phase 12) and volume/env editors (Phase 13). -**Impact on existing phases:** Execution order changed. UI Polish (now Phase 14) remains the final phase with build/test enforcement. - -## Final Review - -- [ ] Comprehensive code review -- [ ] Full build passes -- [ ] Full test suite passes -- [ ] Security review -- [ ] Merged to `main` diff --git a/plans/docker-watcher-core/phase-1-scaffold-store.md b/plans/docker-watcher-core/phase-1-scaffold-store.md deleted file mode 100644 index 5ea7005..0000000 --- a/plans/docker-watcher-core/phase-1-scaffold-store.md +++ /dev/null @@ -1,95 +0,0 @@ -# Phase 1: Project Scaffold & SQLite Store - -**Status:** ✅ Complete -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** backend - -## Objective -Initialize the Go project, establish the directory structure, and implement the SQLite store with schema, migrations, and CRUD operations for all entities. - -## Tasks - -- [x] Task 1: Initialize Go module (`go mod init`), create directory structure per PLAN.md -- [x] Task 2: Add core dependencies to go.mod (sqlite, chi, yaml, uuid, cron) -- [x] Task 3: Define SQLite schema — tables for projects, stages, registries, settings, instances, deploys, deploy_logs -- [x] Task 4: Implement store initialization with auto-migration (create tables if not exist) -- [x] Task 5: Implement projects CRUD (Create, GetByID, GetAll, Update, Delete) -- [x] Task 6: Implement stages CRUD (Create, GetByProjectID, Update, Delete) -- [x] Task 7: Implement registries CRUD (Create, GetByID, GetAll, Update, Delete) -- [x] Task 8: Implement settings Get/Update (single-row config pattern) -- [x] Task 9: Implement instances CRUD (Create, GetByStageID, GetByID, Update, Delete, UpdateStatus) -- [x] Task 10: Implement deploys CRUD (Create, GetByProjectID, GetRecent, GetByID) + deploy_logs append -- [x] Task 11: Create `cmd/server/main.go` entry point (minimal — just opens DB, defers close) - -## Files to Modify/Create -- `go.mod` — module definition and dependencies -- `go.sum` — dependency checksums -- `cmd/server/main.go` — entry point -- `internal/store/store.go` — DB connection, schema, migrations -- `internal/store/projects.go` — project queries -- `internal/store/stages.go` — stage queries -- `internal/store/registries.go` — registry queries -- `internal/store/settings.go` — settings queries -- `internal/store/instances.go` — instance queries -- `internal/store/deploys.go` — deploy history queries - -## Acceptance Criteria -- `go mod tidy` succeeds -- All store CRUD functions are implemented with proper error handling -- Schema covers all entities from the architecture plan -- Entry point compiles (may not fully run until later phases wire everything) - -## Notes -- Use `modernc.org/sqlite` for CGo-free SQLite -- Use `go-chi/chi/v5` for routing (will be wired in Phase 8) -- Settings table uses a single-row pattern (one row, upsert on update) -- Instance status should be an enum-like string: "running", "stopped", "failed", "removing" -- Deploy status: "pending", "pulling", "starting", "configuring_proxy", "health_checking", "success", "failed", "rolled_back" - -## Review Checklist -- [ ] All tasks completed -- [ ] Code follows Go conventions (gofmt, proper error returns) -- [ ] No unintended side effects -- [ ] Schema is normalized and covers all planned entities -- [ ] CRUD functions handle not-found cases properly - -## Handoff to Next Phase - -### What was built - -- Go module initialized at `github.com/alexei/docker-watcher` with all core dependencies -- Full directory structure created: `cmd/server/`, `internal/store/`, plus empty dirs for config, docker, npm, registry, deployer, health, notify, webhook, api, crypto -- SQLite store with 7 tables: projects, stages, registries, settings, instances, deploys, deploy_logs -- Auto-migration runs on store initialization (CREATE TABLE IF NOT EXISTS) -- WAL mode, foreign keys, and busy timeout pragmas enabled -- Settings table uses single-row pattern with `INSERT OR IGNORE` seed -- Models extracted to `internal/store/models.go` for clean separation - -### Key files - -- `go.mod` — module definition with modernc.org/sqlite, chi, yaml, uuid, cron -- `cmd/server/main.go` — entry point that creates data dir, opens store, defers close -- `internal/store/store.go` — DB connection, pragmas, schema DDL, migration -- `internal/store/models.go` — all entity structs (Project, Stage, Registry, Settings, Instance, Deploy, DeployLog) -- `internal/store/projects.go` — full CRUD -- `internal/store/stages.go` — full CRUD with bool-to-int conversion for SQLite -- `internal/store/registries.go` — full CRUD -- `internal/store/settings.go` — Get/Update (single-row upsert) -- `internal/store/instances.go` — full CRUD + UpdateStatus -- `internal/store/deploys.go` — Create, GetByID, GetByProjectID, GetRecent, UpdateDeployStatus, SetDeployInstanceID, AppendDeployLog, GetDeployLogs - -### Conventions established - -- UUIDs generated via `github.com/google/uuid` on Create operations -- Timestamps stored as `datetime('now')` defaults in schema, `time.Now().UTC().Format("2006-01-02 15:04:05")` in Go code -- All query errors wrapped with `fmt.Errorf` and `%w` for unwrapping -- Not-found cases return descriptive error strings (not sentinel errors yet — can be refined) -- Boolean fields stored as INTEGER (0/1) in SQLite, converted via `boolToInt` helper -- JSON-encoded maps stored as TEXT for env and volumes fields - -### What Phase 2 needs to know - -- `store.New(dbPath)` returns a `*Store` that is ready to use — no additional init needed -- The `settings` table is pre-seeded with a row (id=1) so `GetSettings` always works -- Registry `token` and settings `npm_password` are stored as plain text — Phase 2 (Crypto) should add encryption/decryption around these fields -- `go.sum` does not exist yet — run `go mod tidy` after Go is available to generate it diff --git a/plans/docker-watcher-core/phase-10-settings-deploy.md b/plans/docker-watcher-core/phase-10-settings-deploy.md deleted file mode 100644 index 26a7e82..0000000 --- a/plans/docker-watcher-core/phase-10-settings-deploy.md +++ /dev/null @@ -1,56 +0,0 @@ -# Phase 10: Quick Deploy & Settings Pages - -**Status:** ⬜ Not Started -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** frontend - -## Objective -Build the Quick Deploy page (paste image, auto-inspect, one-click deploy) and all Settings pages (registries, credentials, global settings, webhook URL). - -## Tasks - -- [ ] Task 1: Quick Deploy page (`routes/deploy/+page.svelte`) — image URL input, inspect button -- [ ] Task 2: Quick Deploy inspect flow — call /api/deploy/inspect, display auto-filled form (project name, port, stage, subdomain) -- [ ] Task 3: Quick Deploy submit — user reviews defaults, clicks Deploy, calls /api/deploy/quick -- [ ] Task 4: Settings layout (`routes/settings/+layout.svelte`) — sub-navigation for settings sections -- [ ] Task 5: Global settings page (`routes/settings/+page.svelte`) — domain, server IP, network, subdomain pattern, polling interval -- [ ] Task 6: Registries page (`routes/settings/registries/+page.svelte`) — list, add, edit, delete, test connection -- [ ] Task 7: Credentials page (`routes/settings/credentials/+page.svelte`) — NPM credentials, registry tokens (masked display) -- [ ] Task 8: Webhook URL display and regenerate button in settings -- [ ] Task 9: Projects config page (`routes/projects/config/+page.svelte` or integrated into project detail) — add/edit/delete projects, configure stages -- [ ] Task 10: Stage configuration form — tag patterns, auto_deploy toggle, max_instances, subdomain override -- [ ] Task 11: Form validation on all input pages — required fields, URL format, port range -- [ ] Task 12: Success/error toast notifications for all form submissions - -## Files to Modify/Create -- `web/src/routes/deploy/+page.svelte` — quick deploy -- `web/src/routes/settings/+layout.svelte` — settings layout -- `web/src/routes/settings/+page.svelte` — global settings -- `web/src/routes/settings/registries/+page.svelte` — registry management -- `web/src/routes/settings/credentials/+page.svelte` — credential management -- `web/src/lib/components/Toast.svelte` — toast notifications -- `web/src/lib/components/FormField.svelte` — reusable form field with validation - -## Acceptance Criteria -- Quick Deploy: paste image URL → inspect → review defaults → deploy works end-to-end -- All settings are editable and saved via API -- Registry test connection shows success/failure -- Credentials are masked in display (`••••••••`) -- Webhook URL is shown with copy button and regenerate option -- Form validation prevents bad submissions - -## Notes -- Quick Deploy is the zero-config entry point — should be dead simple UX -- Credential fields: show mask, edit replaces entirely (no partial edit) -- Registry test: calls POST /api/registries/:id/test, shows connection result -- Toast component: appears top-right, auto-dismiss after 5s, color-coded (green/red) - -## Review Checklist -- [ ] All tasks completed -- [ ] Quick deploy flow is intuitive (minimal clicks) -- [ ] Credentials never shown in plaintext in UI -- [ ] Form validation covers required fields and formats -- [ ] Error states are handled with user-friendly messages - -## Handoff to Next Phase - diff --git a/plans/docker-watcher-core/phase-11-embed-sse.md b/plans/docker-watcher-core/phase-11-embed-sse.md deleted file mode 100644 index 90d4f15..0000000 --- a/plans/docker-watcher-core/phase-11-embed-sse.md +++ /dev/null @@ -1,76 +0,0 @@ -# Phase 11: Frontend Embed & Real-Time Updates - -**Status:** Done -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** fullstack - -## Objective -Build SvelteKit to static files, embed into the Go binary with `go:embed`, serve from Go, and implement SSE for real-time deploy progress and instance status updates. - -## Tasks - -- [x] Task 1: Configure SvelteKit static adapter to output to `web/build/` (already configured) -- [x] Task 2: Add `//go:embed web/build` directive in Go — `web.go` at project root -- [x] Task 3: Create Go handler for serving embedded SPA — `internal/api/static.go` with SPA fallback -- [x] Task 4: Implement SSE endpoint for deploy logs — `GET /api/deploys/:id/logs` (SSE + JSON fallback) -- [x] Task 5: Implement SSE endpoint for instance status — `GET /api/events` streams instance status changes -- [x] Task 6: Create event bus/broadcaster in Go — `internal/events/bus.go` with pub/sub channels -- [x] Task 7: Frontend: connect to SSE for deploy progress — `connectDeployLogs()` in `web/src/lib/sse.ts` -- [x] Task 8: Frontend: connect to SSE for instance status — global SSE in `+layout.svelte` via store -- [x] Task 9: Handle SSE reconnection in frontend — exponential backoff with jitter in `connectSSE()` -- [x] Task 10: Build script/Makefile — `make build` builds frontend then Go binary - -## Files to Modify/Create -- `web/svelte.config.js` — already configured with static adapter outputting to `web/build/` -- `web.go` — root-level embed directive (`//go:embed web/build`) -- `internal/api/static.go` — embedded static file server with SPA fallback -- `internal/api/sse.go` — SSE endpoints for deploy logs and instance events -- `internal/events/bus.go` — event bus for publishing/subscribing to events -- `web/src/lib/sse.ts` — SSE client helper with auto-reconnect -- `web/src/lib/stores/instance-status.ts` — Svelte store for real-time instance status -- `web/src/routes/+layout.svelte` — wired up global SSE connection for instance status -- `Makefile` — build frontend + backend -- `cmd/server/main.go` — wired embedded static serving and event bus -- `internal/api/router.go` — added eventBus to Server, SSE routes -- `internal/api/deploys.go` — removed old JSON stub, replaced by SSE handler -- `internal/deployer/deployer.go` — added event publishing for deploy logs, status, instance status - -## Acceptance Criteria -- `make build` produces a single Go binary with embedded frontend -- Go binary serves the SvelteKit SPA on all non-API routes -- Deploy progress streams in real-time via SSE -- Instance status updates appear without page refresh -- SSE reconnects automatically after network hiccups - -## Notes -- `go:embed` requires the embedded directory to be relative to the Go source file -- SPA fallback: any request that doesn't match `/api/*` gets `index.html` -- Event bus: simple pub/sub with channels — no external dependency needed -- SSE format: `data: {"type": "deploy_log", "payload": {...}}\n\n` -- Keep SSE connections lightweight — use context cancellation for cleanup -- WriteTimeout on HTTP server set to 0 to support long-lived SSE connections -- Deploy logs endpoint serves both SSE (Accept: text/event-stream) and JSON (default) - -## Review Checklist -- [x] All tasks completed -- [x] Single binary serves both API and frontend -- [x] SSE handles multiple concurrent clients (buffered channels, non-blocking publish) -- [x] No goroutine leaks on SSE disconnect (context cancellation + Unsubscribe) -- [x] Build process is reproducible (Makefile) - -## Handoff to Next Phase - -### What was implemented -- **Event bus** (`internal/events/bus.go`): In-process pub/sub with topic filtering, buffered subscriber channels (64 events), non-blocking publish. Supports `EventDeployLog`, `EventInstanceStatus`, and `EventDeployStatus` event types. -- **SSE endpoints**: `GET /api/deploys/{id}/logs` streams deploy logs with JSON fallback; `GET /api/events` streams global instance/deploy status changes. -- **Static file serving**: `web.go` at project root embeds `web/build/`, `internal/api/static.go` serves SPA with fallback. Mounted via chi's `NotFound` handler. -- **Frontend SSE client** (`web/src/lib/sse.ts`): `connectSSE()` with exponential backoff + jitter, `connectDeployLogs()` and `connectGlobalEvents()` convenience functions. -- **Instance status store** (`web/src/lib/stores/instance-status.ts`): Svelte writable store updated by global SSE connection in `+layout.svelte`. -- **Deployer integration**: `deployer.go` now publishes deploy log, deploy status, and instance status events via `EventPublisher` interface. - -### Key integration points for next phase -- `events.Bus` is passed to both `api.NewServer` and `deployer.New` -- `api.NewServer` now requires an `*events.Bus` parameter (6th arg before encKey) -- `deployer.New` now requires an `EventPublisher` parameter (6th arg before encKey) -- HTTP server `WriteTimeout` is 0 to support SSE -- The `web.go` file at project root uses package name `dockerwatcher` (imported as `github.com/alexei/docker-watcher`) diff --git a/plans/docker-watcher-core/phase-12-hardening.md b/plans/docker-watcher-core/phase-12-hardening.md deleted file mode 100644 index a195218..0000000 --- a/plans/docker-watcher-core/phase-12-hardening.md +++ /dev/null @@ -1,72 +0,0 @@ -# Phase 12: Hardening - -**Status:** ⬜ Not Started -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** backend - -## Objective -Production hardening — blue-green deploys, promote flow, dashboard auth, graceful shutdown, structured logging, and config export. - -## Tasks - -- [ ] Task 1: Blue-green deploys — start new container, health check, swap NPM proxy, then stop old container (zero downtime) -- [ ] Task 2: Promote flow — enforce `promote_from` for production deploys (only tags running in source stage are eligible) -- [ ] Task 3: Local auth — username/password stored in SQLite (bcrypt hashed), login endpoint, session token (JWT or cookie) -- [ ] Task 4: OAuth2/OIDC auth — integration with Authentik or any OIDC provider (configurable client ID, client secret, discovery URL) -- [ ] Task 5: Auth settings UI — settings page to choose auth mode (local/OIDC), configure OIDC provider, manage local users -- [ ] Task 6: Auth middleware — protect all /api/* routes except webhook; check session/JWT/OIDC token -- [ ] Task 7: Graceful shutdown — handle SIGTERM/SIGINT, drain in-progress deploys, close DB, stop poller -- [ ] Task 8: Structured logging — JSON logs with deploy context (project, stage, tag, instance ID) -- [ ] Task 9: Config export — download current SQLite state as YAML (reverse of seed import) -- [ ] Task 10: Dockerfile — multi-stage build (build frontend + Go, copy to minimal image) -- [ ] Task 11: docker-compose.yml — production-ready compose file with volumes, network, env -- [ ] Task 12: Final wiring review — ensure all services are properly initialized and shut down - -## Files to Modify/Create -- `internal/deployer/bluegreen.go` — blue-green deploy strategy -- `internal/deployer/promote.go` — promote flow logic -- `internal/auth/local.go` — local auth (bcrypt password hashing, session tokens) -- `internal/auth/oidc.go` — OAuth2/OIDC provider integration -- `internal/auth/middleware.go` — auth middleware (session/JWT/OIDC token validation) -- `internal/auth/models.go` — user model, auth settings, session store -- `internal/api/auth.go` — auth API endpoints (login, logout, OIDC callback, user management) -- `internal/config/export.go` — config export to YAML -- `internal/logging/logger.go` — structured JSON logger -- `internal/store/users.go` — user CRUD, auth settings persistence -- `web/src/routes/login/+page.svelte` — login page -- `web/src/routes/settings/auth/+page.svelte` — auth settings UI -- `cmd/server/main.go` — graceful shutdown, structured logging, auth init -- `Dockerfile` — multi-stage build -- `docker-compose.yml` — production compose file - -## Acceptance Criteria -- Blue-green: zero downtime during deploy (old container serves until new one is healthy) -- Promote: production deploy only accepts tags from the specified source stage -- Auth: unauthenticated requests to /api/* (except webhook) return 401 -- Graceful shutdown: in-progress deploys complete before exit -- Logs are JSON-formatted with contextual fields -- Config export produces valid YAML that could be re-imported -- Docker image builds and runs correctly - -## Notes -- Blue-green: keep old container running until new one passes health check, then swap NPM proxy and stop old -- Auth has two modes configurable via settings: - - **Local auth**: username/password in SQLite (bcrypt hashed), JWT session tokens - - **OAuth2/OIDC**: integration with Authentik or any OIDC provider (client ID, secret, discovery URL) -- First launch: create default admin user with configurable password via ADMIN_PASSWORD env var -- OIDC flow: redirect to provider → callback → create/link local user → issue session -- SIGTERM handling: use Go's `os/signal` + `context.WithCancel` -- Structured logging: use `log/slog` (Go stdlib since 1.21) -- Dockerfile: build stage with Node.js + Go, runtime stage with scratch/alpine -- Phase 13 (UI Polish) and Phase 14 (Volumes & Env) follow this phase - -## Review Checklist -- [ ] All tasks completed -- [ ] Blue-green deploy handles rollback if new container fails -- [ ] Auth doesn't block webhook endpoint -- [ ] Graceful shutdown tested with concurrent deploys -- [ ] Dockerfile produces a minimal image -- [ ] docker-compose.yml matches the example in PLAN.md - -## Handoff to Next Phase - diff --git a/plans/docker-watcher-core/phase-13-ui-polish.md b/plans/docker-watcher-core/phase-13-ui-polish.md deleted file mode 100644 index 0fba31f..0000000 --- a/plans/docker-watcher-core/phase-13-ui-polish.md +++ /dev/null @@ -1,86 +0,0 @@ -# Phase 13: Frontend Polish & Modern UI - -**Status:** COMPLETED -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** frontend - -## Objective -Enhance the web UI with a modern, polished look and feel — custom SVG icons, refined typography, consistent color palette, smooth transitions, and overall professional frontend quality. - -## Tasks - -- [x] Task 1: Design system foundations — CSS custom properties for color palette (light/dark), spacing scale, typography scale, border radius tokens, shadows, transitions in `web/src/lib/styles/tokens.css` -- [x] Task 2: SVG icon set — 38 Lucide-based inline SVG icon components in `web/src/lib/components/icons/` covering all UI actions (deploy, stop, start, restart, remove, settings, registry, etc.) -- [x] Task 3: Refine layout — polished sidebar with active state indicators (dot + background), smooth transitions, responsive breakpoints, collapsible sidebar on mobile with hamburger menu -- [x] Task 4: Dashboard cards — redesigned project cards with box icon, status indicators, instance count badges, hover effects (-translate-y-0.5, shadow-md), port/healthcheck chips -- [x] Task 5: Project detail view — clean card layout for instances with icon action buttons, inline status badges with pulse animation for "running", deploy history as timeline cards -- [x] Task 6: Form styling — consistent input fields with design tokens, select dropdowns, ToggleSwitch component replacing checkboxes, button hierarchy (primary brand/secondary/danger) -- [x] Task 7: Toast/notification system — slide-in toasts with Lucide icons, rounded-xl, auto-dismiss, stacking -- [x] Task 8: Loading states — Skeleton, SkeletonCard, SkeletonTable loader components with shimmer animation for data fetching, IconLoader spinner for actions -- [x] Task 9: Empty states — EmptyState component with SVG illustrations and call-to-action buttons for all empty list scenarios -- [x] Task 10: Responsive design — mobile-friendly layout with collapsible sidebar, hamburger menu, mobile top bar, touch-friendly controls, horizontal settings nav on mobile -- [x] Task 11: Micro-interactions — button press feedback (active:animate-press), status pulse animation (ping), scale-in for dialogs/forms, fade-in for overlays, slide-in for toasts -- [x] Task 12: Dark mode support — ThemeToggle component with light/dark/system modes, CSS custom properties for dark theme via [data-theme="dark"], localStorage persistence, system preference detection -- [x] Task 13: Localization (EN/RU) — i18n store with derived t() function, en.json and ru.json locale files, LocaleSwitcher component, localStorage persistence, all UI strings translated - -## Files Created -- `web/src/lib/styles/tokens.css` — design tokens (colors, spacing, typography, radius, shadows, transitions, animations) -- `web/src/lib/components/icons/` — 38 Lucide icon components + index.ts barrel export -- `web/src/lib/i18n/en.json` — English locale strings -- `web/src/lib/i18n/ru.json` — Russian locale strings -- `web/src/lib/i18n/index.ts` — i18n store with t() function and locale management -- `web/src/lib/stores/theme.ts` — dark mode store with system preference detection -- `web/src/lib/components/Skeleton.svelte` — base skeleton loader -- `web/src/lib/components/SkeletonCard.svelte` — card skeleton placeholder -- `web/src/lib/components/SkeletonTable.svelte` — table skeleton placeholder -- `web/src/lib/components/EmptyState.svelte` — empty state with SVG illustrations -- `web/src/lib/components/ToggleSwitch.svelte` — toggle switch replacing checkboxes -- `web/src/lib/components/ThemeToggle.svelte` — light/dark/system theme toggle -- `web/src/lib/components/LocaleSwitcher.svelte` — EN/RU locale switcher - -## Files Modified -- `web/src/app.css` — imports tokens.css, adds base styles, custom scrollbar, focus ring utility -- `web/src/routes/+layout.svelte` — polished sidebar with icons, collapsible mobile sidebar, theme/locale controls -- `web/src/routes/+page.svelte` — dashboard with stats cards, skeleton loaders, empty states, i18n -- `web/src/routes/login/+page.svelte` — polished login with design tokens and i18n -- `web/src/routes/deploy/+page.svelte` — quick deploy with icons, animations, i18n -- `web/src/routes/projects/+page.svelte` — projects list with skeleton loaders, empty states, i18n -- `web/src/routes/projects/[id]/+page.svelte` — project detail with deploy timeline, icons, i18n -- `web/src/routes/projects/[id]/env/+page.svelte` — env editor with toggle switches, icons, i18n -- `web/src/routes/projects/[id]/volumes/+page.svelte` — volume editor with icons, i18n -- `web/src/routes/settings/+layout.svelte` — settings nav with icons, responsive horizontal nav -- `web/src/routes/settings/+page.svelte` — general settings with design tokens, i18n -- `web/src/routes/settings/registries/+page.svelte` — registries with icons, empty states, i18n -- `web/src/routes/settings/credentials/+page.svelte` — credentials with design tokens, i18n -- `web/src/routes/settings/auth/+page.svelte` — auth settings with icons, empty states, i18n -- `web/src/lib/components/Toast.svelte` — slide-in toasts with Lucide icons -- `web/src/lib/components/StatusBadge.svelte` — pulse animation for running status -- `web/src/lib/components/ConfirmDialog.svelte` — fade/scale-in animation, icon -- `web/src/lib/components/FormField.svelte` — consistent styling with design tokens -- `web/src/lib/components/ProjectCard.svelte` — redesigned with hover effects, badges -- `web/src/lib/components/InstanceCard.svelte` — icon action buttons, improved layout - -## Acceptance Criteria -- [x] UI looks modern and professional — not "default framework" appearance -- [x] Consistent icon language throughout the app -- [x] Smooth transitions and meaningful animations (not gratuitous) -- [x] Responsive down to mobile viewport -- [x] Loading and empty states provide good UX -- [x] Color palette works well in both light and dark contexts -- [x] All UI strings available in English and Russian, switchable via locale picker - -## Review Checklist -- [x] All tasks completed -- [x] Visual consistency across all pages -- [x] No functionality regressions -- [x] Responsive on mobile/tablet/desktop -- [x] Accessible (proper contrast ratios, focus states, aria labels on icons) - -## Handoff Notes -This is the FINAL phase. All 13 phases of Docker Watcher are now complete. The application has: -- Full Go backend with SQLite, Docker management, Nginx Proxy Manager integration -- SvelteKit frontend with dark mode, i18n (EN/RU), responsive design, skeleton loaders, empty states -- Real-time SSE events for deploy/instance status -- Authentication (local + OIDC), RBAC, registry management -- Environment variable overrides, volume management, config export -- Webhook-based and polling-based image detection diff --git a/plans/docker-watcher-core/phase-14-volumes-env.md b/plans/docker-watcher-core/phase-14-volumes-env.md deleted file mode 100644 index 68edfcd..0000000 --- a/plans/docker-watcher-core/phase-14-volumes-env.md +++ /dev/null @@ -1,58 +0,0 @@ -# Phase 14: Volumes & Environment - -**Status:** ⬜ Not Started -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** fullstack - -## Objective -Implement per-project environment variables with per-stage overrides, volume mounts with shared/isolated modes, sensitive env value encryption, and UI for managing both. - -## Tasks - -- [ ] Task 1: Extend store schema — add `stage_env` table for per-stage env overrides (stage_id, key, value, encrypted bool) -- [ ] Task 2: Extend store schema — add `volumes` table for volume config (project_id, source, target, mode: shared|isolated) -- [ ] Task 3: Implement store CRUD for stage env overrides (Create, GetByStageID, Update, Delete) -- [ ] Task 4: Implement store CRUD for volumes (Create, GetByProjectID, Update, Delete) -- [ ] Task 5: Encrypt sensitive env values (values marked as secret) using crypto.Encrypt before storage -- [ ] Task 6: Merge env vars during deploy — project-level env + stage-level overrides, decrypt secrets -- [ ] Task 7: Compute volume mounts during deploy — shared mode uses path as-is, isolated mode appends `/{stage}-{tag}/` to source -- [ ] Task 8: Pass merged env vars and volume mounts to Docker container creation -- [ ] Task 9: API endpoints — CRUD for stage env vars and project volumes -- [ ] Task 10: Frontend — env var editor in project/stage settings (key/value pairs, secret toggle) -- [ ] Task 11: Frontend — volume editor in project settings (source/target/mode) -- [ ] Task 12: Frontend — per-stage env override UI showing inherited vs overridden values - -## Files to Modify/Create -- `internal/store/stage_env.go` — stage env CRUD -- `internal/store/volumes.go` — volume CRUD -- `internal/store/store.go` — add new tables to schema -- `internal/deployer/deployer.go` — merge env vars and compute volume mounts during deploy -- `internal/docker/container.go` — accept volume mounts in ContainerConfig -- `internal/api/stages.go` — add env var endpoints -- `internal/api/projects.go` — add volume endpoints -- `web/src/routes/projects/[id]/env/+page.svelte` — env var editor -- `web/src/routes/projects/[id]/volumes/+page.svelte` — volume editor - -## Acceptance Criteria -- Project-level env vars applied to all containers -- Stage-level overrides replace project-level values for matching keys -- Sensitive env values encrypted at rest, decrypted only during deploy -- Shared volumes: all instances mount same host path -- Isolated volumes: each instance gets `{source}/{stage}-{tag}/` subdirectory -- UI allows managing env vars and volumes per project and per stage - -## Notes -- Project `env` field already exists as JSON blob in the store — this phase may migrate to a proper table or keep JSON and add stage overrides separately -- Volume `mode` is either "shared" or "isolated" -- Isolated volume subdirectory is created automatically by Docker (bind mount creates parent dirs) -- Sensitive env display: masked in UI, "Change" button pattern (same as credentials page) - -## Review Checklist -- [ ] All tasks completed -- [ ] Env merge logic is correct (stage overrides project) -- [ ] Secret values never appear in plaintext in API responses -- [ ] Volume paths are validated (no path traversal) -- [ ] Isolated volume subdirectory naming is deterministic - -## Handoff to Next Phase - diff --git a/plans/docker-watcher-core/phase-2-crypto-config.md b/plans/docker-watcher-core/phase-2-crypto-config.md deleted file mode 100644 index f9f4aa4..0000000 --- a/plans/docker-watcher-core/phase-2-crypto-config.md +++ /dev/null @@ -1,61 +0,0 @@ -# Phase 2: Crypto & Config Seed Loader - -**Status:** ✅ Complete -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** backend - -## Objective -Implement AES-256 encryption for credential storage and the YAML seed config parser that imports into SQLite on first launch. - -## Tasks - -- [x] Task 1: Implement AES-256-GCM encrypt/decrypt functions using Go stdlib `crypto/aes` + `crypto/cipher` -- [x] Task 2: Key derivation from ENCRYPTION_KEY env var (SHA-256 hash to get 32 bytes) -- [x] Task 3: Define YAML config structs matching the seed format from PLAN.md -- [x] Task 4: Implement YAML parser — read and validate seed file -- [x] Task 5: Implement seed importer — checks if DB is empty, if so imports YAML into SQLite via store CRUD -- [x] Task 6: Encrypt credential fields (registry tokens, NPM password) during import -- [x] Task 7: Create `docker-watcher.example.yaml` with documented example config -- [x] Task 8: Wire seed import into `cmd/server/main.go` startup sequence - -## Files to Modify/Create -- `internal/crypto/crypto.go` — AES-256-GCM encrypt/decrypt -- `internal/config/config.go` — YAML structs and parser -- `internal/config/seed.go` — seed import logic (YAML → SQLite) -- `docker-watcher.example.yaml` — example seed config -- `cmd/server/main.go` — add seed import to startup - -## Acceptance Criteria -- Encrypt then decrypt round-trips correctly -- Different plaintexts produce different ciphertexts (random nonce) -- YAML parsing handles all fields from the seed format -- Seed import creates projects, stages, registries, and settings in SQLite -- Credentials are encrypted before storage -- Import is idempotent — skipped if DB already has data - -## Notes -- ENCRYPTION_KEY is the only secret env var — everything else is encrypted in SQLite -- Use GCM mode for authenticated encryption (integrity + confidentiality) -- Seed import should be transactional — all or nothing -- The example YAML should have placeholder values, not real credentials - -## Review Checklist -- [x] All tasks completed -- [x] Crypto uses secure practices (random nonce, GCM, no ECB) -- [x] No hardcoded keys or secrets -- [x] YAML parsing validates required fields -- [x] Import is transactional - -## Handoff to Next Phase - -- `crypto.Encrypt(key, plaintext)` and `crypto.Decrypt(key, ciphertextHex)` handle AES-256-GCM encryption; ciphertext is hex-encoded with prepended nonce -- `crypto.KeyFromEnv()` derives a `[32]byte` key from the `ENCRYPTION_KEY` env var via SHA-256 -- `crypto.EncryptIfNotEmpty(key, value)` is a convenience wrapper that passes through empty strings unchanged -- `config.ImportSeed(db, seedPath)` is the single entry point for seed import — called from `main.go` at startup -- Import is idempotent: skipped if the DB already has projects or registries -- Import is transactional: all inserts happen within a single SQLite transaction (rollback on any failure) -- Registry `token` and settings `npm_password` are now stored encrypted in SQLite — later phases that read these fields must decrypt with `crypto.Decrypt(key, value)` -- `store.DB()` method was added to expose the underlying `*sql.DB` for transaction use -- Seed file path is configurable via `SEED_FILE` env var (default: `./docker-watcher.yaml`) -- YAML validation ensures: `global.domain` is required, every project needs `image`, project registry references must exist, stages need `tag_pattern` -- `go.sum` still does not exist — run `go mod tidy` when Go toolchain is available diff --git a/plans/docker-watcher-core/phase-3-docker-client.md b/plans/docker-watcher-core/phase-3-docker-client.md deleted file mode 100644 index 4c07f1b..0000000 --- a/plans/docker-watcher-core/phase-3-docker-client.md +++ /dev/null @@ -1,98 +0,0 @@ -# Phase 3: Docker Client - -**Status:** :white_check_mark: Complete -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** backend - -## Objective -Implement the Docker Engine API wrapper for container lifecycle management — pull images, inspect, create/start/stop/remove containers, and manage networks. - -## Tasks - -- [x] Task 1: Create Docker client wrapper with socket connection (`/var/run/docker.sock`) -- [x] Task 2: Implement `PullImage(ctx, image, tag, authConfig)` — pull with optional registry auth -- [x] Task 3: Implement `InspectImage(ctx, image)` — extract EXPOSE ports, HEALTHCHECK, labels -- [x] Task 4: Implement `CreateContainer(ctx, config)` — create with name, image, env, ports, network, labels -- [x] Task 5: Implement `StartContainer(ctx, containerID)`, `StopContainer(ctx, containerID, timeout)`, `RemoveContainer(ctx, containerID, force)` -- [x] Task 6: Implement `RestartContainer(ctx, containerID, timeout)` -- [x] Task 7: Implement `ListContainers(ctx, filters)` — filter by labels to find managed containers -- [x] Task 8: Implement `EnsureNetwork(ctx, networkName)` — create network if not exists -- [x] Task 9: Implement `ConnectNetwork(ctx, networkID, containerID)` — attach container to network -- [x] Task 10: Add docker-watcher labels to all managed containers (`docker-watcher.project`, `docker-watcher.stage`, `docker-watcher.instance-id`) - -## Files to Modify/Create -- `internal/docker/client.go` — Docker client wrapper, connection setup -- `internal/docker/container.go` — container lifecycle operations -- `internal/docker/image.go` — pull and inspect operations -- `internal/docker/network.go` — network management - -## Acceptance Criteria -- Client connects to Docker socket -- Pull handles both public and authenticated registries -- Image inspection extracts port, healthcheck, and label metadata -- Container creation applies all config (env, ports, network, labels) -- All operations return meaningful errors -- Managed containers are identifiable via labels - -## Notes -- Use `github.com/docker/docker/client` SDK -- Container names should be deterministic: `dw-{project}-{stage}-{tag-sanitized}` -- All containers should be on the shared network (e.g., `staging-net`) -- Port mapping: container's EXPOSE port → random host port (Docker auto-assigns) -- Auth config for private registries will come from the store (encrypted tokens) - -## Review Checklist -- [x] All tasks completed -- [x] Proper context propagation for cancellation -- [x] Resource cleanup (close client, remove failed containers) -- [x] No hardcoded values -- [x] Error messages include container/image identifiers - -## Handoff to Next Phase - -### Exported API surface (`internal/docker` package) - -**Client lifecycle:** -- `docker.New() (*Client, error)` — creates client with env-based config and API version negotiation -- `(*Client).Close() error` — releases resources -- `(*Client).Ping(ctx) error` — checks daemon connectivity - -**Image operations (`image.go`):** -- `(*Client).PullImage(ctx, imageRef, tag, authConfig) error` — pulls image; authConfig is base64-encoded JSON (use `EncodeRegistryAuth` helper) -- `(*Client).InspectImage(ctx, imageRef) (ImageInfo, error)` — returns `ImageInfo{ExposedPorts, Healthcheck, Labels}` -- `docker.EncodeRegistryAuth(username, password, serverAddress) (string, error)` — builds auth payload for `PullImage` - -**Container operations (`container.go`):** -- `(*Client).CreateContainer(ctx, ContainerConfig) (containerID string, error)` — creates container with labels, env, ports, network -- `(*Client).StartContainer(ctx, containerID) error` -- `(*Client).StopContainer(ctx, containerID, timeoutSeconds) error` -- `(*Client).RemoveContainer(ctx, containerID, force) error` -- `(*Client).RestartContainer(ctx, containerID, timeoutSeconds) error` -- `(*Client).ListContainers(ctx, labelFilters) ([]ManagedContainer, error)` — always scoped to docker-watcher labels -- `(*Client).InspectContainerPort(ctx, containerID, containerPort) (uint16, error)` — gets auto-assigned host port -- `docker.ContainerName(project, stage, tag) string` — deterministic name: `dw-{project}-{stage}-{tag-sanitized}` - -**Network operations (`network.go`):** -- `(*Client).EnsureNetwork(ctx, networkName) (networkID string, error)` — idempotent create-if-not-exists -- `(*Client).ConnectNetwork(ctx, networkID, containerID) error` - -**Label constants:** -- `docker.LabelProject` = `"docker-watcher.project"` -- `docker.LabelStage` = `"docker-watcher.stage"` -- `docker.LabelInstanceID` = `"docker-watcher.instance-id"` - -**Key types:** -- `docker.ContainerConfig` — input for `CreateContainer` (Name, Image, Env, ExposedPorts, NetworkName, NetworkID, Labels, Project, Stage, InstanceID) -- `docker.ImageInfo` — output of `InspectImage` (ExposedPorts, Healthcheck, Labels) -- `docker.ManagedContainer` — output of `ListContainers` (ID, Name, Image, Status, State, Project, Stage, InstanceID, Ports) - -### Dependencies added -- `github.com/docker/docker v27.5.1+incompatible` -- `github.com/docker/go-connections v0.5.0` -- Run `go mod tidy` to resolve transitive dependencies before building - -### Conventions maintained -- `context.Context` as first parameter on all methods -- Errors wrapped with `fmt.Errorf("context: %w", err)` -- Package-level constants for labels -- Immutable patterns (new maps created rather than mutating input) diff --git a/plans/docker-watcher-core/phase-4-npm-client.md b/plans/docker-watcher-core/phase-4-npm-client.md deleted file mode 100644 index ba11b0a..0000000 --- a/plans/docker-watcher-core/phase-4-npm-client.md +++ /dev/null @@ -1,78 +0,0 @@ -# Phase 4: NPM Client - -**Status:** ✅ Complete -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** backend - -## Objective -Implement the Nginx Proxy Manager API client — JWT authentication, CRUD for proxy hosts, and host lookup. - -## Tasks - -- [x] Task 1: Create NPM client struct with base URL, cached JWT token, and auto-refresh -- [x] Task 2: Implement `Authenticate(ctx, email, password)` — POST /api/tokens, store JWT -- [x] Task 3: Implement `CreateProxyHost(ctx, config)` — POST /api/nginx/proxy-hosts -- [x] Task 4: Implement `UpdateProxyHost(ctx, id, config)` — PUT /api/nginx/proxy-hosts/{id} -- [x] Task 5: Implement `DeleteProxyHost(ctx, id)` — DELETE /api/nginx/proxy-hosts/{id} -- [x] Task 6: Implement `ListProxyHosts(ctx)` — GET /api/nginx/proxy-hosts -- [x] Task 7: Implement `FindProxyHostByDomain(ctx, domain)` — search existing hosts by domain name -- [x] Task 8: Define proxy host config struct (domain, forward host/port, SSL settings, etc.) -- [x] Task 9: Handle JWT token expiry — re-authenticate automatically on 401 - -## Files to Modify/Create -- `internal/npm/client.go` — NPM API client, auth, HTTP helpers -- `internal/npm/types.go` — request/response types for proxy hosts - -## Acceptance Criteria -- Client authenticates and caches JWT -- CRUD operations work for proxy hosts -- Token refresh happens transparently on expiry -- Proxy host config supports: domain, forward host, forward port, SSL (Let's Encrypt optional) -- FindByDomain enables checking if a proxy already exists before creating - -## Notes -- NPM API base: typically `http://npm:81/api` -- Forward host for containers: use container name on the shared Docker network -- Forward port: the container's internal port (from EXPOSE) -- SSL: for staging, can be disabled; production may want Let's Encrypt -- NPM credentials come from settings (encrypted in SQLite, decrypted at runtime) - -## Review Checklist -- [ ] All tasks completed -- [ ] JWT caching and refresh work correctly -- [ ] HTTP errors are properly handled (not just status code, but response body) -- [ ] No credentials logged or leaked in errors -- [ ] Struct types match NPM API contract - -## Handoff to Next Phase - -### What was built - -- `internal/npm/types.go` — `ProxyHostConfig` (create/update input), `ProxyHost` (API response), `Meta`, auth types, and `boolInt` custom JSON type for NPM's 0/1 boolean fields. -- `internal/npm/client.go` — Full NPM API client with JWT auth, auto-refresh, and CRUD. - -### Public API surface - -```go -npm.New(baseURL string) *Client -(*Client).Authenticate(ctx, email, password string) error -(*Client).CreateProxyHost(ctx, config ProxyHostConfig) (ProxyHost, error) -(*Client).UpdateProxyHost(ctx, id int, config ProxyHostConfig) (ProxyHost, error) -(*Client).DeleteProxyHost(ctx, id int) error -(*Client).ListProxyHosts(ctx) ([]ProxyHost, error) -(*Client).FindProxyHostByDomain(ctx, domain string) (ProxyHost, bool, error) -``` - -### Key design decisions - -- JWT token is cached with expiry; auto-refreshed 5 minutes before expiry or on 401. -- Credentials are stored in memory after `Authenticate` to enable transparent re-auth. -- All HTTP errors include the response body text for debugging. -- Credentials are never included in error messages. -- `boolInt` type handles NPM API's inconsistent 0/1 vs true/false for boolean fields. -- `FindProxyHostByDomain` does case-insensitive matching against all domain names. - -### Dependencies for next phase - -- Caller must provide decrypted NPM credentials (email + password from settings via `crypto.Decrypt`). -- `ProxyHost.ID` (int) maps to `Instance.NpmProxyID` in the store for tracking. diff --git a/plans/docker-watcher-core/phase-5-registry-poller.md b/plans/docker-watcher-core/phase-5-registry-poller.md deleted file mode 100644 index 5b849b2..0000000 --- a/plans/docker-watcher-core/phase-5-registry-poller.md +++ /dev/null @@ -1,49 +0,0 @@ -# Phase 5: Registry Client & Poller - -**Status:** ⬜ Not Started -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** backend - -## Objective -Implement the registry client interface with Gitea implementation, and the periodic tag polling scheduler. - -## Tasks - -- [ ] Task 1: Define `Registry` interface — `ListTags(ctx, image)`, `GetLatestTag(ctx, image, pattern)` -- [ ] Task 2: Implement Gitea registry client — uses Gitea API to list container image tags -- [ ] Task 3: Implement tag pattern matching — match tags against glob patterns (e.g., `dev-*`, `v*`) -- [ ] Task 4: Implement tag comparison — detect new tags since last poll (store last-seen tag per project/stage) -- [ ] Task 5: Create poller service — periodic scheduler using `robfig/cron` -- [ ] Task 6: Poller logic — for each project/stage with polling enabled, check for new tags, trigger deploy if auto_deploy -- [ ] Task 7: Add `last_polled_tag` field to instances or a new `poll_state` table in store -- [ ] Task 8: Implement registry factory — create client based on registry type (gitea, future: github, dockerhub) - -## Files to Modify/Create -- `internal/registry/registry.go` — interface definition + factory -- `internal/registry/gitea.go` — Gitea registry implementation -- `internal/registry/poller.go` — polling scheduler service -- `internal/store/poll_state.go` — poll state persistence (optional, or extend existing tables) - -## Acceptance Criteria -- Gitea client can list tags for a given image -- Tag pattern matching correctly filters tags (glob-style) -- Poller runs on configurable interval -- New tags are detected by comparing against stored state -- Registry factory returns correct client based on type - -## Notes -- Gitea API: `GET /api/v1/packages/{owner}/container/{image}/tags` (or similar, verify against Gitea docs) -- Auth: Bearer token from registry config -- Polling interval comes from global settings -- The poller is a fallback — webhooks are the primary detection mechanism (Phase 6) -- GitHub Container Registry support is future work — just define the interface now - -## Review Checklist -- [ ] All tasks completed -- [ ] Interface is clean and minimal -- [ ] Pattern matching handles edge cases (empty pattern, no tags) -- [ ] Poller doesn't leak goroutines -- [ ] Registry auth tokens handled securely - -## Handoff to Next Phase - diff --git a/plans/docker-watcher-core/phase-6-webhook-handler.md b/plans/docker-watcher-core/phase-6-webhook-handler.md deleted file mode 100644 index fbb6545..0000000 --- a/plans/docker-watcher-core/phase-6-webhook-handler.md +++ /dev/null @@ -1,78 +0,0 @@ -# Phase 6: Webhook Handler - -**Status:** ✅ Complete -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** backend - -## Objective -Implement the secret UUID-based webhook endpoint that receives image push notifications from CI systems, with auto-creation of unknown projects. - -## Tasks - -- [x] Task 1: Implement webhook HTTP handler — `POST /api/webhook/:secret-uuid` -- [x] Task 2: Validate incoming payload — extract image name and tag -- [x] Task 3: Look up project by image name in store — match against configured project images -- [x] Task 4: If known project: match tag to stage via tag patterns, determine if auto_deploy -- [x] Task 5: If unknown project: auto-create project with defaults from image inspection (EXPOSE port, labels) -- [x] Task 6: Generate and store webhook secret UUID in settings (on first launch) -- [x] Task 7: Implement webhook URL regeneration (new UUID, invalidates old one) -- [x] Task 8: Define webhook payload struct (`{"image": "registry/org/app:tag"}`) - -## Files to Modify/Create -- `internal/webhook/handler.go` — webhook HTTP handler + payload parsing -- `internal/webhook/matcher.go` — project/stage matching logic -- `internal/webhook/autocreate.go` — auto-create project from unknown image - -## Acceptance Criteria -- Valid webhook URL with correct UUID triggers processing -- Invalid/missing UUID returns 404 (no information leak) -- Known images are matched to projects and stages -- Unknown images trigger auto-creation with sensible defaults -- Webhook URL can be regenerated - -## Notes -- Webhook URL format: `POST /api/webhook/d8f2a1e9-...` -- No authentication needed beyond the secret UUID -- Auto-created projects use: image EXPOSE port, "dev" as default stage, auto_deploy: true -- The webhook handler calls into the deployer (Phase 7) — for now, define the interface/callback -- Keep the handler thin — it matches and delegates - -## Review Checklist -- [x] All tasks completed -- [x] No information leak on invalid UUIDs -- [x] Payload validation rejects malformed input -- [x] Auto-creation uses safe defaults -- [x] Handler is stateless (delegates to store/deployer) - -## Handoff to Next Phase - -### Exported API - -- `webhook.NewHandler(store, deployer, inspector)` — creates the HTTP handler -- `webhook.Handler.Route()` — returns a `chi.Router` to mount at `/api/webhook` -- `webhook.EnsureWebhookSecret(store)` — generates UUID on first launch, returns current secret -- `webhook.RegenerateWebhookSecret(store)` — replaces secret with new UUID, invalidates old one -- `webhook.ParseImageRef(ref)` — parses `registry/owner/name:tag` into components - -### Interfaces Defined - -- `webhook.DeployTriggerer` — `TriggerDeploy(ctx, projectID, stageID, imageTag) error` (mirrors `registry.DeployTriggerer`) -- `webhook.ImageInspector` — `InspectImage(ctx, imageRef) (docker.ImageInfo, error)` (wraps `docker.Client`) - -### Integration Points - -- Mount the webhook router: `r.Mount("/api/webhook", webhookHandler.Route())` -- Call `webhook.EnsureWebhookSecret(store)` at application startup to generate the secret on first launch -- The deployer must implement `webhook.DeployTriggerer` (same signature as `registry.DeployTriggerer`) -- The Docker client (`*docker.Client`) satisfies `webhook.ImageInspector` directly - -### Auto-Create Behavior - -- Unknown images create a project with name from image name, port from EXPOSE, healthcheck from image metadata -- A default "dev" stage is created with `tag_pattern: "*"`, `auto_deploy: true`, `max_instances: 1` -- If image inspection fails (not pulled locally), project is created with port=0 and empty healthcheck - -### Tag Matching - -- Uses `path.Match` (glob semantics) — same approach as the registry poller -- Stages are checked in name-sorted order; first matching stage wins diff --git a/plans/docker-watcher-core/phase-7-deployer.md b/plans/docker-watcher-core/phase-7-deployer.md deleted file mode 100644 index d54e54a..0000000 --- a/plans/docker-watcher-core/phase-7-deployer.md +++ /dev/null @@ -1,54 +0,0 @@ -# Phase 7: Deployer & Health Checker - -**Status:** ✅ Complete -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** backend - -## Objective -Implement the core deployment orchestrator: pull → start container → configure NPM proxy → health check → success/rollback. Plus multi-instance support and notifications. - -## Tasks - -- [x] Task 1: Define deployer service struct — depends on Docker client, NPM client, store, notifier -- [x] Task 2: Implement deploy flow: pull image → create container → start → connect to network → configure proxy → health check -- [x] Task 3: Implement subdomain generation per convention: `stage-{stage}-{project}` for default, `stage-{stage}-{project}-{tag}` for specific -- [x] Task 4: Sanitize tags for DNS (dots → dashes, lowercase, truncate) -- [x] Task 5: Implement health checker — HTTP GET to `http://container:{port}{healthcheck_path}` with retries and timeout -- [x] Task 6: Implement rollback on health check failure — remove new container, delete NPM proxy host, update instance status -- [x] Task 7: Implement multi-instance support — multiple tags of same project/stage can run simultaneously -- [x] Task 8: Implement max_instances enforcement — remove oldest instance when limit reached -- [x] Task 9: Implement notification webhook — POST to configured URL on deploy success/failure -- [x] Task 10: Create deploy history records in store (status, timestamps, logs) -- [x] Task 11: Implement deploy log streaming — append log entries during deploy for real-time visibility - -## Files to Modify/Create -- `internal/deployer/deployer.go` — main deploy orchestrator -- `internal/deployer/subdomain.go` — subdomain generation and DNS sanitization -- `internal/deployer/rollback.go` — rollback logic -- `internal/health/checker.go` — HTTP health checker with retries -- `internal/notify/notifier.go` — webhook notification sender - -## Acceptance Criteria -- Full deploy flow works end-to-end (pull → proxy → health check) -- Failed health checks trigger automatic rollback -- Multi-instance: deploying a new tag doesn't stop existing instances -- max_instances removes oldest instance when exceeded -- Notifications fire on success and failure -- Deploy history is recorded with status and timestamps - -## Notes -- Health check: 3 retries, 5s between retries, 10s timeout per attempt (configurable later) -- Subdomain pattern comes from global settings -- Notifications are fire-and-forget (don't block deploy on notification failure) -- Deploy logs should be structured entries (timestamp + message) for SSE streaming later -- The deployer is the central orchestrator — webhook handler and poller both call into it - -## Review Checklist -- [ ] All tasks completed -- [ ] Rollback cleans up ALL resources (container, proxy, instance record) -- [ ] No goroutine leaks -- [ ] Error handling at every step of the deploy flow -- [ ] Subdomain generation produces valid DNS names - -## Handoff to Next Phase - diff --git a/plans/docker-watcher-core/phase-8-api-layer.md b/plans/docker-watcher-core/phase-8-api-layer.md deleted file mode 100644 index 73d668e..0000000 --- a/plans/docker-watcher-core/phase-8-api-layer.md +++ /dev/null @@ -1,112 +0,0 @@ -# Phase 8: REST API Layer - -**Status:** ✅ Complete -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** backend - -## Objective -Wire up all REST API endpoints using chi router, connecting the store, deployer, and other services to HTTP handlers. - -## Tasks - -- [x] Task 1: Set up chi router with middleware (logging, recovery, CORS, JSON content-type) -- [x] Task 2: Implement project endpoints — GET/POST /api/projects, GET/PUT/DELETE /api/projects/:id -- [x] Task 3: Implement stage endpoints — POST /api/projects/:id/stages, PUT/DELETE /api/projects/:id/stages/:stage -- [x] Task 4: Implement instance endpoints — GET /api/projects/:id/stages/:stage/instances, POST (deploy), DELETE (remove) -- [x] Task 5: Implement instance control endpoints — POST .../instances/:iid/stop, start, restart -- [x] Task 6: Implement quick deploy endpoints — POST /api/deploy/inspect, POST /api/deploy/quick -- [x] Task 7: Implement registry endpoints — GET/POST /api/registries, PUT/DELETE /api/registries/:id, POST .../test -- [x] Task 8: Implement settings endpoints — GET/PUT /api/settings, GET /api/settings/webhook-url, POST .../regenerate -- [x] Task 9: Implement deploy history endpoints — GET /api/deploys, GET /api/deploys/:id/logs (SSE stub) -- [x] Task 10: Implement registry tags endpoint — GET /api/registries/:id/tags/:image -- [x] Task 11: Wire webhook handler into router — POST /api/webhook/:secret-uuid -- [x] Task 12: Wire everything in main.go — initialize all services, start HTTP server - -## Files to Modify/Create -- `internal/api/router.go` — chi router setup, middleware -- `internal/api/projects.go` — project CRUD handlers -- `internal/api/stages.go` — stage CRUD handlers -- `internal/api/instances.go` — instance lifecycle handlers -- `internal/api/deploys.go` — deploy + quick deploy handlers -- `internal/api/registries.go` — registry CRUD + test + tags handlers -- `internal/api/settings.go` — settings handlers -- `internal/api/middleware.go` — middleware (logging, CORS, recovery) -- `internal/api/response.go` — consistent API response helpers (envelope format) -- `cmd/server/main.go` — full service wiring and HTTP server start - -## Acceptance Criteria -- All endpoints from the API spec in PLAN.md are implemented -- Consistent JSON envelope response format (success, data, error, metadata) -- CORS configured for frontend dev (localhost origins) -- Proper HTTP status codes (200, 201, 400, 404, 500) -- main.go starts a fully wired HTTP server - -## Notes -- Response envelope: `{"success": bool, "data": any, "error": string|null, "meta": {pagination}}` -- CORS: allow all origins in dev, restrict in production (configurable later) -- SSE for deploy logs is a stub in this phase — real implementation in Phase 11 -- Quick deploy: /inspect pulls and inspects image, returns defaults; /quick creates project + deploys -- All handlers should validate input and return 400 for bad requests - -## Review Checklist -- [x] All tasks completed -- [x] All API endpoints from PLAN.md are covered -- [x] Consistent response format across all endpoints -- [x] Input validation on all POST/PUT handlers -- [x] No business logic in handlers (delegates to services) - -## Handoff to Next Phase - -### API Surface -- `api.NewServer(store, docker, deployer, webhookHandler, encKey)` creates the server -- `server.Router()` returns a `chi.Router` with all routes mounted under `/api` -- Response envelope: `{"success": bool, "data": ..., "error": "..."}` - -### Endpoints Implemented -| Method | Path | Handler | -|--------|------|---------| -| GET | /api/projects | listProjects | -| POST | /api/projects | createProject | -| GET | /api/projects/{id} | getProject (includes stages) | -| PUT | /api/projects/{id} | updateProject | -| DELETE | /api/projects/{id} | deleteProject | -| POST | /api/projects/{id}/stages | createStage | -| PUT | /api/projects/{id}/stages/{stage} | updateStage | -| DELETE | /api/projects/{id}/stages/{stage} | deleteStage | -| GET | /api/projects/{id}/stages/{stage}/instances | listInstances | -| POST | /api/projects/{id}/stages/{stage}/instances | deployInstance | -| DELETE | /api/projects/{id}/stages/{stage}/instances/{iid} | removeInstance | -| POST | .../instances/{iid}/stop | stopInstance | -| POST | .../instances/{iid}/start | startInstance | -| POST | .../instances/{iid}/restart | restartInstance | -| GET | /api/deploys | listDeploys | -| GET | /api/deploys/{id}/logs | getDeployLogs (JSON stub) | -| POST | /api/deploy/inspect | inspectImage | -| POST | /api/deploy/quick | quickDeploy | -| GET | /api/registries | listRegistries | -| POST | /api/registries | createRegistry | -| PUT | /api/registries/{id} | updateRegistry | -| DELETE | /api/registries/{id} | deleteRegistry | -| POST | /api/registries/{id}/test | testRegistry | -| GET | /api/registries/{id}/tags/* | listRegistryTags | -| GET | /api/settings | getSettings | -| PUT | /api/settings | updateSettings | -| GET | /api/settings/webhook-url | getWebhookURL | -| POST | /api/settings/regenerate | regenerateWebhookSecret | -| POST | /api/webhook/{secret} | webhook handler (mounted from webhook package) | - -### main.go Wiring -- All services initialized: store, docker, npm, deployer, health, notifier, webhook, poller -- HTTP server with graceful shutdown on SIGTERM/SIGINT -- Environment variables: `DATA_DIR`, `SEED_FILE`, `ENCRYPTION_KEY`, `NPM_URL`, `POLLING_INTERVAL`, `LISTEN_ADDR` -- Default listen address: `:8080` - -### SSE Stub -- `GET /api/deploys/{id}/logs` returns logs as JSON array (not SSE yet) -- Real SSE streaming deferred to Phase 11 - -### Security Notes -- Registry tokens are encrypted before storage, decrypted on read for API calls -- Settings response strips `npm_password` and `webhook_secret`, returns `has_npm_password` boolean -- Registry list response strips tokens, returns `has_token` boolean -- CORS allows all origins (dev mode) -- restrict in Phase 12 diff --git a/plans/docker-watcher-core/phase-9-dashboard.md b/plans/docker-watcher-core/phase-9-dashboard.md deleted file mode 100644 index 6dcd7e5..0000000 --- a/plans/docker-watcher-core/phase-9-dashboard.md +++ /dev/null @@ -1,99 +0,0 @@ -# Phase 9: SvelteKit Dashboard & Project Views - -**Status:** ✅ Complete -**Parent plan:** [PLAN.md](./PLAN.md) -**Domain:** frontend - -## Objective -Build the SvelteKit frontend with the dashboard overview and project detail views — project list, instance status, controls, and deploy history. - -## Tasks - -- [x] Task 1: Initialize SvelteKit project in `web/` directory with TypeScript, static adapter -- [x] Task 2: Set up Tailwind CSS v4 with @tailwindcss/vite plugin -- [x] Task 3: Create shared API client (`lib/api.ts`) — typed fetch wrapper for all backend endpoints -- [x] Task 4: Define TypeScript types (`lib/types.ts`) — Project, Stage, Instance, Deploy, Registry, Settings -- [x] Task 5: Create layout with navigation — sidebar with Dashboard, Projects, Deploy, Settings links -- [x] Task 6: Dashboard page (`routes/+page.svelte`) — project overview cards with instance counts, status indicators -- [x] Task 7: Projects list page (`routes/projects/+page.svelte`) — all projects with quick stats, "Add Project" button -- [x] Task 8: Project detail page (`routes/projects/[id]/+page.svelte`) — stages, instances per stage, controls -- [x] Task 9: Instance controls — Stop, Start, Restart, Remove buttons with confirmation dialogs -- [x] Task 10: Deploy history section in project detail — recent deploys with status, timestamp, tag -- [x] Task 11: "Deploy new version" dropdown — list available tags from registry, trigger deploy -- [x] Task 12: Create reusable components: StatusBadge, InstanceCard, ProjectCard, ConfirmDialog - -## Files to Modify/Create -- `web/package.json` — SvelteKit project config -- `web/svelte.config.js` — SvelteKit config with static adapter -- `web/vite.config.ts` — Vite config with API proxy for dev -- `web/src/app.html` — base HTML -- `web/src/lib/api.ts` — API client -- `web/src/lib/types.ts` — shared TypeScript types -- `web/src/routes/+layout.svelte` — app layout with navigation -- `web/src/routes/+page.svelte` — dashboard -- `web/src/routes/projects/+page.svelte` — project list -- `web/src/routes/projects/[id]/+page.svelte` — project detail -- `web/src/lib/components/StatusBadge.svelte` — status indicator -- `web/src/lib/components/InstanceCard.svelte` — instance display -- `web/src/lib/components/ProjectCard.svelte` — project summary card -- `web/src/lib/components/ConfirmDialog.svelte` — confirmation modal - -## Acceptance Criteria -- SvelteKit project builds to static output -- Dashboard shows all projects with live status -- Project detail shows stages, instances, and controls -- Instance controls trigger correct API calls -- Deploy dropdown fetches and displays available tags -- UI is responsive and clean - -## Notes -- SvelteKit static adapter for embedding in Go binary -- API proxy in vite.config.ts for dev: proxy `/api` to `http://localhost:8080` -- Use SvelteKit's `fetch` for SSR-compatible data loading -- Status colors: green=running, yellow=starting, red=failed, gray=stopped -- Keep components small and reusable - -## Review Checklist -- [ ] All tasks completed -- [ ] TypeScript types match backend API response format -- [ ] API client handles errors gracefully with user feedback -- [ ] No hardcoded API URLs (use relative paths) -- [ ] Components are reusable and well-structured - -## Handoff to Next Phase - -Phase 9 is complete. All 14 files have been created in the `web/` directory: - -**Configuration files:** -- `web/package.json` — Svelte 5, SvelteKit 2, Tailwind CSS v4, static adapter, TypeScript -- `web/svelte.config.js` — Static adapter with SPA fallback (`index.html`) -- `web/vite.config.ts` — Tailwind v4 vite plugin + `/api` proxy to `localhost:8080` -- `web/tsconfig.json` — Strict TypeScript, bundler module resolution -- `web/src/app.html` — Base HTML shell -- `web/src/app.css` — Tailwind v4 import -- `web/src/routes/+layout.ts` — Disables SSR, enables prerender for static adapter - -**Core library:** -- `web/src/lib/types.ts` — All TypeScript types matching Go backend models exactly (Project, Stage, Instance, Deploy, DeployLog, Registry, Settings, ApiEnvelope) -- `web/src/lib/api.ts` — Full typed API client covering all endpoints (projects, instances, deploys, registries, settings). Unwraps envelope, throws `ApiError` on failure. - -**Components (Svelte 5 runes):** -- `StatusBadge.svelte` — Color-coded status pill (green/yellow/red/gray/blue) -- `ConfirmDialog.svelte` — Modal with danger/primary variants -- `InstanceCard.svelte` — Instance display with stop/start/restart/remove controls -- `ProjectCard.svelte` — Project summary card for dashboard grid - -**Pages:** -- `+layout.svelte` — Sidebar navigation (Dashboard, Projects, Deploy, Settings) -- `routes/+page.svelte` — Dashboard with stats cards and project grid -- `routes/projects/+page.svelte` — Project table with inline add-project form -- `routes/projects/[id]/+page.svelte` — Full project detail: stages, instances, deploy form, deploy history - -**Key decisions:** -- Used Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`) throughout -- Tailwind CSS v4 with `@tailwindcss/vite` plugin (no PostCSS config needed) -- Client-side only rendering (SSR disabled) for static adapter compatibility -- API client uses relative `/api/` paths — works in both dev (vite proxy) and prod (embedded) -- All API calls include loading spinners and error states with retry buttons - -**Ready for Phase 10:** Settings pages, Quick Deploy page, and remaining UI routes. The API client already includes all endpoint wrappers needed. diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 56618f7..18bbeb9 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -22,7 +22,9 @@ import type { ValidationResult, Volume, VolumeScopeInfo, - BrowseResult + BrowseResult, + DnsZone, + DnsRecordView } from './types'; // ── Helpers ───────────────────────────────────────────────────────── @@ -268,6 +270,29 @@ export function listNpmCertificates(): Promise { return get('/api/settings/npm-certificates'); } +// ── DNS ──────────────────────────────────────────────────────────── + +export function testDnsConnection(provider: string, token: string, zoneId: string): Promise<{ success: boolean; error?: string }> { + return post<{ success: boolean; error?: string }>('/api/settings/dns/test', { provider, token, zone_id: zoneId }); +} + +export function listDnsZones(token?: string): Promise { + const params = token ? `?token=${encodeURIComponent(token)}` : ''; + return get(`/api/settings/dns/zones${params}`); +} + +export function getDnsRecords(): Promise { + return get('/api/dns/records'); +} + +export function syncDnsRecords(): Promise<{ created: number; deleted: number; already_synced: number }> { + return post<{ created: number; deleted: number; already_synced: number }>('/api/dns/sync'); +} + +export function deleteDnsRecord(fqdn: string): Promise { + return del(`/api/dns/records/${encodeURIComponent(fqdn)}`); +} + // ── Health ────────────────────────────────────────────────────────── export function getHealth(): Promise<{ docker: DockerHealth }> { diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 74025ef..53e4ade 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -16,7 +16,8 @@ "proxies": "Proxies", "events": "Events", "settings": "Settings", - "logout": "Log out" + "logout": "Log out", + "dns": "DNS Records" }, "dashboard": { "title": "Dashboard", @@ -243,7 +244,26 @@ "noCertificate": "None (no SSL)", "clearCertificate": "Clear", "loadingCertificates": "Loading certificates...", - "noCertificatesFound": "No wildcard certificates found in NPM" + "noCertificatesFound": "No wildcard certificates found in NPM", + "dnsConfig": "DNS Configuration", + "wildcardDns": "Wildcard DNS is configured", + "wildcardDnsHelp": "When enabled, all subdomains resolve to your server via a wildcard DNS rule. Disable to manage DNS records per service.", + "dnsProvider": "DNS Provider", + "dnsProviderHelp": "Select a DNS provider for automatic record management", + "cloudflareApiToken": "Cloudflare API Token", + "cloudflareApiTokenHelp": "API token with DNS edit permissions for your zone", + "cloudflareApiTokenPlaceholder": "Enter Cloudflare API token", + "cloudflareApiTokenConfigured": "API token is configured", + "cloudflareZone": "Cloudflare Zone", + "cloudflareZoneHelp": "Select the DNS zone to manage records in", + "selectZone": "Select Zone", + "noZone": "No zone selected", + "loadingZones": "Loading zones...", + "noZonesFound": "No zones found for this token", + "testConnection": "Test Connection", + "testingConnection": "Testing...", + "connectionSuccess": "Connection successful", + "connectionFailed": "Connection failed" }, "settingsRegistries": { "title": "Container Registries", @@ -540,6 +560,43 @@ "proxies": "Proxies", "recentErrors": "Recent Errors" }, + "dns": { + "title": "DNS Records", + "description": "View and manage DNS records created by Docker Watcher.", + "wildcardActive": "Wildcard DNS Mode Active", + "wildcardActiveDesc": "DNS records are managed externally via wildcard DNS. Disable wildcard DNS in Settings to manage records individually.", + "refresh": "Refresh", + "syncNow": "Sync Now", + "syncing": "Syncing...", + "syncComplete": "Sync complete: {created} created, {deleted} deleted, {synced} already synced", + "syncFailed": "DNS sync failed", + "searchPlaceholder": "Search by FQDN...", + "allConsumers": "All consumers", + "managed": "Managed (instances)", + "standalone": "Standalone proxies", + "orphaned": "Orphaned", + "allStatuses": "All statuses", + "statusSynced": "Synced", + "statusMissing": "Missing", + "statusOrphaned": "Orphaned", + "columnFqdn": "FQDN", + "columnType": "Type", + "columnValue": "Value", + "columnConsumer": "Consumer", + "columnStatus": "Status", + "columnActions": "Actions", + "noConsumer": "No consumer", + "noRecords": "No DNS records found. Records will appear here when services are deployed.", + "noMatchingRecords": "No records match the current filters.", + "deleteRecord": "Delete record", + "recordDeleted": "DNS record {fqdn} deleted", + "deleteFailed": "Failed to delete DNS record", + "loadFailed": "Failed to load DNS records", + "totalRecords": "Total: {count}", + "syncedCount": "Synced: {count}", + "missingCount": "Missing: {count}", + "orphanedCount": "Orphaned: {count}" + }, "language": { "en": "English", "ru": "Russian" diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index c884f71..4e5020d 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -16,7 +16,8 @@ "proxies": "Прокси", "events": "События", "settings": "Настройки", - "logout": "Выйти" + "logout": "Выйти", + "dns": "DNS-записи" }, "dashboard": { "title": "Панель управления", @@ -243,7 +244,26 @@ "noCertificate": "Нет (без SSL)", "clearCertificate": "Очистить", "loadingCertificates": "Загрузка сертификатов...", - "noCertificatesFound": "Wildcard-сертификаты в NPM не найдены" + "noCertificatesFound": "Wildcard-сертификаты в NPM не найдены", + "dnsConfig": "Настройки DNS", + "wildcardDns": "Wildcard DNS настроен", + "wildcardDnsHelp": "Когда включено, все поддомены разрешаются на ваш сервер через wildcard DNS правило. Отключите для управления DNS-записями для каждого сервиса.", + "dnsProvider": "DNS-провайдер", + "dnsProviderHelp": "Выберите DNS-провайдера для автоматического управления записями", + "cloudflareApiToken": "API-токен Cloudflare", + "cloudflareApiTokenHelp": "API-токен с правами редактирования DNS для вашей зоны", + "cloudflareApiTokenPlaceholder": "Введите API-токен Cloudflare", + "cloudflareApiTokenConfigured": "API-токен настроен", + "cloudflareZone": "Зона Cloudflare", + "cloudflareZoneHelp": "Выберите DNS-зону для управления записями", + "selectZone": "Выбрать зону", + "noZone": "Зона не выбрана", + "loadingZones": "Загрузка зон...", + "noZonesFound": "Зоны для этого токена не найдены", + "testConnection": "Проверить соединение", + "testingConnection": "Проверка...", + "connectionSuccess": "Соединение успешно", + "connectionFailed": "Ошибка соединения" }, "settingsRegistries": { "title": "Реестры контейнеров", @@ -540,6 +560,43 @@ "proxies": "Прокси", "recentErrors": "Недавние ошибки" }, + "dns": { + "title": "DNS-записи", + "description": "Просмотр и управление DNS-записями, созданными Docker Watcher.", + "wildcardActive": "Режим Wildcard DNS активен", + "wildcardActiveDesc": "DNS-записи управляются внешне через wildcard DNS. Отключите wildcard DNS в настройках для индивидуального управления записями.", + "refresh": "Обновить", + "syncNow": "Синхронизировать", + "syncing": "Синхронизация...", + "syncComplete": "Синхронизация завершена: {created} создано, {deleted} удалено, {synced} уже синхронизировано", + "syncFailed": "Ошибка синхронизации DNS", + "searchPlaceholder": "Поиск по FQDN...", + "allConsumers": "Все потребители", + "managed": "Управляемые (инстансы)", + "standalone": "Автономные прокси", + "orphaned": "Осиротевшие", + "allStatuses": "Все статусы", + "statusSynced": "Синхронизировано", + "statusMissing": "Отсутствует", + "statusOrphaned": "Осиротевшее", + "columnFqdn": "FQDN", + "columnType": "Тип", + "columnValue": "Значение", + "columnConsumer": "Потребитель", + "columnStatus": "Статус", + "columnActions": "Действия", + "noConsumer": "Нет потребителя", + "noRecords": "DNS-записи не найдены. Записи появятся здесь после развёртывания сервисов.", + "noMatchingRecords": "Нет записей, соответствующих текущим фильтрам.", + "deleteRecord": "Удалить запись", + "recordDeleted": "DNS-запись {fqdn} удалена", + "deleteFailed": "Не удалось удалить DNS-запись", + "loadFailed": "Не удалось загрузить DNS-записи", + "totalRecords": "Всего: {count}", + "syncedCount": "Синхронизировано: {count}", + "missingCount": "Отсутствует: {count}", + "orphanedCount": "Осиротевших: {count}" + }, "language": { "en": "Английский", "ru": "Русский" diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index e9ea7a3..53941af 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -108,9 +108,30 @@ export interface Settings { ssl_certificate_id: number; stale_threshold_days: number; allowed_volume_paths: string; + wildcard_dns: boolean; + dns_provider: string; + has_cloudflare_api_token: boolean; + cloudflare_zone_id: string; updated_at: string; } +/** A DNS zone from a provider (e.g., Cloudflare). */ +export interface DnsZone { + id: string; + name: string; +} + +/** A DNS record view for the DNS Records page. */ +export interface DnsRecordView { + fqdn: string; + type: string; + content: string; + consumer_type: string; + consumer_name: string; + consumer_id: string; + status: string; +} + /** An SSL certificate from Nginx Proxy Manager. */ export interface NpmCertificate { id: number; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index e2004e6..73c6a4c 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -6,7 +6,7 @@ import Toast from '$lib/components/Toast.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte'; - import { IconDashboard, IconProjects, IconDeploy, IconProxies, IconEvents, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons'; + import { IconDashboard, IconProjects, IconDeploy, IconProxies, IconEvents, IconSettings, IconMenu, IconX, IconLogout, IconGlobe } from '$lib/components/icons'; import { connectGlobalEvents, type SSEConnection } from '$lib/sse'; import { isAuthenticated, clearAuth } from '$lib/auth'; import * as api from '$lib/api'; @@ -25,6 +25,7 @@ { href: '/projects', labelKey: 'nav.projects', icon: 'projects' }, { href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' }, { href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' }, + { href: '/dns', labelKey: 'nav.dns', icon: 'globe' }, { href: '/events', labelKey: 'nav.events', icon: 'events' }, { href: '/settings', labelKey: 'nav.settings', icon: 'settings' } ] as const; @@ -170,6 +171,8 @@ {:else if item.icon === 'proxies'} + {:else if item.icon === 'globe'} + {:else if item.icon === 'events'} {:else if item.icon === 'settings'} diff --git a/web/src/routes/dns/+page.svelte b/web/src/routes/dns/+page.svelte new file mode 100644 index 0000000..35fa96c --- /dev/null +++ b/web/src/routes/dns/+page.svelte @@ -0,0 +1,223 @@ + + + + {$t('dns.title')} - {$t('app.name')} + + +
+ {#if loading} +
+ + +
+ {:else} + +
+
+

{$t('dns.title')}

+

{$t('dns.description')}

+
+
+ + {#if !wildcardDns} + + {/if} +
+
+ + +
+
+ + +
+ + {#if !wildcardDns} + + {/if} +
+ + +
+ {#if filteredRecords.length === 0} +
+ {records.length === 0 ? $t('dns.noRecords') : $t('dns.noMatchingRecords')} +
+ {:else} +
+ + + + + + + + + + + + + {#each filteredRecords as record} + + + + + + + + + {/each} + +
{$t('dns.columnFqdn')}{$t('dns.columnType')}{$t('dns.columnValue')}{$t('dns.columnConsumer')}{$t('dns.columnStatus')}{$t('dns.columnActions')}
{record.fqdn}{record.type}{record.content} + {#if record.consumer_name} + {record.consumer_name} + ({record.consumer_type}) + {:else} + {$t('dns.noConsumer')} + {/if} + + + {record.status} + + + {#if record.status === 'orphaned' || record.status === 'missing'} + + {/if} +
+
+ {/if} +
+ + +
+ {$t('dns.totalRecords', { count: String(records.length) })} + {$t('dns.syncedCount', { count: String(records.filter(r => r.status === 'synced').length) })} + {$t('dns.missingCount', { count: String(records.filter(r => r.status === 'missing').length) })} + {$t('dns.orphanedCount', { count: String(records.filter(r => r.status === 'orphaned').length) })} +
+ {/if} +
diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 3ad60a3..f94e3bd 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -1,5 +1,5 @@