Split the console from the resolver: fancy-dns-console writes the cluster
fancy-dns-console is a second binary holding the web UI, authentication and every edit. It writes ClientGroup, BlockList and DNSZone objects through the new kubeedit package and forwards all other /api routes to a running resolver with its own key, dropping the browser's credentials, so it can run in the cluster or on a workstation with a kubeconfig and nothing about the resolver's placement matters. The resolver never imports kubeedit and the console is not on the DNS path. kubeedit validates every spec with the runtime's own converters (kubesource.CheckGroup/CheckList/CheckZone) before writing, so the console cannot store an object the resolver would reject. Zone edits keep the file-based writer's behaviour: duplicate and below-a-delegation records are refused, a delegation in a new zone also gives the zone its servers, and a zone with no apex NS is never stored. Each object records the editing user in an annotation; updates retry on conflict. The resolver reports groups from ClientGroup objects as source "kubernetes" and counter-only rows (the "(default)" bucket, or a group removed since) as "history", and lists DNSZone zones as the editable ones. The console uses that to leave the YAML's groups alone and to drop history rows, which the UI would otherwise send back on save. Its GET /api/clients overlays the cluster's current objects on the resolver's list, because the resolver only sees an edit after its watch fires and a save made just after another would delete the first. An end-to-end test runs both halves against one fake apiserver: a group saved through the console refuses queries, a zone built record by record answers authoritatively, and a request with the resolver's own key is rejected by the console. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d38945d94
commit
1a536e7d0e
25 files changed
+1950
-8
No files matched your search
@@ -0,0 +1,155 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package kubeedit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/kubesource"
|
||||
)
|
||||
|
||||
func newEditor() *Editor {
|
||||
c := dynfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(),
|
||||
map[schema.GroupVersionResource]string{
|
||||
kubesource.ZoneGVR: "DNSZoneList",
|
||||
kubesource.GroupGVR: "ClientGroupList",
|
||||
kubesource.ListGVR: "BlockListList",
|
||||
})
|
||||
return New(c, "dns")
|
||||
}
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
func grp(name, policy string) config.Group {
|
||||
return config.Group{Name: name, Policy: policy, Match: config.Match{CIDRs: []string{"10.0.0.0/8"}}}
|
||||
}
|
||||
|
||||
func TestSyncGroupsCreatesUpdatesDeletesAndOrders(t *testing.T) {
|
||||
e := newEditor()
|
||||
res, err := e.SyncGroups(ctx, []config.Group{grp("a", "allow"), grp("b", "deny")}, nil, "alice")
|
||||
if err != nil || len(res.Created) != 2 {
|
||||
t.Fatalf("create: %+v %v", res, err)
|
||||
}
|
||||
// Nothing changed: nothing written.
|
||||
res, err = e.SyncGroups(ctx, []config.Group{grp("a", "allow"), grp("b", "deny")}, nil, "alice")
|
||||
if err != nil || len(res.Created)+len(res.Updated)+len(res.Deleted) != 0 {
|
||||
t.Fatalf("idempotent: %+v %v", res, err)
|
||||
}
|
||||
// Reorder, change one, drop none.
|
||||
res, err = e.SyncGroups(ctx, []config.Group{grp("b", "deny"), grp("a", "block_only")}, nil, "alice")
|
||||
if err != nil || len(res.Updated) != 2 {
|
||||
t.Fatalf("reorder: %+v %v", res, err)
|
||||
}
|
||||
got, _ := e.Groups(ctx)
|
||||
if got[0].Name != "b" || got[1].Name != "a" || got[1].Spec.Policy != "block_only" {
|
||||
t.Fatalf("groups = %+v", got)
|
||||
}
|
||||
res, err = e.SyncGroups(ctx, []config.Group{grp("b", "deny")}, nil, "alice")
|
||||
if err != nil || len(res.Deleted) != 1 || res.Deleted[0] != "a" {
|
||||
t.Fatalf("delete: %+v %v", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncGroupsLeavesForeignGroupsAlone(t *testing.T) {
|
||||
e := newEditor()
|
||||
yaml := func(n string) bool { return n == "from-yaml" }
|
||||
res, err := e.SyncGroups(ctx, []config.Group{grp("from-yaml", "allow"), grp("mine", "deny")}, yaml, "alice")
|
||||
if err != nil || len(res.Created) != 1 || res.Created[0] != "mine" {
|
||||
t.Fatalf("%+v %v", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncGroupsValidatesBeforeWriting(t *testing.T) {
|
||||
e := newEditor()
|
||||
_, err := e.SyncGroups(ctx, []config.Group{grp("ok", "allow"), grp("bad", "maybe")}, nil, "alice")
|
||||
if err == nil || !strings.Contains(err.Error(), "bad") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if got, _ := e.Groups(ctx); len(got) != 0 {
|
||||
t.Fatalf("a rejected save wrote %+v", got)
|
||||
}
|
||||
if _, err := e.SyncGroups(ctx, []config.Group{grp("Kids Room", "allow")}, nil, "alice"); err == nil {
|
||||
t.Fatal("a name that is not a Kubernetes object name must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActorIsRecorded(t *testing.T) {
|
||||
e := newEditor()
|
||||
if _, err := e.SyncGroups(ctx, []config.Group{grp("a", "allow")}, nil, "alice@example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := e.res(kubesource.GroupGVR).Get(ctx, "a", getOpts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.GetAnnotations()[ActorAnnotation] != "alice@example.com" {
|
||||
t.Fatalf("annotations = %v", u.GetAnnotations())
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRecordLifecycle(t *testing.T) {
|
||||
e := newEditor()
|
||||
const apex = "home.example.com."
|
||||
|
||||
// A zone starts with its servers: without an NS the runtime cannot answer
|
||||
// NXDOMAIN and the editor says so rather than storing a dead zone.
|
||||
if _, err := e.AddRecord(ctx, apex, RecordInput{Name: "nas", Type: "A", Value: "10.0.0.2"}, "a"); err == nil {
|
||||
t.Fatal("a zone with no NS must be refused")
|
||||
}
|
||||
if zs, _ := e.Zones(ctx); len(zs) != 0 {
|
||||
t.Fatal("the refused zone was stored")
|
||||
}
|
||||
|
||||
// An apex NS on its own is a valid zone.
|
||||
if _, err := e.AddRecord(ctx, apex, RecordInput{Name: "@", Type: "NS", Value: "ns1.home.example.com."}, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := e.AddRecord(ctx, apex, RecordInput{Name: "nas", Type: "A", TTL: 60, Value: "10.0.0.2"}, "a")
|
||||
if err != nil || res.Serial < 1 {
|
||||
t.Fatalf("add: %+v %v", res, err)
|
||||
}
|
||||
// Names are stored the way an operator writes them.
|
||||
zs, _ := e.Zones(ctx)
|
||||
if len(zs) != 1 || zs[0].Name != "home.example.com" {
|
||||
t.Fatalf("zones = %+v", zs)
|
||||
}
|
||||
var names []string
|
||||
for _, r := range zs[0].Spec.Records {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
if strings.Join(names, ",") != "@,nas" {
|
||||
t.Fatalf("stored names = %v", names)
|
||||
}
|
||||
|
||||
// The same record, spelled differently, is a duplicate.
|
||||
if _, err := e.AddRecord(ctx, apex, RecordInput{Name: "nas.home.example.com.", Type: "A", Value: "10.0.0.2"}, "a"); err == nil {
|
||||
t.Fatal("duplicate accepted")
|
||||
}
|
||||
// Put replaces the RRset.
|
||||
if _, err := e.PutRecord(ctx, apex, RecordInput{Name: "nas", Type: "A", Value: "10.0.0.9"}, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zs, _ = e.Zones(ctx)
|
||||
if n := countType(zs[0].Spec, "A"); n != 1 || zs[0].Spec.Records[len(zs[0].Spec.Records)-1].Value != "10.0.0.9" {
|
||||
t.Fatalf("after put = %+v", zs[0].Spec.Records)
|
||||
}
|
||||
// Delete one record, then one that is not there.
|
||||
if _, err := e.DeleteRecord(ctx, apex, "nas", "A", "10.0.0.9", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.DeleteRecord(ctx, apex, "nas", "A", "", "a"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err = %v, want not found", err)
|
||||
}
|
||||
// An edit that would leave the zone invalid is refused and changes nothing.
|
||||
if _, err := e.DeleteRecord(ctx, apex, "@", "NS", "", "a"); err == nil {
|
||||
t.Fatal("removing the last apex NS must be refused")
|
||||
}
|
||||
if zs, _ = e.Zones(ctx); countType(zs[0].Spec, "NS") != 1 {
|
||||
t.Fatal("the refused delete changed the zone")
|
||||
}
|
||||
}
|
||||
|
||||
func countType(s kubesource.DNSZoneSpec, typ string) int {
|
||||
n := 0
|
||||
for _, r := range s.Records {
|
||||
if r.Type == typ {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestDelegationsAndZoneDelete(t *testing.T) {
|
||||
e := newEditor()
|
||||
const apex = "lab.example.com."
|
||||
// A delegation in a brand new zone also gives the zone its own servers.
|
||||
if _, err := e.SetDelegation(ctx, apex, "sub", []string{"ns.sub.lab.example.com.", "ns2.sub.lab.example.com."}, 300, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zs, _ := e.Zones(ctx)
|
||||
if got := countType(zs[0].Spec, "NS"); got != 4 {
|
||||
t.Fatalf("NS records = %d, want 2 for the cut and 2 for the apex: %+v", got, zs[0].Spec.Records)
|
||||
}
|
||||
// Data under the cut is refused, as in the file-based writer.
|
||||
if _, err := e.AddRecord(ctx, apex, RecordInput{Name: "x.sub", Type: "A", Value: "10.0.0.1"}, "a"); err == nil {
|
||||
t.Fatal("a record below a delegation was accepted")
|
||||
}
|
||||
if _, err := e.RemoveDelegation(ctx, apex, "sub", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.RemoveDelegation(ctx, apex, "sub", "a"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if err := e.DeleteZone(ctx, apex); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.DeleteZone(ctx, apex); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEditing(t *testing.T) {
|
||||
e := newEditor()
|
||||
if err := e.PutList(ctx, "ads", kubesource.BlockListSpec{URL: "https://e.com/hosts", Format: "hosts"}, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.PutList(ctx, "evil", kubesource.BlockListSpec{URL: "file:///etc/passwd"}, "a"); err == nil {
|
||||
t.Fatal("file:// accepted")
|
||||
}
|
||||
if err := e.SetListDisabled(ctx, "ads", true, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ls, _ := e.Lists(ctx)
|
||||
if len(ls) != 1 || !ls[0].Spec.Disabled || ls[0].Spec.Format != "hosts" {
|
||||
t.Fatalf("lists = %+v", ls)
|
||||
}
|
||||
if err := e.SetListDisabled(ctx, "nope", true, "a"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if err := e.DeleteList(ctx, "ads"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package kubeedit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
|
||||
)
|
||||
|
||||
// GroupObject is a stored ClientGroup.
|
||||
type GroupObject struct {
|
||||
Name string
|
||||
Spec kubesource.ClientGroupSpec
|
||||
}
|
||||
|
||||
// Groups lists the ClientGroup objects in evaluation order.
|
||||
func (e *Editor) Groups(ctx context.Context) ([]GroupObject, error) {
|
||||
items, err := e.list(ctx, kubesource.GroupGVR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]GroupObject, 0, len(items))
|
||||
for i := range items {
|
||||
var s kubesource.ClientGroupSpec
|
||||
if err := decodeSpec(&items[i], &s); err != nil {
|
||||
continue // the runtime reports an undecodable object; the console skips it
|
||||
}
|
||||
out = append(out, GroupObject{Name: items[i].GetName(), Spec: s})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Spec.Priority != out[j].Spec.Priority {
|
||||
return out[i].Spec.Priority < out[j].Spec.Priority
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SpecFromGroup converts the console's group to an object spec.
|
||||
func SpecFromGroup(g config.Group, priority int32) kubesource.ClientGroupSpec {
|
||||
return kubesource.ClientGroupSpec{
|
||||
Priority: priority,
|
||||
Comment: g.Comment,
|
||||
Match: g.Match,
|
||||
Policy: g.Policy,
|
||||
AllowDomains: g.AllowDomains,
|
||||
DenyDomains: g.DenyDomains,
|
||||
SkipBlocklist: g.SkipBlocklist,
|
||||
SkipCache: g.SkipCache,
|
||||
RateLimit: g.RateLimit,
|
||||
MaxConcurrent: g.MaxConcurrent,
|
||||
LogQueries: g.LogQueries,
|
||||
Upstreams: g.Upstreams,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncResult says what SyncGroups changed.
|
||||
type SyncResult struct {
|
||||
Created, Updated, Deleted []string
|
||||
}
|
||||
|
||||
// SyncGroups makes the stored ClientGroups match the console's list.
|
||||
//
|
||||
// desired is the full list the console sent, in the order the operator put it.
|
||||
// Groups for which foreign returns true belong to something else (the runtime's
|
||||
// YAML) and are neither written nor deleted. Every group is validated before
|
||||
// anything is written, so a bad group changes nothing. Priorities follow the
|
||||
// list position, so reordering in the console reorders evaluation.
|
||||
func (e *Editor) SyncGroups(ctx context.Context, desired []config.Group, foreign func(string) bool, actor string) (SyncResult, error) {
|
||||
var res SyncResult
|
||||
existing, err := e.Groups(ctx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
have := make(map[string]GroupObject, len(existing))
|
||||
for _, g := range existing {
|
||||
have[g.Name] = g
|
||||
}
|
||||
|
||||
type write struct {
|
||||
name string
|
||||
spec kubesource.ClientGroupSpec
|
||||
new bool
|
||||
}
|
||||
var writes []write
|
||||
keep := map[string]bool{}
|
||||
for i, g := range desired {
|
||||
if foreign != nil && foreign(g.Name) {
|
||||
if _, ours := have[g.Name]; !ours {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := checkName("group", g.Name); err != nil {
|
||||
return res, err
|
||||
}
|
||||
spec := SpecFromGroup(g, int32(i+1)*10)
|
||||
if _, err := kubesource.CheckGroup(g.Name, spec); err != nil {
|
||||
return res, fmt.Errorf("group %q: %w", g.Name, err)
|
||||
}
|
||||
keep[g.Name] = true
|
||||
cur, ok := have[g.Name]
|
||||
switch {
|
||||
case !ok:
|
||||
writes = append(writes, write{g.Name, spec, true})
|
||||
case !sameSpec(cur.Spec, spec):
|
||||
writes = append(writes, write{g.Name, spec, false})
|
||||
}
|
||||
}
|
||||
for _, w := range writes {
|
||||
if _, err := e.upsert(ctx, kubesource.GroupGVR, "ClientGroup", w.name, actor, w.spec); err != nil {
|
||||
return res, fmt.Errorf("write group %q: %w", w.name, err)
|
||||
}
|
||||
if w.new {
|
||||
res.Created = append(res.Created, w.name)
|
||||
} else {
|
||||
res.Updated = append(res.Updated, w.name)
|
||||
}
|
||||
}
|
||||
for _, g := range existing {
|
||||
if keep[g.Name] {
|
||||
continue
|
||||
}
|
||||
if err := e.remove(ctx, kubesource.GroupGVR, g.Name); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Deleted = append(res.Deleted, g.Name)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package kubeedit
|
||||
|
||||
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
var getOpts = metav1.GetOptions{}
|
||||
@@ -0,0 +1,63 @@
|
||||
package kubeedit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
|
||||
)
|
||||
|
||||
// ListObject is a stored BlockList.
|
||||
type ListObject struct {
|
||||
Name string
|
||||
Spec kubesource.BlockListSpec
|
||||
}
|
||||
|
||||
// Lists returns the BlockList objects.
|
||||
func (e *Editor) Lists(ctx context.Context) ([]ListObject, error) {
|
||||
items, err := e.list(ctx, kubesource.ListGVR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ListObject, 0, len(items))
|
||||
for i := range items {
|
||||
var s kubesource.BlockListSpec
|
||||
if err := decodeSpec(&items[i], &s); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, ListObject{Name: items[i].GetName(), Spec: s})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PutList creates or replaces a BlockList.
|
||||
func (e *Editor) PutList(ctx context.Context, name string, spec kubesource.BlockListSpec, actor string) error {
|
||||
if err := checkName("blocklist", name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := kubesource.CheckList(name, spec); err != nil {
|
||||
return fmt.Errorf("blocklist %q: %w", name, err)
|
||||
}
|
||||
_, err := e.upsert(ctx, kubesource.ListGVR, "BlockList", name, actor, spec)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetListDisabled flips spec.disabled on one BlockList.
|
||||
func (e *Editor) SetListDisabled(ctx context.Context, name string, disabled bool, actor string) error {
|
||||
items, err := e.Lists(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range items {
|
||||
if l.Name == name {
|
||||
l.Spec.Disabled = disabled
|
||||
return e.PutList(ctx, name, l.Spec, actor)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: no BlockList named %q", ErrNotFound, name)
|
||||
}
|
||||
|
||||
// DeleteList removes a BlockList.
|
||||
func (e *Editor) DeleteList(ctx context.Context, name string) error {
|
||||
return e.remove(ctx, kubesource.ListGVR, name)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package kubeedit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/util/retry"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
|
||||
)
|
||||
|
||||
// ZoneObject is a stored DNSZone.
|
||||
type ZoneObject struct {
|
||||
Name string
|
||||
Apex string // canonical, fully qualified
|
||||
Spec kubesource.DNSZoneSpec
|
||||
}
|
||||
|
||||
// Zones lists the DNSZone objects.
|
||||
func (e *Editor) Zones(ctx context.Context) ([]ZoneObject, error) {
|
||||
items, err := e.list(ctx, kubesource.ZoneGVR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ZoneObject, 0, len(items))
|
||||
for i := range items {
|
||||
z, ok := zoneOf(&items[i])
|
||||
if ok {
|
||||
out = append(out, z)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func zoneOf(u *unstructured.Unstructured) (ZoneObject, bool) {
|
||||
var s kubesource.DNSZoneSpec
|
||||
if err := decodeSpec(u, &s); err != nil {
|
||||
return ZoneObject{}, false
|
||||
}
|
||||
apex, err := localzone.NormalizeOrigin(s.Zone)
|
||||
if err != nil {
|
||||
return ZoneObject{}, false
|
||||
}
|
||||
return ZoneObject{Name: u.GetName(), Apex: apex, Spec: s}, true
|
||||
}
|
||||
|
||||
// WriteResult is what a zone edit reports.
|
||||
type WriteResult struct {
|
||||
Zone string
|
||||
// Serial is the SOA serial the runtime will serve: the object's
|
||||
// generation after this write.
|
||||
Serial uint32
|
||||
}
|
||||
|
||||
// editZone applies fn to the zone's spec and writes it back, retrying on a
|
||||
// conflicting concurrent edit. A zone that does not exist yet is created when
|
||||
// create is set. The result is rendered with the runtime's own code before it
|
||||
// is stored, so a spec the runtime would reject is never written.
|
||||
func (e *Editor) editZone(ctx context.Context, apex string, create bool, actor string, fn func(spec *kubesource.DNSZoneSpec) error) (WriteResult, error) {
|
||||
apex, err := localzone.NormalizeOrigin(apex)
|
||||
if err != nil {
|
||||
return WriteResult{}, err
|
||||
}
|
||||
var res WriteResult
|
||||
err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
||||
items, err := e.list(ctx, kubesource.ZoneGVR)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cur *unstructured.Unstructured
|
||||
var zo ZoneObject
|
||||
for i := range items {
|
||||
if z, ok := zoneOf(&items[i]); ok && z.Apex == apex {
|
||||
cur, zo = &items[i], z
|
||||
break
|
||||
}
|
||||
}
|
||||
spec := zo.Spec
|
||||
if cur == nil {
|
||||
if !create {
|
||||
return fmt.Errorf("%w: no DNSZone for %s", ErrNotFound, apex)
|
||||
}
|
||||
spec = kubesource.DNSZoneSpec{Zone: strings.TrimSuffix(apex, ".")}
|
||||
}
|
||||
spec.Records = append([]kubesource.ZoneRecord(nil), spec.Records...)
|
||||
if err := fn(&spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := kubesource.CheckZone(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
var out *unstructured.Unstructured
|
||||
if cur == nil {
|
||||
name := strings.TrimSuffix(apex, ".")
|
||||
if err := checkName("zone", name); err != nil {
|
||||
return err
|
||||
}
|
||||
obj, err := newObject("DNSZone", name, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj.SetNamespace(e.ns)
|
||||
stamp(obj, actor)
|
||||
out, err = e.res(kubesource.ZoneGVR).Create(ctx, obj, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
raw, err := encodeSpec(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur.Object["spec"] = raw
|
||||
stamp(cur, actor)
|
||||
out, err = e.res(kubesource.ZoneGVR).Update(ctx, cur, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
res = WriteResult{Zone: apex, Serial: uint32(max(out.GetGeneration(), 1))}
|
||||
return nil
|
||||
})
|
||||
return res, err
|
||||
}
|
||||
|
||||
// RecordInput is one record as the console sends it.
|
||||
type RecordInput struct {
|
||||
Name string
|
||||
Type string
|
||||
TTL uint32
|
||||
Value string
|
||||
}
|
||||
|
||||
func canon(apex string, in RecordInput) (localzone.Record, error) {
|
||||
name := in.Name
|
||||
if name == "@" {
|
||||
name = apex
|
||||
}
|
||||
return localzone.CanonicalRecord(apex, localzone.Record{Name: name, Type: in.Type, TTL: in.TTL, Value: in.Value})
|
||||
}
|
||||
|
||||
// relative stores a name the way an operator would write it.
|
||||
func relative(apex, name string) string {
|
||||
if name == apex {
|
||||
return "@"
|
||||
}
|
||||
return strings.TrimSuffix(name, "."+apex)
|
||||
}
|
||||
|
||||
func stored(apex string, r localzone.Record) kubesource.ZoneRecord {
|
||||
return kubesource.ZoneRecord{Name: relative(apex, r.Name), Type: r.Type, TTL: r.TTL, Value: r.Value}
|
||||
}
|
||||
|
||||
// existing canonicalises the spec's records for comparison. A record that does
|
||||
// not canonicalise is kept as it is, under a key that matches nothing.
|
||||
func existing(apex string, spec *kubesource.DNSZoneSpec) []localzone.Record {
|
||||
out := make([]localzone.Record, len(spec.Records))
|
||||
for i, r := range spec.Records {
|
||||
c, err := canon(apex, RecordInput{Name: r.Name, Type: r.Type, TTL: r.TTL, Value: r.Value})
|
||||
if err != nil {
|
||||
c = localzone.Record{Name: "\x00invalid", Type: r.Type, Value: r.Value}
|
||||
}
|
||||
out[i] = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AddRecord adds one record, creating the zone when it does not exist.
|
||||
func (e *Editor) AddRecord(ctx context.Context, apex string, in RecordInput, actor string) (WriteResult, error) {
|
||||
return e.editZone(ctx, apex, true, actor, func(spec *kubesource.DNSZoneSpec) error {
|
||||
a, _ := localzone.NormalizeOrigin(apex)
|
||||
rec, err := canon(a, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, have := range existing(a, spec) {
|
||||
if have.Key() == rec.Key() && have.Value == rec.Value {
|
||||
return fmt.Errorf("%s %s already exists in %s", rec.Name, rec.Type, a)
|
||||
}
|
||||
}
|
||||
spec.Records = append(spec.Records, stored(a, rec))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// PutRecord replaces the whole RRset of one name and type.
|
||||
func (e *Editor) PutRecord(ctx context.Context, apex string, in RecordInput, actor string) (WriteResult, error) {
|
||||
return e.editZone(ctx, apex, true, actor, func(spec *kubesource.DNSZoneSpec) error {
|
||||
a, _ := localzone.NormalizeOrigin(apex)
|
||||
rec, err := canon(a, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec.Records = dropWhere(a, spec, func(have localzone.Record) bool { return have.Key() == rec.Key() })
|
||||
spec.Records = append(spec.Records, stored(a, rec))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRecord removes one record, or the whole RRset when value is empty.
|
||||
func (e *Editor) DeleteRecord(ctx context.Context, apex, name, rrtype, value, actor string) (WriteResult, error) {
|
||||
return e.editZone(ctx, apex, false, actor, func(spec *kubesource.DNSZoneSpec) error {
|
||||
a, _ := localzone.NormalizeOrigin(apex)
|
||||
var probe localzone.Record
|
||||
var want string
|
||||
if value != "" {
|
||||
full, err := canon(a, RecordInput{Name: name, Type: rrtype, Value: value})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
probe, want = full, full.Value
|
||||
} else {
|
||||
var err error
|
||||
if probe, err = canonProbe(a, name, rrtype); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
before := len(spec.Records)
|
||||
spec.Records = dropWhere(a, spec, func(have localzone.Record) bool {
|
||||
return have.Key() == probe.Key() && (want == "" || have.Value == want)
|
||||
})
|
||||
if len(spec.Records) == before {
|
||||
return fmt.Errorf("%w: no record matches %s %s in %s", ErrNotFound, name, rrtype, a)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// canonProbe canonicalises only the owner and type of a record, for matching.
|
||||
func canonProbe(apex, name, rrtype string) (localzone.Record, error) {
|
||||
for _, v := range []string{"10.0.0.1", "ns.example.", "x", "0 0 0 x", "10 ns.example.", "0 issue \"x\""} {
|
||||
if r, err := canon(apex, RecordInput{Name: name, Type: rrtype, Value: v}); err == nil {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
return localzone.Record{}, fmt.Errorf("cannot interpret record %s %s", name, rrtype)
|
||||
}
|
||||
|
||||
func dropWhere(apex string, spec *kubesource.DNSZoneSpec, match func(localzone.Record) bool) []kubesource.ZoneRecord {
|
||||
canonical := existing(apex, spec)
|
||||
out := make([]kubesource.ZoneRecord, 0, len(spec.Records))
|
||||
for i, r := range spec.Records {
|
||||
if !match(canonical[i]) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetDelegation hands a name below the apex to other servers: it replaces the
|
||||
// NS set at that name. In a zone that does not exist yet the targets also
|
||||
// become the zone's own NS set, as the file-based writer does.
|
||||
func (e *Editor) SetDelegation(ctx context.Context, apex, name string, targets []string, ttl uint32, actor string) (WriteResult, error) {
|
||||
return e.editZone(ctx, apex, true, actor, func(spec *kubesource.DNSZoneSpec) error {
|
||||
a, _ := localzone.NormalizeOrigin(apex)
|
||||
if len(targets) == 0 {
|
||||
return errors.New("a delegation needs at least one NS target")
|
||||
}
|
||||
fresh := len(spec.Records) == 0
|
||||
cut, err := canon(a, RecordInput{Name: name, Type: "NS", TTL: ttl, Value: targets[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cut.Name == a {
|
||||
return errors.New("the apex NS set is the zone's own; a delegation is a name below it")
|
||||
}
|
||||
spec.Records = dropWhere(a, spec, func(have localzone.Record) bool { return have.Key() == cut.Key() })
|
||||
for _, t := range targets {
|
||||
r, err := canon(a, RecordInput{Name: name, Type: "NS", TTL: ttl, Value: strings.TrimSpace(t)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec.Records = append(spec.Records, stored(a, r))
|
||||
}
|
||||
if fresh {
|
||||
for _, t := range targets {
|
||||
r, err := canon(a, RecordInput{Name: "@", Type: "NS", TTL: ttl, Value: strings.TrimSpace(t)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec.Records = append(spec.Records, stored(a, r))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveDelegation removes the cut at name.
|
||||
func (e *Editor) RemoveDelegation(ctx context.Context, apex, name, actor string) (WriteResult, error) {
|
||||
return e.editZone(ctx, apex, false, actor, func(spec *kubesource.DNSZoneSpec) error {
|
||||
a, _ := localzone.NormalizeOrigin(apex)
|
||||
cut, err := canon(a, RecordInput{Name: name, Type: "NS", Value: "ns.example."})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cut.Name == a {
|
||||
return errors.New("the apex NS set is the zone's own, not a delegation")
|
||||
}
|
||||
before := len(spec.Records)
|
||||
spec.Records = dropWhere(a, spec, func(have localzone.Record) bool { return have.Key() == cut.Key() })
|
||||
if len(spec.Records) == before {
|
||||
return fmt.Errorf("%w: no delegation for %s in %s", ErrNotFound, name, a)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteZone removes the DNSZone object, which hands the names back to
|
||||
// whatever answered for them before.
|
||||
func (e *Editor) DeleteZone(ctx context.Context, apex string) error {
|
||||
apex, err := localzone.NormalizeOrigin(apex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zones, err := e.Zones(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, z := range zones {
|
||||
if z.Apex == apex {
|
||||
return e.remove(ctx, kubesource.ZoneGVR, z.Name)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: no DNSZone for %s", ErrNotFound, apex)
|
||||
}
|
||||
|
||||
// ZoneApexes is the set of apexes held by DNSZone objects.
|
||||
func (e *Editor) ZoneApexes(ctx context.Context) ([]string, error) {
|
||||
zones, err := e.Zones(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, len(zones))
|
||||
for i, z := range zones {
|
||||
out[i] = z.Apex
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
Reference in new issue
Block a user