ns-controller: manage txt records
diff --git a/core/ns-controller/controllers/dnszone_controller.go b/core/ns-controller/controllers/dnszone_controller.go
index 40883ef..8b32ecf 100644
--- a/core/ns-controller/controllers/dnszone_controller.go
+++ b/core/ns-controller/controllers/dnszone_controller.go
@@ -22,6 +22,7 @@
 	"time"
 
 	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/runtime"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -71,7 +72,11 @@
 		Namespace: req.Namespace,
 		Name:      req.Name,
 	}, resource); err != nil {
-		return ctrl.Result{RequeueAfter: time.Minute}, err
+		if apierrors.IsGone(err) {
+			fmt.Printf("GONE %s %s\n", req.Name, req.Namespace)
+		} else {
+			return ctrl.Result{RequeueAfter: time.Minute}, err
+		}
 	}
 	if resource.Status.Ready {
 		return ctrl.Result{}, nil
@@ -113,10 +118,13 @@
 			DS:       ds,
 		}
 	}
-	_, err := r.Store.Create(zoneConfig)
+	zs, err := r.Store.Create(zoneConfig)
 	if err != nil {
 		return ctrl.Result{RequeueAfter: time.Minute}, err
 	}
+	if err := zs.CreateConfigFile(); err != nil {
+		return ctrl.Result{RequeueAfter: time.Minute}, err
+	}
 	resource.Status.Ready = true
 	if zoneConfig.DNSSec != nil {
 		resource.Status.RecordsToPublish = string(zoneConfig.DNSSec.DS)
diff --git a/core/ns-controller/controllers/store.go b/core/ns-controller/controllers/store.go
index 2b865ce..2e1e955 100644
--- a/core/ns-controller/controllers/store.go
+++ b/core/ns-controller/controllers/store.go
@@ -1,6 +1,7 @@
 package controllers
 
 import (
+	"encoding/json"
 	"fmt"
 	"io"
 	"io/fs"
@@ -12,44 +13,82 @@
 	"github.com/go-git/go-billy/v5/util"
 )
 
+const dodoConfigFilename = "dodo.json"
 const zoneConfigFilename = "coredns.conf"
 const rootConfigFilename = "coredns.conf"
 const importAllConfigFiles = "import */" + zoneConfigFilename
 
 type ZoneStore interface {
 	ConfigPath() string
+	CreateConfigFile() error
 	AddDNSSec(key DNSSecKey) error
+	AddTextRecord(entry, txt string) error
+	DeleteTextRecord(entry, txt string) error
 }
 
 type ZoneConfig struct {
-	Zone        string
-	PublicIPs   []string
-	PrivateIP   string
-	Nameservers []string
-	DNSSec      *DNSSecKey
+	Zone        string     `json:"zone,omitempty"`
+	PublicIPs   []string   `json:"publicIPs,omitempty"`
+	PrivateIP   string     `json:"privateIP",omitempty`
+	Nameservers []string   `json:"nameservers,omitempty"`
+	DNSSec      *DNSSecKey `json:"dnsSec,omitempty"`
 }
 
 type ZoneStoreFactory interface {
 	ConfigPath() string
 	Create(zone ZoneConfig) (ZoneStore, error)
+	Get(zone string) (ZoneStore, error)
 	Debug()
+	Purge()
 }
 
 type fsZoneStoreFactory struct {
-	fs billy.Filesystem
+	fs    billy.Filesystem
+	zones map[string]ZoneStore
 }
 
 func NewFSZoneStoreFactory(fs billy.Filesystem) (ZoneStoreFactory, error) {
 	if err := util.WriteFile(fs, rootConfigFilename, []byte(importAllConfigFiles), os.ModePerm); err != nil {
 		return nil, err
 	}
-	return &fsZoneStoreFactory{fs}, nil
+	f, err := fs.ReadDir(".")
+	if err != nil {
+		return nil, err
+	}
+	zf := fsZoneStoreFactory{fs: fs, zones: make(map[string]ZoneStore)}
+	for _, i := range f {
+		if i.IsDir() {
+			var zone ZoneConfig
+			r, err := fs.Open(fs.Join(i.Name(), dodoConfigFilename))
+			if err != nil {
+				continue // TODO(gio): clean up the dir to enforce config file
+			}
+			defer r.Close()
+			if err := json.NewDecoder(r).Decode(&zone); err != nil {
+				return nil, err
+			}
+			zfs, err := fs.Chroot(zone.Zone)
+			if err != nil {
+				return nil, err
+			}
+			z, err := NewFSZoneStore(zone, zfs)
+			zf.zones[zone.Zone] = z
+		}
+	}
+	return &zf, nil
 }
 
 func (f *fsZoneStoreFactory) ConfigPath() string {
 	return f.fs.Join(f.fs.Root(), rootConfigFilename)
 }
 
+func (f *fsZoneStoreFactory) Purge() {
+	items, _ := f.fs.ReadDir(".")
+	for _, i := range items {
+		f.fs.Remove(i.Name())
+	}
+}
+
 func (f *fsZoneStoreFactory) Debug() {
 	fmt.Println("------------")
 	util.Walk(f.fs, ".", func(path string, info fs.FileInfo, err error) error {
@@ -68,7 +107,17 @@
 	fmt.Println("++++++++++++++")
 }
 
+func (f *fsZoneStoreFactory) Get(zone string) (ZoneStore, error) {
+	if z, ok := f.zones[zone]; ok {
+		return z, nil
+	}
+	return nil, fmt.Errorf("%s zone not found", zone)
+}
+
 func (f *fsZoneStoreFactory) Create(zone ZoneConfig) (ZoneStore, error) {
+	if z, ok := f.zones[zone.Zone]; ok {
+		return z, nil
+	}
 	if err := f.fs.MkdirAll(zone.Zone, fs.ModePerm); err != nil {
 		return nil, err
 	}
@@ -84,6 +133,7 @@
 			}
 		}()
 	}
+	f.zones[zone.Zone] = z
 	return z, nil
 }
 
@@ -93,23 +143,41 @@
 }
 
 func NewFSZoneStore(zone ZoneConfig, fs billy.Filesystem) (ZoneStore, error) {
+	return &fsZoneStore{zone, fs}, nil
+}
+
+func (s *fsZoneStore) CreateConfigFile() error {
+	{
+		w, err := s.fs.Create(dodoConfigFilename)
+		if err != nil {
+			return err
+		}
+		defer w.Close()
+		if err := json.NewEncoder(w).Encode(s.zone); err != nil {
+			return err
+		}
+	}
+	zone := s.zone
+	fs := s.fs
 	if zone.DNSSec != nil {
 		sec := zone.DNSSec
 		if err := util.WriteFile(fs, sec.Basename+".key", sec.Key, 0644); err != nil {
-			return nil, err
+			return err
 		}
 		if err := util.WriteFile(fs, sec.Basename+".private", sec.Private, 0600); err != nil {
-			return nil, err
+			return err
 		}
 	}
 	conf, err := fs.Create(zoneConfigFilename)
 	if err != nil {
-		return nil, err
+		return err
 	}
 	defer conf.Close()
 	configTmpl, err := template.New("config").Funcs(sprig.TxtFuncMap()).Parse(`
 {{ .zone.Zone }}:53 {
-	file {{ .rootDir }}/zone.db
+	file {{ .rootDir }}/zone.db {
+      reload 1s
+    }
 	errors
     {{ if .zone.DNSSec }}
 	dnssec {
@@ -127,29 +195,30 @@
 	loadbalance
 }`)
 	if err != nil {
-		return nil, err
+		return err
 	}
 	if err := configTmpl.Execute(conf, map[string]any{
 		"zone":    zone,
 		"rootDir": fs.Root(),
 	}); err != nil {
-		return nil, err
+		return err
 	}
 	recordsTmpl, err := template.New("records").Funcs(sprig.TxtFuncMap()).Parse(`
-{{ .zone }}.   IN SOA ns1.{{ .zone }}. hostmaster.{{ .zone }}. 2015082541 7200 3600 1209600 3600
+{{ $zone := .zone }}
+{{ $zone }}.   IN SOA ns1.{{ $zone }}. hostmaster.{{ $zone }}. {{ .nowUnix }} 7200 3600 1209600 3600
 {{ range $i, $ns := .nameservers }}
-ns{{ add1 $i }} 10800 IN A {{ $ns }}
+ns{{ add1 $i }}.{{ $zone }}. 10800 IN A {{ $ns }}
 {{ end }}
 {{ range .publicIngressIPs }}
 @ 10800 IN A {{ . }}
 {{ end }}
-* 10800 IN CNAME {{ .zone }}.
-p 10800 IN CNAME {{ .zone }}.
-*.p 10800 IN A {{ .privateIngressIP }}
+*.{{ $zone }}. 10800 IN CNAME {{ $zone }}.
+p.{{ $zone }}. 10800 IN CNAME {{ $zone }}.
+*.p.{{ $zone }}. 10800 IN A {{ .privateIngressIP }}
 `)
 	records, err := fs.Create("zone.db")
 	if err != nil {
-		return nil, err
+		return err
 	}
 	defer records.Close()
 	if err := recordsTmpl.Execute(records, map[string]any{
@@ -157,10 +226,11 @@
 		"publicIngressIPs": zone.PublicIPs,
 		"privateIngressIP": zone.PrivateIP,
 		"nameservers":      zone.Nameservers,
+		"nowUnix":          NowUnix(),
 	}); err != nil {
-		return nil, err
+		return err
 	}
-	return &fsZoneStore{zone, fs}, nil
+	return nil
 }
 
 func (s *fsZoneStore) ConfigPath() string {
@@ -170,3 +240,51 @@
 func (s *fsZoneStore) AddDNSSec(key DNSSecKey) error {
 	return nil
 }
+
+func (s *fsZoneStore) AddTextRecord(entry, txt string) error {
+	s.fs.Remove("txt")
+	r, err := s.fs.Open("zone.db")
+	if err != nil {
+		return err
+	}
+	defer r.Close()
+	z, err := NewZoneFile(r)
+	if err != nil {
+		return err
+	}
+	z.CreateOrReplaceTxtRecord(fmt.Sprintf("%s.%s.", entry, s.zone.Zone), txt)
+	w, err := s.fs.Create("zone.db")
+	if err != nil {
+		return err
+	}
+	defer w.Close()
+	if err := z.Write(w); err != nil {
+		return err
+	}
+	// if _, err := r.Write([]byte(fmt.Sprintf("%s 300 IN TXT \"%s\"", entry, txt))); err != nil {
+	// 	return err
+	// }
+	return nil
+}
+
+func (s *fsZoneStore) DeleteTextRecord(entry, txt string) error {
+	r, err := s.fs.Open("zone.db")
+	if err != nil {
+		return err
+	}
+	defer r.Close()
+	z, err := NewZoneFile(r)
+	if err != nil {
+		return err
+	}
+	z.DeleteTxtRecord(fmt.Sprintf("%s.%s.", entry, s.zone.Zone), txt)
+	w, err := s.fs.Create("zone.db")
+	if err != nil {
+		return err
+	}
+	defer w.Close()
+	if err := z.Write(w); err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/core/ns-controller/controllers/suite_test.go b/core/ns-controller/controllers/suite_test.go
deleted file mode 100644
index 009da22..0000000
--- a/core/ns-controller/controllers/suite_test.go
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
-Copyright 2023.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package controllers
-
-import (
-	"path/filepath"
-	"testing"
-
-	. "github.com/onsi/ginkgo"
-	. "github.com/onsi/gomega"
-
-	"k8s.io/client-go/kubernetes/scheme"
-	"k8s.io/client-go/rest"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-	"sigs.k8s.io/controller-runtime/pkg/envtest"
-	"sigs.k8s.io/controller-runtime/pkg/envtest/printer"
-	logf "sigs.k8s.io/controller-runtime/pkg/log"
-	"sigs.k8s.io/controller-runtime/pkg/log/zap"
-
-	dodocloudv1 "github.com/giolekva/pcloud/core/ns-controller/api/v1"
-	//+kubebuilder:scaffold:imports
-)
-
-// These tests use Ginkgo (BDD-style Go testing framework). Refer to
-// http://onsi.github.io/ginkgo/ to learn more about Ginkgo.
-
-var cfg *rest.Config
-var k8sClient client.Client
-var testEnv *envtest.Environment
-
-func TestAPIs(t *testing.T) {
-	RegisterFailHandler(Fail)
-
-	RunSpecsWithDefaultAndCustomReporters(t,
-		"Controller Suite",
-		[]Reporter{printer.NewlineReporter{}})
-}
-
-var _ = BeforeSuite(func() {
-	logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
-
-	By("bootstrapping test environment")
-	testEnv = &envtest.Environment{
-		CRDDirectoryPaths:     []string{filepath.Join("..", "config", "crd", "bases")},
-		ErrorIfCRDPathMissing: true,
-	}
-
-	var err error
-	// cfg is defined in this file globally.
-	cfg, err = testEnv.Start()
-	Expect(err).NotTo(HaveOccurred())
-	Expect(cfg).NotTo(BeNil())
-
-	err = dodocloudv1.AddToScheme(scheme.Scheme)
-	Expect(err).NotTo(HaveOccurred())
-
-	//+kubebuilder:scaffold:scheme
-
-	k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
-	Expect(err).NotTo(HaveOccurred())
-	Expect(k8sClient).NotTo(BeNil())
-
-}, 60)
-
-var _ = AfterSuite(func() {
-	By("tearing down the test environment")
-	err := testEnv.Stop()
-	Expect(err).NotTo(HaveOccurred())
-})
diff --git a/core/ns-controller/controllers/zone.go b/core/ns-controller/controllers/zone.go
new file mode 100644
index 0000000..41fb98f
--- /dev/null
+++ b/core/ns-controller/controllers/zone.go
@@ -0,0 +1,81 @@
+package controllers
+
+import (
+	"fmt"
+	"io"
+	"strings"
+	"time"
+
+	"github.com/miekg/dns"
+)
+
+type ZoneFile struct {
+	rrs []dns.RR
+}
+
+func NewZoneFile(r io.Reader) (*ZoneFile, error) {
+	rrs := make([]dns.RR, 0)
+	p := dns.NewZoneParser(r, "", "")
+	p.SetIncludeAllowed(false)
+	for {
+		if rr, ok := p.Next(); ok {
+			rrs = append(rrs, rr)
+		} else {
+			if err := p.Err(); err != nil {
+				return nil, err
+			}
+			break
+		}
+	}
+	return &ZoneFile{rrs}, nil
+}
+
+func (z *ZoneFile) DeleteTxtRecord(name, value string) {
+	for i, rr := range z.rrs {
+		if txt, ok := rr.(*dns.TXT); ok {
+			if txt.Hdr.Name == name && strings.Join(txt.Txt, "") == value {
+				z.rrs = append(z.rrs[:i], z.rrs[i+1:]...)
+			}
+		}
+	}
+}
+
+func (z *ZoneFile) CreateOrReplaceTxtRecord(name, value string) {
+	for i, rr := range z.rrs {
+		if txt, ok := rr.(*dns.TXT); ok {
+			if txt.Hdr.Name == name && strings.Join(txt.Txt, "") == value {
+				txt.Txt = []string{value}
+				z.rrs = append(z.rrs[:i], z.rrs[i+1:]...)
+				z.rrs = append(z.rrs, txt)
+				return
+			}
+		}
+	}
+	z.rrs = append(z.rrs, &dns.TXT{
+		Hdr: dns.RR_Header{
+			Name:   name,
+			Rrtype: dns.TypeTXT,
+			Class:  dns.ClassINET,
+			Ttl:    300,
+		},
+		Txt: []string{value},
+	})
+}
+
+func (z *ZoneFile) Write(w io.Writer) error {
+	for _, rr := range z.rrs {
+		if soa, ok := rr.(*dns.SOA); ok {
+			soa.Serial = NowUnix()
+		}
+		if _, err := fmt.Fprintf(w, "%s\n", rr.String()); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// TODO(gio): not going to work in 15 years?
+// TODO(gio): remove 10 *
+func NowUnix() uint32 {
+	return 10 * uint32(time.Now().Unix())
+}
diff --git a/core/ns-controller/controllers/zone_test.go b/core/ns-controller/controllers/zone_test.go
new file mode 100644
index 0000000..e3a1da2
--- /dev/null
+++ b/core/ns-controller/controllers/zone_test.go
@@ -0,0 +1,31 @@
+package controllers
+
+import (
+	"strings"
+	"testing"
+
+	"os"
+)
+
+const sample = `
+example.com.   IN SOA ns1.example.com. hostmaster.example.com. 2015082541 7200 3600 1209600 3600
+ns1.example.com. 10800 IN A 10.1.0.1
+ns2.example.com. 10800 IN A 10.1.0.2
+@.example.com. 10800 IN A 10.1.0.1
+@.example.com. 10800 IN A 10.1.0.2
+*.example.com. 10800 IN CNAME example.com.
+p.example.com. 10800 IN CNAME example.com.
+*.p.example.com. 10800 IN A 10.0.0.1
+`
+
+func TestRead(t *testing.T) {
+	z, err := NewZoneFile(strings.NewReader(sample))
+	if err != nil {
+		t.Fatal(err)
+	}
+	z.CreateOrReplaceTxtRecord("foo.example.com.", "bar")
+	z.DeleteTxtRecord("foo.example.com.", "bar")
+	if err := z.Write(os.Stdout); err != nil {
+		t.Fatal(err)
+	}
+}