Files
matsandClaude Sonnet 5.5 2f25463347 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>
2026-10-16 10:12:45 +02:00

298 lines
9.5 KiB
Go

// 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())
}