Skip to content

Commit e735518

Browse files
committed
feat: Docker/Podman-install awareness - honest service messaging, --mycroft-conf override
Investigated how this tool behaves on a Docker/Podman-based OVOS install (ovos-installer's "containers" method, or ovos-docker directly) - researched ovos-docker's own documentation and compose files, then confirmed the service-detection piece empirically against a real locally-run podman container (not just inferred from docs). Two confirmed, real gaps addressed: 1. Services: systemctl --user genuinely has nothing to find on a Docker/Podman install - OVOS runs as containers, not systemd units, in that mode. Previously this just showed a bare "Services: none found", indistinguishable from an actual problem. Added detect_container_runtime() (tries `docker ps` then `podman ps`, looking for OVOS/hivemind-named containers) - when systemd finds nothing but this does, the boot sequence now says so explicitly and lists the containers, rather than a silent, unexplained empty result. Does NOT attempt container start/stop/restart - that's separate, larger work (different commands, different confirmation semantics, potentially needing the Docker/Podman socket mounted) tracked as its own follow-up rather than bolted on here. 2. Pipeline: confirmed via ovos-docker's own compose files (volumes: ${CONFIG_FOLDER}:/home/${OVOS_USER}/.config/mycroft) that the real, live mycroft.conf commonly lives at a host path like ~/ovos/config/mycroft.conf (configurable per-install), not the standard ~/.config/mycroft/mycroft.conf ovos-config's own XDG-based lookup checks by default on the host. Since ovos-tui-client runs on the host, not inside the same containers, it had no way to find the right file. Added --mycroft-conf to point at a specific file directly (bypassing ovos-config's lookup for that one file read), using the same load_commented_json() helper ovos-config itself uses internally so real mycroft.conf files' JSON5-style '//' comments don't break it. Logs were also investigated but NOT changed: ~/.local/state/mycroft (already this tool's first candidate) matches both ovos-docker's common volume-mount convention and the official ovos-logs tool's own assumption, so this likely already works - genuinely unconfirmed without a full running Docker OVOS stack though (a much heavier setup than the lightweight container-naming test used for the services piece), tracked as a follow-up rather than assumed correct. 192 tests passing (11 new, including detect_container_runtime tested against a mocked subprocess AND confirmed once against a real local podman container during development, and --mycroft-conf tested against a real file with genuine JSON5 comments); verified sdist->wheel build. Not tagging yet - commit+push freely, tag once we're both happy.
1 parent f6f2d83 commit e735518

5 files changed

Lines changed: 230 additions & 10 deletions

File tree

README.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,52 @@ ovos-tui
180180
Connects to `127.0.0.1:8181` by default. Options:
181181

182182
```bash
183-
ovos-tui --host 192.168.1.50 --port 8181 --lang da-dk --log-dir ~/.local/state/mycroft
183+
ovos-tui --host 192.168.1.50 --port 8181 --lang da-dk --log-dir ~/.local/state/mycroft --mycroft-conf ~/ovos/config/mycroft.conf
184184
```
185185

186186
- `--log-dir`: the log directory is auto-detected against a list of
187187
known candidate paths (which vary by OVOS install method). If nothing
188188
is found, the logs pane says so - pass this to point at the right
189189
directory explicitly.
190+
- `--mycroft-conf`: path to a specific `mycroft.conf` for the `Pipeline: `
191+
palette entries to read. Only needed on Docker/Podman installs
192+
(`ovos-installer`'s "containers" method, or `ovos-docker` directly) -
193+
confirmed via `ovos-docker`'s own compose files that the real, live
194+
config commonly lives at a host path like `~/ovos/config/mycroft.conf`
195+
(configurable per-install), not the standard
196+
`~/.config/mycroft/mycroft.conf` `ovos-config` looks for by default.
197+
Without this, `Pipeline: ` may read the wrong file or find nothing on
198+
a Docker install - it won't crash, but it won't be accurate either.
199+
This direct-read path tolerates the same JSON5-style `//` comments a
200+
real `mycroft.conf` has, but skips `ovos-config`'s own config-layering
201+
(system/user/web-cache merge) - a reasonable trade-off for a quick
202+
lookup.
203+
204+
### Docker/Podman installs
205+
206+
`ovos-tui-client` itself runs on the host, not inside the same
207+
containers, so a few things behave differently on a Docker/Podman
208+
install (confirmed against `ovos-docker`'s own documentation and a
209+
locally-run container, not just inferred):
210+
211+
- **Logs**: usually fine without any extra flags - `ovos-docker`
212+
installs commonly volume-mount the same conventional
213+
`~/.local/state/mycroft` path this tool already checks first, and
214+
the official `ovos-logs` debugging tool relies on that same
215+
assumption. If the install was configured with `"logs": {"path":
216+
"stdout"}` (a documented `ovos-docker` recommendation for
217+
container-log-based debugging) instead, there are no log FILES at
218+
all - this tool will correctly report "no logs found", and
219+
`docker compose logs` / `docker logs <container>` are the right
220+
tools for that case instead.
221+
- **Services**: `systemctl --user` genuinely has nothing to find,
222+
since OVOS runs as containers, not systemd units, in this mode. The
223+
boot sequence detects this (checking `docker ps` / `podman ps` for
224+
OVOS-named containers) and says so explicitly rather than showing a
225+
bare, unexplained "none found" - but starting/stopping/restarting a
226+
container from here isn't supported yet (see the open issue for
227+
this).
228+
- **Pipeline**: see `--mycroft-conf` above.
190229

191230
## Why not just fix ovos-cli-client / neon-cli-client?
192231

ovos_tui_client/app.py

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
find_log_dir, discover_log_sources, line_matches_filter, strip_log_prefix,
6767
extract_log_level, extract_skill_id, KNOWN_LOG_NAMES, KNOWN_LOG_LEVELS,
6868
)
69-
from ovos_tui_client.services import discover_services_with_state, restart_service, stop_service, start_service
69+
from ovos_tui_client.services import discover_services_with_state, restart_service, stop_service, start_service, detect_container_runtime
7070
from ovos_tui_client.state import load_filter_state, save_filter_state
7171

7272
LOG_POLL_INTERVAL = 0.5 # seconds
@@ -190,7 +190,27 @@ class PipelineCommandProvider(Provider):
190190
reading mycroft.conf is fast/local, and pipeline order essentially
191191
never changes without an OVOS restart, so there's no staleness
192192
concern worth caching against. Uses ovos_config (respects OVOS's
193-
own config layering) rather than parsing the raw file.
193+
own config layering) by default - rather than parsing the raw file
194+
- UNLESS --mycroft-conf was given (see build_arg_parser()), in
195+
which case that specific file is read directly instead (still
196+
tolerating JSON5-style '//' comments via the same
197+
load_commented_json() helper ovos_config itself uses internally,
198+
not a plain json.load()).
199+
200+
The override exists because ovos_config's own XDG-based lookup
201+
doesn't know about Docker/Podman installs: confirmed via
202+
ovos-docker's own compose files that the real, live mycroft.conf
203+
commonly lives at a host path like ~/ovos/config/mycroft.conf
204+
(configurable per-install via the compose stack's CONFIG_FOLDER
205+
variable) rather than the standard ~/.config/mycroft/mycroft.conf
206+
ovos_config looks for on the host by default - since
207+
ovos-tui-client itself typically runs on the host, not inside the
208+
same containers, it has no way to know that non-standard path
209+
without being told. The direct-read fallback doesn't get
210+
ovos_config's config-layering (system/user/web-cache merge) - a
211+
reasonable trade-off for a quick lookup, and clearly indicated in
212+
the resulting message so it's never mistaken for the full,
213+
layered picture.
194214
195215
Read-only - toggling stages on/off would mean writing to
196216
mycroft.conf itself, which needs its own careful design pass (see
@@ -203,8 +223,13 @@ class PipelineCommandProvider(Provider):
203223
async def search(self, query: str) -> Hits:
204224
matcher = self.matcher(query)
205225
try:
206-
from ovos_config.config import Configuration
207-
pipeline = Configuration().get("intents", {}).get("pipeline", [])
226+
if self.app.mycroft_conf_override:
227+
from json_database.utils import load_commented_json
228+
conf = load_commented_json(self.app.mycroft_conf_override)
229+
pipeline = (conf or {}).get("intents", {}).get("pipeline", [])
230+
else:
231+
from ovos_config.config import Configuration
232+
pipeline = Configuration().get("intents", {}).get("pipeline", [])
208233
except Exception:
209234
return
210235
for i, stage in enumerate(pipeline, start=1):
@@ -433,13 +458,14 @@ class OVOSTUIApp(App):
433458

434459
COMMANDS = App.COMMANDS | {ServiceCommandProvider, SkillCommandProvider, SkillFilterCommandProvider, PipelineCommandProvider}
435460

436-
def __init__(self, host="127.0.0.1", port=8181, lang="en-us", log_dir_override=None):
461+
def __init__(self, host="127.0.0.1", port=8181, lang="en-us", log_dir_override=None, mycroft_conf_override=None):
437462
super().__init__()
438463
self.host = host
439464
self.port = port
440465
self.bus = OVOSBusConnection(host=host, port=port, lang=lang)
441466
self.log_dir = find_log_dir(override=log_dir_override)
442467
self.log_sources = discover_log_sources(self.log_dir)
468+
self.mycroft_conf_override = mycroft_conf_override
443469
self.utterance_history = []
444470
self.history_index = None
445471
self.log_buffer = deque(maxlen=LOG_BUFFER_SIZE)
@@ -583,7 +609,17 @@ def _check_services_worker(self) -> None:
583609
call_from_thread() only guarantees THIS call finishes before
584610
returning, not that no other thread's call can be scheduled in
585611
between two of THIS thread's separate calls. One call = one
586-
atomic write, no other thread's output can land inside it."""
612+
atomic write, no other thread's output can land inside it.
613+
614+
When systemd finds nothing, this checks for a Docker/Podman
615+
install before settling on a bare "none found" - a confirmed
616+
real gap: OVOS installed via ovos-docker runs everything as
617+
containers, not systemd --user units, so systemctl genuinely
618+
has nothing to report there, and "none found" alone reads like
619+
something's broken rather than "this is a different kind of
620+
install". Only detection + an honest message, not container
621+
start/stop/restart - see detect_container_runtime()'s own
622+
docstring for why that's separate, larger, tracked work."""
587623
services = discover_services_with_state()
588624
if services:
589625
lines = ["Services:"]
@@ -592,7 +628,15 @@ def _check_services_worker(self) -> None:
592628
lines.append(f" {name} {state}")
593629
self.call_from_thread(self._write_status, "\n".join(lines))
594630
else:
595-
self.call_from_thread(self._write_status, "Services: none found")
631+
containers = detect_container_runtime()
632+
if containers:
633+
lines = ["Services: not managed via systemd - looks like a Docker/Podman install:"]
634+
for name in containers:
635+
lines.append(f" {name}")
636+
lines.append(" (start/stop/restart from here isn't supported yet for containers - see `docker/podman ps`)")
637+
self.call_from_thread(self._write_status, "\n".join(lines))
638+
else:
639+
self.call_from_thread(self._write_status, "Services: none found")
596640
self.call_from_thread(self._finish_startup)
597641

598642
def _write_to_log(self, widget: RichLog, content) -> None:
@@ -1053,12 +1097,13 @@ def build_arg_parser():
10531097
parser.add_argument("--port", type=int, default=8181, help="messagebus port (default: 8181)")
10541098
parser.add_argument("--lang", default="en-us", help="BCP-47 language code for typed utterances (default: en-us)")
10551099
parser.add_argument("--log-dir", default=None, help="override log directory auto-detection")
1100+
parser.add_argument("--mycroft-conf", default=None, help="path to a specific mycroft.conf to read for 'Pipeline: ' (bypasses ovos-config's own XDG-based lookup - needed on Docker/Podman installs, where the real config commonly lives at a host path like ~/ovos/config/mycroft.conf rather than the standard ~/.config/mycroft/mycroft.conf ovos-config looks for by default)")
10561101
return parser
10571102

10581103

10591104
def run():
10601105
args = build_arg_parser().parse_args()
1061-
app = OVOSTUIApp(host=args.host, port=args.port, lang=args.lang, log_dir_override=args.log_dir)
1106+
app = OVOSTUIApp(host=args.host, port=args.port, lang=args.lang, log_dir_override=args.log_dir, mycroft_conf_override=args.mycroft_conf)
10621107
app.run()
10631108

10641109

ovos_tui_client/services.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,45 @@ def start_service(unit_name):
9696
"""Starts a single systemd --user unit. Same (success, message)
9797
contract as restart_service()."""
9898
return _systemctl_action("start", unit_name)
99+
100+
101+
def detect_container_runtime():
102+
"""Best-effort detection of OVOS running under Docker/Podman
103+
instead of systemd - meant to be checked when
104+
discover_services_with_state() finds nothing, so the boot sequence
105+
can give an honest, specific explanation ("looks like a
106+
Docker/Podman install") instead of a bare "none found" that reads
107+
like something's broken.
108+
109+
Confirmed via ovos-docker's own documentation
110+
(openvoiceos.github.io/ovos-docker) that OVOS services run as
111+
containers, not systemd units, when installed this way -
112+
`systemctl --user` genuinely has nothing to find in that case, so
113+
there's no bug to fix there, just a UI message worth improving.
114+
This function does NOT attempt to replace systemctl's start/stop/
115+
restart functionality for containers - that's real, separate work
116+
(different commands, different confirmation semantics, potentially
117+
needing the Docker/Podman socket mounted if ovos-tui-client itself
118+
ever runs containerized) tracked as its own follow-up rather than
119+
bolted on here as an afterthought.
120+
121+
Tries `docker` first, then `podman` (whichever is actually
122+
installed) - returns a sorted list of container names that look
123+
OVOS-related (containing 'ovos' or 'hivemind', case-insensitive,
124+
matching ovos-docker's own naming convention), or [] if neither
125+
runtime is available or neither has any matching containers."""
126+
for binary in ("docker", "podman"):
127+
try:
128+
result = subprocess.run(
129+
[binary, "ps", "--format", "{{.Names}}"],
130+
capture_output=True, text=True, timeout=5,
131+
)
132+
except (subprocess.SubprocessError, FileNotFoundError, OSError):
133+
continue
134+
if result.returncode != 0:
135+
continue
136+
names = [n.strip() for n in result.stdout.splitlines() if n.strip()]
137+
matching = [n for n in names if "ovos" in n.lower() or "hivemind" in n.lower()]
138+
if matching:
139+
return sorted(matching)
140+
return []

tests/test_command_palette.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,64 @@ async def test_pipeline_search_handles_empty_pipeline_gracefully(tmp_path):
402402
assert hits == []
403403

404404

405+
# --- PipelineCommandProvider: --mycroft-conf override (Docker/Podman
406+
# installs, where ovos_config's own XDG lookup finds the wrong file or
407+
# nothing - see build_arg_parser()'s help text and this Provider's
408+
# docstring for why) ---
409+
410+
@pytest.mark.asyncio
411+
async def test_pipeline_uses_mycroft_conf_override_when_set(tmp_path):
412+
"""A real file on disk, with JSON5-style '//' comments (as real
413+
mycroft.conf files commonly have) - confirms load_commented_json()
414+
is actually used, not a plain json.load() that would choke on
415+
them."""
416+
conf_path = tmp_path / "mycroft.conf"
417+
conf_path.write_text('''{
418+
// this is a real mycroft.conf-style comment
419+
"intents": {
420+
"pipeline": ["stop_high", "ocp_high"]
421+
}
422+
}''')
423+
app = _app_with_fake_bus(tmp_path)
424+
app.mycroft_conf_override = str(conf_path)
425+
async with app.run_test() as pilot:
426+
provider = PipelineCommandProvider(app.screen)
427+
hits = await _collect_hits(provider, "pipeline")
428+
429+
texts = [str(h.match_display) for h in hits]
430+
assert "Pipeline: 1. stop_high" in texts
431+
assert "Pipeline: 2. ocp_high" in texts
432+
433+
434+
@pytest.mark.asyncio
435+
async def test_pipeline_override_takes_priority_over_ovos_config(tmp_path):
436+
"""When set, the override is used INSTEAD of ovos_config's own
437+
lookup, not merged with it - confirms Configuration() is never
438+
even called in that case."""
439+
conf_path = tmp_path / "mycroft.conf"
440+
conf_path.write_text('{"intents": {"pipeline": ["from_override"]}}')
441+
app = _app_with_fake_bus(tmp_path)
442+
app.mycroft_conf_override = str(conf_path)
443+
async with app.run_test() as pilot:
444+
with patch("ovos_config.config.Configuration") as mock_config:
445+
provider = PipelineCommandProvider(app.screen)
446+
hits = await _collect_hits(provider, "pipeline")
447+
mock_config.assert_not_called()
448+
449+
assert "Pipeline: 1. from_override" in [str(h.match_display) for h in hits]
450+
451+
452+
@pytest.mark.asyncio
453+
async def test_pipeline_override_handles_missing_file_gracefully(tmp_path):
454+
app = _app_with_fake_bus(tmp_path)
455+
app.mycroft_conf_override = str(tmp_path / "does-not-exist.conf")
456+
async with app.run_test() as pilot:
457+
provider = PipelineCommandProvider(app.screen)
458+
hits = await _collect_hits(provider, "pipeline") # must not raise
459+
460+
assert hits == []
461+
462+
405463
# --- SkillFilterCommandProvider: log-display skill filter, in the palette ---
406464

407465
@pytest.mark.asyncio

tests/test_services.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
real service management is exercised here."""
33
from unittest.mock import MagicMock, patch
44

5-
from ovos_tui_client.services import discover_services, discover_services_with_state, restart_service, stop_service, start_service
5+
from ovos_tui_client.services import discover_services, discover_services_with_state, restart_service, stop_service, start_service, detect_container_runtime
66

77

88
def _fake_completed(stdout="", stderr="", returncode=0):
@@ -139,3 +139,39 @@ def test_discover_services_still_returns_name_only_list():
139139
fake_output = "ovos-core.service loaded active running X\n"
140140
with patch("subprocess.run", return_value=_fake_completed(stdout=fake_output)):
141141
assert discover_services() == ["ovos-core.service"]
142+
143+
144+
# --- detect_container_runtime() (confirmed against a real podman container during development) ---
145+
146+
def test_detect_container_runtime_finds_ovos_named_containers():
147+
fake_output = "ovos-core-test\nsome-other-container\nhivemind-relay\n"
148+
with patch("subprocess.run", return_value=_fake_completed(stdout=fake_output)):
149+
assert detect_container_runtime() == ["hivemind-relay", "ovos-core-test"]
150+
151+
152+
def test_detect_container_runtime_returns_empty_when_no_matching_containers():
153+
fake_output = "some-other-container\nanother-one\n"
154+
with patch("subprocess.run", return_value=_fake_completed(stdout=fake_output)):
155+
assert detect_container_runtime() == []
156+
157+
158+
def test_detect_container_runtime_returns_empty_when_neither_binary_available():
159+
with patch("subprocess.run", side_effect=FileNotFoundError()):
160+
assert detect_container_runtime() == []
161+
162+
163+
def test_detect_container_runtime_falls_back_from_docker_to_podman():
164+
"""docker not installed (FileNotFoundError) but podman is and has a
165+
match - confirms the fallback actually tries the second binary
166+
rather than giving up after the first failure."""
167+
call_count = {"n": 0}
168+
169+
def fake_run(cmd, **kwargs):
170+
call_count["n"] += 1
171+
if cmd[0] == "docker":
172+
raise FileNotFoundError()
173+
return _fake_completed(stdout="ovos-messagebus\n")
174+
175+
with patch("subprocess.run", side_effect=fake_run):
176+
assert detect_container_runtime() == ["ovos-messagebus"]
177+
assert call_count["n"] == 2

0 commit comments

Comments
 (0)