Files
pve-confsync/internal/config/config.go
T

242 lines
7.0 KiB
Go

// Package config loads the YAML configuration of the CLI (phase 0): clusters,
// field policies, per client mappings and object selection.
// Token secrets are never written in this file: they are read from an
// environment variable or a file (docker secret), see README.
package config
import (
"errors"
"fmt"
"os"
"path"
"strings"
"time"
"gopkg.in/yaml.v3"
"pve-confsync/internal/adapter"
"pve-confsync/internal/model"
"pve-confsync/internal/pve"
)
// Cluster is the connection to one PVE cluster.
type Cluster struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
TokenID string `yaml:"token_id"`
TokenSecretEnv string `yaml:"token_secret_env,omitempty"`
TokenSecretFile string `yaml:"token_secret_file,omitempty"`
CAFile string `yaml:"ca_file,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"`
Insecure bool `yaml:"insecure,omitempty"`
Timeout string `yaml:"timeout,omitempty"`
// Set by the web UI from its database, never read from nor written to YAML.
Secret string `yaml:"-"` // token secret, decrypted
CAPEM string `yaml:"-"` // CA bundle, PEM
}
// Client is a target cluster with its mapping (kind -> id -> field -> value)
// and its own policy overrides.
type Client struct {
Cluster `yaml:",inline"`
Mapping map[model.Kind]map[string]map[string]any `yaml:"mapping,omitempty"`
Policies map[model.Kind]map[string]adapter.Policy `yaml:"policies,omitempty"`
}
// Selection filters the objects of a kind with glob patterns on their id.
type Selection struct {
Include []string `yaml:"include,omitempty"`
Exclude []string `yaml:"exclude,omitempty"`
}
// Config is the whole file.
type Config struct {
Reference Cluster `yaml:"reference"`
Clients []Client `yaml:"clients"`
Kinds []model.Kind `yaml:"kinds,omitempty"`
Objects map[model.Kind]Selection `yaml:"objects,omitempty"`
Policies map[model.Kind]map[string]adapter.Policy `yaml:"policies,omitempty"`
Prune bool `yaml:"prune,omitempty"`
}
// Load reads and validates a configuration file.
func Load(file string) (*Config, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var c Config
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
dec.KnownFields(true) // typos must fail, not be silently ignored
if err := dec.Decode(&c); err != nil {
return nil, fmt.Errorf("%s: %w", file, err)
}
if len(c.Kinds) == 0 {
c.Kinds = append(c.Kinds, model.Kinds...)
}
return &c, c.validate()
}
// 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
if cl.Secret == "" && cl.TokenSecretEnv == "" && cl.TokenSecretFile == "" {
errs = append(errs, fmt.Errorf("cluster %s: token_secret_env or token_secret_file is required", cl.Name))
}
}
for _, k := range c.Kinds {
if _, err := adapter.ByKind(k); err != nil {
errs = append(errs, err)
}
}
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, resolving its token secret.
func (cl Cluster) PVE() (*pve.Client, error) {
secret, err := cl.secret()
if err != nil {
return nil, err
}
var timeout time.Duration
if cl.Timeout != "" {
if timeout, err = time.ParseDuration(cl.Timeout); err != nil {
return nil, fmt.Errorf("cluster %s: timeout: %w", cl.Name, err)
}
}
return pve.New(pve.Options{
Name: cl.Name, BaseURL: cl.URL, TokenID: cl.TokenID, TokenSecret: secret,
CAFile: cl.CAFile, CAPEM: []byte(cl.CAPEM), Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: timeout,
})
}
// ResolveSecret reads the token secret from the environment variable or file of
// the YAML configuration (used when a YAML file is imported in the web UI).
func (cl Cluster) ResolveSecret() (string, error) { return cl.secret() }
func (cl Cluster) secret() (string, error) {
if cl.Secret != "" {
return cl.Secret, nil
}
if cl.TokenSecretEnv != "" {
if v := os.Getenv(cl.TokenSecretEnv); v != "" {
return v, nil
}
if cl.TokenSecretFile == "" {
return "", fmt.Errorf("cluster %s: environment variable %s is empty", cl.Name, cl.TokenSecretEnv)
}
}
b, err := os.ReadFile(cl.TokenSecretFile)
if err != nil {
return "", fmt.Errorf("cluster %s: token secret: %w", cl.Name, err)
}
return strings.TrimSpace(string(b)), nil
}
// 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
}