Deploy: read cluster objects, drop the zones and RPZ ConfigMaps

DO NOT SYNC THIS BEFORE MIGRATING. Removing fancy-dns-zones and fancy-dns-rpz
from the kustomization lets a pruning GitOps tool delete them, and they hold the
only copy of the zones and address redirects until the objects exist. Follow
deploy/MIGRATION.md: install the definitions, run `fancy-dns-console migrate`
against the live cluster, then roll this out.

The StatefulSet starts the resolver with -kube and no longer mounts the two
ConfigMaps; its service account loses the ConfigMap role and keeps only the
read-only Role on the fancy-dns objects (crds/rbac.yaml). The kustomization now
installs the definitions and that Role. The shipped config no longer lists the
migrated sources.

Config validation no longer refuses an enabled blocklist with no sources in the
file, because BlockList, DomainRule and AddressOverride objects can supply every
rule and the file cannot know. The existing "no source" warning says so. That
removed the only validation message the settings tests used as a fixture; they
now use an invalid acl.default_policy, and an empty settings document is refused
by the handler instead of by that check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 10:19:47 +02:00
1 parent 467733a7f9
commit 04ebf68c0f
9 files changed
+27 -105

No files matched your search

+4 -14
View File
@@ -86,20 +86,10 @@ data:
blocklist: blocklist:
enabled: true enabled: true
default_action: nxdomain default_action: nxdomain
local_zones: [/etc/fancy-dns/zones/*.zone] # Lists, zones, address redirects, inline rules and client groups are
lists: # cluster objects now (BlockList, DNSZone, AddressOverride, DomainRule,
- name: edge-router-x-address-overrides # ClientGroup); see deploy/MIGRATION.md. `fancy-dns-console migrate`
format: rpz # converts what was here.
path: /etc/fancy-dns/rpz/address-overrides.rpz
enabled: true
refresh: 0s
- name: haas
format: plain
url: https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
path: /var/lib/fancy-dns/lists/hosts
enabled: true
refresh: 24h
categories: [ads, tracking]
acl: acl:
default_policy: allow default_policy: allow
+2 -28
View File
@@ -1,6 +1,5 @@
# The UI edits the shared `fancy-dns-zones` ConfigMap. Its projected volume is # The resolver's identity. It reads the fancy-dns objects through the Role in
# read-only; these narrowly scoped permissions let the API update only that # crds/rbac.yaml and writes nothing; the console has its own account.
# one ConfigMap so both DNS replicas receive the same records.
apiVersion: v1 apiVersion: v1
kind: ServiceAccount kind: ServiceAccount
metadata: metadata:
@@ -9,28 +8,3 @@ metadata:
labels: labels:
app.kubernetes.io/name: fancy-dns app.kubernetes.io/name: fancy-dns
automountServiceAccountToken: true automountServiceAccountToken: true
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: fancy-dns-zones
namespace: dns
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["fancy-dns-zones"]
verbs: ["get", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: fancy-dns-zones
namespace: dns
subjects:
- kind: ServiceAccount
name: fancy-dns
namespace: dns
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: fancy-dns-zones
-11
View File
@@ -1,11 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: fancy-dns-zones
namespace: dns
labels:
app.kubernetes.io/name: fancy-dns
app.kubernetes.io/component: local-zones
# Leave data unmanaged by GitOps. The Local zones page writes one
# zone--<apex>.zone
# key per zone through the API; server-side apply preserves those user fields.
-16
View File
@@ -1,16 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: fancy-dns-rpz
namespace: dns
labels:
app.kubernetes.io/name: fancy-dns
app.kubernetes.io/component: address-overrides
data:
address-overrides.rpz: |
$TTL 60
tornberg.me A 10.10.3.10
knatofs.se A 10.10.3.10
k6n.net A 10.10.3.10
slask.local A 10.10.3.10
iamu.dev A 10.10.3.10
+4 -18
View File
@@ -27,7 +27,6 @@ spec:
annotations: annotations:
k8s-helper.io/git-url: "https://git.k6n.net/mats/fancy-dns" k8s-helper.io/git-url: "https://git.k6n.net/mats/fancy-dns"
k8s-helper.io/git-ssh: "git@git.k6n.net:mats/fancy-dns.git" k8s-helper.io/git-ssh: "git@git.k6n.net:mats/fancy-dns.git"
dns.k6n.net/zones-api: "enabled"
# A config change is a SIGHUP, not a restart: the server re-reads the # A config change is a SIGHUP, not a restart: the server re-reads the
# file and hot-swaps every component. This annotation is for # file and hot-swaps every component. This annotation is for
# stakater/reloader-class controllers; without one, `kubectl rollout # stakater/reloader-class controllers; without one, `kubectl rollout
@@ -66,6 +65,10 @@ spec:
args: args:
- -config - -config
- /etc/fancy-dns/config.yaml - /etc/fancy-dns/config.yaml
# Read ClientGroup, BlockList, DNSZone, AddressOverride and
# DomainRule objects from this namespace with the pod's service
# account. The console (console/console.yaml) is what writes them.
- -kube
env: env:
# Substituted into the config by the loader, so the admin API key # Substituted into the config by the loader, so the admin API key
# comes from the Secret and not from the ConfigMap. # comes from the Secret and not from the ConfigMap.
@@ -136,12 +139,6 @@ spec:
mountPath: /etc/fancy-dns/config.yaml mountPath: /etc/fancy-dns/config.yaml
subPath: config.yaml subPath: config.yaml
readOnly: true readOnly: true
- name: zones
mountPath: /etc/fancy-dns/zones
readOnly: true
- name: rpz
mountPath: /etc/fancy-dns/rpz
readOnly: true
- name: tls - name: tls
mountPath: /etc/fancy-dns/tls mountPath: /etc/fancy-dns/tls
readOnly: true readOnly: true
@@ -153,17 +150,6 @@ spec:
- name: config - name: config
configMap: configMap:
name: fancy-dns-config name: fancy-dns-config
# Local zones are ordinary master files. The API writes them to the
# shared ConfigMap, which projects one zone--<apex>.zone file per zone here.
# The GitOps manifest creates the ConfigMap empty; the UI owns its data.
- name: zones
configMap:
name: fancy-dns-zones
optional: false
- name: rpz
configMap:
name: fancy-dns-rpz
optional: false
- name: tls - name: tls
secret: secret:
secretName: fancy-dns-tls secretName: fancy-dns-tls
+2 -2
View File
@@ -11,8 +11,8 @@ namespace: dns
resources: resources:
- 00-config.yaml - 00-config.yaml
- 05-serviceaccount.yaml - 05-serviceaccount.yaml
- 06-zones.yaml - crds/crds.yaml
- 07-rpz.yaml - crds/rbac.yaml
- 10-deployment.yaml - 10-deployment.yaml
- 20-service.yaml - 20-service.yaml
- 30-ingress.yaml - 30-ingress.yaml
+4 -1
View File
@@ -578,6 +578,9 @@ func (s *Server) Commit(next *config.Config) error { return s.commit(next) }
// parseConfigYAML decodes a config document on top of the defaults, which is // parseConfigYAML decodes a config document on top of the defaults, which is
// what makes a partial document from the UI editor work. // what makes a partial document from the UI editor work.
func parseConfigYAML(text string) (*config.Config, error) { func parseConfigYAML(text string) (*config.Config, error) {
if strings.TrimSpace(text) == "" {
return nil, errors.New("the document is empty")
}
next := config.Default() next := config.Default()
if err := yaml.Unmarshal([]byte(text), next); err != nil { if err := yaml.Unmarshal([]byte(text), next); err != nil {
return nil, err return nil, err
@@ -640,7 +643,7 @@ func configWarnings(cfg *config.Config) []string {
add("blocklist.enabled: blocklists are off, so this instance is an open resolver for everything") add("blocklist.enabled: blocklists are off, so this instance is an open resolver for everything")
} else if len(cfg.Block.Lists) == 0 && len(cfg.Block.LocalZones) == 0 && } else if len(cfg.Block.Lists) == 0 && len(cfg.Block.LocalZones) == 0 &&
len(cfg.Block.BlockEntries) == 0 { len(cfg.Block.BlockEntries) == 0 {
add("blocklist: enabled but no source is configured") add("blocklist: enabled but this config names no source (fine if BlockList or DomainRule objects supply it)")
} }
for _, l := range cfg.Block.Lists { for _, l := range cfg.Block.Lists {
if strings.HasPrefix(l.URL, "http://") { if strings.HasPrefix(l.URL, "http://") {
+7 -9
View File
@@ -146,7 +146,7 @@ func TestSettingsPutRejects(t *testing.T) {
{"broken yaml", `{"config_yaml": "api: [\n"}`, "cannot parse config"}, {"broken yaml", `{"config_yaml": "api: [\n"}`, "cannot parse config"},
{ {
"invalid config", "invalid config",
`{"config_yaml": "blocklist:\n enabled: true\n lists: []\n"}`, `{"config_yaml": "acl:\n default_policy: maybe\n"}`,
"invalid config", "invalid config",
}, },
} }
@@ -261,11 +261,9 @@ func TestSettingsValidate(t *testing.T) {
}) })
t.Run("empty document is not valid", func(t *testing.T) { t.Run("empty document is not valid", func(t *testing.T) {
// Not the obvious answer, and worth pinning: an empty document // An empty document would decode onto the defaults and be valid, so the
// decodes onto config.Default, and the shipped defaults enable the // editor could be used to wipe the config by accident. It is refused
// blocklist without a single source, so Validate rejects them. The // outright; a reset to defaults has to send a real default document.
// UI therefore cannot be used to "reset to nothing"; a reset to
// defaults has to send a real default document.
res := h.do(http.MethodPost, "/api/settings/validate", `{"config_yaml": ""}`, "X-API-Key: rw-key") res := h.do(http.MethodPost, "/api/settings/validate", `{"config_yaml": ""}`, "X-API-Key: rw-key")
if res.status != http.StatusOK { if res.status != http.StatusOK {
t.Fatalf("validate = %d, want 200: %s", res.status, res.body) t.Fatalf("validate = %d, want 200: %s", res.status, res.body)
@@ -297,7 +295,7 @@ func TestSettingsValidate(t *testing.T) {
t.Run("invalid config", func(t *testing.T) { t.Run("invalid config", func(t *testing.T) {
res := h.do(http.MethodPost, "/api/settings/validate", res := h.do(http.MethodPost, "/api/settings/validate",
`{"config_yaml": "blocklist:\n enabled: true\n lists: []\n"}`, "X-API-Key: rw-key") `{"config_yaml": "acl:\n default_policy: maybe\n"}`, "X-API-Key: rw-key")
if res.status != http.StatusOK { if res.status != http.StatusOK {
t.Fatalf("validate = %d, want 200: %s", res.status, res.body) t.Fatalf("validate = %d, want 200: %s", res.status, res.body)
} }
@@ -316,8 +314,8 @@ func TestSettingsValidate(t *testing.T) {
msg, _ := m["message"].(string) msg, _ := m["message"].(string)
joined += msg + "\n" joined += msg + "\n"
} }
if !strings.Contains(joined, "blocklist is enabled but no lists") { if !strings.Contains(joined, "acl.default_policy must be") {
t.Errorf("errors = %q, want the blocklist complaint", joined) t.Errorf("errors = %q, want the default_policy complaint", joined)
} }
// fieldOf falls back to "config" for a message with no dotted path, // fieldOf falls back to "config" for a message with no dotted path,
// which is what this one is. Pinned so the editor's field highlighting // which is what this one is. Pinned so the editor's field highlighting
+4 -6
View File
@@ -1152,12 +1152,10 @@ func (c *Config) Validate() error {
add("blocklist.lists[%d] %q: refresh must not be negative", i, l.Name) add("blocklist.lists[%d] %q: refresh must not be negative", i, l.Name)
} }
} }
if c.Block.Enabled { // An enabled blocklist with nothing configured is not an error: BlockList,
if len(c.Block.Lists) == 0 && len(c.Block.LocalZones) == 0 && // DomainRule and AddressOverride objects can supply every rule, and the
len(c.Block.BlockEntries) == 0 && len(c.Block.AllowEntries) == 0 { // config file cannot know about them. configWarnings says so for a
add("blocklist is enabled but no lists, local_zones or inline entries are configured") // standalone config.
}
}
switch c.ACL.DefaultPolicy { switch c.ACL.DefaultPolicy {
case "allow", "deny", "block_only": case "allow", "deny", "block_only":