M3 work package: cli (the kudurru client verbs)
Branch worktree-wf_287eb68b-e67-4, based on 3e52e5a. Covers
docs/architecture.md §3.5 (CLI), §8.3 (CLI login), §8.7 (kudurru admin over
the unix socket) and the client side of the §10 API contract.
Packages: internal/apiclient (new, hand-written client), internal/cli (new:
contexts, keyring, kinds and tables, manifests, admin socket client),
cmd/kudurru (every file except serve.go, edge.go and vip.go).
internal/config is untouched. New dependency: github.com/zalando/go-keyring
v0.2.8 (MIT; pulls in godbus/dbus/v5 BSD-2 and danieljoos/wincred MIT, all
pure Go). golang.org/x/term moved from indirect to direct. No NOTICE entry is
needed: nothing was adapted from another project.
The previous attempt's work-in-progress was not on the branch (it was still at the initial commit), so the package was written from scratch.
What was built
internal/apiclient (the transport)
A small client over the contract, kept in client.go with typed helpers in
side files so that a generated client (the api agent's
internal/apiclient/openapi) can sit underneath without the CLI changing:
New(server, Options{Token, InsecureSkipVerify, CAFile, Timeout, UserAgent, HTTPClient}). A bare host means https. A path in the URL is kept as a prefix, for servers behind a path-prefix proxy. Every client has a cookie jar for the password login.Do(ctx, method, path, query, in, out)plusGet,PostandDelete. Paths are relative to/api/v1unless they start with/api/.inmay be any value (marshalled),[]byteor anio.Reader.outmay be a value, anio.Writeror a*[]byte.Raw(ctx, Request)returns the response as is, for text/dns, merge patches, If-Match and SSE.- Auth:
Authorization: Bearer <token>when a token is set. With the session cookie,X-Kudurru-CSRFis sent on every mutating request;MeandLoginpick up thecsrfvalue. - Errors: every non-2xx response becomes
*apiclient.ErrorwithStatusCode,Key,Message,Hint,Missing{resource,verb,scope},RetryAfterandTookMS. The 403missingblock is read from the error object, from a hint object, or from a hint that is a JSON string. Bodies that are not the envelope (a proxy's HTML) keep their first line.Error()reads, for example,forbidden: not allowed (missing zones.write on zone:home)ormanaged_by: ... (managed by file:objects.d/zones.yaml). Helpers:IsKeyandIsStatus. - Pagination:
PagesandListAllfollowcontinue, and stop if the server repeats a token. - SSE:
Subscribe(ctx, path, query, SubscribeOptions{LastEventID, Reconnect, RetryDelay, MaxDelay}, fn). It parses the WHATWG event-stream format: multi-line data, comments,retry:and CRLF. On reconnect it resendsLast-Event-IDand backs off exponentially. Transient statuses (429, 502, 503, 504) are retried. A 401, 403 or 404 on the first connection is returned at once.ParseSSEis exported. - Typed helpers:
Me,Status,Login,Logout,CreateToken,ListObjects,GetObject,PutObject(If-Match),DeleteObjectandApply(sends a JSON array and accepts a bare array,{items}or{results}back), plusObjectPathandSeg.
internal/cli (client-side state and formatting)
- Contexts file:
$KUDURRU_CLI_CONFIG, else$XDG_CONFIG_HOME/kudurru/config.yaml(~/.config/kudurru/config.yaml). It holdsapiVersion/kind: Config,currentContextandcontexts[]{name, server, token?, tokenStore, namespace, insecureSkipVerify, caFile, user}. Unknown fields are rejected on load. Saves are atomic, mode 0600 in a 0700 directory. - Resolve applies precedence in this order: flags (
--context,--server,--token,-n), then the environment (KUDURRU_CONTEXT,KUDURRU_SERVER,KUDURRU_TOKEN), then the current context. A context's stored token and its TLS settings are only used when the server is that context's own server (compared after normalisation), so--serverpointing elsewhere never leaks it. - Credentials: tokens go to the OS keyring (service
kudurru, account = context name) throughSystemKeyring, a go-keyring wrapper with a 3 s timeout so a locked Secret Service cannot hang the CLI. When the keyring refuses, the token stays in the 0600 file (tokenStore: file) and the CLI prints a warning.KUDURRU_KEYRING=fileskips the keyring entirely (containers, CI). - Kinds:
dnszones(zone, zones, dnszone, dz; cluster-scoped),dnsrecordsets(rrset, rrsets, dnsrecordset, recordset),blocklists(list, lists, blocklist, bl),clientgroups(cg, clientgroup) andpolicies(pol, policy). CamelCase kinds andresource.groupspellings are accepted. The print columns follow the CRD printcolumns, plus MANAGED-BY for zones (from thekudurru.io/managed-byannotation) and AGE everywhere;-o wideadds more.Tablerenders kubectl-style tables. - Manifests: files, directories (
-Rrecurses,.yaml/.yml/.jsonin lexical order) and-for stdin. Accepts multi-document YAML, JSON objects, JSON arrays andList/*Listdocuments. Documents that are empty or only comments are skipped. Errors name the file and the document index. - Admin socket client (
AdminClient): protocolauto(default) speaks HTTP/1.1 over the unix socket (POST /admin/v1/reset-password,POST /admin/v1/create-token,GET /admin/v1/list-users, API-style errors). That is what the auth agent'sinternal/auth/adminsockserves in its worktree. When the peer does not answer HTTP, it falls back to the task's NDJSON protocol ({op,args}\n→{ok,result,error}\n).--protocolforces either one.
Commands (cmd/kudurru)
Global flags on the root: --cli-config, --context, --server, --token,
-n/--namespace, -o/--output table|wide|json|yaml|name,
--insecure-skip-tls-verify, --ca-file and --request-timeout. --config
still names the server's kudurru.yaml. Help is grouped into Server, Objects,
DNS and lists, Observe, and Setup and administration.
| Verb | API |
|---|---|
login --server URL (--token kdr_… | --token-stdin | -u user [-p pw | --password-stdin | prompt]) [--name ctx] | GET /auth/me; password: POST /auth/login, GET /auth/me (csrf), POST /auth/tokens {name: kudurru-cli@host, expiresIn?}, POST /auth/logout |
logout [ctx] [--remove] | (local) |
config get-contexts | use-context | current-context | delete-context | set-context | (local) |
get <kind> [name…] [-n ns] [-A] [-l sel] [-o …] | GET /objects/{kind} (all pages), GET /objects/{kind}[/{ns}]/{name} |
apply -f file|dir|- [-R] | POST /apply (JSON array; namespaced docs without a namespace get -n/context/default) |
delete <kind> <name…> | -f … [--ignore-not-found] | DELETE /objects/{kind}[/{ns}]/{name} |
describe <kind> <name> | GET /objects/… + GET /info/messages filtered to the object |
zones list | get | import <zone> -f file [--origin] | export <zone> [--out] | notify <zone> | /zones… (import as text/dns, export with Accept: text/dns) |
records list <zone> | add <zone> <name> <type> <targets…> [--ttl --record-set --comment --set-identifier] | rm <zone> <name> <type> | GET|POST /zones/{zone}/records, DELETE /zones/{zone}/records/{name}/{type} |
lists list [-A] | refresh <name…> | search <domain> | allow|deny <domain…> [--regex --list] | /blocklists, /blocklists/{ns}/{name}/refresh, /blocklists/_search, /domains/{allow|deny}/{exact|regex} |
blocking status | enable | disable [5m] [--until RFC3339] | GET|POST /dns/blocking |
cache flush [name] | POST /cache/flush?name= |
query <name> [type] [--client ip] | POST /dns/query; dig-style output with flags, the kudurru verdict, sections, query time and node |
stats summary [--window], stats top --dim … [--top N --blocked --window] | /stats/summary, /stats/top (upstreams too) |
logs [--limit N] [-f] [--client --domain --status --status-class --qtype --upstream --list --zone --since --until] | GET /queries following cursor_next, printed oldest first; -f streams /queries/stream from the newest paged id with Last-Event-ID resume and client-side filtering; -o json prints ndjson |
backup --out f [--safe --exclude-managed], export [kind…], restore -f f | GET /objects/* all namespaces, cleaned for re-apply; restore uses POST /apply |
import bind <zone> -f file | same as zones import |
settings get [key…] [--scope], settings set key=value… key- | GET /settings, GET /node/config; PATCH (merge patch) by key scope; hostOnly and unknown keys refused locally with the env var name |
nodes, audit [--since --kind --name --actor --limit] | /nodes, /audit (cursor_next) |
api [METHOD] <path> [-d inline|@file|@-] [-H …] [--content-type] | raw |
completion bash|zsh|fish|powershell | dynamic completion of kinds, object names, zones and contexts |
admin reset-password [user] [--password(-stdin) --create] | create-token [--user --description --expires-in] | list-users | admin socket |
doctor [--skip-api --skip-ports] | adds dataDir writability and mode, port bindability, API reachability (/version), token validity (/auth/me), diagnoses (/info/messages); exits non-zero when a check fails |
serve, health, version and config show|schema are unchanged. doctor
moved to doctor.go and keeps its existing lines. main runs
newRoot().Execute() as before; logs --follow installs its own SIGINT and
SIGTERM context so Ctrl-C ends a stream with status 0. Every other command
keeps the default signal behaviour, so Ctrl-C at the password prompt still
kills the process.
backup --safe redacts URL userinfo passwords and the query parameters
token, key, apikey, api_key, secret, password, pass, auth, access_token and
sig. --exclude-managed drops objects owned by file: or integration:.
The backup file is written 0600. restore warns when a document still holds
REDACTED.
doctor probes the local listener when no context or --server is set. A
plain-HTTP 400 "…HTTPS server" answer switches it to https without
verification, which covers the api agent's listeners.api.tls.auto: selfsigned.
Serve wiring
None. The CLI does not touch serve.go. For the integrator:
The edge and vip commands. The root is built in
newRootWith(o *cliOpts)incmd/kudurru/main.go. When the edge/vip agent'sedge.goandvip.goland, register their constructors in the "server" group next toserve:add("server", newServeCmd(&configPath), newHealthCmd(), newDoctorCmd(&configPath, o), newVersionCmd(), newEdgeCmd(&configPath), newVIPCmd(&configPath)) // names as the edge agent defines themThe root has persistent flags
-nand-o; the supervisors must not define flags with those shorthands.The admin socket.
kudurru adminneeds the auth agent'sadminsock.Serverlistening on/run/kudurru/kudurru.sock(the default of both sides). No CLI change is needed.The API. Every verb only needs
internal/apimounted onlisteners.api.address.
Measurements
Taken on the shared 8-CPU machine (five other agents running), Go 1.27.2, linux/amd64.
| What | Value |
|---|---|
| Stripped CGO_ENABLED=0 binary | 48,677,024 bytes, up from 47,808,672 at 3e52e5a (+868 KB, +1.8 %); 95 % of the 51,234,000 D75 budget (2.5 MiB headroom) |
kudurru version wall time, 50 runs | 94 ms per run, the same as the 3e52e5a binary (the CLI adds no init work) |
A refused API call (get zones --server http://127.0.0.1:1) | 78 ms per process, mostly process start |
| Top-level commands | 30 (from 5) |
| Tests | cmd/kudurru 1.7 s, internal/apiclient 1.4 s, internal/cli 1.0 s under -race |
| Golden files | 39 under cmd/kudurru/testdata/golden (go test ./cmd/kudurru -update rewrites them) |
Cross-builds: CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build ./cmd/kudurru
passes, and GOOS=windows go vet ./internal/cli ./internal/apiclient passes.
go-keyring uses /usr/bin/security on macOS and wincred on Windows, with no
cgo on either.
Tests
cmd/kudurruruns againstfakeapi_test.go, an httptest server for the contract subset. It has bearer and cookie+CSRF auth,continueandcursorpagination (small pages force several round trips), managed_by conflicts, the SSE stream with scripted reconnects, and response shapes copied from the api agent'sinternal/apitypes: DiagnosisMessage, SettingsBody, audit.Event, ListSearchHit, the query result, nodes and ApplyResult with errorKey and hint.- Golden tables for every kind, plain and wide,
-A,-land-o name. Alsodescribe, zones, records, lists, query (forwarded and blocked), stats summary and top (upstreams too), nodes, audit, settings, export and admin list-users. - Login with a token, with
--token-stdin, and with a password (the test checks the CSRF header on the token mint and that the session is logged out). Also the keyring fallback to the 0600 file and six failure cases. - Context switching (
use-context,--context,KUDURRU_CONTEXT,delete-contextdropping the keyring entry). Env and flag precedence, including that a foreignKUDURRU_SERVERgets no token. - Multi-document apply from a file and from stdin, a directory with
-R, per-document errors, and malformed manifests. logs: three pages, filters, and ndjson.logs --follow: duplicate suppression,Last-Event-ID5 then 7 across a dropped stream, the event shape ofquerylog.Event, client-side--domainfiltering, and a 401 on the stream.- The admin socket: a fake of
adminsockwith strict JSON decoding (the exact request bodies are asserted) and an NDJSON server reached throughautoand--protocol ndjson. - Completion scripts for four shells, and dynamic completion of kinds, object names, zones and contexts.
doctoragainst the fake: diagnoses, a rejected token, an unreachable explicit server, a local TLS listener, a world-writable dataDir and a world-readable state.db.versionandconfig schemaunchanged.
- Golden tables for every kind, plain and wide,
internal/apiclient: URL building, error decoding (eight cases), auth headers and CSRF only on writes, token secret field names, pagination (including a repeated token), the three apply result shapes, If-Match, body kinds, the SSE parser, reconnect withLast-Event-IDthrough a 503, and the error paths.internal/cli: config round trip and modes, Resolve precedence (ten cases), the credentials fallback,SystemKeyringagainst go-keyring's mock, kind aliases, manifest parsing, the table writer and formats, and the admin client over both protocols.
The client surface a generated client must satisfy
The CLI calls only these, so a generated transport can replace the internals
of apiclient.Client while keeping:
func New(server string, opts Options) (*Client, error)
func (c *Client) Server() string
func (c *Client) SetToken(string); func (c *Client) Token() string; func (c *Client) SetCSRF(string)
func (c *Client) URL(path string, q url.Values) string
func (c *Client) Raw(ctx, Request{Method, Path, Query, Body, ContentType, Accept, Header, NoTimeout}) (*http.Response, error)
func (c *Client) Do(ctx, method, path string, q url.Values, in, out any) error // + Get, Post, Delete
func CheckResponse(*http.Response) error // → *Error{StatusCode, Key, Message, Hint, Missing, RetryAfter, TookMS}
func IsKey(error, string) bool; func IsStatus(error, int) bool
func (c *Client) Pages(ctx, path, q, pageSize, func(ListPage) error) error // ListPage{Items []json.RawMessage, Continue}
func (c *Client) ListAll(ctx, path, q, pageSize) ([]json.RawMessage, error)
func (c *Client) Subscribe(ctx, path, q, SubscribeOptions, func(Event) error) error // Event{ID, Event, Data}
func (c *Client) Me(ctx) (*Me, error); Status; Login(ctx, user, pw) (*Me, error); Logout; CreateToken(ctx, CreateTokenRequest) (*Token, error)
func (c *Client) ListObjects(ctx, kind, ListOptions) ([]json.RawMessage, error); GetObject; PutObject(…, resourceVersion, obj); DeleteObject
func (c *Client) Apply(ctx, docs []json.RawMessage) ([]ApplyResult, error)
func ObjectPath(kind, ns, name string) string; func Seg(string) string
Objects travel as json.RawMessage on purpose. The CLI decodes them into
api/v1alpha1 types only to print them, so a generated client with its own
model types need not match the CRD Go types.
What the contract left ambiguous
The CLI accepts every reading below. Where the api agent's code (read-only, its worktree) already settles it, that is noted.
- 403 hint. "missing:{resource,verb,scope} in hint" could be a hint
object, a JSON string, or a sibling of the message.
internal/apiputsmissingbesidemessagein the error object. All three forms are decoded. - The
POST /auth/tokenssecret field. It is read fromtoken,secretorvalue. internal/api usestoken. POST /auth/loginresponse. The contract does not say whether csrf is in the body. The CLI reads it if present and otherwise calls/auth/me.POST /applyresponse. It could be an array,{items}or{results}. internal/api usesList{items}and addserrorKey,hintandresourceVersionper document; the contract should list those.GET /zonesitems. The apex could bezoneNameorzone, andreadya bool or a condition string. internal/api useszoneNameand a bool.- Record names in
POST /zones/{zone}/recordsand the DELETE path. Relative or absolute? internal/api accepts both; the CLI passes them through. /cache/flush {?name}. Query or body? The CLI sends the query parameter. internal/api accepts both.POST /dns/queryrecord format. Presentation strings or objects? internal/api uses strings plus aflagsobject. The CLI renders both.GET /info/messages. The contract gives no shape. internal/api uses{severity, source, reason, object: "Kind/[ns/]name", message, hint, node};describematches onobject./blocklists/_searchhits. internal/api uses{list: "ns/name", mode, rule}.GET /settingsand/node/config. These are wrapped as{scope, node, settings:{…}}in internal/api; the PATCH body is the bare tree and the answer addschangedandrestartRequired. The CLI sendsapplication/merge-patch+json.- The
/queriesstatus filter. Comma-separated or repeated? internal/api uses comma-separated. - The SSE event name and payload. internal/api documents
event: querywith rows shaped like/queriesitems. The CLI prints any event that decodes to a row with a domain, and also reads thequerylog.Eventshape. - Audit pagination. The CLI reads both
cursor_nextandcontinue, and both theaudit.Eventobjects (actor, resource) and flat fields. - Default namespace. When none is given, the CLI uses
-n> context namespace >default, matchingbootstrap.DefaultNamespace. The contract does not say what the server assumes for a namespaced document without one, soapplyfills it in client-side. - The admin socket protocol. The task specified NDJSON. The auth
agent's adminsock serves HTTP/1.1 at
/admin/v1/*withDisallowUnknownFields, so the CLI sends exactly{username,password,create}and{user,description,expiresIn}.create-tokenhas no--nameor--service-account: a token without--userbelongs to the break-glass service account. stats top -n. It collides with the global-n/--namespace, so the CLI uses--top N(the server caps n at 100).
Surprises
- The branch named in the resume note had no WIP commits; it pointed at the initial commit. Nothing could be recovered, so the package started from scratch.
- sigs.k8s.io/yaml (YAML 1.1 through go-yaml v2) reads an unquoted
namespace
n,y,onoroffas a boolean. A manifest withnamespace: nfails to decode with a type error. That is the standard Kubernetes behaviour, so it is left alone; the tests use other names. - The global
-nand-oshorthands rule them out for every subcommand, hencestats top --topandlogs --limit. - In the full
make testrun (48 packages ok), onlyinternal/controllerfailed:TestMeasureRecordSetChanges/resync_of_1000_converged_record_setssaw 948 status writes. That is the timing-sensitive measurement already listed in docs/followups.md. It passes when run alone (23 s), and the controller package depends on none of the packages this work touched. logs --followresumes from the newest paged id. Without that, rows logged between the page fetch and the stream connect would be lost or duplicated. The client also drops ids at or below the last printed id.
Follow-ups
Added to docs/followups.md:
--contextselecting a kubeconfig, so CRD verbs go straight to the apiserver (§3.5). Today a context is always a kudurru API endpoint.- The remaining §3.5 verbs:
import pihole|adguard|blocky|technitium,lameduck,health --vrrp,cluster status|promote|join-token,integrations …, andzonesscoped writes (PUT/PATCH /zones/{zone}). - Browser (loopback + PKCE) and device-code
login(§8.3, v0.2). - Swapping
internal/apiclient's transport for the generatedinternal/apiclient/openapiclient once it is stable, behind the surface above. - A conformance test that runs the CLI against the real
internal/apirouter (apitest) instead of the fake. It needs both packages on one branch, so it belongs to the integrator.