The runtime can now take ClientGroup, BlockList and DNSZone objects (fancydns.k6n.net/v1alpha1) from a cluster with -kube, from a pod's service account or a kubeconfig alike. internal/kubesource watches them with dynamic informers, converts and validates each object on its own, and publishes an immutable snapshot. A bad object is skipped and keeps its last good version, so one broken edit cannot remove a running policy; an invalid merged config is dropped whole. The last snapshot is saved so a restart without apiserver access still serves. config.Store gains an overlay: Base() is what the YAML says, Get() is base plus cluster objects. Everything that writes the config back (mutateConfig, SetAdminAddr, the settings editor) now starts from Base(), otherwise cluster objects would be copied into the file and shadow their own objects. YAML wins every name collision. putACL and toggleBlocklist refuse to edit cluster-owned objects. BlockList has no path field and refuses file:// URLs so a namespaced object cannot pick files the runtime reads or writes. DNSZone is rendered through the zone writer's own prove-by-reparse step, with the SOA serial taken from metadata.generation so every replica renders identical bytes. qlog: Flush reset the pending slice to [:0], so entries logged while it was still storing the batch were appended over the same backing array. The race detector found it through the new Kubernetes integration test. pending is now set to nil, with a regression test that fails without the change. CRD manifests, RBAC and examples are in deploy/k8s/crds; tests pin the schemas to the Go spec structs so the two cannot drift. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
184 lines
4.7 KiB
Go
184 lines
4.7 KiB
Go
package kubesource
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
|
)
|
|
|
|
func signature(s *Snapshot) string {
|
|
b, _ := json.Marshal(struct {
|
|
G []config.Group
|
|
L []config.ListSource
|
|
Z map[string]string
|
|
}{s.Groups, s.Lists, s.Zones})
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// Merge returns base with the cluster's groups and lists added; base is not
|
|
// modified. Precedence is deliberate: whatever the YAML declares is the
|
|
// operator's and wins. YAML groups are evaluated before cluster groups, and a
|
|
// cluster list that reuses a YAML list's name is dropped. The names of what was
|
|
// dropped are returned so the caller can say so.
|
|
func Merge(base *config.Config, snap *Snapshot) (*config.Config, []string) {
|
|
if snap == nil {
|
|
return base, nil
|
|
}
|
|
next := *base
|
|
next.ACL = base.ACL
|
|
next.ACL.Groups = append([]config.Group(nil), base.ACL.Groups...)
|
|
next.Block = base.Block
|
|
next.Block.Lists = append([]config.ListSource(nil), base.Block.Lists...)
|
|
|
|
var dropped []string
|
|
groups := map[string]bool{}
|
|
for _, g := range base.ACL.Groups {
|
|
groups[g.Name] = true
|
|
}
|
|
for _, g := range snap.Groups {
|
|
if groups[g.Name] {
|
|
dropped = append(dropped, "ClientGroup "+g.Name+" (name used in the YAML)")
|
|
continue
|
|
}
|
|
next.ACL.Groups = append(next.ACL.Groups, g)
|
|
}
|
|
lists := map[string]bool{}
|
|
for _, l := range base.Block.Lists {
|
|
lists[l.Name] = true
|
|
}
|
|
for _, l := range snap.Lists {
|
|
if lists[l.Name] {
|
|
dropped = append(dropped, "BlockList "+l.Name+" (name used in the YAML)")
|
|
continue
|
|
}
|
|
// The same defaults config.applyDefaults gives a YAML list.
|
|
if l.Format == "" {
|
|
l.Format = "auto"
|
|
}
|
|
if l.Refresh == 0 {
|
|
l.Refresh = config.Duration(24 * time.Hour)
|
|
}
|
|
if l.Timeout <= 0 {
|
|
l.Timeout = base.Server.Timeouts.List
|
|
}
|
|
if l.MaxBytes <= 0 {
|
|
l.MaxBytes = 512 << 20
|
|
}
|
|
next.Block.Lists = append(next.Block.Lists, l)
|
|
}
|
|
return &next, dropped
|
|
}
|
|
|
|
// SyncZoneDir makes dir hold exactly one <apex>.zone file per zone, written
|
|
// atomically, and removes the files of zones that no longer exist. It reports
|
|
// whether anything on disk changed. Files it did not write (anything that is
|
|
// not *.zone) are left alone.
|
|
func SyncZoneDir(dir string, zones map[string]string) (bool, error) {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return false, fmt.Errorf("zone directory %s: %w", dir, err)
|
|
}
|
|
changed := false
|
|
want := map[string]bool{}
|
|
for apex, text := range zones {
|
|
name := zoneFile(apex)
|
|
want[name] = true
|
|
path := filepath.Join(dir, name)
|
|
if old, err := os.ReadFile(path); err == nil && string(old) == text {
|
|
continue
|
|
}
|
|
tmp, err := os.CreateTemp(dir, ".kube-zone-*")
|
|
if err != nil {
|
|
return changed, err
|
|
}
|
|
_, werr := tmp.WriteString(text)
|
|
cerr := tmp.Close()
|
|
if werr != nil || cerr != nil {
|
|
_ = os.Remove(tmp.Name())
|
|
return changed, fmt.Errorf("write %s: %w", name, firstErr(werr, cerr))
|
|
}
|
|
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
|
|
_ = os.Remove(tmp.Name())
|
|
return changed, err
|
|
}
|
|
if err := os.Rename(tmp.Name(), path); err != nil {
|
|
_ = os.Remove(tmp.Name())
|
|
return changed, err
|
|
}
|
|
changed = true
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return changed, err
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".zone") || want[e.Name()] {
|
|
continue
|
|
}
|
|
if err := os.Remove(filepath.Join(dir, e.Name())); err != nil {
|
|
return changed, err
|
|
}
|
|
changed = true
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
// zoneFile is the file name for an apex. The apex has been normalised by
|
|
// NormalizeName, so it holds only DNS name characters; the wildcard label is
|
|
// the one that is not file-safe on every platform.
|
|
func zoneFile(apex string) string {
|
|
n := strings.TrimSuffix(apex, ".")
|
|
if n == "" {
|
|
n = "root"
|
|
}
|
|
return strings.ReplaceAll(n, "*", "_wild") + ".zone"
|
|
}
|
|
|
|
func firstErr(errs ...error) error {
|
|
for _, e := range errs {
|
|
if e != nil {
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Save writes the snapshot so a restart that cannot reach the apiserver still
|
|
// has the last known policy. Problems are not saved.
|
|
func Save(path string, s *Snapshot) error {
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return config.WriteFileAtomic(path, b, 0o644)
|
|
}
|
|
|
|
// Load reads a snapshot written by Save. A missing file is (nil, nil).
|
|
func Load(path string) (*Snapshot, error) {
|
|
b, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var s Snapshot
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
if s.Zones == nil {
|
|
s.Zones = map[string]string{}
|
|
}
|
|
return &s, nil
|
|
}
|