diff --git a/debian/changelog b/debian/changelog index 1594a39..99df9fb 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,16 @@ +confconsole (2.2.3+keel13) trixie; urgency=low + + * The first boot role screen does not run keel inspect on a machine + with no database server. keel inspect reads the whole machine to find + the engine: 2 s on a Web container, 8 s with a quarter of a CPU, with + the password screen still up and nothing new on it (the maintainer's + Proxmox console after , 2026-10-02). Without any server binary + of keel's own table (keel.inspect.dbengines.ENGINES) there is nothing + to find, so the role stays standalone at once, and the journal says + why as before. When the table cannot be read, keel is asked. + + -- Marcos Méndez Fri, 02 Oct 2026 18:00:00 +0000 + confconsole (2.2.3+keel12) trixie; urgency=low * The Instance menu offers a screen only where what it configures is in diff --git a/keelfirstboot.py b/keelfirstboot.py index 0d02b01..d3e8f86 100644 --- a/keelfirstboot.py +++ b/keelfirstboot.py @@ -38,6 +38,7 @@ (_TURNKEY_INIT) is an explicit interactive run and asks again. """ +import glob import os import shutil import sys @@ -346,13 +347,38 @@ def choose_role(console, path: str, document: dict) -> None: def find_engine(server: dict) -> str: - """The declared engine, else the one keel inspect finds, quietly""" + """The declared engine, else the one keel inspect finds, quietly + + keel inspect reads the whole machine, which took 2 s on a Web + container and 8 s on a quarter of a CPU, a gap with nothing on the + screen after the password; with no server binary there is nothing for + it to find, so it is not asked. + """ declared = str(server.get("engine") or "") if declared: return declared + if not database_server_installed(): + return "" return dbscreen.observed_engine()[0] +def database_server_installed(root: str = "/") -> bool: + """Whether a server binary keel inspect looks for is under ROOT + + The binaries are keel's own table (keel.inspect.dbengines.ENGINES), so + the two never disagree; when it cannot be read, keel is asked. + """ + try: + from keel.inspect.dbengines import ENGINES + except ImportError: + return True + return any( + glob.glob(os.path.join(root, pattern)) + for engine in ENGINES + for pattern in engine.server_binaries + ) + + def standalone(console, path: str, document: dict, server: dict, engine: str) -> None: """Record the default, or go back to it through its own screen diff --git a/tests/test_first_boot.py b/tests/test_first_boot.py index 8b169a6..3a61c83 100644 --- a/tests/test_first_boot.py +++ b/tests/test_first_boot.py @@ -12,6 +12,7 @@ import runpy import stat import subprocess +import sys from pathlib import Path import pytest @@ -399,6 +400,8 @@ def test_the_engine_comes_from_the_machine_when_undeclared( self, keel, spec, flows, monkeypatch ): spec({"version": 1}) + monkeypatch.setattr(keelfirstboot, "database_server_installed", + lambda: True) monkeypatch.setattr(dbscreen, "observed_engine", lambda: ("mariadb", "", "")) console = FakeConsole(menus=[("ok", "Primary")]) @@ -407,10 +410,29 @@ def test_the_engine_comes_from_the_machine_when_undeclared( assert flows == [("primary", {}, "mariadb")] + def test_no_server_binary_means_no_role_and_no_keel_inspect( + self, keel, spec, flows, monkeypatch, capsys + ): + # 2026-10-02: keel inspect took 2 s on a Web container (8 s with a + # quarter of a CPU) to say what the missing server binary says + spec({"version": 1}) + monkeypatch.setattr(keelfirstboot, "database_server_installed", + lambda: False) + monkeypatch.setattr(dbscreen, "observed_engine", pytest.fail) + console = FakeConsole() + + keelfirstboot.choose_role(console, str(spec.path), read(spec.path)) + + assert console.calls == [] + assert flows == [] + assert "no database server" in capsys.readouterr().err + def test_a_machine_without_a_database_server_has_no_role_to_ask( self, keel, spec, flows, monkeypatch, capsys ): spec({"version": 1}) + monkeypatch.setattr(keelfirstboot, "database_server_installed", + lambda: True) monkeypatch.setattr(dbscreen, "observed_engine", lambda: ("", "nothing", "")) console = FakeConsole() @@ -432,6 +454,49 @@ def test_a_menu_that_did_not_answer_changes_nothing( assert flows == [] +class TestTheServerBinaries: + """keel's own table says which binaries prove a server is installed""" + + @pytest.fixture + def engines(self, monkeypatch): + """A keel.inspect.dbengines whose ENGINES name these patterns""" + def install(*patterns): + engine = type("Engine", (), {"server_binaries": patterns}) + module = type(sys)("keel.inspect.dbengines") + module.ENGINES = (engine,) + for name in ("keel", "keel.inspect"): + monkeypatch.setitem(sys.modules, name, type(sys)(name)) + monkeypatch.setitem(sys.modules, "keel.inspect.dbengines", + module) + return install + + def test_a_server_binary_present_is_installed(self, engines, tmp_path): + engines("usr/sbin/mariadbd", "usr/lib/postgresql/*/bin/postgres") + binary = tmp_path / "usr/lib/postgresql/17/bin/postgres" + binary.parent.mkdir(parents=True) + binary.touch() + + assert keelfirstboot.database_server_installed(str(tmp_path)) + + def test_no_server_binary_is_not_installed(self, engines, tmp_path): + engines("usr/sbin/mariadbd", "usr/sbin/mysqld") + + assert not keelfirstboot.database_server_installed(str(tmp_path)) + + def test_without_keel_s_table_keel_is_asked(self, monkeypatch, tmp_path): + monkeypatch.setitem(sys.modules, "keel.inspect.dbengines", None) + + assert keelfirstboot.database_server_installed(str(tmp_path)) + + def test_a_table_of_another_shape_means_keel_is_asked( + self, engines, monkeypatch, tmp_path + ): + engines() + monkeypatch.delattr(sys.modules["keel.inspect.dbengines"], "ENGINES") + + assert keelfirstboot.database_server_installed(str(tmp_path)) + + class TestStandalone: def test_a_fresh_node_records_the_role_without_applying( self, keel, spec