Files
fancy-dns/internal/kubesource/client.go
T
matsandClaude Sonnet 5.5 4d38945d94 Add a read-only Kubernetes source for zones, client groups and blocklists
The runtime can now take ClientGroup, BlockList and DNSZone objects
(fancydns.k6n.net/v1alpha1) from a cluster with -kube, from a pod's service
account or a kubeconfig alike. internal/kubesource watches them with dynamic
informers, converts and validates each object on its own, and publishes an
immutable snapshot. A bad object is skipped and keeps its last good version,
so one broken edit cannot remove a running policy; an invalid merged config is
dropped whole. The last snapshot is saved so a restart without apiserver access
still serves.

config.Store gains an overlay: Base() is what the YAML says, Get() is base plus
cluster objects. Everything that writes the config back (mutateConfig,
SetAdminAddr, the settings editor) now starts from Base(), otherwise cluster
objects would be copied into the file and shadow their own objects. YAML wins
every name collision. putACL and toggleBlocklist refuse to edit cluster-owned
objects. BlockList has no path field and refuses file:// URLs so a namespaced
object cannot pick files the runtime reads or writes. DNSZone is rendered
through the zone writer's own prove-by-reparse step, with the SOA serial taken
from metadata.generation so every replica renders identical bytes.

qlog: Flush reset the pending slice to [:0], so entries logged while it was
still storing the batch were appended over the same backing array. The race
detector found it through the new Kubernetes integration test. pending is now
set to nil, with a regression test that fails without the change.

CRD manifests, RBAC and examples are in deploy/k8s/crds; tests pin the schemas
to the Go spec structs so the two cannot drift.

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

67 lines
2.0 KiB
Go

package kubesource
import (
"fmt"
"os"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
// ClientOptions say where the cluster is.
type ClientOptions struct {
// Kubeconfig is a kubeconfig path. Empty means: the in-cluster service
// account if there is one, else the standard loading rules ($KUBECONFIG,
// then ~/.kube/config). The same binary therefore runs in a pod and on a
// workstation with no flag at all.
Kubeconfig string
// Context selects a kubeconfig context; empty is the file's current one.
Context string
}
// Namespace returns the namespace to watch when none was configured: the
// pod's own when running in-cluster, else "" (every namespace the credentials
// can list).
func Namespace() string {
b, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace")
if err != nil {
return ""
}
return string(b)
}
// NewClient builds a dynamic client and reports which source it used.
func NewClient(o ClientOptions) (dynamic.Interface, string, error) {
cfg, from, err := restConfig(o)
if err != nil {
return nil, "", err
}
// The runtime lists and watches a handful of resources; the defaults
// (5 QPS) are fine for that, but a hung apiserver must not hang us.
cfg.UserAgent = "fancy-dns"
c, err := dynamic.NewForConfig(cfg)
if err != nil {
return nil, "", fmt.Errorf("kubernetes client: %w", err)
}
return c, from, nil
}
func restConfig(o ClientOptions) (*rest.Config, string, error) {
if o.Kubeconfig == "" && o.Context == "" {
if cfg, err := rest.InClusterConfig(); err == nil {
return cfg, "in-cluster service account", nil
}
}
rules := clientcmd.NewDefaultClientConfigLoadingRules()
if o.Kubeconfig != "" {
rules.ExplicitPath = o.Kubeconfig
}
cfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
rules, &clientcmd.ConfigOverrides{CurrentContext: o.Context}).ClientConfig()
if err != nil {
return nil, "", fmt.Errorf("no usable Kubernetes credentials (in-cluster or kubeconfig): %w", err)
}
return cfg, "kubeconfig", nil
}