- Status: done
- Date: 2026-05-24
- Specs touched:
DISCOVERY.md(§ "Per-app A records" rewritten),DECISIONS.md(new entry 2026-05-24)
Slice 0012 wrote /etc/avahi/services/app-<slug>.service XML files as the per-app A-record mechanism. We tested this against a real Avahi daemon and it does not work: static service files announce services, not bare A-record aliases. The file loaded without error (Avahi logged "Service ... successfully established") but avahi-resolve -n <slug>.malmo.local timed out. Avahi will not publish a standalone A record for a <host-name> declared inside a service-group file — it owns only the service record. See DECISIONS.md entry 2026-05-24 for the full rationale.
0012 is deleted. Its FilePublisher implementation, test file, and progress doc are gone. The nspawn lane entry ("provision Avahi, write a service file, verify the A record resolves") is dropped — the premise was wrong.
publisher.go (//go:build linux) — DBusPublisher:
- Connects to the system bus (lazily on first
Publishcall). - Detects the box's local IPv4 via
net.InterfaceAddrs()(first non-loopback, non-link-local address; cached after first call). - Per
Publish(slug): callsEntryGroupNew→AddAddress(ifaceUnspec, protoUnspec, flagNone, hostname, ip)→Commit. Stores the group path inp.groups[slug]. Returns the hostname andErrCollisionfor name-collision DBus errors. - Per
Unpublish(slug): callsEntryGroup.Freeand removes the group from the map. Idempotent (missing slug = nil). Close(): frees all groups, closes the bus connection. Called on host-agent shutdown.ErrCollisionsentinel (package-levelerrors.New) for callers to distinguish collision from other failures.
slug.go (no build tag) — slugRE := regexp.MustCompile(^[a-z0-9-]+$). Shared across Linux and the non-Linux stub; the test file references it without a build tag.
dbus_other.go (//go:build !linux) — no-op stub. DBusPublisher and ErrCollision defined; all methods return "not supported on this OS". Allows go build ./... on macOS without errors. cmd/host-agent-real is Linux-only and is the only binary that instantiates DBusPublisher.
helpers_test.go (no build tag) — runs everywhere:
TestSlugRE_RejectsInvalidSlugs— path traversal, slashes, uppercase, dots, spaces, emptyTestSlugRE_AcceptsValidSlugs— representative valid slugsTestPublish_RejectsInvalidSlug— invalid slugs return error before any DBus callTestErrCollision_Sentinel—errors.Iscontract
dbus_linux_test.go (//go:build linux && avahitest) — skipped by default; run with -tags avahitest against a real avahi-daemon:
- Publish + Unpublish round-trip
- Unpublish idempotency (never-published slug)
- Double-Publish idempotency (old group freed, one group left)
- ErrCollision sentinel wrapping behavior
No DBus mocking. Rationale: mocking the Avahi server side is more code than the publisher itself and wrong in different ways. The nspawn CI lane is the right place for real Avahi coverage.
The startup reconcile (lifecycle.Reconcile) already calls m.host.Publish(ctx, inst.Slug) for every running instance via reassertRouting. This is the replay hook: it fires on every brain startup (with or without host-agent having restarted). reassertRouting now returns a bool (Avahi success/failure) so Reconcile can emit a summary log line:
avahi replay total=N ok=M failed=K
This covers both "brain restart while host-agent was running" and "both restart together."
cmd/host-agent-real/main.go— instantiates&avahipublisher.DBusPublisher{HostSuffix: ".malmo.local"}, passes it tohostagent.New. Deferspub.Close()on shutdown.cmd/host-agent/main.go— unchanged; still usesFakePublisher.internal/hostagent/agent.go— unchanged;Publisherinterface is unmodified.
github.com/godbus/dbus/v5 v5.2.2. Pure Go. MIT license. Added to go.mod / go.sum.
DISCOVERY.md§ "Per-app A records" — implementation is now accurate: DBusEntryGroup.AddAddress, not static files.BRAIN_HOST_PROTOCOL.md— wire shape unchanged;publishstill returns{name, state: "established"}immediately after DBus Commit succeeds. No protocol change needed.- CLAUDE.md "consumer-side interfaces" —
Publisherinterface stays ininternal/hostagent;DBusPublisheris the provider. - CLAUDE.md
log/slogwith standard fields —slug,name,ip,errused throughout.
- Mid-life host-agent restart while brain is running is not covered. The brain does not detect that host-agent restarted mid-life (it only knows the agent is reachable). A future mitigation: poll
GET /v1/system/statusforuptime_sdecreasing and replay on detection. - No collision-resolution UX.
ErrCollisionis returned to the caller but the brain's install transaction only logs a warning — no slug rename or retry. Tracked inNEXT.md. - Local-IP detection assumes single primary IPv4. First non-loopback non-link-local address wins. Multi-homed or dual-stack boxes may announce on the wrong interface. Future: prefer the NM primary connection's address.
- DBus calls not real-tested in CI until the nspawn lane lands.
go test ./...does not runavahitest-tagged tests. Unit tests cover slug validation and the ErrCollision sentinel only. /etc/avahi/avahi-daemon.confinterface scoping not written.DISCOVERY.md§ "Interface scoping" specifies host-agent computes the allow/deny-interfaces list from NetworkManager state at boot. Deferred.- The host record (
malmo.local) — published at boot time, not by the per-app reconciler. Deferred to boot-sequence integration. setPassword,setRole,deleteUserstill fake inhost-agent-real. Carried from 0011.
- nspawn lane: provision avahi-daemon, run
-tags avahitesttests, verifyavahi-resolveoutput. - Mid-life host-agent restart detection (uptime_s polling or DBus signal subscription).
- Real
setPassword/setRole/deleteUserinhost-agent-real. /etc/avahi/avahi-daemon.confallow/deny-interface config from NM state.