// 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"` } // 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() } 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.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, Fingerprint: cl.Fingerprint, Insecure: cl.Insecure, Timeout: timeout, }) } func (cl Cluster) secret() (string, error) { 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 }