Files
0xJacky 9f70d47b35 feat(cluster): unify multi-node configuration synchronization
Deployment to other nodes was file by file, manual, and always validated
against the local Nginx. One synchronization engine now backs every path.

- Directory deployment: a directory carries its own deployment targets and
  every file below it inherits them, so a whole tree replicates in one
  request per node (#1559).
- Node sync: one action pushes all configurations, sites and streams to the
  selected nodes, which is what a freshly added node needs (#1484).
- Namespace replication: the namespace record is mirrored to its member nodes
  and synced sites and streams carry the namespace name, so every node groups
  them identically (#1744).
- Automatic sync: a namespace can switch to the auto strategy with its own
  interval and a cron job re-pushes its content (#1582).
- deploy_mode=remote no longer touches the local Nginx: no sites-enabled
  symlink, no local nginx -t and no local reload. The deployment intent moves
  to the database, and moving a site into a remote namespace detaches its
  leftover local symlink (#1505).

The receiving side gains a batch config endpoint that writes every file and
reloads once, with a per-file fallback for nodes that predate it. Disabling a
site or stream became idempotent so converging a node reports no spurious
failures, renaming only tests and reloads when the enabled tree actually
changed, and stream delete now removes the stream record instead of a site one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:07:34 +08:00

257 lines
6.3 KiB
Go

package clustersync
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query"
"github.com/samber/lo"
"github.com/uozi-tech/cosy/logger"
)
// SyncDirectory replicates every configuration file below dir to the given
// nodes. It is what turns file-by-file deployment into directory deployment.
func SyncDirectory(ctx context.Context, dir string, nodeIDs []uint64, overwrite bool) (*Summary, error) {
nodes, err := resolveNodes(nodeIDs)
if err != nil {
return nil, err
}
if len(nodes) == 0 {
return nil, ErrNoTargetNode
}
info, err := os.Stat(dir)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, ErrPathOutsideConfDir
}
files, err := CollectConfigFiles(dir)
if err != nil {
return nil, err
}
relative, err := filepath.Rel(filepath.Clean(nginx.GetConfPath()), filepath.Clean(dir))
if err != nil || relative == "." {
relative = "/"
}
label := fmt.Sprintf("%s (%d)", filepath.ToSlash(relative), len(files))
return run(ctx, nodes, []item{configBatchItem(label, files, overwrite)}), nil
}
// SyncNodes replicates the selected kinds of local content to the given nodes.
// A full scope is the one-click "bring this new node up to date" action.
func SyncNodes(ctx context.Context, nodeIDs []uint64, scope Scope) (*Summary, error) {
if scope.IsEmpty() {
return nil, ErrEmptyScope
}
nodes, err := resolveNodes(nodeIDs)
if err != nil {
return nil, err
}
if len(nodes) == 0 {
return nil, ErrNoTargetNode
}
items, err := buildItems(scope, nil)
if err != nil {
return nil, err
}
return run(ctx, nodes, items), nil
}
// SyncNamespace replicates the namespace record and every site and stream that
// belongs to it onto all of its member nodes, so each node ends up with the same
// namespace content.
func SyncNamespace(ctx context.Context, namespaceID uint64) (*Summary, error) {
n := query.Namespace
namespace, err := n.Where(n.ID.Eq(namespaceID)).First()
if err != nil {
return nil, err
}
nodes, err := resolveNodes(namespace.SyncNodeIds)
if err != nil {
return nil, err
}
if len(nodes) == 0 {
return nil, ErrNamespaceHasNoNode
}
items, err := buildItems(Scope{Sites: true, Streams: true, Overwrite: true}, namespace)
if err != nil {
return nil, err
}
items = append([]item{namespaceItem(namespace)}, items...)
return run(ctx, nodes, items), nil
}
// buildItems collects the local content matching the scope. When namespace is
// set only its members are collected and they are tagged with its name.
func buildItems(scope Scope, namespace *model.Namespace) ([]item, error) {
var items []item
if scope.Configs {
files, err := CollectConfigFiles(nginx.GetConfPath())
if err != nil {
return nil, err
}
if len(files) > 0 {
items = append(items, configBatchItem(fmt.Sprintf("/ (%d)", len(files)), files, scope.Overwrite))
}
}
if scope.Sites {
siteItems, err := collectSiteItems(namespace, scope.Overwrite)
if err != nil {
return nil, err
}
items = append(items, siteItems...)
}
if scope.Streams {
streamItems, err := collectStreamItems(namespace, scope.Overwrite)
if err != nil {
return nil, err
}
items = append(items, streamItems...)
}
return items, nil
}
func collectSiteItems(namespace *model.Namespace, overwrite bool) ([]item, error) {
s := query.Site
stmt := s.Preload(s.Namespace)
if namespace != nil {
stmt = stmt.Where(s.NamespaceID.Eq(namespace.ID))
}
sites, err := stmt.Find()
if err != nil {
return nil, err
}
items := make([]item, 0, len(sites))
for _, siteModel := range sites {
content, err := os.ReadFile(siteModel.Path)
if err != nil {
logger.Debugf("cluster sync skips unreadable site %s: %v", siteModel.Path, err)
continue
}
name := filepath.Base(siteModel.Path)
items = append(items, siteItem(
name,
string(content),
namespaceName(lo.Ternary(namespace != nil, namespace, siteModel.Namespace)),
postSyncAction(siteModel.Namespace),
siteEnabled(siteModel),
overwrite,
))
}
return items, nil
}
func collectStreamItems(namespace *model.Namespace, overwrite bool) ([]item, error) {
s := query.Stream
stmt := s.Preload(s.Namespace)
if namespace != nil {
stmt = stmt.Where(s.NamespaceID.Eq(namespace.ID))
}
streams, err := stmt.Find()
if err != nil {
return nil, err
}
items := make([]item, 0, len(streams))
for _, streamModel := range streams {
content, err := os.ReadFile(streamModel.Path)
if err != nil {
logger.Debugf("cluster sync skips unreadable stream %s: %v", streamModel.Path, err)
continue
}
name := filepath.Base(streamModel.Path)
items = append(items, streamItem(
name,
string(content),
namespaceName(lo.Ternary(namespace != nil, namespace, streamModel.Namespace)),
postSyncAction(streamModel.Namespace),
streamEnabled(streamModel),
overwrite,
))
}
return items, nil
}
func namespaceName(namespace *model.Namespace) string {
if namespace == nil {
return ""
}
return namespace.Name
}
func postSyncAction(namespace *model.Namespace) string {
if namespace == nil || namespace.PostSyncAction == "" {
return model.PostSyncActionReloadNginx
}
return namespace.PostSyncAction
}
// siteEnabled reports the deployment intent: remote namespaces keep it in the
// database, local ones in the sites-enabled directory.
func siteEnabled(siteModel *model.Site) bool {
if siteModel.Namespace.IsRemoteDeploy() {
return siteModel.RemoteEnabled
}
return enabledLinkExists("sites-enabled", filepath.Base(siteModel.Path))
}
func streamEnabled(streamModel *model.Stream) bool {
if streamModel.Namespace.IsRemoteDeploy() {
return streamModel.RemoteEnabled
}
return enabledLinkExists("streams-enabled", filepath.Base(streamModel.Path))
}
func enabledLinkExists(dir, name string) bool {
_, err := os.Stat(filepath.Join(nginx.GetConfPath(dir), name))
return err == nil
}
// ResolveNamespaceIDByName maps a namespace name onto a local namespace id,
// creating the namespace when the node does not know it yet. It is used by the
// receiving side of a sync where namespace ids differ between nodes.
func ResolveNamespaceIDByName(name string) uint64 {
if name == "" {
return 0
}
n := query.Namespace
namespace, err := n.Where(n.Name.Eq(name)).FirstOrCreate()
if err != nil {
logger.Errorf("cluster sync could not resolve namespace %s: %v", name, err)
return 0
}
return namespace.ID
}