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/config"
|
||||||
"github.com/alexei/docker-watcher/internal/crypto"
|
"github.com/alexei/docker-watcher/internal/crypto"
|
||||||
"github.com/alexei/docker-watcher/internal/deployer"
|
"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/docker"
|
||||||
"github.com/alexei/docker-watcher/internal/events"
|
"github.com/alexei/docker-watcher/internal/events"
|
||||||
"github.com/alexei/docker-watcher/internal/health"
|
"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.
|
// Build API server.
|
||||||
apiServer := api.NewServer(db, dockerClient, npmClient, dep, webhookHandler, eventBus, encKey)
|
apiServer := api.NewServer(db, dockerClient, npmClient, dep, webhookHandler, eventBus, encKey)
|
||||||
apiServer.SetStaleScanner(staleScanner)
|
apiServer.SetStaleScanner(staleScanner)
|
||||||
apiServer.SetProxyManager(proxyManager)
|
apiServer.SetProxyManager(proxyManager)
|
||||||
|
apiServer.SetDNSProvider(dnsProvider)
|
||||||
|
apiServer.SetDNSProviderChangedCallback(func(provider dns.Provider) {
|
||||||
|
dep.SetDNSProvider(provider)
|
||||||
|
proxyManager.SetDNSProvider(provider)
|
||||||
|
})
|
||||||
router := apiServer.Router()
|
router := apiServer.Router()
|
||||||
|
|
||||||
// Serve embedded static files for the SPA frontend.
|
// 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")
|
slog.Info("default admin user created", "username", "admin")
|
||||||
return nil
|
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/auth"
|
||||||
"github.com/alexei/docker-watcher/internal/crypto"
|
"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/docker"
|
||||||
"github.com/alexei/docker-watcher/internal/events"
|
"github.com/alexei/docker-watcher/internal/events"
|
||||||
"github.com/alexei/docker-watcher/internal/npm"
|
"github.com/alexei/docker-watcher/internal/npm"
|
||||||
@@ -17,6 +18,10 @@ import (
|
|||||||
"github.com/alexei/docker-watcher/internal/webhook"
|
"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.
|
// Server holds all dependencies for the API layer.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
@@ -30,6 +35,9 @@ type Server struct {
|
|||||||
oidcProvider *auth.OIDCProvider
|
oidcProvider *auth.OIDCProvider
|
||||||
staleScanner *stale.Scanner
|
staleScanner *stale.Scanner
|
||||||
proxyManager *proxy.Manager
|
proxyManager *proxy.Manager
|
||||||
|
|
||||||
|
dnsProvider dns.Provider
|
||||||
|
onDNSProviderChanged DNSProviderChangedFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new API Server with all required dependencies.
|
// NewServer creates a new API Server with all required dependencies.
|
||||||
@@ -76,6 +84,16 @@ func (s *Server) SetProxyManager(pm *proxy.Manager) {
|
|||||||
s.proxyManager = pm
|
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.
|
// initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal.
|
||||||
func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) {
|
func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) {
|
||||||
// Decrypt the OIDC client secret if it's encrypted.
|
// 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.Put("/settings", s.updateSettings)
|
||||||
r.Get("/settings/webhook-url", s.getWebhookURL)
|
r.Get("/settings/webhook-url", s.getWebhookURL)
|
||||||
r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret)
|
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"
|
"strings"
|
||||||
|
|
||||||
"github.com/alexei/docker-watcher/internal/crypto"
|
"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/npm"
|
||||||
"github.com/alexei/docker-watcher/internal/store"
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
"github.com/alexei/docker-watcher/internal/volume"
|
"github.com/alexei/docker-watcher/internal/volume"
|
||||||
@@ -29,6 +30,10 @@ type settingsRequest struct {
|
|||||||
SSLCertificateID *int `json:"ssl_certificate_id,omitempty"`
|
SSLCertificateID *int `json:"ssl_certificate_id,omitempty"`
|
||||||
StaleThresholdDays *int `json:"stale_threshold_days,omitempty"`
|
StaleThresholdDays *int `json:"stale_threshold_days,omitempty"`
|
||||||
AllowedVolumePaths *string `json:"allowed_volume_paths,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.
|
// getSettings handles GET /api/settings.
|
||||||
@@ -41,19 +46,23 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Return settings without sensitive fields.
|
// Return settings without sensitive fields.
|
||||||
respondJSON(w, http.StatusOK, map[string]any{
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
"domain": settings.Domain,
|
"domain": settings.Domain,
|
||||||
"server_ip": settings.ServerIP,
|
"server_ip": settings.ServerIP,
|
||||||
"network": settings.Network,
|
"network": settings.Network,
|
||||||
"subdomain_pattern": settings.SubdomainPattern,
|
"subdomain_pattern": settings.SubdomainPattern,
|
||||||
"notification_url": settings.NotificationURL,
|
"notification_url": settings.NotificationURL,
|
||||||
"npm_url": settings.NpmURL,
|
"npm_url": settings.NpmURL,
|
||||||
"npm_email": settings.NpmEmail,
|
"npm_email": settings.NpmEmail,
|
||||||
"has_npm_password": settings.NpmPassword != "",
|
"has_npm_password": settings.NpmPassword != "",
|
||||||
"polling_interval": settings.PollingInterval,
|
"polling_interval": settings.PollingInterval,
|
||||||
"ssl_certificate_id": settings.SSLCertificateID,
|
"ssl_certificate_id": settings.SSLCertificateID,
|
||||||
"stale_threshold_days": settings.StaleThresholdDays,
|
"stale_threshold_days": settings.StaleThresholdDays,
|
||||||
"allowed_volume_paths": settings.AllowedVolumePaths,
|
"allowed_volume_paths": settings.AllowedVolumePaths,
|
||||||
"updated_at": settings.UpdatedAt,
|
"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
|
_ = 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 {
|
if err := s.store.UpdateSettings(updated); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
|
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -142,6 +170,15 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
go s.reapplySSLToAllProxies(updated)
|
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"})
|
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))
|
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
|
inst.NpmProxyID = npmProxyID
|
||||||
d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info")
|
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 {
|
} else {
|
||||||
d.logDeploy(deployID, "Blue-green: proxy skipped (disabled for this stage)", "info")
|
d.logDeploy(deployID, "Blue-green: proxy skipped (disabled for this stage)", "info")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/alexei/docker-watcher/internal/crypto"
|
"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/docker"
|
||||||
"github.com/alexei/docker-watcher/internal/events"
|
"github.com/alexei/docker-watcher/internal/events"
|
||||||
"github.com/alexei/docker-watcher/internal/health"
|
"github.com/alexei/docker-watcher/internal/health"
|
||||||
@@ -32,6 +33,7 @@ type Deployer struct {
|
|||||||
notifier *notify.Notifier
|
notifier *notify.Notifier
|
||||||
eventBus EventPublisher
|
eventBus EventPublisher
|
||||||
encKey [32]byte
|
encKey [32]byte
|
||||||
|
dns dns.Provider // nil when wildcard DNS is active
|
||||||
|
|
||||||
// Graceful shutdown: tracks in-progress deploys.
|
// Graceful shutdown: tracks in-progress deploys.
|
||||||
activeWg sync.WaitGroup
|
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.
|
// Drain waits for all in-progress deploys to complete. Call this during graceful shutdown.
|
||||||
func (d *Deployer) Drain() {
|
func (d *Deployer) Drain() {
|
||||||
d.shuttingDown.Store(true)
|
d.shuttingDown.Store(true)
|
||||||
@@ -357,6 +365,10 @@ func (d *Deployer) executeDeploy(
|
|||||||
if err := d.store.UpdateInstance(inst); err != nil {
|
if err := d.store.UpdateInstance(inst); err != nil {
|
||||||
slog.Warn("update instance with proxy ID", "error", err)
|
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 {
|
} else {
|
||||||
d.logDeploy(deployID, "Proxy creation skipped (disabled for this stage)", "info")
|
d.logDeploy(deployID, "Proxy creation skipped (disabled for this stage)", "info")
|
||||||
inst.Subdomain = subdomain
|
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 {
|
} else if delErr := d.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil {
|
||||||
slog.Warn("delete proxy host", "proxy_id", inst.NpmProxyID, "error", delErr)
|
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.
|
// 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.
|
// truncateID safely truncates a Docker ID to 12 characters for display.
|
||||||
func truncateID(id string) string {
|
func truncateID(id string) string {
|
||||||
if len(id) > 12 {
|
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.
|
// Update instance status to failed if it was created.
|
||||||
if instanceID != "" {
|
if instanceID != "" {
|
||||||
if err := d.store.UpdateInstanceStatus(instanceID, "failed"); err != nil {
|
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"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/alexei/docker-watcher/internal/dns"
|
||||||
"github.com/alexei/docker-watcher/internal/npm"
|
"github.com/alexei/docker-watcher/internal/npm"
|
||||||
"github.com/alexei/docker-watcher/internal/store"
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
)
|
)
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
type Manager struct {
|
type Manager struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
npm *npm.Client
|
npm *npm.Client
|
||||||
|
dns dns.Provider // nil when wildcard DNS is active
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new proxy manager.
|
// 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.
|
// CreateProxyRequest is the input for creating a standalone proxy.
|
||||||
type CreateProxyRequest struct {
|
type CreateProxyRequest struct {
|
||||||
Domain string `json:"domain"`
|
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)
|
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
|
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)
|
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.
|
// Re-read from store to get updated timestamps.
|
||||||
return m.store.GetStandaloneProxy(id)
|
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 {
|
if err := m.store.DeleteStandaloneProxy(id); err != nil {
|
||||||
return fmt.Errorf("delete standalone proxy: %w", err)
|
return fmt.Errorf("delete standalone proxy: %w", err)
|
||||||
}
|
}
|
||||||
@@ -294,6 +313,56 @@ func (m *Manager) ListAllProxies() ([]ProxyView, error) {
|
|||||||
return views, nil
|
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.
|
// lastFailedStep returns the message of the last failed validation step.
|
||||||
func lastFailedStep(result ValidationResult) string {
|
func lastFailedStep(result ValidationResult) string {
|
||||||
for _, step := range result.Steps {
|
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"`
|
SSLCertificateID int `json:"ssl_certificate_id"`
|
||||||
StaleThresholdDays int `json:"stale_threshold_days"`
|
StaleThresholdDays int `json:"stale_threshold_days"`
|
||||||
AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths
|
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"`
|
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.
|
// Instance represents a running (or stopped) container for a project stage.
|
||||||
type Instance struct {
|
type Instance struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -7,36 +7,46 @@ import (
|
|||||||
// GetSettings returns the global settings (single-row pattern, always row id=1).
|
// GetSettings returns the global settings (single-row pattern, always row id=1).
|
||||||
func (s *Store) GetSettings() (Settings, error) {
|
func (s *Store) GetSettings() (Settings, error) {
|
||||||
var st Settings
|
var st Settings
|
||||||
|
var wildcardDNS int
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
`SELECT domain, server_ip, network, subdomain_pattern, notification_url,
|
`SELECT domain, server_ip, network, subdomain_pattern, notification_url,
|
||||||
npm_url, npm_email, npm_password, webhook_secret, polling_interval,
|
npm_url, npm_email, npm_password, webhook_secret, polling_interval,
|
||||||
base_volume_path, ssl_certificate_id, stale_threshold_days,
|
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`,
|
FROM settings WHERE id = 1`,
|
||||||
).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
|
).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
|
||||||
&st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval,
|
&st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval,
|
||||||
&st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
|
&st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
|
||||||
&st.AllowedVolumePaths, &st.UpdatedAt)
|
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
|
||||||
|
&st.CloudflareAPIToken, &st.CloudflareZoneID, &st.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Settings{}, fmt.Errorf("query settings: %w", err)
|
return Settings{}, fmt.Errorf("query settings: %w", err)
|
||||||
}
|
}
|
||||||
|
st.WildcardDNS = wildcardDNS != 0
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSettings upserts the global settings row.
|
// UpdateSettings upserts the global settings row.
|
||||||
func (s *Store) UpdateSettings(st Settings) error {
|
func (s *Store) UpdateSettings(st Settings) error {
|
||||||
st.UpdatedAt = Now()
|
st.UpdatedAt = Now()
|
||||||
|
wildcardDNS := 0
|
||||||
|
if st.WildcardDNS {
|
||||||
|
wildcardDNS = 1
|
||||||
|
}
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`UPDATE settings SET
|
`UPDATE settings SET
|
||||||
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
|
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
|
||||||
npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?,
|
npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?,
|
||||||
base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?,
|
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`,
|
WHERE id = 1`,
|
||||||
st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL,
|
st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL,
|
||||||
st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval,
|
st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval,
|
||||||
st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
|
st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
|
||||||
st.AllowedVolumePaths, st.UpdatedAt,
|
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
|
||||||
|
st.CloudflareAPIToken, st.CloudflareZoneID, st.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update settings: %w", err)
|
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 ''`,
|
`ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`,
|
||||||
// Add allowed_volume_paths to settings for absolute volume scope allowlist (2026-04-01).
|
// 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 '[]'`,
|
`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 {
|
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_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_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_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 {
|
for _, idx := range indexes {
|
||||||
if _, err := s.db.Exec(idx); err != nil {
|
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')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_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.
|
// 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,
|
ValidationResult,
|
||||||
Volume,
|
Volume,
|
||||||
VolumeScopeInfo,
|
VolumeScopeInfo,
|
||||||
BrowseResult
|
BrowseResult,
|
||||||
|
DnsZone,
|
||||||
|
DnsRecordView
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
@@ -268,6 +270,29 @@ export function listNpmCertificates(): Promise<NpmCertificate[]> {
|
|||||||
return get<NpmCertificate[]>('/api/settings/npm-certificates');
|
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 ──────────────────────────────────────────────────────────
|
// ── Health ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getHealth(): Promise<{ docker: DockerHealth }> {
|
export function getHealth(): Promise<{ docker: DockerHealth }> {
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"proxies": "Proxies",
|
"proxies": "Proxies",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"logout": "Log out"
|
"logout": "Log out",
|
||||||
|
"dns": "DNS Records"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -243,7 +244,26 @@
|
|||||||
"noCertificate": "None (no SSL)",
|
"noCertificate": "None (no SSL)",
|
||||||
"clearCertificate": "Clear",
|
"clearCertificate": "Clear",
|
||||||
"loadingCertificates": "Loading certificates...",
|
"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": {
|
"settingsRegistries": {
|
||||||
"title": "Container Registries",
|
"title": "Container Registries",
|
||||||
@@ -540,6 +560,43 @@
|
|||||||
"proxies": "Proxies",
|
"proxies": "Proxies",
|
||||||
"recentErrors": "Recent Errors"
|
"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": {
|
"language": {
|
||||||
"en": "English",
|
"en": "English",
|
||||||
"ru": "Russian"
|
"ru": "Russian"
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"proxies": "Прокси",
|
"proxies": "Прокси",
|
||||||
"events": "События",
|
"events": "События",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"logout": "Выйти"
|
"logout": "Выйти",
|
||||||
|
"dns": "DNS-записи"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Панель управления",
|
"title": "Панель управления",
|
||||||
@@ -243,7 +244,26 @@
|
|||||||
"noCertificate": "Нет (без SSL)",
|
"noCertificate": "Нет (без SSL)",
|
||||||
"clearCertificate": "Очистить",
|
"clearCertificate": "Очистить",
|
||||||
"loadingCertificates": "Загрузка сертификатов...",
|
"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": {
|
"settingsRegistries": {
|
||||||
"title": "Реестры контейнеров",
|
"title": "Реестры контейнеров",
|
||||||
@@ -540,6 +560,43 @@
|
|||||||
"proxies": "Прокси",
|
"proxies": "Прокси",
|
||||||
"recentErrors": "Недавние ошибки"
|
"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": {
|
"language": {
|
||||||
"en": "Английский",
|
"en": "Английский",
|
||||||
"ru": "Русский"
|
"ru": "Русский"
|
||||||
|
|||||||
@@ -108,9 +108,30 @@ export interface Settings {
|
|||||||
ssl_certificate_id: number;
|
ssl_certificate_id: number;
|
||||||
stale_threshold_days: number;
|
stale_threshold_days: number;
|
||||||
allowed_volume_paths: string;
|
allowed_volume_paths: string;
|
||||||
|
wildcard_dns: boolean;
|
||||||
|
dns_provider: string;
|
||||||
|
has_cloudflare_api_token: boolean;
|
||||||
|
cloudflare_zone_id: string;
|
||||||
updated_at: 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. */
|
/** An SSL certificate from Nginx Proxy Manager. */
|
||||||
export interface NpmCertificate {
|
export interface NpmCertificate {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import Toast from '$lib/components/Toast.svelte';
|
import Toast from '$lib/components/Toast.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
import LocaleSwitcher from '$lib/components/LocaleSwitcher.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 { connectGlobalEvents, type SSEConnection } from '$lib/sse';
|
||||||
import { isAuthenticated, clearAuth } from '$lib/auth';
|
import { isAuthenticated, clearAuth } from '$lib/auth';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
|
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
|
||||||
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
||||||
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' },
|
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' },
|
||||||
|
{ href: '/dns', labelKey: 'nav.dns', icon: 'globe' },
|
||||||
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
|
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
|
||||||
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
|
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
|
||||||
] as const;
|
] 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" />
|
<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'}
|
{: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" />
|
<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'}
|
{: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" />
|
<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'}
|
{: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">
|
<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 type { EntityPickerItem } from '$lib/types';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||||
@@ -28,6 +28,18 @@
|
|||||||
let certPickerItems = $state<EntityPickerItem[]>([]);
|
let certPickerItems = $state<EntityPickerItem[]>([]);
|
||||||
let loadingCerts = $state(false);
|
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>>({});
|
let errors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
function validateDomain(value: string): string {
|
function validateDomain(value: string): string {
|
||||||
@@ -81,6 +93,10 @@
|
|||||||
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
||||||
notificationUrl = settings.notification_url ?? '';
|
notificationUrl = settings.notification_url ?? '';
|
||||||
staleThresholdDays = String(settings.stale_threshold_days ?? 7);
|
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) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -99,13 +115,20 @@
|
|||||||
if (!validateAll()) return;
|
if (!validateAll()) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
await updateSettings({
|
const payload: Record<string, unknown> = {
|
||||||
domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(),
|
domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(),
|
||||||
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
|
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
|
||||||
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
|
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
|
||||||
ssl_certificate_id: sslCertificateId,
|
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'));
|
toasts.success($t('settingsGeneral.saved'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
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() {
|
async function init() {
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
await resolveCertName();
|
await resolveCertName();
|
||||||
|
if (!wildcardDns && cloudflareZoneId) {
|
||||||
|
resolveZoneName();
|
||||||
|
}
|
||||||
loadWebhookUrlValue();
|
loadWebhookUrlValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +344,93 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<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}
|
{#if saving}<IconLoader size={16} />{/if}
|
||||||
@@ -314,3 +485,12 @@
|
|||||||
onselect={handleCertSelect}
|
onselect={handleCertSelect}
|
||||||
onclose={() => { certPickerOpen = false; }}
|
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