// Package config is the synchronization configuration given to the engine: // the primary cluster, the targets with their mappings and policies, the // replicated kinds and the object selection. It is built from the database of // the web UI (internal/store); credentials are decrypted only in memory. package config import ( "errors" "fmt" "net" "net/url" "path" "strings" "time" "pve-confsync/internal/adapter" "pve-confsync/internal/model" "pve-confsync/internal/pve" "pve-confsync/internal/sshx" ) // Cluster is the connection to one PVE cluster: the API with the root@pam // credentials and SSH as root on one node for what the API does not cover. type Cluster struct { Name string URL string User string // API user, root@pam Password string // decrypted, never logged Fingerprint string // pinned TLS certificate (SHA-256) CAPEM string // CA bundle, PEM Insecure bool Timeout string SSHHost string // empty: the host of URL SSHPort int SSHHostKey string // pinned host key (authorized_keys format) } // Client is a target cluster with its mapping (kind -> id -> field -> value) // and its own policy overrides. type Client struct { Cluster Mapping map[model.Kind]map[string]map[string]any Policies map[model.Kind]map[string]adapter.Policy } // Selection filters the objects of a kind with glob patterns on their id. type Selection struct { Include []string `json:"include,omitempty"` Exclude []string `json:"exclude,omitempty"` } // Config is the whole synchronization: Reference is the primary cluster. type Config struct { Reference Cluster Clients []Client Kinds []model.Kind Objects map[model.Kind]Selection Policies map[model.Kind]map[string]adapter.Policy Prune bool } // Validate checks a configuration built elsewhere (web UI database, YAML import). func (c *Config) Validate() error { return c.validate() } func (c *Config) validate() error { var errs []error seen := map[string]bool{} all := append([]Cluster{c.Reference}, clusters(c.Clients)...) for i, cl := range all { if cl.Name == "" { errs = append(errs, fmt.Errorf("cluster #%d: name is required", i)) } if seen[cl.Name] { errs = append(errs, fmt.Errorf("cluster %s: duplicate name", cl.Name)) } seen[cl.Name] = true } for _, k := range c.Kinds { if _, err := adapter.ByKind(k); err != nil { errs = append(errs, err) } for _, d := range adapter.KindDeps[k] { if !c.HasKind(d) { errs = append(errs, fmt.Errorf("%s needs %s", k, d)) } } } check := func(where string, p map[model.Kind]map[string]adapter.Policy) { for k, fields := range p { a, err := adapter.ByKind(k) if err != nil { errs = append(errs, fmt.Errorf("%s: %w", where, err)) continue } for f, pol := range fields { switch pol { case adapter.Sync, adapter.Ignore, adapter.Override: default: errs = append(errs, fmt.Errorf("%s: %s.%s: unknown policy %q", where, k, f, pol)) } if pol == adapter.Sync && a.Forbidden(f) { errs = append(errs, fmt.Errorf("%s: %s.%s cannot be synchronized", where, k, f)) } } } } check("policies", c.Policies) for _, cl := range c.Clients { check("client "+cl.Name, cl.Policies) for k := range cl.Mapping { if _, err := adapter.ByKind(k); err != nil { errs = append(errs, fmt.Errorf("client %s mapping: %w", cl.Name, err)) } } } return errors.Join(errs...) } func clusters(cs []Client) []Cluster { out := make([]Cluster, len(cs)) for i, c := range cs { out[i] = c.Cluster } return out } // Client returns the client with this name. func (c *Config) Client(name string) (*Client, bool) { for i := range c.Clients { if c.Clients[i].Name == name { return &c.Clients[i], true } } return nil, false } // PVE builds the API client of a cluster, with its SSH access when the host // key is recorded. func (cl Cluster) PVE() (*pve.Client, error) { var timeout time.Duration var err error if cl.Timeout != "" { if timeout, err = time.ParseDuration(cl.Timeout); err != nil { return nil, fmt.Errorf("cluster %s: timeout: %w", cl.Name, err) } } o := pve.Options{ Name: cl.Name, BaseURL: cl.URL, User: cl.User, Password: cl.Password, CAPEM: []byte(cl.CAPEM), Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: timeout, } if cl.SSHHostKey != "" { sh, err := sshx.New(cl.SSHOptions()) if err != nil { return nil, fmt.Errorf("cluster %s: %w", cl.Name, err) } o.Shell = sh } return pve.New(o) } // SSHOptions is the SSH access of the cluster: root on SSHHost, or on the host of the API URL. func (cl Cluster) SSHOptions() sshx.Options { host := cl.SSHHost if host == "" { if u, err := url.Parse(cl.URL); err == nil { host = u.Hostname() } } return sshx.Options{Host: host, Port: cl.SSHPort, User: "root", Password: cl.Password, HostKey: cl.SSHHostKey} } // Policy resolves the policy of a field for a client: client policy, then // global policy, then the adapter default. Forbidden fields are always ignored. func (c *Config) Policy(cl *Client, a adapter.Adapter, field string) adapter.Policy { if a.Forbidden(field) { return adapter.Ignore } if cl != nil { if p, ok := cl.Policies[a.Kind()][field]; ok { return p } } if p, ok := c.Policies[a.Kind()][field]; ok { return p } return a.DefaultPolicy(field) } // Selected tells whether an object id is part of the synchronization. func (c *Config) Selected(k model.Kind, id string) bool { s, ok := c.Objects[k] if !ok { return true } in := len(s.Include) == 0 for _, p := range s.Include { if m, _ := path.Match(p, id); m { in = true break } } for _, p := range s.Exclude { if m, _ := path.Match(p, id); m { return false } } return in } // HasKind tells whether a kind is synchronized. func (c *Config) HasKind(k model.Kind) bool { for _, x := range c.Kinds { if x == k { return true } } return false } // NormalizeURL trims the URL of a cluster and adds the PVE port (8006) when // none is given: https://pve1 becomes https://pve1:8006. func NormalizeURL(raw string) string { s := strings.TrimRight(strings.TrimSpace(raw), "/") u, err := url.Parse(s) if err != nil || u.Host == "" || u.Port() != "" { return s } u.Host = net.JoinHostPort(u.Hostname(), "8006") return u.String() }