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>
190 lines
5.7 KiB
Go
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
|
|
}
|