feat: Cloudflare DNS management with automatic record sync
Add flexible DNS management to Docker Watcher. By default, wildcard DNS is assumed (current behavior). When disabled, users can configure a Cloudflare DNS provider with API token and zone selection. DNS A records are automatically created/updated/deleted in sync with proxy consumers (deployed instances and standalone proxies). - Settings: wildcard_dns toggle, dns_provider, cloudflare credentials - Cloudflare client: Provider interface with EnsureRecord/DeleteRecord/ListRecords - DNS lifecycle hooks in deployer and proxy manager (best-effort) - Settings UI: DNS config section with provider picker, zone selector, test button - DNS Records page at /dns with filtering, sync status, reconciliation - Records visible in both wildcard and managed modes - Cleanup on provider change: removes old records when switching modes
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+204
-13
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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
|
||||
<!-- Filled in after completion -->
|
||||
@@ -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 <token>` 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
|
||||
<!-- Filled in after completion -->
|
||||
@@ -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
|
||||
<!-- Filled in after completion -->
|
||||
@@ -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
|
||||
<!-- Filled in after completion -->
|
||||
@@ -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
|
||||
<!-- Filled in after completion -->
|
||||
@@ -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
|
||||
<!-- Filled in after completion -->
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -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`)
|
||||
@@ -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
|
||||
<!-- This is the final phase — no handoff needed. -->
|
||||
@@ -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
|
||||
@@ -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
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -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
|
||||
@@ -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
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -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
|
||||
@@ -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.
|
||||
+26
-1
@@ -22,7 +22,9 @@ import type {
|
||||
ValidationResult,
|
||||
Volume,
|
||||
VolumeScopeInfo,
|
||||
BrowseResult
|
||||
BrowseResult,
|
||||
DnsZone,
|
||||
DnsRecordView
|
||||
} from './types';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
@@ -268,6 +270,29 @@ export function listNpmCertificates(): Promise<NpmCertificate[]> {
|
||||
return get<NpmCertificate[]>('/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<DnsZone[]> {
|
||||
const params = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
return get<DnsZone[]>(`/api/settings/dns/zones${params}`);
|
||||
}
|
||||
|
||||
export function getDnsRecords(): Promise<DnsRecordView[]> {
|
||||
return get<DnsRecordView[]>('/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<void> {
|
||||
return del<void>(`/api/dns/records/${encodeURIComponent(fqdn)}`);
|
||||
}
|
||||
|
||||
// ── Health ──────────────────────────────────────────────────────────
|
||||
|
||||
export function getHealth(): Promise<{ docker: DockerHealth }> {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "Русский"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
<IconDeploy size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'proxies'}
|
||||
<IconProxies size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'globe'}
|
||||
<IconGlobe size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'events'}
|
||||
<IconEvents size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'settings'}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<script lang="ts">
|
||||
import { getDnsRecords, syncDnsRecords, deleteDnsRecord, getSettings } from '$lib/api';
|
||||
import type { DnsRecordView } from '$lib/types';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconSearch, IconRefresh, IconTrash, IconLoader } from '$lib/components/icons';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
|
||||
let loading = $state(true);
|
||||
let records = $state<DnsRecordView[]>([]);
|
||||
let wildcardDns = $state(true);
|
||||
let syncing = $state(false);
|
||||
|
||||
// Filters
|
||||
let searchQuery = $state('');
|
||||
let filterConsumerType = $state('all');
|
||||
let filterStatus = $state('all');
|
||||
|
||||
let filteredRecords = $derived.by(() => {
|
||||
let result = records;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(r => r.fqdn.toLowerCase().includes(q));
|
||||
}
|
||||
if (filterConsumerType !== 'all') {
|
||||
if (filterConsumerType === 'orphaned') {
|
||||
result = result.filter(r => r.consumer_type === '');
|
||||
} else {
|
||||
result = result.filter(r => r.consumer_type === filterConsumerType);
|
||||
}
|
||||
}
|
||||
if (filterStatus !== 'all') {
|
||||
result = result.filter(r => r.status === filterStatus);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
async function loadRecords() {
|
||||
loading = true;
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
wildcardDns = settings.wildcard_dns ?? true;
|
||||
records = await getDnsRecords();
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('dns.loadFailed'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSync() {
|
||||
syncing = true;
|
||||
try {
|
||||
const result = await syncDnsRecords();
|
||||
toasts.success($t('dns.syncComplete', { created: String(result.created), deleted: String(result.deleted), synced: String(result.already_synced) }));
|
||||
await loadRecords();
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('dns.syncFailed'));
|
||||
} finally {
|
||||
syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(fqdn: string) {
|
||||
try {
|
||||
await deleteDnsRecord(fqdn);
|
||||
toasts.success($t('dns.recordDeleted', { fqdn }));
|
||||
records = records.filter(r => r.fqdn !== fqdn);
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('dns.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
await loadRecords();
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'synced': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'missing': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
||||
case 'orphaned': return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||||
case 'wildcard': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { loadRecords(); });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('dns.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
<Skeleton height="2rem" width="12rem" />
|
||||
<Skeleton height="20rem" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-[var(--text-primary)]">{$t('dns.title')}</h1>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('dns.description')}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={handleRefresh}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconRefresh size={16} />
|
||||
{$t('dns.refresh')}
|
||||
</button>
|
||||
{#if !wildcardDns}
|
||||
<button
|
||||
onclick={handleSync}
|
||||
disabled={syncing}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{#if syncing}<IconLoader size={16} />{/if}
|
||||
{syncing ? $t('dns.syncing') : $t('dns.syncNow')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<IconSearch size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder={$t('dns.searchPlaceholder')}
|
||||
class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-2 pl-9 pr-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
||||
/>
|
||||
</div>
|
||||
<select bind:value={filterConsumerType}
|
||||
class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)]">
|
||||
<option value="all">{$t('dns.allConsumers')}</option>
|
||||
<option value="instance">{$t('dns.managed')}</option>
|
||||
<option value="standalone">{$t('dns.standalone')}</option>
|
||||
<option value="orphaned">{$t('dns.orphaned')}</option>
|
||||
</select>
|
||||
{#if !wildcardDns}
|
||||
<select bind:value={filterStatus}
|
||||
class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)]">
|
||||
<option value="all">{$t('dns.allStatuses')}</option>
|
||||
<option value="synced">{$t('dns.statusSynced')}</option>
|
||||
<option value="missing">{$t('dns.statusMissing')}</option>
|
||||
<option value="orphaned">{$t('dns.statusOrphaned')}</option>
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Records Table -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)] overflow-hidden">
|
||||
{#if filteredRecords.length === 0}
|
||||
<div class="p-8 text-center text-sm text-[var(--text-tertiary)]">
|
||||
{records.length === 0 ? $t('dns.noRecords') : $t('dns.noMatchingRecords')}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-[var(--border-primary)] bg-[var(--surface-card-hover)]">
|
||||
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('dns.columnFqdn')}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('dns.columnType')}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('dns.columnValue')}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('dns.columnConsumer')}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('dns.columnStatus')}</th>
|
||||
<th class="px-4 py-3 text-right font-medium text-[var(--text-secondary)]">{$t('dns.columnActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredRecords as record}
|
||||
<tr class="border-b border-[var(--border-primary)] last:border-b-0 hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<td class="px-4 py-3 font-mono text-xs text-[var(--text-primary)]">{record.fqdn}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-secondary)]">{record.type}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-[var(--text-secondary)]">{record.content}</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if record.consumer_name}
|
||||
<span class="text-[var(--text-primary)]">{record.consumer_name}</span>
|
||||
<span class="ml-1 text-xs text-[var(--text-tertiary)]">({record.consumer_type})</span>
|
||||
{:else}
|
||||
<span class="text-[var(--text-tertiary)] italic">{$t('dns.noConsumer')}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {statusColor(record.status)}">
|
||||
{record.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
{#if record.status === 'orphaned' || record.status === 'missing'}
|
||||
<button
|
||||
onclick={() => handleDelete(record.fqdn)}
|
||||
class="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors"
|
||||
title={$t('dns.deleteRecord')}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="flex gap-4 text-xs text-[var(--text-tertiary)]">
|
||||
<span>{$t('dns.totalRecords', { count: String(records.length) })}</span>
|
||||
<span>{$t('dns.syncedCount', { count: String(records.filter(r => r.status === 'synced').length) })}</span>
|
||||
<span>{$t('dns.missingCount', { count: String(records.filter(r => r.status === 'missing').length) })}</span>
|
||||
<span>{$t('dns.orphanedCount', { count: String(records.filter(r => r.status === 'orphaned').length) })}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl, listNpmCertificates } from '$lib/api';
|
||||
import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl, listNpmCertificates, testDnsConnection, listDnsZones } from '$lib/api';
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
@@ -28,6 +28,18 @@
|
||||
let certPickerItems = $state<EntityPickerItem[]>([]);
|
||||
let loadingCerts = $state(false);
|
||||
|
||||
// DNS settings state.
|
||||
let wildcardDns = $state(true);
|
||||
let dnsProvider = $state('');
|
||||
let cloudflareApiToken = $state('');
|
||||
let hasCloudflareApiToken = $state(false);
|
||||
let cloudflareZoneId = $state('');
|
||||
let zonePickerOpen = $state(false);
|
||||
let zonePickerItems = $state<EntityPickerItem[]>([]);
|
||||
let loadingZones = $state(false);
|
||||
let zoneName = $state('');
|
||||
let testingDns = $state(false);
|
||||
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
function validateDomain(value: string): string {
|
||||
@@ -81,6 +93,10 @@
|
||||
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
||||
notificationUrl = settings.notification_url ?? '';
|
||||
staleThresholdDays = String(settings.stale_threshold_days ?? 7);
|
||||
wildcardDns = settings.wildcard_dns ?? true;
|
||||
dnsProvider = settings.dns_provider ?? '';
|
||||
hasCloudflareApiToken = settings.has_cloudflare_api_token ?? false;
|
||||
cloudflareZoneId = settings.cloudflare_zone_id ?? '';
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
||||
} finally {
|
||||
@@ -99,13 +115,20 @@
|
||||
if (!validateAll()) return;
|
||||
saving = true;
|
||||
try {
|
||||
await updateSettings({
|
||||
const payload: Record<string, unknown> = {
|
||||
domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(),
|
||||
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
|
||||
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
|
||||
ssl_certificate_id: sslCertificateId,
|
||||
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7)
|
||||
});
|
||||
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
|
||||
wildcard_dns: wildcardDns,
|
||||
dns_provider: wildcardDns ? '' : dnsProvider,
|
||||
cloudflare_zone_id: cloudflareZoneId
|
||||
};
|
||||
if (cloudflareApiToken) {
|
||||
payload.cloudflare_api_token = cloudflareApiToken;
|
||||
}
|
||||
await updateSettings(payload as any);
|
||||
toasts.success($t('settingsGeneral.saved'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||||
@@ -174,9 +197,70 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openZonePicker() {
|
||||
loadingZones = true;
|
||||
zonePickerOpen = true;
|
||||
try {
|
||||
const token = cloudflareApiToken || undefined;
|
||||
const zones = await listDnsZones(token);
|
||||
zonePickerItems = zones.map((zone): EntityPickerItem => ({
|
||||
value: zone.id,
|
||||
label: zone.name,
|
||||
description: zone.id
|
||||
}));
|
||||
if (zonePickerItems.length === 0) {
|
||||
toasts.error($t('settingsGeneral.noZonesFound'));
|
||||
zonePickerOpen = false;
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.noZonesFound'));
|
||||
zonePickerOpen = false;
|
||||
} finally {
|
||||
loadingZones = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleZoneSelect(value: string) {
|
||||
cloudflareZoneId = value;
|
||||
const item = zonePickerItems.find((i) => i.value === value);
|
||||
zoneName = item?.label ?? '';
|
||||
zonePickerOpen = false;
|
||||
}
|
||||
|
||||
async function handleTestDns() {
|
||||
testingDns = true;
|
||||
try {
|
||||
const token = cloudflareApiToken || '';
|
||||
const result = await testDnsConnection('cloudflare', token, cloudflareZoneId);
|
||||
if (result.success) {
|
||||
toasts.success($t('settingsGeneral.connectionSuccess'));
|
||||
} else {
|
||||
toasts.error(`${$t('settingsGeneral.connectionFailed')}: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.connectionFailed'));
|
||||
} finally {
|
||||
testingDns = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveZoneName() {
|
||||
if (!cloudflareZoneId) return;
|
||||
try {
|
||||
const zones = await listDnsZones();
|
||||
const match = zones.find((z) => z.id === cloudflareZoneId);
|
||||
zoneName = match?.name ?? cloudflareZoneId;
|
||||
} catch {
|
||||
zoneName = cloudflareZoneId;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await loadSettings();
|
||||
await resolveCertName();
|
||||
if (!wildcardDns && cloudflareZoneId) {
|
||||
resolveZoneName();
|
||||
}
|
||||
loadWebhookUrlValue();
|
||||
}
|
||||
|
||||
@@ -260,6 +344,93 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DNS Configuration -->
|
||||
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.dnsConfig')}</h3>
|
||||
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={wildcardDns}
|
||||
class="h-4 w-4 rounded border-[var(--border-primary)] text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.wildcardDns')}</span>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.wildcardDnsHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{#if !wildcardDns}
|
||||
<div class="mt-4 space-y-4 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-4">
|
||||
<!-- DNS Provider -->
|
||||
<div>
|
||||
<label for="dnsProvider" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.dnsProvider')}</label>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.dnsProviderHelp')}</p>
|
||||
<select id="dnsProvider" bind:value={dnsProvider}
|
||||
class="mt-1.5 w-full max-w-xs rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="cloudflare">Cloudflare</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if dnsProvider === 'cloudflare'}
|
||||
<!-- Cloudflare API Token -->
|
||||
<div>
|
||||
<FormField
|
||||
label={$t('settingsGeneral.cloudflareApiToken')}
|
||||
name="cloudflareApiToken"
|
||||
type="password"
|
||||
bind:value={cloudflareApiToken}
|
||||
placeholder={hasCloudflareApiToken ? '••••••••' : $t('settingsGeneral.cloudflareApiTokenPlaceholder')}
|
||||
helpText={hasCloudflareApiToken ? $t('settingsGeneral.cloudflareApiTokenConfigured') : $t('settingsGeneral.cloudflareApiTokenHelp')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Zone Picker -->
|
||||
<div>
|
||||
<label for="cloudflareZoneBtn" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.cloudflareZone')}</label>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.cloudflareZoneHelp')}</p>
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
id="cloudflareZoneBtn"
|
||||
type="button"
|
||||
onclick={openZonePicker}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
|
||||
>
|
||||
{#if loadingZones}
|
||||
{$t('settingsGeneral.loadingZones')}
|
||||
{:else if cloudflareZoneId && zoneName}
|
||||
{zoneName}
|
||||
{:else}
|
||||
{$t('settingsGeneral.noZone')}
|
||||
{/if}
|
||||
</button>
|
||||
{#if cloudflareZoneId}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { cloudflareZoneId = ''; zoneName = ''; }}
|
||||
class="inline-flex items-center gap-1 rounded-lg border border-[var(--border-primary)] px-2 py-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--color-danger)] hover:bg-[var(--surface-card)] transition-colors"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Connection -->
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleTestDns}
|
||||
disabled={testingDns || (!cloudflareApiToken && !hasCloudflareApiToken)}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-brand-600)] px-3 py-2 text-sm font-medium text-[var(--color-brand-600)] hover:bg-[var(--color-brand-50)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{#if testingDns}<IconLoader size={16} />{/if}
|
||||
{testingDns ? $t('settingsGeneral.testingConnection') : $t('settingsGeneral.testConnection')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<button onclick={handleSave} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
|
||||
{#if saving}<IconLoader size={16} />{/if}
|
||||
@@ -314,3 +485,12 @@
|
||||
onselect={handleCertSelect}
|
||||
onclose={() => { certPickerOpen = false; }}
|
||||
/>
|
||||
|
||||
<EntityPicker
|
||||
bind:open={zonePickerOpen}
|
||||
items={zonePickerItems}
|
||||
current={cloudflareZoneId}
|
||||
title={$t('settingsGeneral.selectZone')}
|
||||
onselect={handleZoneSelect}
|
||||
onclose={() => { zonePickerOpen = false; }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user