M5 work package: server-side follow-up burn-down
Package key burndown-server, branch worktree-wf_b932d6b4-504-3, based on 347f4c7. It closes ten server-side rows of docs/followups.md, in the order of the assignment.
Commits:
6bac88fconfig: settings layer, Reloadable, watcher348ce2acache, blocking and ratelimit reload; lameduck SERVFAIL in ingressb6a46a8querylog privacy and retention at runtimefaf3b11serve: hot reload, settings writer,/lameduckac0cc81kudurru lameduckc081b5fkudurru.io/enabled8c7cacf/internal/zonesand the zone metrics24611c3list refresh and query-log purge48891e8leader address from the EndpointSlice05cf343GET /api/v1/doctord540f91drops send nothing9d5ab28controller test flakesb57859eaudit prune loope5fbd9ffollowups3ae4bd0memory driver resourceVersion check and conformance case
This report (eeaeb84, then this update) closes the series.
1. Settings hot reload (D25)
The settings layer (internal/config/reload.go)
New node key
settingsFile: hostOnly, restartRequired, default"".KUDURRU_SETTINGS_FILEoverrides it; it is a reserved variable, not a__key.Loadreads its layers in this order:- defaults
- the node file
- the settings file
- the environment
So the environment still locks a key.
Config.SettingsFileholds the path that took effect.The settings file may only contain cluster-scope keys. Node-scope and unknown keys are an error: at start the load fails, and on reload the file is ignored. A missing or empty file is an empty layer.
Other helpers in the package:
ReadSettingsFileWriteSettingsFile: writes the changed keys atomically (temp file, fsync, rename, mode 0640). It keeps the other keys but drops comments.FlattenandFromFlat: the flat dotted-path form that the API already used.MergeCluster(cur, fresh): takes the cluster-scope keys fromfreshand keeps the node-scope keys ofcur.ChangedandChangedUnder.
Reloadable and the watcher
config.Reloadableholds the effective*Configin an atomic pointer.Subscribe(fn(old, next, changed)): subscribers run synchronously, in registration order, oneSetat a time.Set(next): notifies only when something changed.Reload(environ): re-runsLoad, thenMergeCluster.
config.WatchSettings(ctx, r, WatchOptions)watches the settings file with fsnotify on its directory, which covers the Kubernetes ConfigMap..datasymlink swap and editors that rename the file.- It also polls every 10 s.
- It debounces events by 200 ms.
- It compares a sha256 of the content before reloading.
- An invalid file is logged, and the last good settings stay in effect.
What applies without a restart (cmd/kudurru/reload.go)
subscribeReload applies these groups:
| Keys | How |
|---|---|
upstreams.* (groups, bootstrap, conditional, ecs) | builds a new upstream.Set with the same Metrics, upstreamstage.Stage.Swap, flushes the response cache, then prev.CloseReplacedBy(next) after a 30 s grace |
cache.* | new cache.Cache.Reconfigure: atomic stale-TTL and prefetch limits, per-shard capacity resize that evicts at once. The cache stage gets SetSettings (TTL clamps, stale timers) and SetDisabled (maxEntries: 0 passes queries through and flushes). serve now always builds the cache and its stage. |
blocking.enabled, defaultResponse, blockTTL, customIP.* | blockingstage.Stage.Reconfigure (atomic defaults) |
ratelimit.* | ingress.RateLimiter.Reconfigure(*RateLimitConfig): nil disables, and every bucket is dropped. serve always builds the limiter, so ratelimit.enabled can change at runtime. |
querylog.privacy, anonymize, exclude.* | querylog.Async.SetPrivacy plus sqlitelog.Writer.SetPrivacy (what later batches store) |
querylog.retention.raw, maxDBBytes | sqlitelog.Writer.SetRetention. The reader tier, the status and the size-cap loop read the live values. |
log.level (node scope, via PATCH /node/config) | the serve logger's slog.LevelVar |
restartRequired in the metadata table is now truthful. These keys newly carry it:
log.formatquerylog.enabledandquerylog.sink(they decide which sink exists)blocking.allowDegradedandblocking.refreshInterval(read by the list resolver and fetcher at construction)auth.tokens.maxLifetime(read when the auth stack is built)
config.TestRestartRequired pins the set of hot keys. Adding a key to it means adding a subscriber. The settings-get CLI golden file changed with it: log.format now shows R. A PATCH or a file change that touches a restart-bound key is logged as "take effect after a restart".
Writes through the API
api.Options.Reload: when set, the API readsReload.Current()and applies a PATCH withReload.Set. A PATCH and a file reload therefore go through the same subscribers.api.Options.Settings(the settings writer) is now called forPATCH /settingsonly.PATCH /node/configchanges stay in memory (persisted: false). hostOnly keys are refused as before.- serve's
settingsWriter:- kube driver: 409
managed_byfromapi.ErrSettingsManaged("Kubernetes (the kudurru-settings ConfigMap)", ...). - settings file configured:
config.WriteSettingsFilewrites the changed paths. The watcher then sees no difference. - neither: nil, so changes stay in memory (
persisted: false).
- kube driver: 409
api.Options.Upstreamsbecame the interfaceapi.UpstreamStatus.*upstream.Setstill satisfies it, and serve passesapi.UpstreamStatusFuncover the swapped set.
Smoke run. kudurru serve with a settings file:
- Rewriting the file logged
settings applied keys=[cache.maxEntries ratelimit.slip]. PATCH /api/v1/settings {"blocking":{"blockTTL":"30s"}}returnedpersisted: trueand rewrote the file with all three keys.- The process exited 0 on SIGTERM.
2. D51 drain path
ingress.Stage.Lameduck func() bool: while it returns true, any IN-class query forhealth.kudurru.internal.answers SERVFAIL. dnsdist's must-resolve check (2 failures at 1 s) then drains the node from every pool, while every other name keeps resolving.- serve wires it to
health.Checker.IsLameduck. The runner's shutdown lameduck (checker.Lameduck()) therefore sets the ingress flag too. health.Checkerchanges:- New
SetLameduck(bool)andIsLameduck(). - New routes on the ops listener:
POST /lameduck,DELETE /lameduckandGET /lameduck(health.PathLameduck). The POST and DELETE routes are loopback peers only, because the ops listener has no authentication.
- New
kudurru lameduck [duration](cmd/kudurru/lameduck.go): POSTs to--ops-address(default127.0.0.1:8081), then waits for the duration.--cancelleaves lameduck.- Verified.
digreturned SERVFAIL for the health name during lameduck whilehome.example SOAstill answered, and/healthz/readyreturned 503. - preStop story. The chart's
preStop: sleeponly drains the Service endpoints. dnsdist keeps sending queries until the process stops answering. docs/deploy/runbooks.md and compose-ha.md now say so. Switching the chart toexec: ["kudurru", "lameduck", "<n>s"]is a followups row for the deploy owner, because charts/ is not in this package.
3. kudurru.io/enabled: "false"
Decision: an annotation, not spec fields. The v1alpha1 schema stays unchanged, kubectl annotate works, the dashboard already writes the annotation, and spec fields can still come in v1beta1. api/v1alpha1/annotations.go defines:
EnabledAnnotation,CommentAnnotation,ConditionDisabledandReasonDisabledIsDisabled(obj): true for"false", case-insensitive and trimmed
It is a new file, not a type change, so make generate is unaffected.
How the parts treat a disabled object:
- Policy index (
policy.Build):- Disabled ClientGroups, Policies and BlockLists are left out as if absent.
- A reference to one gets a
Disableddiagnosis instead ofMissingReference. - A disabled list is skipped even while its compiled copy is still in the snapshot.
/info/messagesreportsDisableddiagnoses asinfo.
- List resolver and fetcher:
ZoneCacheObjects.BlockLists()filters withlistfetcher.EnabledBlockLists. The leader stops fetching, every node unloads the list, and the cached body is collected once the store is live. Enabling the list fetches it again. - Controller:
- A disabled object gets Ready False with reason Disabled, Degraded False, and a
Disabledcondition with status True. - An enabled object carries no Disabled condition, so existing objects see no status write.
- A Policy's Ready message names the disabled references it skips.
- An enabled/disabled flip of a BlockList or ClientGroup requeues the Policies that reference it.
- A disabled object gets Ready False with reason Disabled, Degraded False, and a
4. /internal/zones and the zone metrics
internalapi.ServerOptions.Zones func(ctx) ZoneList. The route is registered only when this is set, so older nodes keep answering 404 andClient.ZonesreturnsErrZonesUnsupported. The route also gets a metrics label,zones.zoneview.Served(cache)returns the primary and secondary zones of the live view; forward zones are left out, and the result is nil while unsynced.zoneview.Collectorexports:kudurru_zone_serial{zone}kudurru_zone_records{zone,kind}, where kind isrecords(RR count) ornames(owner names)
It reads the snapshot on every scrape.
serve registers the collector and fills
ZonesfromServed.
5. List refresh and query-log purge
listfetcher.Fetcher.RefreshNow(ns, name)marks the list's URL and ConfigMap sources as forced.dueAtreturns now for them and ignores the refresh interval and any failure cooldown. It also kicks the run loop. A list that does not exist or is disabled returnsErrUnknownList.- serve's
listRefresher(cmd/kudurru/servewire.go):- Leader: calls
RefreshNowand the API answers 202. - Replica:
api.ErrNotLeader(leader)gives 409 conflict naming the leader, or 503 while no leader is elected. - Disabled list:
api.ErrListNotRefreshablegives 409. - The API's refresh handler now passes
*api.Errorvalues through.
- Leader: calls
sqlitelog.Writer.Purge(ctx, before)deletes fact rows older thanbefore(zero means all) inDeleteBatchtransactions with an incremental vacuum after each. Rollups and dictionaries are kept. It implementsapi.QuerylogPurger, and serve wires the writer when the sink is sqlite.- Smoke run.
DELETE /querylogreturneddeleted: 1;POST .../starter/refreshreturned 202.
6. Leader address from the EndpointSlice
cluster.EndpointSliceResolve(src, fallback)finds the leader pod (matched bytargetRef.nameor hostname, ready or not) in the headless Service's EndpointSlices and returnsIP:internalPort. It falls back to the<pod>.<headless>DNS name.ElectorMembership.CacheTTL(plus aNowclock for tests) re-resolves the address periodically. A leader restarted under the same name gets a new IP. serve uses 30 s; zero keeps the old behaviour.- serve's
leaderResolver:- takes the Service and namespace from
KUDURRU_HEADLESS_SERVICE(<svc>.<ns>.svc.<domain>) - uses a discovery/v1 client built from the store's rest config (RBAC already grants it)
- keeps the DNS name for the sqlite driver, or when the variable is unset
- takes the Service and namespace from
- TLS still verifies the derived internal certificate name, never the address.
7. GET /api/v1/doctor
internal/api/doctor.go, with permission settings.read (the same as /info/messages). The response is DoctorReport{node, version, checks:[{check, status ok|warn|fail, message}], failures, warnings} with these checks:
- config: the config source and the settings file.
- api: this node answers.
- token: the principal and its roles.
- diagnoses:
/info/messagesseverities. - dataDir: run by the server process. It checks that the directory is writable, not world-writable, and that the databases are not world-readable.
- ready: the health checker. Its
dnscomponent covers bound listeners.
The NOTIFY egress check was not cheap enough to include.
The OpenAPI documents and the generated Go client were regenerated (make openapi, make client-go). The Tools page still builds its report client-side; switching it to this route is a followups row for the UI owner.
Finding from the smoke run. local.db and querylog.db are created 0644, and doctor warns that they are world-readable. This is a new followups row for the store and querylog owners.
8. Drops (ErrDrop)
The row was corrected, not merely confirmed:
pipeline.ErrDropfrom the control stage used to answer SERVFAIL on every transport.- The rate-limit drop sent nothing over UDP only, and SERVFAIL elsewhere.
Both now send nothing:
| Transport | Behaviour on a drop |
|---|---|
| udp | writes nothing |
| tcp, dot | the connection is closed (w.Close()) |
| doq | the stream is reset with DOQ_REQUEST_CANCELLED |
| doh, doh3 | the exchange is aborted (panic(http.ErrAbortHandler)): HTTP/2 and HTTP/3 reset the stream, HTTP/1.1 closes the connection. quic-go's http3 server also recovers ErrAbortHandler silently. |
Handler.Resolvekeeps its never-nil contract and still answers SERVFAIL for drops.resolveFreshIDnow returns nil on a drop.TestDropsSendNothingcovers all seven transport variants (both drop forms, then a normal query on a fresh exchange).TestDropResponsewas updated to the new TCP behaviour.- The log observer counts drops in
kudurru_dns_responses_totalwithrcode="none"(log.RcodeNone). Plain pipeline errors, which are still answered SERVFAIL, keeprcode="SERVFAIL".
9. Controller test flakes
Both fixes address root causes and keep the exact assertions.
TestKubeDriver: the controller emits the SerialBumped Event after the status write the test polls for. The test now waits for the Event that carries the final transition.TestMeasureRecordSetChanges/pacedhad two causes:Too few writes (1,871 and 1,982 out of 2,000 under
-cpu 1). The loop waited only for the zone's write. The record set'sobservedGenerationwrite runs on another worker and could slip past the next change of the same object, so two generations shared one write. The loop now also waits for the record set's write, through anrsWrotehook channel.Too many writes (2,001 to 2,004 under
-race; the original report). I traced this with a temporary log of writes and cache upserts:- A zone reconcile writes status A on base RV r, and the cache marks A as pending.
- The next reconcile builds B on top of the pending A and writes it, still with base RV r, because the echo of A has not arrived yet.
- The echo of A then clears the pending entry, and B is written a second time.
The sqlite and kube drivers reject step 2 with ErrConflict (the controller requeues). The memory driver accepted it, because its
UpdateStatusignored the caller's resourceVersion.Fix:
memory.Store.UpdateStatusnow enforces optimistic concurrency like the other drivers. A new conformance case,UpdateStatusOptimisticConcurrency(internal/store/conformance/status_conflict.go), pins this for memory, sqlite and kube.This is a small edit outside the named packages: the memory driver is the test reference driver, and the fix is parity, not new behaviour.
- Result after both fixes: 23 runs passed in total:
-cpu 1,2,4 -count 5(15) and-race -cpu 1,2 -count 4(8).
10. sqlite audit
- Verified:
WithActorfrom the API principal is wired.api.actorMWsetssqlite.WithActor(actorOf(p).String()), andTestAuditWithSQLiteasserts that the store-write row names the user. - New:
sqlite.AuditPruner(400 d retention, daily, runs once at start) runs as the leader-onlyaudit-prunerunnable for the sqlite driver. It is tested with a fake clock and a verifiable chain afterwards.
Serve wiring added
cmd/kudurru/serve.go keeps its structure; the edits are additive.
- A
config.Reloadablearoundcfg. - The
settingsrunnable (WatchSettings) and thesubscribeReloadtargets. - The logger's
LevelVar. - The upstream
Optionsare kept for rebuilds, and shutdown closesfwd.Set(). - The limiter and the cache stage are always built.
Lameduck: checker.IsLameduckon the ingress stage.zoneview.NewCollector, plusZoneson the internal server.leaderResolverwith a 30 sCacheTTL.- API options:
Reload,Settings,Upstreams(func),Refresher,Purger. - The
audit-pruneleader runnable. main.goregistersnewLameduckCmd.
New files:
cmd/kudurru/reload.go: the subscribers and the settings writer.cmd/kudurru/servewire.go: the refresher, purger and leader resolver.cmd/kudurru/lameduck.go
internal/config: the settingsFile key, the settings layer in Load and reload.go. The edits are additive and the drift test is green.
Architecture changes (for the integrator's decision log)
- Settings file layer (implements D25 in the binary): the order is defaults, node file, settings file, environment. The file holds cluster-scope keys only and is watched with fsnotify plus a 10 s poll. A PATCH persists into the file in Docker and answers 409 managed_by in Kubernetes. Node-scope PATCHes stay in memory.
restartRequiredis enforced byTestRestartRequired. kudurru.io/enabled: "false"as the v0.1 switch for BlockList, ClientGroup and Policy, with a Disabled condition. Rejected:spec.enabledandspec.commentin v1alpha1.- Drops send nothing on every transport. tcp and dot close, doq resets, doh and doh3 abort; the rcode label is
none. This replaces "SERVFAIL over TCP". - Leader address from the EndpointSlice, with the DNS name as fallback and a 30 s address TTL.
/lameduckon the ops listener, loopback only. Rejected: an admin-socket verb (adminsock lives in the auth package) and an unauthenticated remote POST.
Followups
Opened in docs/followups.md:
- Chart: set
settingsFile, drop the settings merge, and usepreStop: kudurru lameduck. - Compose: set
KUDURRU_SETTINGS_FILE. - Store and querylog owners: create
local.dbandquerylog.dbwith mode 0600. - UI owner: the Tools page reads
/doctor. - Deploy owner: add the serial-divergence panel and alert, now that the metric exists.
- Reload caveats: the pseudonym key rotates when privacy settings reload, and the cache keeps its shard count.
- Node-scope persistence:
PATCH /node/configchanges are not persisted.