package configsource import ( "context" "encoding/json" "errors" "fmt" "log/slog" "sync" "time" "tug/internal/platform/config" "tug/internal/platform/configsrc" "tug/internal/storage" ) type Store interface { ConfigCommit(ctx context.Context) (storage.Checkpoint, error) CompareAndSwapConfigCommit(ctx context.Context, expected storage.Checkpoint, next string) error AppendAudit(ctx context.Context, input storage.AppendAuditInput) (storage.AuditEvent, error) } type Source interface { Step(ctx context.Context, in configsrc.Input) (configsrc.Result, error) Pin(head string) error Read(commit string) ([]byte, error) } type Metrics interface { Applied(commit string) error Fetched(at time.Time) RollbackRefused() } type Setters struct { SessionTTLs func(ttl time.Duration, hostTTL map[string]time.Duration) error AllowedHD func(hd string) DefaultGroups func(ctx context.Context, defaults, referenced []string) error } type Config struct { ConfigPath, SecretsPath string Bootstrap *config.Config Store Store Source Source Logger *slog.Logger Now func() time.Time Restart func(keys []string) } var ( ErrRefused = errors.New("configsource: the configuration was refused") ErrUnavailable = errors.New("configsource: the checkpoint is unreadable and the remote unreachable") errPinFailed = errors.New("configsource: the accepted head could not be pinned") ) const ( sourceGit = "git" startTimeout = 15 * time.Second pollInterval = 5 * time.Minute kindConfigChanged = "config-changed" actionApplied = "config.applied" actionRefused = "config.refused" auditActor = "system" auditSubject = "config" ) type refusalKey struct { kind, head string } type Controller struct { cfg Config boot *config.Config git bool logger *slog.Logger now func() time.Time tick func(time.Duration) (<-chan time.Time, func()) stepMu sync.Mutex running *config.Config audited map[refusalKey]bool restarting bool mu sync.Mutex set Setters metrics Metrics bound bool commit string fetchedAt time.Time rollbacks int } func New(cfg Config) (*Controller, error) { if cfg.Bootstrap == nil { return nil, errors.New("configsource: the bootstrap configuration is required") } if cfg.Restart == nil { return nil, errors.New("configsource: the restart request is required") } git := cfg.Bootstrap.Source == sourceGit if git && (cfg.Source == nil || cfg.Store == nil || cfg.Bootstrap.ConfigGit == nil) { return nil, errors.New("configsource: the git source needs a source, a store and [config.git]") } logger := cfg.Logger if logger == nil { logger = slog.New(slog.DiscardHandler) } now := cfg.Now if now == nil { now = time.Now } return &Controller{cfg: cfg, boot: cfg.Bootstrap, git: git, logger: logger, now: now, tick: newTicker, audited: map[refusalKey]bool{}}, nil } func newTicker(d time.Duration) (<-chan time.Time, func()) { t := time.NewTicker(d) return t.C, t.Stop } func (c *Controller) Start(ctx context.Context) (*config.Config, error) { c.stepMu.Lock() defer c.stepMu.Unlock() if !c.git { c.running = c.boot return c.boot, nil } k, err := c.cfg.Store.ConfigCommit(ctx) if err != nil { return nil, err } stepCtx, cancel := context.WithTimeout(ctx, startTimeout) res, err := c.cfg.Source.Step(stepCtx, c.input(k, startTimeout)) cancel() if err != nil { return nil, err } c.noteFetched(res) c.noteRepaired(res) switch res.Outcome { case configsrc.Accepted: if cand, ok := c.candidate(ctx, res); ok { err := c.advance(ctx, k, res.Head) if err == nil { c.recordApplied(ctx, res.Head, config.Changes{}) return c.started(cand, res.Head, origin(res.Head)), nil } if !nothingMoved(err) { return nil, err } } case configsrc.Refused: c.refused(ctx, res.Kind, res.Head, res.Cause, nil) } if !k.Set { return c.startFromDisk() } return c.startFromCheckpoint(k.Value, res) } func (c *Controller) startFromDisk() (*config.Config, error) { secrets, err := config.CheckSecrets(c.boot, c.cfg.SecretsPath) if err != nil { return nil, fmt.Errorf("%w: %w", ErrRefused, err) } cfg := *c.boot cfg.Secrets = secrets return c.started(&cfg, "", c.cfg.ConfigPath), nil } func (c *Controller) startFromCheckpoint(k string, res configsrc.Result) (*config.Config, error) { data, err := c.cfg.Source.Read(k) if err != nil { c.logger.Error("config checkpoint unreadable", "checkpoint", short(k), "kind", res.Kind, "err", err) if res.Outcome == configsrc.Refused && res.Kind == configsrc.KindCheckpointMissing { return nil, fmt.Errorf("%w: %s: %w", ErrRefused, configsrc.KindCheckpointMissing, err) } return nil, fmt.Errorf("%w: %w", ErrUnavailable, err) } cfg, err := config.LoadCandidate(origin(k), data, c.cfg.SecretsPath) if err != nil { return nil, fmt.Errorf("%w: %w", ErrRefused, err) } if err := c.cfg.Source.Pin(k); err != nil { c.logger.Warn("config pin failed", "head", short(k), "err", err) } return c.started(cfg, k, origin(k)), nil } func (c *Controller) started(cfg *config.Config, commit, from string) *config.Config { c.running = cfg c.setCommit(commit) c.logger.Info("config running", "source", sourceGit, "commit", commit, "from", from) return cfg } func (c *Controller) input(k storage.Checkpoint, timeout time.Duration) configsrc.Input { g := c.boot.ConfigGit return configsrc.Input{ Checkpoint: k.Value, Remote: configsrc.Remote{ URL: g.Remote, Branch: g.Branch, DeployKey: c.boot.Secrets.ConfigDeployKey.Bytes(), KnownHosts: g.KnownHostsData, }, Signers: g.Signers, Timeout: timeout, } } func (c *Controller) candidate(ctx context.Context, res configsrc.Result) (*config.Config, bool) { cand, err := config.LoadCandidate(origin(res.Head), res.Config, c.cfg.SecretsPath) if err != nil { c.refused(ctx, configsrc.KindInvalid, res.Head, nil, err) return nil, false } if !cand.SameSource(c.boot) { c.refused(ctx, kindConfigChanged, res.Head, nil, nil) return nil, false } return cand, true } func (c *Controller) advance(ctx context.Context, k storage.Checkpoint, head string) error { if err := c.cfg.Source.Pin(head); err != nil { c.logger.Warn("config pin failed", "head", short(head), "err", err) return fmt.Errorf("%w: %w", errPinFailed, err) } if err := c.cfg.Store.CompareAndSwapConfigCommit(ctx, k, head); err != nil { if errors.Is(err, storage.ErrConflict) { c.logger.Warn("config checkpoint conflict", "remote", c.boot.ConfigGit.Remote, "head", short(head)) } return err } return nil } func nothingMoved(err error) bool { return errors.Is(err, storage.ErrConflict) || errors.Is(err, errPinFailed) } func (c *Controller) refused(ctx context.Context, kind, head string, cause, problems error) { remote := c.boot.ConfigGit.Remote attrs := []any{"kind", kind, "remote", remote, "head", short(head)} if kind == configsrc.KindUnreachable { attrs = append(attrs, "timeout", configsrc.IsTimeout(cause)) } if lines := problemLines(problems); lines != nil { attrs = append(attrs, "problems", lines) } c.logger.Warn("config refused", attrs...) if kind == configsrc.KindUnreachable { return } key := refusalKey{kind, head} if c.audited[key] { return } c.audited[key] = true if kind == configsrc.KindRewound || kind == configsrc.KindDiverged { c.noteRollback() } c.audit(ctx, actionRefused, map[string]any{"kind": kind, "remote": remote, "head": short(head)}) } func (c *Controller) recordApplied(ctx context.Context, commit string, ch config.Changes) { hot, restart := list(ch.Hot), list(ch.Restart) if !c.git { c.logger.Info("config applied", "hot", hot, "restart", restart) return } c.logger.Info("config applied", "commit", commit, "hot", hot, "restart", restart) c.audit(ctx, actionApplied, map[string]any{"commit": commit, "hot": hot, "restart": restart}) } func (c *Controller) audit(ctx context.Context, action string, detail map[string]any) { data, err := json.Marshal(detail) if err == nil { _, err = c.cfg.Store.AppendAudit(ctx, storage.AppendAuditInput{Actor: auditActor, Action: action, Subject: auditSubject, Detail: string(data)}) } if err != nil { c.logger.Error("config audit failed", "action", action, "err", err) } } func (c *Controller) Bind(set Setters, m Metrics) { c.mu.Lock() defer c.mu.Unlock() c.set, c.metrics, c.bound = set, m, true if m == nil { return } if !c.fetchedAt.IsZero() { m.Fetched(c.fetchedAt) } for ; c.rollbacks > 0; c.rollbacks-- { m.RollbackRefused() } if c.commit != "" { c.applied(c.commit) } } func (c *Controller) setters() Setters { c.mu.Lock() defer c.mu.Unlock() return c.set } func (c *Controller) setCommit(commit string) { c.mu.Lock() defer c.mu.Unlock() c.commit = commit if c.bound && commit != "" { c.applied(commit) } } func (c *Controller) applied(commit string) { if c.metrics == nil { return } if err := c.metrics.Applied(commit); err != nil { c.logger.Warn("config metric refused the commit", "commit", commit, "err", err) } } func (c *Controller) noteRepaired(res configsrc.Result) { if res.Repaired { c.logger.Warn("config clone repaired", "remote", c.boot.ConfigGit.Remote, "head", short(res.Head)) } } func (c *Controller) noteFetched(res configsrc.Result) { if !res.Fetched { return } at := c.now() c.mu.Lock() defer c.mu.Unlock() if !c.bound { c.fetchedAt = at return } if c.metrics != nil { c.metrics.Fetched(at) } } func (c *Controller) noteRollback() { c.mu.Lock() defer c.mu.Unlock() if !c.bound { c.rollbacks++ return } if c.metrics != nil { c.metrics.RollbackRefused() } } func origin(commit string) string { return "git:" + short(commit) + ":tug.toml" } func short(commit string) string { if len(commit) > 12 { return commit[:12] } return commit } func list(s []string) []string { if s == nil { return []string{} } return s } func problemLines(err error) []string { var cfgErr *config.Error if !errors.As(err, &cfgErr) { return nil } lines := make([]string, len(cfgErr.Problems)) for i, p := range cfgErr.Problems { lines[i] = p.String() } return lines }