Files

231 lines
6.2 KiB
Go

// 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()
}