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