Add fancy-dns-console migrate to convert an existing deployment into cluster objects

The settings of the ConfigMap-and-YAML deployment have to become objects before
the mechanisms that held them can go. The command reads what the old console
wrote (the zone--<apex>.zone keys of the zones ConfigMap and the
address-overrides RPZ), and optionally the resolver's config.yaml, and converts:

  zone master files            -> DNSZone
  address-overrides RPZ        -> AddressOverride, one per name
  URL blocklists               -> BlockList
  allow_entries/block_entries  -> DomainRule inline-allow / inline-block
  acl.groups                   -> ClientGroup

By default it only prints manifests, which can be reviewed or committed; -apply
creates or updates the objects. It never deletes or edits the ConfigMaps or the
config. Each conversion is validated with the code the resolver uses to read the
objects, and anything it cannot represent faithfully is listed and left alone:
a zone with a record type the API does not write, a list that is a file on the
pod, a name that is not a legal object name, an RPZ line that is not an A or
AAAA override. Tests apply a conversion of the live data to a fake cluster and
read it back through the resolver's source, and run the command against fake
ConfigMaps, offline from files, and with the ConfigMaps missing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 10:12:45 +02:00
1 parent bdcdbaf23a
commit 2f25463347
9 files changed
+919

No files matched your search

+297
View File
@@ -0,0 +1,297 @@
// Package migrate turns what a ConfigMap-and-YAML deployment held into fancy-dns
// objects: zone master files become DNSZone, the address-overrides RPZ becomes
// AddressOverride, URL blocklists become BlockList, inline allow/block entries
// become DomainRule, and ACL groups become ClientGroup.
//
// It converts, validates with the same code the resolver uses to read the
// objects, and reports what it could not carry over. It never deletes anything
// and never guesses: an input it cannot represent faithfully is listed and left
// where it is.
package migrate
import (
"fmt"
"reflect"
"sort"
"strings"
"time"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/yaml"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubeedit"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
)
// Inputs is everything to convert. Any part may be empty.
type Inputs struct {
// ZoneFiles maps an apex ("home.example.com") to its master file.
ZoneFiles map[string]string
// RPZ is the address-overrides RPZ text.
RPZ string
// Config is the resolver's YAML configuration.
Config *config.Config
// Namespace is where the objects will live.
Namespace string
}
// Result is the converted objects and the account of what was not converted.
type Result struct {
Objects []*unstructured.Unstructured
// Notes say what was converted and what to remove from the config afterwards.
Notes []string
// Skipped are inputs that could not be represented, each with the reason.
Skipped []string
}
const managedBy = "fancy-dns-migrate"
// Convert does the whole conversion.
func Convert(in Inputs) Result {
var r Result
r.zones(in)
r.overrides(in)
if in.Config != nil {
r.config(in)
}
sort.SliceStable(r.Objects, func(i, j int) bool {
a, b := r.Objects[i], r.Objects[j]
if a.GetKind() != b.GetKind() {
return a.GetKind() < b.GetKind()
}
return a.GetName() < b.GetName()
})
return r
}
func (r *Result) add(kind, name, ns string, spec any) {
m, err := toMap(spec)
if err != nil {
r.Skipped = append(r.Skipped, fmt.Sprintf("%s %s: %v", kind, name, err))
return
}
u := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": kubesource.APIGroup + "/" + kubesource.Version,
"kind": kind,
"metadata": map[string]any{
"name": name, "namespace": ns,
"labels": map[string]any{"app.kubernetes.io/managed-by": managedBy},
},
"spec": m,
}}
r.Objects = append(r.Objects, u)
}
func (r *Result) skip(format string, args ...any) {
r.Skipped = append(r.Skipped, fmt.Sprintf(format, args...))
}
func (r *Result) note(format string, args ...any) {
r.Notes = append(r.Notes, fmt.Sprintf(format, args...))
}
// zones converts master files.
func (r *Result) zones(in Inputs) {
apexes := make([]string, 0, len(in.ZoneFiles))
for a := range in.ZoneFiles {
apexes = append(apexes, a)
}
sort.Strings(apexes)
for _, name := range apexes {
apex, err := localzone.NormalizeOrigin(name)
if err != nil {
r.skip("zone file %q: %v", name, err)
continue
}
zf, err := localzone.ParseZoneFile(apex, in.ZoneFiles[name])
if err != nil {
r.skip("zone file %q: does not parse: %v", name, err)
continue
}
spec := kubesource.DNSZoneSpec{Zone: strings.TrimSuffix(apex, "."), TTL: zf.TTL, SOA: zf.Soa}
for _, rec := range zf.Records {
rel := "@"
if rec.Name != apex {
rel = strings.TrimSuffix(rec.Name, "."+apex)
}
ttl := rec.TTL
if ttl == zf.TTL {
ttl = 0 // inherits the zone's, as it did in the file
}
spec.Records = append(spec.Records, kubesource.ZoneRecord{Name: rel, Type: rec.Type, TTL: ttl, Value: rec.Value})
}
if _, err := kubesource.CheckZone(spec); err != nil {
r.skip("zone %s: not representable as a DNSZone (%v); it stays a master file", apex, err)
continue
}
objName := strings.TrimSuffix(apex, ".")
r.add("DNSZone", objName, in.Namespace, spec)
r.note("zone %s: %d records -> DNSZone/%s", apex, len(spec.Records), objName)
}
}
// overrides converts the address-overrides RPZ.
func (r *Result) overrides(in Inputs) {
if strings.TrimSpace(in.RPZ) == "" {
return
}
byName := map[string][]string{}
for i, line := range strings.Split(in.RPZ, "\n") {
if j := strings.IndexAny(line, ";#"); j >= 0 {
line = line[:j]
}
f := strings.Fields(line)
if len(f) == 0 || strings.HasPrefix(f[0], "$") {
continue
}
// "name [ttl] [IN] A|AAAA address"
rest := f[1:]
for len(rest) > 0 && (strings.EqualFold(rest[0], "IN") || isNumber(rest[0])) {
rest = rest[1:]
}
if len(rest) < 2 || !(strings.EqualFold(rest[0], "A") || strings.EqualFold(rest[0], "AAAA")) {
r.skip("address overrides line %d %q: only A and AAAA overrides are converted", i+1, strings.TrimSpace(line))
continue
}
name := strings.TrimSuffix(strings.ToLower(f[0]), ".")
byName[name] = append(byName[name], rest[1])
}
names := make([]string, 0, len(byName))
for n := range byName {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
spec := kubesource.AddressOverrideSpec{Domain: n, Addresses: byName[n]}
o, err := kubesource.CheckOverride(spec)
if err != nil {
r.skip("address override %s: %v", n, err)
continue
}
r.add("AddressOverride", kubeedit.ObjectName(o.Domain), in.Namespace,
kubesource.AddressOverrideSpec{Domain: o.Domain, Addresses: o.Addresses})
}
if len(names) > 0 {
r.note("address overrides: %d names -> AddressOverride objects", len(names))
}
}
func isNumber(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return s != ""
}
// config converts the parts of the resolver's YAML that cluster objects replace.
func (r *Result) config(in Inputs) {
cfg := in.Config
for _, l := range cfg.Block.Lists {
switch {
case l.URL == "" && strings.EqualFold(l.Format, "rpz") && strings.Contains(l.Path, "address-overrides"):
r.note("list %q is the address-overrides file; the AddressOverride objects replace it, remove it from blocklist.lists", l.Name)
case l.URL == "":
r.skip("list %q: it is a file on the pod (%s), which a cluster object cannot point at; host it at a URL or keep it in the config", l.Name, l.Path)
case !strings.HasPrefix(l.URL, "http://") && !strings.HasPrefix(l.URL, "https://"):
r.skip("list %q: url %q is not http(s)", l.Name, l.URL)
default:
spec := kubesource.BlockListSpec{
URL: l.URL, Format: l.Format, Mirror: l.Mirror, Disabled: !l.Enabled,
MaxBytes: l.MaxBytes, Categories: l.Categories, Comment: l.Comment,
InsecureSkipTLSVerify: l.InsecureSkipTLSVerify,
}
if l.Refresh > 0 {
spec.Refresh = time.Duration(l.Refresh).String()
}
// The config loader fills these in; only a value the operator chose
// is worth writing down.
if l.Timeout > 0 && l.Timeout != cfg.Server.Timeouts.List {
spec.Timeout = time.Duration(l.Timeout).String()
}
if spec.MaxBytes == 512<<20 {
spec.MaxBytes = 0
}
if _, err := kubesource.CheckList(l.Name, spec); err != nil {
r.skip("list %q: %v", l.Name, err)
continue
}
if name := kubeedit.ObjectName(l.Name); name != l.Name {
r.note("list %q: object name will be %q (not a legal Kubernetes name as written)", l.Name, name)
r.add("BlockList", name, in.Namespace, spec)
} else {
r.add("BlockList", l.Name, in.Namespace, spec)
}
r.note("list %q -> BlockList; remove it from blocklist.lists", l.Name)
}
}
for _, rules := range []struct {
action string
entries []string
}{{"block", cfg.Block.BlockEntries}, {"allow", cfg.Block.AllowEntries}} {
if len(rules.entries) == 0 {
continue
}
var ok []string
for _, e := range rules.entries {
e = strings.TrimSpace(e)
if e == "" || strings.HasPrefix(e, "#") {
continue
}
if _, err := kubesource.CheckRules(kubesource.DomainRuleSpec{Action: rules.action, Names: []string{e}}); err != nil {
r.skip("%s entry %q: %v", rules.action, e, err)
continue
}
ok = append(ok, e)
}
if len(ok) == 0 {
continue
}
r.add("DomainRule", "inline-"+rules.action, in.Namespace, kubesource.DomainRuleSpec{
Action: rules.action, Names: ok, Comment: "Migrated from blocklist." + rules.action + "_entries",
})
r.note("%d %s entries -> DomainRule/inline-%s; remove blocklist.%s_entries", len(ok), rules.action, rules.action, rules.action)
}
for i, g := range cfg.ACL.Groups {
spec := kubeedit.SpecFromGroup(g, int32(i+1)*10)
if _, err := kubesource.CheckGroup(g.Name, spec); err != nil {
r.skip("acl group %q: %v", g.Name, err)
continue
}
if name := g.Name; kubeedit.ObjectName(name) != name {
r.skip("acl group %q: not a legal Kubernetes object name; rename it", g.Name)
continue
}
r.add("ClientGroup", g.Name, in.Namespace, spec)
}
if len(cfg.ACL.Groups) > 0 {
r.note("%d acl groups -> ClientGroup; remove acl.groups from the config (default_policy stays)", len(cfg.ACL.Groups))
}
}
// YAML renders the objects as a multi-document manifest.
func YAML(objs []*unstructured.Unstructured) ([]byte, error) {
var b strings.Builder
for i, o := range objs {
out, err := yaml.Marshal(o.Object)
if err != nil {
return nil, err
}
if i > 0 {
b.WriteString("---\n")
}
b.Write(out)
}
return []byte(b.String()), nil
}
// toMap converts a spec struct to unstructured form. ToUnstructured wants a
// pointer to the concrete type.
func toMap(spec any) (map[string]any, error) {
v := reflect.New(reflect.TypeOf(spec))
v.Elem().Set(reflect.ValueOf(spec))
return runtime.DefaultUnstructuredConverter.ToUnstructured(v.Interface())
}
+168
View File
@@ -0,0 +1,168 @@
package migrate
import (
"context"
"strings"
"testing"
"time"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
dynfake "k8s.io/client-go/dynamic/fake"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubeedit"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
)
// The data the live deployment actually has.
const liveRPZ = `$TTL 60
tornberg.me A 10.10.3.10
knatofs.se A 10.10.3.10
k6n.net A 10.10.3.10
slask.local A 10.10.3.10
iamu.dev A 10.10.3.10
`
const homeZone = `$TTL 300
$ORIGIN home.example.com.
@ IN SOA ns1.home.example.com. hostmaster.home.example.com. 2024010101 3600 600 604800 300
@ IN NS ns1.home.example.com.
ns1 IN A 10.0.0.53
nas 60 IN A 10.0.0.20
mail IN MX 10 mx.home.example.com.
txt IN TXT "v=spf1 -all"
*.apps IN A 10.0.0.30
`
func liveConfig(t *testing.T) *config.Config {
t.Helper()
cfg := config.Default()
cfg.Block.Lists = []config.ListSource{
{Name: "edge-router-x-address-overrides", Format: "rpz", Path: "/etc/fancy-dns/rpz/address-overrides.rpz", Enabled: true},
{Name: "haas", Format: "plain", URL: "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
Path: "/var/lib/fancy-dns/lists/hosts", Enabled: true, Refresh: config.Duration(24 * time.Hour), Categories: []string{"ads", "tracking"}},
{Name: "local file", Format: "hosts", Path: "/etc/x/hosts", Enabled: true},
}
cfg.Block.BlockEntries = []string{"ads.example.com", "*.tracker.example", "bad name"}
cfg.Block.AllowEntries = []string{"good.example"}
cfg.ACL.Groups = []config.Group{
{Name: "kids", Policy: "block_only", Match: config.Match{CIDRs: []string{"10.0.20.0/24"}}},
{Name: "Bad Name", Policy: "allow", Match: config.Match{CIDRs: []string{"10.0.30.0/24"}}},
}
return cfg
}
func TestConvertEverythingTheDeploymentHolds(t *testing.T) {
res := Convert(Inputs{
ZoneFiles: map[string]string{"home.example.com": homeZone, "broken.example": "not a zone @@@ ("},
RPZ: liveRPZ + "bad.test CNAME x.example.\n",
Config: liveConfig(t),
Namespace: "dns",
})
kinds := map[string]int{}
for _, o := range res.Objects {
kinds[o.GetKind()]++
if o.GetNamespace() != "dns" || o.GetLabels()["app.kubernetes.io/managed-by"] != managedBy {
t.Errorf("%s/%s: namespace/labels = %q %v", o.GetKind(), o.GetName(), o.GetNamespace(), o.GetLabels())
}
}
want := map[string]int{"DNSZone": 1, "AddressOverride": 5, "BlockList": 1, "DomainRule": 2, "ClientGroup": 1}
for k, n := range want {
if kinds[k] != n {
t.Errorf("%s: %d objects, want %d (%v)", k, kinds[k], n, kinds)
}
}
skipped := strings.Join(res.Skipped, "\n")
for _, must := range []string{"broken.example", "bad.test", "local file", "bad name", "Bad Name"} {
if !strings.Contains(skipped, must) {
t.Errorf("%q should have been reported as skipped:\n%s", must, skipped)
}
}
notes := strings.Join(res.Notes, "\n")
for _, must := range []string{"edge-router-x-address-overrides", "haas", "blocklist.block_entries"} {
if !strings.Contains(notes, must) {
t.Errorf("notes should mention %q:\n%s", must, notes)
}
}
}
// TestConvertedObjectsAreWhatTheResolverAccepts applies the result to a cluster
// and reads it back through the resolver's own source: nothing converted may be
// rejected there, and the data must come out the same.
func TestConvertedObjectsAreWhatTheResolverAccepts(t *testing.T) {
res := Convert(Inputs{
ZoneFiles: map[string]string{"home.example.com": homeZone},
RPZ: liveRPZ,
Config: liveConfig(t),
Namespace: "dns",
})
client := dynfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(),
map[schema.GroupVersionResource]string{
kubesource.ZoneGVR: "DNSZoneList", kubesource.GroupGVR: "ClientGroupList", kubesource.ListGVR: "BlockListList",
kubesource.OverrideGVR: "AddressOverrideList", kubesource.RuleGVR: "DomainRuleList",
})
ed := kubeedit.New(client, "dns")
for _, o := range res.Objects {
if err := ed.Apply(context.Background(), o, "migrate"); err != nil {
t.Fatalf("apply %s/%s: %v", o.GetKind(), o.GetName(), err)
}
}
src, err := kubesource.New(kubesource.Options{Client: client, Namespace: "dns", Debounce: 10 * time.Millisecond})
if err != nil {
t.Fatal(err)
}
got := make(chan *kubesource.Snapshot, 4)
src.OnChange(func(s *kubesource.Snapshot) { got <- s })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() { _ = src.Run(ctx) }()
var sn *kubesource.Snapshot
select {
case sn = <-got:
case <-time.After(5 * time.Second):
t.Fatal("no snapshot")
}
if len(sn.Problems) != 0 {
t.Fatalf("the resolver rejected migrated objects: %+v", sn.Problems)
}
if len(sn.Overrides) != 5 || sn.Overrides[0].Domain != "iamu.dev" || sn.Overrides[0].Addresses[0] != "10.10.3.10" {
t.Errorf("overrides = %+v", sn.Overrides)
}
if len(sn.Groups) != 1 || sn.Groups[0].Name != "kids" || sn.Groups[0].Policy != "block_only" {
t.Errorf("groups = %+v", sn.Groups)
}
if len(sn.Lists) != 1 || sn.Lists[0].Name != "haas" || !sn.Lists[0].Enabled || sn.Lists[0].Path != "" {
t.Errorf("lists = %+v", sn.Lists)
}
if strings.Join(sn.BlockNames, ",") != "ads.example.com,*.tracker.example" || strings.Join(sn.AllowNames, ",") != "good.example" {
t.Errorf("rules = %v / %v", sn.BlockNames, sn.AllowNames)
}
text := sn.Zones["home.example.com."]
if text == "" {
t.Fatalf("zones = %v", sn.Zones)
}
for _, line := range []string{"ns1", "10.0.0.20", "mx.home.example.com.", "v=spf1 -all", "*.apps", "IN SOA"} {
if !strings.Contains(text, line) {
t.Errorf("rendered zone lacks %q:\n%s", line, text)
}
}
// nas had its own TTL (60); the rest inherit the zone's 300.
if !strings.Contains(text, "nas") || !strings.Contains(text, " 1m IN A ") && !strings.Contains(text, " 60 IN A ") {
t.Errorf("nas lost its TTL:\n%s", text)
}
}
func TestYAMLRoundTrips(t *testing.T) {
res := Convert(Inputs{RPZ: liveRPZ, Namespace: "dns"})
out, err := YAML(res.Objects)
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Count(s, "---") != 4 || !strings.Contains(s, "kind: AddressOverride") || !strings.Contains(s, "namespace: dns") {
t.Fatalf("YAML:\n%s", s)
}
}