Files
matsandClaude Sonnet 5.5 242bcd98d9 migrate: read the live address overrides from the zones ConfigMap, not the seed
The old console kept the zone files and the live address-overrides.rpz in the
same fancy-dns-zones ConfigMap; fancy-dns-rpz was only the static seed the
overrides started from. Reading the live cluster showed the two had diverged
(6 entries against 5, the missing one being stackship.k6n.net), so the default
would have silently dropped a redirect. The default is now fancy-dns-zones, and
the test puts a different stale seed in fancy-dns-rpz so reading the wrong one
fails.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:34:31 +02:00

189 lines
6.4 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubeedit"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/migrate"
)
var configMaps = schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}
// newClient builds the cluster client; a test replaces it.
var newClient = func(o kubesource.ClientOptions) (dynamic.Interface, error) {
c, _, err := kubesource.NewClient(o)
return c, err
}
// migrateMain converts an existing ConfigMap-and-YAML deployment into cluster
// objects. By default it only prints manifests, so the result can be reviewed
// or committed; -apply writes them. It changes nothing else.
func migrateMain(args []string, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
fs.SetOutput(stderr)
var (
ns = fs.String("kube-namespace", os.Getenv("FANCYDNS_KUBE_NAMESPACE"), "namespace of the ConfigMaps and of the new objects (default: the pod's own)")
kubeconfig = fs.String("kubeconfig", os.Getenv("FANCYDNS_KUBECONFIG"), "kubeconfig (default: in-cluster, then $KUBECONFIG, then ~/.kube/config)")
kubeContext = fs.String("kube-context", os.Getenv("FANCYDNS_KUBE_CONTEXT"), "kubeconfig context")
zonesCM = fs.String("zones-configmap", "fancy-dns-zones", "ConfigMap holding the zone--<apex>.zone files (\"\" to skip)")
rpzCM = fs.String("rpz-configmap", "fancy-dns-zones", "ConfigMap holding the address-overrides RPZ (\"\" to skip). The old console kept it in the same ConfigMap as the zone files; fancy-dns-rpz was only the seed it started from")
rpzKey = fs.String("rpz-key", "address-overrides.rpz", "key of the RPZ in that ConfigMap")
zonesDir = fs.String("zones-dir", "", "read zone files from this directory (<apex>.zone) instead of the ConfigMap")
rpzFile = fs.String("rpz-file", "", "read the address-overrides RPZ from this file instead of the ConfigMap")
cfgPath = fs.String("config", "", "the resolver's config.yaml, to convert its URL lists, inline entries and ACL groups")
apply = fs.Bool("apply", false, "create the objects in the cluster (default: print the manifests)")
)
if err := fs.Parse(args); err != nil {
return err
}
in := migrate.Inputs{Namespace: *ns, ZoneFiles: map[string]string{}}
if in.Namespace == "" {
in.Namespace = kubesource.Namespace()
}
if in.Namespace == "" {
return errors.New("-kube-namespace is required when not running in a pod")
}
needCluster := *apply || (*zonesDir == "" && *zonesCM != "") || (*rpzFile == "" && *rpzCM != "")
var client dynamic.Interface
if needCluster {
var err error
client, err = newClient(kubesource.ClientOptions{Kubeconfig: *kubeconfig, Context: *kubeContext})
if err != nil {
return err
}
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
var notes []string
if *zonesDir != "" {
files, err := readZoneDir(*zonesDir)
if err != nil {
return err
}
in.ZoneFiles = files
} else if *zonesCM != "" {
data, found, err := configMapData(ctx, client, in.Namespace, *zonesCM)
if err != nil {
return err
}
if !found {
notes = append(notes, fmt.Sprintf("ConfigMap %s/%s does not exist: no zone files to convert", in.Namespace, *zonesCM))
}
for k, v := range data {
if strings.HasPrefix(k, "zone--") && strings.HasSuffix(k, ".zone") {
in.ZoneFiles[strings.TrimSuffix(strings.TrimPrefix(k, "zone--"), ".zone")] = v
}
}
}
if *rpzFile != "" {
b, err := os.ReadFile(*rpzFile)
if err != nil {
return err
}
in.RPZ = string(b)
} else if *rpzCM != "" {
data, found, err := configMapData(ctx, client, in.Namespace, *rpzCM)
if err != nil {
return err
}
if !found {
notes = append(notes, fmt.Sprintf("ConfigMap %s/%s does not exist: no address overrides to convert", in.Namespace, *rpzCM))
}
in.RPZ = data[*rpzKey]
}
if *cfgPath != "" {
cfg, err := config.Load(*cfgPath)
if err != nil {
return fmt.Errorf("%w\n(the config expands ${VAR} references; set any variable it names, its value is not used)", err)
}
in.Config = cfg
}
res := migrate.Convert(in)
for _, n := range notes {
fmt.Fprintln(stderr, "note:", n)
}
for _, n := range res.Notes {
fmt.Fprintln(stderr, "converted:", n)
}
for _, s := range res.Skipped {
fmt.Fprintln(stderr, "NOT converted:", s)
}
if len(res.Objects) == 0 {
fmt.Fprintln(stderr, "nothing to migrate")
return nil
}
if !*apply {
out, err := migrate.YAML(res.Objects)
if err != nil {
return err
}
_, err = stdout.Write(out)
fmt.Fprintf(stderr, "\n%d objects printed. Review them, then run again with -apply, or `kubectl apply -f -`.\n", len(res.Objects))
return err
}
ed := kubeedit.New(client, in.Namespace)
for _, o := range res.Objects {
if err := ed.Apply(ctx, o, "fancy-dns-migrate"); err != nil {
return fmt.Errorf("apply %s/%s: %w", o.GetKind(), o.GetName(), err)
}
fmt.Fprintf(stderr, "applied %s/%s\n", o.GetKind(), o.GetName())
}
fmt.Fprintf(stderr, "\n%d objects applied in namespace %s. The old ConfigMaps were not touched.\n", len(res.Objects), in.Namespace)
return nil
}
func readZoneDir(dir string) (map[string]string, error) {
paths, err := filepath.Glob(filepath.Join(dir, "*.zone"))
if err != nil {
return nil, err
}
out := map[string]string{}
for _, p := range paths {
b, err := os.ReadFile(p)
if err != nil {
return nil, err
}
name := strings.TrimSuffix(filepath.Base(p), ".zone")
out[strings.TrimPrefix(name, "zone--")] = string(b)
}
return out, nil
}
// configMapData returns a ConfigMap's data, and whether the ConfigMap exists.
func configMapData(ctx context.Context, c dynamic.Interface, ns, name string) (map[string]string, bool, error) {
u, err := c.Resource(configMaps).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("read ConfigMap %s/%s: %w", ns, name, err)
}
raw, _ := u.Object["data"].(map[string]any)
out := make(map[string]string, len(raw))
for k, v := range raw {
if s, ok := v.(string); ok {
out[k] = s
}
}
return out, true, nil
}