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 }