Agent configuration (agent-<role>.yaml, strict decoding, role section passed undecoded to the role factory) and the state directory of ADR 0011: identity with interrupted-renewal recovery, verified revisions under revisions/<n>/, state.json with apply results, lease sequence and queued deployment reports, atomic writes, pruning and a lock file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
5.4 KiB
Go
179 lines
5.4 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package core
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"go.yaml.in/yaml/v3"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/internal/brand"
|
|
"git.beugsiud.de/beug.k/DDI/internal/logging"
|
|
)
|
|
|
|
// Configuration defaults (docs/adr/0021-agent-strecke-m3.md).
|
|
const (
|
|
DefaultApplyTimeout = 5 * time.Minute
|
|
DefaultHealthTimeout = 30 * time.Second
|
|
DefaultLeaseInterval = 30 * time.Second
|
|
DefaultLeaseFullSync = time.Hour
|
|
)
|
|
|
|
// Config is the agent configuration file (<ConfigDir>/agent-<role>.yaml).
|
|
// Server address, server name and the identity are not configured here; the
|
|
// enroll command stores them in the state directory.
|
|
type Config struct {
|
|
// StateDir holds identity, revisions and state.json (ADR 0011).
|
|
StateDir string `yaml:"state_dir"`
|
|
// Log configures the structured logger (written to stderr).
|
|
Log logging.Options `yaml:"log"`
|
|
// ApplyTimeout bounds each role call (Validate, Activate) of an apply.
|
|
ApplyTimeout time.Duration `yaml:"apply_timeout"`
|
|
// HealthTimeout is how long the health check of a freshly activated
|
|
// revision may take until it reports OK.
|
|
HealthTimeout time.Duration `yaml:"health_timeout"`
|
|
// LeaseInterval is the lease polling interval (DHCP).
|
|
LeaseInterval time.Duration `yaml:"lease_interval"`
|
|
// LeaseFullSync is the interval of full lease snapshots.
|
|
LeaseFullSync time.Duration `yaml:"lease_full_sync"`
|
|
|
|
// role holds the undecoded section of the agent's role.
|
|
role yaml.Node
|
|
}
|
|
|
|
// fileConfig mirrors Config for strict decoding, including the role
|
|
// sections that are handed to the role factory undecoded.
|
|
type fileConfig struct {
|
|
StateDir string `yaml:"state_dir"`
|
|
Log logging.Options `yaml:"log"`
|
|
ApplyTimeout time.Duration `yaml:"apply_timeout"`
|
|
HealthTimeout time.Duration `yaml:"health_timeout"`
|
|
LeaseInterval time.Duration `yaml:"lease_interval"`
|
|
LeaseFullSync time.Duration `yaml:"lease_full_sync"`
|
|
DNS yaml.Node `yaml:"dns"`
|
|
DHCP yaml.Node `yaml:"dhcp"`
|
|
}
|
|
|
|
// DefaultConfigPath returns <brand.ConfigDir>/agent-<role>.yaml.
|
|
func DefaultConfigPath(kind Kind) string {
|
|
return filepath.Join(brand.ConfigDir, "agent-"+string(kind)+".yaml")
|
|
}
|
|
|
|
// DefaultStateDir returns <brand.StateDir>/agent-<role>.
|
|
func DefaultStateDir(kind Kind) string {
|
|
return filepath.Join(brand.StateDir, "agent-"+string(kind))
|
|
}
|
|
|
|
// LoadConfig reads the configuration file of the role. A missing file yields
|
|
// the defaults.
|
|
func LoadConfig(path string, kind Kind) (*Config, error) {
|
|
data, err := os.ReadFile(path) //nolint:gosec // path is given by the operator
|
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return nil, fmt.Errorf("read configuration: %w", err)
|
|
}
|
|
cfg, err := ParseConfig(data, kind)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("configuration %s: %w", path, err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// ParseConfig decodes a configuration (strictly: unknown keys are errors),
|
|
// applies defaults and validates it.
|
|
func ParseConfig(data []byte, kind Kind) (*Config, error) {
|
|
var fc fileConfig
|
|
if len(bytes.TrimSpace(data)) > 0 {
|
|
dec := yaml.NewDecoder(bytes.NewReader(data))
|
|
dec.KnownFields(true)
|
|
if err := dec.Decode(&fc); err != nil && !errors.Is(err, io.EOF) {
|
|
return nil, err
|
|
}
|
|
}
|
|
cfg := &Config{
|
|
StateDir: fc.StateDir, Log: fc.Log, ApplyTimeout: fc.ApplyTimeout,
|
|
HealthTimeout: fc.HealthTimeout, LeaseInterval: fc.LeaseInterval, LeaseFullSync: fc.LeaseFullSync,
|
|
}
|
|
switch kind {
|
|
case KindDNS:
|
|
cfg.role = fc.DNS
|
|
if !isEmptyNode(&fc.DHCP) {
|
|
return nil, errors.New("section dhcp is not used by the DNS agent")
|
|
}
|
|
case KindDHCP:
|
|
cfg.role = fc.DHCP
|
|
if !isEmptyNode(&fc.DNS) {
|
|
return nil, errors.New("section dns is not used by the DHCP agent")
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unknown role %q", kind)
|
|
}
|
|
if cfg.StateDir == "" {
|
|
cfg.StateDir = DefaultStateDir(kind)
|
|
}
|
|
setDefault(&cfg.ApplyTimeout, DefaultApplyTimeout)
|
|
setDefault(&cfg.HealthTimeout, DefaultHealthTimeout)
|
|
setDefault(&cfg.LeaseInterval, DefaultLeaseInterval)
|
|
setDefault(&cfg.LeaseFullSync, DefaultLeaseFullSync)
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func setDefault(d *time.Duration, def time.Duration) {
|
|
if *d == 0 {
|
|
*d = def
|
|
}
|
|
}
|
|
|
|
func (c *Config) validate() error {
|
|
for _, d := range []struct {
|
|
name string
|
|
v time.Duration
|
|
}{
|
|
{"apply_timeout", c.ApplyTimeout}, {"health_timeout", c.HealthTimeout},
|
|
{"lease_interval", c.LeaseInterval}, {"lease_full_sync", c.LeaseFullSync},
|
|
} {
|
|
if d.v < time.Second {
|
|
return fmt.Errorf("%s must be at least 1s", d.name)
|
|
}
|
|
}
|
|
if c.LeaseFullSync < c.LeaseInterval {
|
|
return errors.New("lease_full_sync must not be shorter than lease_interval")
|
|
}
|
|
if _, err := logging.New(io.Discard, c.Log); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isEmptyNode reports whether a section is absent or null.
|
|
func isEmptyNode(n *yaml.Node) bool {
|
|
return n.Kind == 0 || (n.Kind == yaml.ScalarNode && n.Tag == "!!null")
|
|
}
|
|
|
|
// DecodeRole decodes the role's section into v (strictly). An absent section
|
|
// leaves v unchanged so that roles keep their defaults.
|
|
func (c *Config) DecodeRole(v any) error {
|
|
if isEmptyNode(&c.role) {
|
|
return nil
|
|
}
|
|
data, err := yaml.Marshal(&c.role)
|
|
if err != nil {
|
|
return fmt.Errorf("encode role section: %w", err)
|
|
}
|
|
dec := yaml.NewDecoder(bytes.NewReader(data))
|
|
dec.KnownFields(true)
|
|
if err := dec.Decode(v); err != nil && !errors.Is(err, io.EOF) {
|
|
return fmt.Errorf("role section: %w", err)
|
|
}
|
|
return nil
|
|
}
|