Files
DDI/agent/core/config.go
T
beug.k@beugsiud.deandClaude Opus 5.5 c96eb33e58 feat(agent): configuration file and file-based agent state
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>
2026-10-13 19:05:16 +02:00

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
}