Milestone v0.3.0 — Tier 2 Container Management¶
Target: v0.3.0 — Controller manages Recorder lifecycle (start/stop), Hardware Detection, State Reconciliation & Log Streaming
Status: ✅ Complete — All Phases (1–6) done
References: ADR-0013, ADR-0007 §6, ADR-0009, VISION.md, Controller README, Recorder README
User Stories: US-C01, US-C02, US-C03, US-C04, US-C06, US-C07, US-C08, US-C09, US-R01, US-R02, US-R05
Phase 1: Controller ↔ Podman Socket Connection¶
Goal: Controller connects to the host Podman engine and can list running containers.
Tasks¶
- [x] Add
podman-pyas dependency toservices/controller/pyproject.toml - [x] Create
silvasonic/controller/podman_client.py— PodmanClient wrapper - Connect to socket (
SILVASONIC_CONTAINER_SOCKETenv var, default/var/run/container.sock) ping()health check on startup- Reconnect logic (socket may not be available immediately)
- [x] Verify socket mount works in
compose.yml(${SILVASONIC_PODMAN_SOCKET}:/var/run/container.sock:z) - [x] Unit test: mock PodmanClient, verify connection logic
- [x] Integration test: Controller container connects to host Podman, lists containers
Config Changes (✅ Already Applied)¶
| File | Change |
|---|---|
compose.yml |
Socket volume mount, SILVASONIC_CONTAINER_SOCKET and SILVASONIC_NETWORK env vars |
.env / .env.example |
SILVASONIC_PODMAN_SOCKET, SILVASONIC_NETWORK activated |
Phase 2: Configuration & Seeding¶
Goal: Controller bootstraps the system configuration and default microphone profiles on startup (ADR-0016, ADR-0023).
Tasks¶
- [x] Create
silvasonic/controller/seeder.py— Startup Seeding Logic - [x] Implement
ConfigSeeder: - Populates
system_configtable with missing defaults (e.g.,auto_enrollment: true). - Does not overwrite values changed by the user.
- [x] Implement
ProfileBootstrapper: - Reads YAML seed files from bundled
profiles/directory. - Checks if a profile with the same
slugexists inmicrophone_profilestable. - If it exists → skip. If not → insert.
- Validate all seeded profiles against the
MicrophoneProfilePydantic schema before insertion. - [x] Implement
AuthSeeder(ADR-0023 §2.4, US-C08): - Reads
authsection fromconfig/defaults.yml(default_username, default_password). - Checks if user with same username already exists → skip.
- Hashes password with
bcryptbefore insertion intouserstable. - Existing user accounts are never overwritten (idempotent).
- [x] Unit tests: Verify idempotence of seeders and that existing overrides are protected.
- [x] Unit tests: AuthSeeder — admin creation with bcrypt hash, skip existing, missing file, no auth section, invalid YAML (5 tests).
- [x] Integration test: AuthSeeder inserts admin user with bcrypt hash into real PostgreSQL.
Phase 3: Container Lifecycle Management & State Reconciliation¶
Goal: Controller can start, stop, list Tier 2 containers, enforce limits, and maintain desired state via reconciliation and nudges.
Tasks — Container Management¶
- [x] Create Pydantic model
Tier2ServiceSpecdefining: - Image name, container name pattern
- Labels (auto-populated:
io.silvasonic.tier,.owner,.service,.device_id,.profile) - Environment variables, devices, mounts (with RO/RW distinction per ADR-0009)
- Restart policy (
on-failure, max 5 retries) - Network name (from
SILVASONIC_NETWORK) - Resource limits:
memory_limit,cpu_limit,oom_score_adj(ADR-0020). Recorder getsoom_score_adj=-999. - [x] Create
silvasonic/controller/container_manager.py: start(spec: Tier2ServiceSpec) → Container— callspodman.containers.run()with resource limitsstop(name: str)— sends SIGTERM, waits for graceful shutdownlist_managed() → list[Container]— queriesio.silvasonic.owner=controllerreconcile()— evaluates DB state vs Actual Podman state.
Tasks — State Reconciliation & Nudge Subscriber (US-C03, US-C07)¶
- [x] Implement Device State Evaluation logic (reconcile only starts a Recorder if:
status == "online" AND enabled == true AND enrollment_status == "enrolled" AND profile_slug IS NOT NULL). - [x] Implement Reconciliation Loop (async, configurable interval) to enforce the above logic.
- [x] Create
silvasonic/controller/nudge_subscriber.py: - Subscribe to Redis channel
silvasonic:nudge. - On receiving
"reconcile", immediately trigger the reconciliation logic to execute web-interface commands (e.g., enable/disable microphone). - [x] Unit tests: mock
podman-py& DB, verify state evaluation and start/stop/reconcile logic.
Phase 4: USB Detection & Recorder Spawning¶
Goal: Controller detects USB microphones within ≤ 1 s (polling), matches profiles, and starts Recorder containers dynamically (US-C01, US-R01, US-R05).
Tasks — USB Detection¶
- [x] Create
silvasonic/controller/device_scanner.py—DeviceScanner - Enumerate ALSA cards via
/proc/asound/cards - Correlate each card with USB parent via
sysfs/pathlib(→DeviceInfo) - Extract:
usb_vendor_id,usb_product_id,usb_serial,alsa_name,alsa_device - [x] Implement stable device identity (
devices.nameas PK): - With USB serial:
{vendor_id}-{product_id}-{serial}(globally unique) - Without serial:
{vendor_id}-{product_id}-port{bus_path}(port-bound) - Fallback:
alsa-card{index}(unstable across reboots) - [x] Implement
upsert_device()— insert-or-update device indevicestable - [x] Integrate hardware rescan into Reconciliation Loop (
_rescan_hardware()— runs every cycle) - [x] Implement disconnect detection: devices no longer found are marked
status=offline - [x] Unit tests:
DeviceInfo.stable_device_id,parse_asound_cards,DeviceScanner.scan_all,upsert_device(22 tests, all passing)
Tasks — Profile Matching & Auto-Enrollment (US-C06)¶
- [x] Create
silvasonic/controller/profile_matcher.py—ProfileMatcher - Score 100: USB Vendor+Product ID match → Auto-Enroll (if
auto_enrollmentis true) - Score 50: ALSA name substring match → suggest profile (set as pending)
- Score 0: no match → pending
- [x] Integrate reading
auto_enrollmentflag fromsystem_configtable on each evaluation cycle. - [x] Unit tests: exact match, ALSA match, no match, auto_enrollment=false, case-insensitive, empty profiles (7 tests)
Tasks — Recorder Spawning (US-R01)¶
- [x] Create
build_recorder_spec()factory function incontainer_spec.py: - Image:
localhost/silvasonic_recorder:latest - Name pattern:
silvasonic-recorder-{slug}-{suffix}(z.B.silvasonic-recorder-ultramic-384-evo-034f) - Devices:
/dev/snd:/dev/snd - Group add:
audio - Privileged:
true(see ADR-0007 §6) - Mounts: Recorder workspace = RW (producer), with
controller_sourcefor mkdir - Env vars:
SILVASONIC_RECORDER_DEVICE,SILVASONIC_RECORDER_PROFILE_SLUG,SILVASONIC_REDIS_URL,SILVASONIC_INSTANCE_ID(Profile Injection, ADR-0013) - Resource limits from env vars with defaults (
512m,1.0CPU,oom_score_adj=-999) - [x] Connect Recorder spawning to reconciliation loop (DeviceStateEvaluator → build_recorder_spec → ContainerManager.reconcile)
- [x] Add Redis heartbeat to Recorder (fire-and-forget via
SilvaServicebase class, ADR-0019) - [x] Integration test: Controller spawns Recorder container, verifies health, stops it.
Phase 5: Live Log Streaming¶
Goal: Provide realtime log streaming for Tier 2 services to the Web-Interface via Redis (US-C09, ADR-0022).
Tasks¶
- [x] Create
silvasonic/controller/log_forwarder.py - Continuously follow logs of running Tier 2 containers via
container.logs(stream=True). - Publish log lines as JSON to Redis channel
silvasonic:logs. - Design to be resilient: automatically reconnect string if container restarts.
- Implement fire-and-forget (if no subscribers, Redis discards the event).
- [x] Unit tests:
_parse_log_line,_sync_follow_tasks,run()main loop,_follow_containererror handling,_cancel_all_tasks(26 tests, 98% coverage) - [x] Integration tests: LogForwarder ↔ real Redis Pub/Sub — publish, non-JSON fallback, container removal, graceful shutdown (4 tests)
Phase 6: Integration & Hardening¶
Goal: End-to-end lifecycle works reliably. System survives crashes and restarts (US-C02, US-R02).
Tasks¶
- [x] Implement graceful shutdown handler in Controller (
run()) - On SIGTERM: stop all owned Tier 2 containers, then exit
_stop_all_tier2()method querieslist_managed()and stops each container before closing the Podman client.- [x] Test crash recovery: kill Controller, verify Recorder keeps running (Podman manages restart limit).
- [x] Test reconciliation: restart Controller, verify it adopts existing Recorder without restarting it.
- [x] Test multi-instance: start 2 Recorders for different devices, verify labels and isolated file structures.
- [x] ~~Update smoke tests~~ — N/A: smoke test Controller lacks Podman socket (DooD). Lifecycle is fully covered by
test_crash_recovery.pyagainst real Podman (3 tests, 33s).
Out of Scope (Deferred)¶
| Item | Target Version |
|---|---|
Actual audio recording (recorder/__main__.py) |
v0.4.0 |
| Uploader, BirdNET, BatDetect as Tier 2 | v0.6.0+ |
| Icecast live Opus stream (Recorder → Icecast) | v1.1.0 |
| Quadlet generation for production | v1.0.0 |
Note: Resource limits (CPU/RAM) and QoS (
oom_score_adj) are now in scope for Phase 3 as part of theTier2ServiceSpecmodel. See ADR-0020.Note: Live Log Streaming via Redis Pub/Sub has been added as Phase 5.
Note: Configuration Seeding (DB bootstrapper) has been added as Phase 2.
Note: USB detection uses
sysfs/pathlibdirectly (nopyudevdependency).