internal/server/fleet_deletions.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"fmt"
"log"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/X4Applegate/caddyui/internal/models"
)
// v2.59.0 (issue #120): opt-in deletion propagation for a source server's
// automatic deployment targets.
//
// When CaddyServer.PropagateDeletions is on, deleting a proxy host, redirection,
// advanced route or Layer4 proxy on the source also deletes the paired copies
// it previously deployed to the source's *current* DefaultDeployTargets — and
// only those copies. A target-local resource, one created independently on the
// target, a copy on a one-off "Also deploy to" server that is not an automatic
// target, and a row another source resource is still paired with are never
// touched.
//
// The pairing is not forgotten when a target cannot be reached: each deletion
// becomes a fleet_pending_deletions row that stays until the target's Caddy has
// converged, and a background reconciler retries it.
const fleetDeletionRetryEvery = time.Minute
// fleetKindLabel is the Activity Log / UI word for a resource kind.
func fleetKindLabel(kind string) string {
switch kind {
case models.FleetResourceProxy:
return "proxy host"
case models.FleetResourceRedirect:
return "redirection"
case models.FleetResourceRawRoute:
return "advanced route"
case models.FleetResourceLayer4:
return "Layer4 proxy"
}
return kind
}
// propagateFleetDeletion is called after a source resource row has been
// deleted. It always forgets the source row's pairings (so a reused row ID can
// never inherit one); when the source has deletion propagation enabled it also
// queues and attempts the deletion of each paired copy on an automatic target.
// sourceServerID must be the server the row lived on (noted before deleting
// it); 0 means unknown and is a no-op.
func (s *Server) propagateFleetDeletion(actor string, sourceServerID int64, kind string, sourceID int64, label string) {
if sourceServerID <= 0 || sourceID <= 0 {
return
}
targets, err := models.FleetDeploymentsForSource(s.DB, sourceServerID, kind, sourceID)
if err != nil {
log.Printf("fleet deletion: list pairings for %s %d: %v", kind, sourceID, err)
return
}
if len(targets) == 0 {
return
}
if err := models.DeleteFleetDeploymentsForSource(s.DB, sourceServerID, kind, sourceID); err != nil {
log.Printf("fleet deletion: forget pairings for %s %d: %v", kind, sourceID, err)
}
src, err := models.GetCaddyServer(s.DB, sourceServerID)
if err != nil || src == nil || !src.PropagateDeletions {
return
}
automatic := src.DefaultDeployTargetSet()
queued := false
for targetServerID, targetResourceID := range targets {
if !automatic[targetServerID] {
continue // a one-off "Also deploy to" copy: not ours to remove
}
// Another source resource still pairs with this very row (identity
// adoption): it is still wanted, so leave it.
if shared, err := fleetTargetStillPaired(s, kind, targetServerID, targetResourceID); err != nil || shared {
continue
}
if err := models.EnqueueFleetPendingDeletion(s.DB, models.FleetPendingDeletion{
SourceServerID: sourceServerID, ResourceKind: kind, SourceResourceID: sourceID,
TargetServerID: targetServerID, TargetResourceID: targetResourceID, Label: label,
}); err != nil {
log.Printf("fleet deletion: queue %s %d -> server %d: %v", kind, sourceID, targetServerID, err)
continue
}
queued = true
}
if queued {
s.processFleetPendingDeletions(actor, sourceServerID)
}
}
// fleetTargetStillPaired reports whether any fleet_deployments row still points
// at the given target row.
func fleetTargetStillPaired(s *Server, kind string, targetServerID, targetResourceID int64) (bool, error) {
var n int
err := s.DB.QueryRow(`SELECT COUNT(*) FROM fleet_deployments WHERE resource_kind=? AND target_server_id=? AND target_resource_id=?`,
kind, targetServerID, targetResourceID).Scan(&n)
return n > 0, err
}
// processFleetPendingDeletions attempts every queued deletion (of one source,
// or of all when sourceServerID is 0) and reports how many finished and how
// many are still pending.
func (s *Server) processFleetPendingDeletions(actor string, sourceServerID int64) (done, pending int) {
items, err := models.ListFleetPendingDeletions(s.DB, sourceServerID)
if err != nil {
log.Printf("fleet deletion: list queue: %v", err)
return 0, 0
}
for _, p := range items {
if err := s.attemptFleetPendingDeletion(actor, p); err != nil {
pending++
continue
}
done++
}
return done, pending
}
// attemptFleetPendingDeletion removes one paired copy from the target's DB and
// pushes the target's Caddy. It returns nil only when the target has converged
// (or the target server no longer exists); any failure leaves the queue entry
// in place with the error recorded.
func (s *Server) attemptFleetPendingDeletion(actor string, p models.FleetPendingDeletion) error {
s.fleetDeployMu.Lock()
defer s.fleetDeployMu.Unlock()
fail := func(err error) error {
_ = models.RecordFleetPendingAttempt(s.DB, p, err.Error())
_ = models.LogActivity(s.DB, p.TargetServerID, actor, "fleet_delete_pending", fmt.Sprintf("%s:%d", p.ResourceKind, p.TargetResourceID),
fmt.Sprintf("%s %q: %v (will retry)", fleetKindLabel(p.ResourceKind), p.Label, err), false)
return err
}
target, err := models.GetCaddyServer(s.DB, p.TargetServerID)
if err != nil || target == nil {
_ = models.DeleteFleetPendingDeletion(s.DB, p) // the target server was removed
return nil
}
forceTLS := false
exists, err := models.FleetDeploymentTargetExists(s.DB, p.ResourceKind, p.TargetResourceID, p.TargetServerID)
if err != nil {
return fail(err)
}
if exists {
forceTLS = s.cleanupFleetCopy(p)
if _, err := models.DeleteFleetResourceRow(s.DB, p.ResourceKind, p.TargetResourceID, p.TargetServerID); err != nil {
return fail(err)
}
}
if s.syncHoldFor(p.TargetServerID) != nil {
return fail(fmt.Errorf("syncing %s is held after failed post-apply checks", target.Name))
}
// allowEmpty: if this was the target's last resource, the live Caddy must
// converge to the empty state too, not keep serving the deleted route.
if err := s.syncCaddyOpts(p.TargetServerID, forceTLS, true); err != nil {
return fail(err)
}
_ = models.DeleteFleetPendingDeletion(s.DB, p)
_ = models.LogActivity(s.DB, p.TargetServerID, actor, "fleet_delete", fmt.Sprintf("%s:%d", p.ResourceKind, p.TargetResourceID),
fmt.Sprintf("deleted %s %q (source deleted it)", fleetKindLabel(p.ResourceKind), p.Label), true)
return nil
}
// cleanupFleetCopy drops the managed DNS record of a copy about to be deleted,
// exactly as deleting that row by hand would, and reports whether it used a
// custom certificate (so the TLS section is rebuilt on the next sync).
func (s *Server) cleanupFleetCopy(p models.FleetPendingDeletion) (forceTLS bool) {
switch p.ResourceKind {
case models.FleetResourceProxy:
if old, _ := models.GetProxyHost(s.DB, p.TargetResourceID); old != nil {
s.dnsDeleteRecord(old.DNSProvider, old.DNSProfileID, old.DNSZoneID, old.DNSZoneName, old.DNSRecordID)
return old.CertificateID != 0
}
case models.FleetResourceRedirect:
if old, _ := models.GetRedirectionHost(s.DB, p.TargetResourceID); old != nil {
s.dnsDeleteRecord(old.DNSProvider, old.DNSProfileID, old.DNSZoneID, old.DNSZoneName, old.DNSRecordID)
return old.CertificateID != 0
}
case models.FleetResourceRawRoute:
if old, _ := models.GetRawRoute(s.DB, p.TargetResourceID); old != nil {
s.dnsDeleteRecord(old.DNSProvider, old.DNSProfileID, old.DNSZoneID, old.DNSZoneName, old.DNSRecordID)
return old.CertificateID != 0
}
}
return false
}
// runFleetDeletionReconciler retries queued deletions until their targets
// converge. Cheap when the queue is empty (one small query per tick).
func (s *Server) runFleetDeletionReconciler() {
time.Sleep(30 * time.Second)
ticker := time.NewTicker(fleetDeletionRetryEvery)
defer ticker.Stop()
for {
if items, err := models.ListFleetPendingDeletions(s.DB, 0); err == nil && len(items) > 0 {
done, pending := s.processFleetPendingDeletions("system", 0)
log.Printf("fleet deletion reconcile: %d finished, %d still pending", done, pending)
}
<-ticker.C
}
}
// retryFleetPendingDeletions is the "Retry now" button on the server edit page.
func (s *Server) retryFleetPendingDeletions(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
done, pending := s.processFleetPendingDeletions(s.currentUserEmail(r), id)
msg := fmt.Sprintf("Retried pending deletions: %d finished, %d still pending.", done, pending)
http.Redirect(w, r, "/servers/"+strconv.FormatInt(id, 10)+"/edit?flash="+urlQueryEscape(msg), http.StatusSeeOther)
}
// discardFleetPendingDeletion forgets one queued deletion without doing it —
// for a target that is gone for good. The copy, if it still exists, stays.
func (s *Server) discardFleetPendingDeletion(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
_ = r.ParseForm()
kind := r.FormValue("kind")
srcRes, _ := strconv.ParseInt(r.FormValue("source_resource_id"), 10, 64)
tgt, _ := strconv.ParseInt(r.FormValue("target_server_id"), 10, 64)
if err := models.DeleteFleetPendingDeletion(s.DB, models.FleetPendingDeletion{
SourceServerID: id, ResourceKind: kind, SourceResourceID: srcRes, TargetServerID: tgt,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = models.LogActivity(s.DB, id, s.currentUserEmail(r), "fleet_delete_discarded", fmt.Sprintf("%s:%d", kind, srcRes), "pending deletion discarded", true)
http.Redirect(w, r, "/servers/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
}
// fleetDeletionNote remembers, before a resource row is deleted, which server
// it lived on — the row is gone by the time the deletion is propagated.
type fleetDeletionNote struct {
serverID int64
kind string
id int64
label string
}
func (s *Server) noteFleetDeletion(kind string, id int64, label string) fleetDeletionNote {
return fleetDeletionNote{serverID: models.FleetResourceServerID(s.DB, kind, id), kind: kind, id: id, label: label}
}
func (s *Server) propagateNotedDeletion(actor string, n fleetDeletionNote) {
s.propagateFleetDeletion(actor, n.serverID, n.kind, n.id, n.label)
}