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

190 lines
5.7 KiB
Go

// Package kubeedit is the write side of the Kubernetes integration: the
// console's edits become changes to ClientGroup, BlockList and DNSZone objects.
//
// It is the only code that writes to the cluster, and the runtime never
// imports it. Every spec is validated with the runtime's own converters
// (kubesource.Check*) before it is written, so the console cannot store an
// object the runtime would reject.
package kubeedit
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"sort"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/validation"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/util/retry"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
)
// ActorAnnotation records who last changed an object through the console.
const ActorAnnotation = "fancydns.k6n.net/last-edited-by"
// ErrNotFound is returned when the object or record to change does not exist.
var ErrNotFound = errors.New("not found")
// Editor writes fancy-dns objects into one namespace.
type Editor struct {
c dynamic.Interface
ns string
}
// New returns an Editor for namespace ns.
func New(c dynamic.Interface, ns string) *Editor { return &Editor{c: c, ns: ns} }
// Namespace is where objects are written.
func (e *Editor) Namespace() string { return e.ns }
func (e *Editor) res(gvr schema.GroupVersionResource) dynamic.ResourceInterface {
return e.c.Resource(gvr).Namespace(e.ns)
}
func decodeSpec(u *unstructured.Unstructured, into any) error {
raw, _ := u.Object["spec"].(map[string]any)
if raw == nil {
return nil
}
return runtime.DefaultUnstructuredConverter.FromUnstructured(raw, into)
}
func encodeSpec(spec any) (map[string]any, error) {
// ToUnstructured wants a pointer; the specs are passed by value.
v := reflect.New(reflect.TypeOf(spec))
v.Elem().Set(reflect.ValueOf(spec))
return runtime.DefaultUnstructuredConverter.ToUnstructured(v.Interface())
}
func sameSpec(a, b any) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
}
func checkName(kind, name string) error {
if errs := validation.IsDNS1123Subdomain(name); len(errs) > 0 {
return fmt.Errorf("%s name %q cannot be a Kubernetes object name (%s): use lowercase letters, digits, '-' and '.'", kind, name, errs[0])
}
return nil
}
func stamp(u *unstructured.Unstructured, actor string) {
ann := u.GetAnnotations()
if ann == nil {
ann = map[string]string{}
}
if actor != "" {
ann[ActorAnnotation] = actor
}
u.SetAnnotations(ann)
labels := u.GetLabels()
if labels == nil {
labels = map[string]string{}
}
labels["app.kubernetes.io/managed-by"] = "fancy-dns-console"
u.SetLabels(labels)
}
func newObject(kind, name string, spec any) (*unstructured.Unstructured, error) {
raw, err := encodeSpec(spec)
if err != nil {
return nil, err
}
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": kubesource.APIGroup + "/" + kubesource.Version,
"kind": kind,
"metadata": map[string]any{"name": name, "namespace": ""},
"spec": raw,
}}, nil
}
// upsert creates the object or replaces its spec, retrying on conflict.
func (e *Editor) upsert(ctx context.Context, gvr schema.GroupVersionResource, kind, name, actor string, spec any) (*unstructured.Unstructured, error) {
var out *unstructured.Unstructured
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
cur, err := e.res(gvr).Get(ctx, name, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
obj, err := newObject(kind, name, spec)
if err != nil {
return err
}
obj.SetNamespace(e.ns)
stamp(obj, actor)
out, err = e.res(gvr).Create(ctx, obj, metav1.CreateOptions{})
return err
}
if err != nil {
return err
}
raw, err := encodeSpec(spec)
if err != nil {
return err
}
cur.Object["spec"] = raw
stamp(cur, actor)
out, err = e.res(gvr).Update(ctx, cur, metav1.UpdateOptions{})
return err
})
return out, err
}
func (e *Editor) remove(ctx context.Context, gvr schema.GroupVersionResource, name string) error {
err := e.res(gvr).Delete(ctx, name, metav1.DeleteOptions{})
if apierrors.IsNotFound(err) {
return fmt.Errorf("%w: %s", ErrNotFound, name)
}
return err
}
func (e *Editor) list(ctx context.Context, gvr schema.GroupVersionResource) ([]unstructured.Unstructured, error) {
l, err := e.res(gvr).List(ctx, metav1.ListOptions{})
if err != nil {
return nil, err
}
sort.Slice(l.Items, func(i, j int) bool { return l.Items[i].GetName() < l.Items[j].GetName() })
return l.Items, nil
}
// upsertRaw is upsert for a spec that is already unstructured.
func (e *Editor) upsertRaw(ctx context.Context, gvr schema.GroupVersionResource, kind, name, actor string, spec map[string]any, labels map[string]string) (*unstructured.Unstructured, error) {
var out *unstructured.Unstructured
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
cur, err := e.res(gvr).Get(ctx, name, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
obj := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": kubesource.APIGroup + "/" + kubesource.Version,
"kind": kind,
"metadata": map[string]any{"name": name, "namespace": e.ns},
"spec": spec,
}}
stamp(obj, actor)
if len(labels) > 0 {
merged := obj.GetLabels()
for k, v := range labels {
merged[k] = v
}
obj.SetLabels(merged)
}
out, err = e.res(gvr).Create(ctx, obj, metav1.CreateOptions{})
return err
}
if err != nil {
return err
}
cur.Object["spec"] = spec
stamp(cur, actor)
out, err = e.res(gvr).Update(ctx, cur, metav1.UpdateOptions{})
return err
})
return out, err
}