From dedb979ccc635477a851f3089a556d1e2cf8e9e3 Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 13:47:47 +0000 Subject: [PATCH 1/3] fix: Keel screens follow the appliance manifest, and fit 80x24 The maintainer's screenshots of the Keel Web step 8 image on Proxmox showed Database mode on an appliance with no database, WireGuard in the first menu of a simple installation, a Keel Cloud screen asking for a key nobody can generate, and descriptions cut at the box edge. keelmenu.py reads the appliance the spec names, resolved along its chain by keel itself (keel.manifest.facts.gather, decision 0041), and the installation mode: Database mode only with a mariadb or postgresql data service in the chain; Overlay network in the menu in the cloud modes or once the overlay is in use, behind a new Advanced entry otherwise; Keel Cloud only once /etc/keel/cloud-endpoint names the service, which also gates the first boot step 80keel-cloud runs. A chain that cannot be read keeps the screens a machine had. keelfit.py sizes the boxes: a menu as wide as its widest choice or line, a message box as its text, both up to what the terminal leaves, so nothing is cut and nothing is drawn over the backtitle. Show drift stacks its table when it is wider than the box, the overlay screen lists one line a peer, and the Keel descriptions fit 80 columns, held by a test. The overlay address text says it is this node's address on Keel's private mesh, randomly generated, not a LAN address. --- .github/workflows/tests.yml | 9 +- COVERAGE.md | 15 + confconsole.py | 36 +- debian/changelog | 32 ++ docs/Instance.rst | 54 ++- keelcli.py | 32 +- keelfirstboot.py | 21 +- keelfit.py | 96 +++++ keelmenu.py | 200 +++++++++++ plugin.py | 62 ++-- .../Instance/Database_mode/01Standalone.py | 2 +- .../Instance/Database_mode/Cloud/01Primary.py | 2 +- .../Instance/Database_mode/Cloud/02Replica.py | 2 +- .../Cloud/03Promote_this_replica.py | 2 +- plugins.d/Instance/Keel_Cloud.py | 2 +- plugins.d/Instance/Overlay_network.py | 2 +- plugins.d/Instance/Show_drift.py | 4 +- plugins.d/Instance/description | 2 +- tests/conftest.py | 65 ++++ tests/test_confconsole_boxes.py | 166 +++++++++ tests/test_first_boot.py | 82 +++++ tests/test_instance_menu.py | 177 +++++++++- tests/test_keelcli.py | 29 ++ tests/test_keelfit.py | 121 +++++++ tests/test_keelmenu.py | 329 ++++++++++++++++++ tests/test_overlay_screen.py | 57 ++- wgcli.py | 43 ++- wgscreen.py | 27 +- 28 files changed, 1580 insertions(+), 91 deletions(-) create mode 100644 keelfit.py create mode 100644 keelmenu.py create mode 100644 tests/test_confconsole_boxes.py create mode 100644 tests/test_keelfit.py create mode 100644 tests/test_keelmenu.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4ef8286..23aa6ea 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,9 +6,10 @@ on: jobs: tests: # Threshold 100: ifutil.py, keelcli.py, keelbanner.py, dbscreen.py, - # wgcli.py, wgscreen.py, keelfirstboot.py and the Instance menu - # entries, the database mode, overlay and Keel Cloud screens - # included, measure 100 percent (682 tests, lines and branches). The + # wgcli.py, wgscreen.py, keelfirstboot.py, keelfit.py, keelmenu.py + # and the Instance menu entries, the database mode, overlay and Keel + # Cloud screens included, measure 100 percent (796 tests, lines and + # branches). The # package list names the modules with tests; a module is appended as # its tests land (COVERAGE.md plan) and the threshold is only ever # raised (decision 0006). Baseline before the first test: 0 percent. @@ -18,7 +19,7 @@ jobs: uses: keel-linux/.github/.github/workflows/test-python.yml@main with: threshold: 100 - package: ifutil,keelbanner,keelcli,dbscreen,wgcli,wgscreen,keelfirstboot,plugins.d/Instance + package: ifutil,keelbanner,keelcli,dbscreen,wgcli,wgscreen,keelfirstboot,keelfit,keelmenu,plugins.d/Instance # A change that ships must come with a changelog entry, or it cannot be # installed and never reaches an appliance. The rule and its tests are # bin/require-changelog of the .github repository. The check is diff --git a/COVERAGE.md b/COVERAGE.md index 642999c..3c1484e 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -339,6 +339,21 @@ of the first version is gone: on 80 by 24 a 20 row box is what fits, so the small mark goes above a usage text of up to seven rows, and a dual stack usage screen keeps no mark. +## Measured on 2026-10-02 with the menus the manifest decides + +| File | Tests | Stmts | Branches | Cover | +|------|-------|-------|----------|-------| +| keelmenu.py (new: which Keel screens a machine shows) | in tests/test_keelmenu.py: the chain's overlays and data engines from keel's resolution (replaced; two tests run keel's own where it is installed), an application's services, a chain keel cannot resolve or a keel that fails or is missing, the spec's appliance and mode and the overlay in use, Database mode, Overlay network and Keel Cloud placed for Core, Web, a MariaDB appliance and WordPress in each mode, the flag that turns Keel Cloud on, the Advanced menu and where the overlay screen is | 107 | 24 | 100 percent, 0 missed, 0 partial | +| keelfit.py (new: box sizes) | in tests/test_keelfit.py: the room the terminal leaves, a box cut to it, a menu as wide as its widest choice or line of text, a message box sized to its text, an item shortened with an ellipsis only past the full width | 43 | 8 | 100 percent, 0 missed, 0 partial | +| keelcli.py | as before plus the drift table stacked two lines a field when it is wider than the box | | | 100 percent, 0 missed, 0 partial | +| keelfirstboot.py, wgcli.py, wgscreen.py, plugins.d/Instance | as before plus the Keel Cloud gate at first boot and run by name, the overlay screen in 24 rows, the mesh address wording, the Instance menu built for each appliance and every Keel menu fitting 80 columns | | | 100 percent, 0 missed, 0 partial | + +Total 796 tests with keel at hand (789 passed and 7 skipped without +it), 100 percent over the measured package, which gains `keelfit` and +`keelmenu`. Threshold in the caller: 100, unchanged. +`tests/test_confconsole_boxes.py` holds `Console`'s sizes; confconsole.py +stays unmeasured. + ## Plan to reach 90 percent per file Priority order (size: small under 30 lines of test, medium under 150, diff --git a/confconsole.py b/confconsole.py index 7f76f0e..2c164d5 100755 --- a/confconsole.py +++ b/confconsole.py @@ -32,6 +32,7 @@ import ifutil import conf import keelbanner +import keelfit import plugin from typing import NoReturn, Iterable, Mapping, Any @@ -213,6 +214,11 @@ def __init__( if title: self.console.add_persistent_args(["--backtitle", title]) + def _box(self) -> tuple[int, int]: + """The usual box, no larger than the terminal leaves: on 24 rows + 25 drew over the backtitle""" + return keelfit.box(self.height, self.width, keelfit.room()) + def _handle_exitcode(self, retcode: str) -> bool: if retcode == "esc": text = "Do you really want to quit?" @@ -267,7 +273,7 @@ def infobox(self, text: str) -> str: def yesno(self, text: str, autosize: bool = False) -> str: if autosize: text += "\n " - height, width = 0, 0 + height, width = keelfit.text_box(text, keelfit.room()) else: height, width = 10, 30 v = self._wrapper("yesno", text, height, width) @@ -287,8 +293,12 @@ def msgbox( # more than the usual text, such as the usage screen with the mark # above it. if autosize: + # sized to the text and the terminal (keelfit), where dialog's + # own autosize drew over the backtitle on 24 rows text += "\n " - height, width = 0, 0 + height, width = keelfit.text_box(text, keelfit.room()) + elif height is None and width is None: + height, width = self._box() else: height, width = height or self.height, width or self.width @@ -307,11 +317,12 @@ def inputbox( cancel_label: str = "Cancel", ) -> tuple[str, str]: no_cancel = True if cancel_label == "" else False + height, width = self._box() v = self._wrapper( "inputbox", text, - self.height, - self.width, + height, + width, title=title, init=init, ok_label=ok_label, @@ -332,14 +343,23 @@ def menu( # default_item: the choice highlighted when the menu opens, such # as the role a node already has; dialog's first one otherwise extra = {} if default_item is None else {"default_item": default_item} + # as wide as the widest line, up to the terminal; a description + # is shortened, with an ellipsis, only when even that is too + # narrow (keelfit), never cut at the border by dialog + space = keelfit.room() + height, width = keelfit.box( + self.height, + keelfit.menu_width(choices, self.width, space[1], text), + space, + ) v = self._wrapper( "menu", text, - self.height, - self.width, + height, + width, menu_height=len(choices) + 1, title=title, - choices=choices, + choices=keelfit.fit_choices(choices, width), no_cancel=no_cancel, **extra, ) @@ -359,7 +379,7 @@ def form( text += "\n " height, width = 0, 0 else: - height, width = self.height, self.width + height, width = self._box() v = self._wrapper( "form", text, diff --git a/debian/changelog b/debian/changelog index 02a2b82..1f04a46 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,35 @@ +confconsole (2.2.3+keel12) trixie; urgency=low + + * The Instance menu offers a screen only where what it configures is in + the appliance, read from the appliance manifest (handbook decision + 0041) through keel's own resolution: Database mode only where the + chain has a mariadb or postgresql data service, so not on Keel Core + or Keel Web (the maintainer's screenshots of the Keel Web step 8 + image); Overlay network in the menu in the cloud modes or once the + overlay is in use, behind a new Advanced entry in a simple + installation. A machine whose chain cannot be read keeps the screens + it had. + * Keel Cloud is hidden until it exists: the Instance entry and the first + boot step (keelfirstboot.py cloud, run by inithooks' 80keel-cloud) ask + for no key until /etc/keel/cloud-endpoint names the service. The + screen, its tests and its first boot step stay. + * No text is cut at the box edge: a menu is as wide as its widest line, + up to the terminal, a message box is sized to its text up to the + terminal, and no box is taller than the terminal (on 24 rows it was + drawn over the backtitle). Show drift stacks the table two lines a + field when it is wider than the box, where every row wrapped in two + at 80 columns. The Keel descriptions were + shortened to fit 80 columns, and a test holds them to it. The overlay + screen lists one line a peer, at most three, so its menu keeps its + rows on 24 (two peers drew the menu over its buttons); the remove + peer menu shows the peer's address, not its endpoint. + * The overlay address screen says what the suggested fdXX::1/64 is: + this node's address on Keel's private mesh, randomly generated, not + a LAN address; the first node keeps it, the others take ::2, ::3 on + the same /64; and the port is the UDP port the other nodes reach. + + -- Marcos Méndez Fri, 02 Oct 2026 16:00:00 +0000 + confconsole (2.2.3+keel11) trixie; urgency=low * Database mode on a machine with no database server says only that: diff --git a/docs/Instance.rst b/docs/Instance.rst index ff89710..9ec9740 100644 --- a/docs/Instance.rst +++ b/docs/Instance.rst @@ -11,6 +11,49 @@ makes a headless run and a menu run equivalent. The spec file is ``/etc/keel/instance.yaml``, or ``$KEEL_SPEC`` when that variable is set, the same default the command uses. +Which screens a machine shows +----------------------------- + +The menu offers a screen only where what it configures is part of the +appliance (handbook decision 0041). ``keelmenu.py`` asks keel to resolve +the appliance the spec names (``appliance.name``) along its chain of +manifests under ``/usr/share/keel``, the same resolution as ``keel +manifest show NAME --resolved``, and reads ``installation.mode`` from the +spec. + ++-------------------+--------------------------------------------------------+ +| Entry | Offered when | ++===================+========================================================+ +| View, Apply, | always | +| Show drift, | | +| Export spec | | ++-------------------+--------------------------------------------------------+ +| Database mode | a ``mariadb`` or ``postgresql`` data service is in the | +| | chain: an overlay that provides the engine, or a | +| | service the application consumes. Not on Keel Core or | +| | Keel Web | ++-------------------+--------------------------------------------------------+ +| Overlay network | the ``wireguard`` overlay is in the chain. In the menu | +| | in ``cloud_simple`` and ``cloud_advanced``, or once | +| | the spec enables or configures the overlay; otherwise | +| | behind **Advanced** | ++-------------------+--------------------------------------------------------+ +| Keel Cloud | only once Keel Cloud exists: ``/etc/keel/cloud- | +| | endpoint`` holds the service's endpoint. Hidden by | +| | default, and the first boot (``keelfirstboot.py | +| | cloud``, run by inithooks' ``80keel-cloud``) asks no | +| | key either. Writing that file turns both on | ++-------------------+--------------------------------------------------------+ + +When the chain cannot be read (keel or the manifests missing, as on a +machine of before 0041, or a spec that names no appliance) the screens are +offered as before, the overlay behind Advanced. + +Every menu is as wide as its widest line, up to what the terminal leaves +(``keelfit.py``), and no box is taller than the terminal; a description +is shortened with an ellipsis only when even the full width is too +narrow. The Keel screens' own descriptions fit an 80 column console. + Entries and their headless equivalents -------------------------------------- @@ -78,11 +121,12 @@ key pair the first time, on this machine, and prints only the public key. Then: Address - This node's overlay address and port. The first time, the field holds - what ``keel network wireguard suggest-address`` prints: a random unique - local address (``fd00::/8``), ``::1`` on its /64. The other nodes take - ``::2``, ``::3`` on the same /64. The other entries appear once there - is an address. + This node's address on Keel's private mesh, not an address on the LAN, + and the UDP port the other nodes reach it on. The first time, the field + holds what ``keel network wireguard suggest-address`` prints: a randomly + generated unique local address (``fd00::/8``), ``::1`` on its /64. The + first node keeps it; the other nodes take ``::2``, ``::3`` on the same + /64. The other entries appear once there is an address. Add peer Another node, as its own screen shows it: its public key, its overlay diff --git a/keelcli.py b/keelcli.py index 4909421..071e318 100644 --- a/keelcli.py +++ b/keelcli.py @@ -215,15 +215,32 @@ def summary(document: dict) -> str: return f"diff: {', '.join(parts)}; {verdict}" -def render_diff(text: str) -> list[str]: - """Lines for the JSON document ``keel diff --format json`` prints. +def stacked(rows: list[tuple[str, str, str, str]]) -> list[str]: + """The rows two lines a field, for a box the table does not fit: + the field and its status, then the values, once when they agree.""" + lines = [] + for field, status, declared, observed in rows: + lines.append(f"{field}: {status}") + if declared == observed: + lines.append(f" {declared}") + else: + lines.append(f" declared {declared}, observed {observed}") + return lines + + +def render_diff(text: str, width: int | None = None) -> list[str]: + """Lines for the JSON document ``keel diff --format json`` prints: + a table, or two lines a field when the table is wider than `width`. Raises ValueError when ``text`` is not that document.""" document = json.loads(text) if not isinstance(document, dict) or "fields" not in document: raise ValueError("not a keel diff document") - rows = [DIFF_HEADER, *diff_rows(document["fields"])] - return [*align(rows), "", summary(document)] + rows = diff_rows(document["fields"]) + lines = align([DIFF_HEADER, *rows]) + if width is not None and max(len(line) for line in lines) > width: + lines = stacked(rows) + return [*lines, "", summary(document)] def view_text(path: str, spec: str, result: Result) -> str: @@ -243,10 +260,11 @@ def apply_text(result: Result) -> str: ) -def drift_text(result: Result) -> str: - """The Show drift screen: the table when there is one, else the text.""" +def drift_text(result: Result, width: int | None = None) -> str: + """The Show drift screen: the table when there is one, else the text; + `width` is what a line has inside the box.""" try: - lines = render_diff(result.stdout) + lines = render_diff(result.stdout, width) except ValueError: lines = [result.output] return "\n".join([*lines, "", describe_exit("diff", result.code)]) diff --git a/keelfirstboot.py b/keelfirstboot.py index 1027278..8b57dc5 100644 --- a/keelfirstboot.py +++ b/keelfirstboot.py @@ -45,6 +45,7 @@ import dbscreen import keelbanner import keelcli +import keelmenu import plugin import wgcli import wgscreen @@ -190,6 +191,11 @@ "The key takes effect once the Keel Cloud service exists; nothing on" " this appliance contacts any service now." ) +CLOUD_UNAVAILABLE = ( + "Keel Cloud is not available yet: no key is asked, and this node runs" + " standalone. It is offered once " + keelmenu.CLOUD_ENDPOINT + + " names the service." +) CLOUD_SAVED = "Saved: hub.api_key references {path} (root, mode 0600)." CLOUD_SKIPPED = "No Keel Cloud key: this node runs standalone." KEEP = "Keep" @@ -251,6 +257,8 @@ def skip_reason(step: str, document: dict, environ) -> str: """Why a step is not asked, or "" when it is""" if not shutil.which(keelcli.KEEL): return keelcli.NOT_INSTALLED + if step == CLOUD and not keelmenu.cloud_available(): + return CLOUD_UNAVAILABLE if environ.get(EXPLICIT_RUN): return "" if step == ROLE: @@ -418,14 +426,14 @@ def overlay_choices(role: str, wireguard: dict) -> list[tuple[str, str]]: """An address first; a replica goes on only with its primary a peer""" later_choice = (LATER, "finish this in confconsole") if not wireguard.get("address"): - return [(wgscreen.ADDRESS, "this node's overlay address and port"), + return [(wgscreen.ADDRESS, "this node's mesh address and UDP port"), later_choice] found = [] if role == PRIMARY or wgcli.peers(wireguard): found.append((CONTINUE, "on to the database mode")) return found + [ (wgscreen.ADD, "accept another node: its key, address, endpoint"), - (wgscreen.ADDRESS, "change this node's address or port"), + (wgscreen.ADDRESS, "change this node's mesh address or port"), later_choice, ] @@ -449,7 +457,9 @@ def finish(console, path: str, role: str) -> None: def later(console, role: str, overlay_done: bool) -> None: lines = [LATER_TEXT] if not overlay_done: - lines.append(f" {OVERLAY_WHERE}: this node's address and peers") + # behind Advanced in a simple installation, until it is in use + lines.append(f" {keelmenu.overlay_where()}: this node's address" + " and peers") lines.append(f" {MODE_WHERE[role]}") if role == REPLICA: lines += ["", LATER_REPLICA] @@ -463,7 +473,12 @@ def cloud_screen(console) -> None: """The Instance menu entry: the same screen as the first boot's keel is asked first, so nobody types a key that cannot be kept. + The menu hides the entry until Keel Cloud exists; run by name + (confconsole --plugin) it says so and asks nothing. """ + if not keelmenu.cloud_available(): + console.msgbox(CLOUD_TITLE, CLOUD_UNAVAILABLE, autosize=True) + return if dbscreen.call(console, CLOUD_TITLE, ["--version"]) is None: return path = keelcli.spec_path() diff --git a/keelfit.py b/keelfit.py new file mode 100644 index 0000000..607a7a7 --- /dev/null +++ b/keelfit.py @@ -0,0 +1,96 @@ +"""The size of a dialog box, so that no text is cut at its edge. + +confconsole drew every menu 65 columns wide and 25 rows tall. On an +80x24 console (lxc-console, pct console, Proxmox's noVNC) a description +longer than the box was cut at its right border, mid-word, and the box +was drawn over the backtitle. A menu now takes the width its widest line +needs, up to what the terminal leaves (``keelbanner.available``), and a +box is never taller or wider than that. A description is shortened, with +an ellipsis, only when even the full width is not enough; the Keel +screens' own descriptions are written to fit at 80 columns, and a test +holds them to it. + +The constants are dialog 1.3's, measured on trixie: a menu whose widest +tag, two columns and widest item fill W - 6 columns is drawn whole, and +two more keep one column clear on each side; a line of a box's text +takes W - 4. +""" + +import keelbanner + +TAG_GAP = 2 +MENU_CHROME = 8 +# the frame and one column of margin each side of a box's text +TEXT_CHROME = 4 +# the rows of a message box that are not its text (keelbanner measured) +BOX_CHROME = keelbanner.BOX_CHROME +# a short message keeps a box its buttons sit well in +MIN_TEXT_WIDTH = 40 +ELLIPSIS = "..." + + +def room() -> tuple[int, int]: + """The rows and columns a box may take on this terminal""" + return keelbanner.available(*keelbanner.terminal_size()) + + +def box(height: int, width: int, space: tuple[int, int]) -> tuple[int, int]: + """`height` by `width`, cut to the `space` the terminal leaves""" + rows, cols = space + return min(height, rows), min(width, cols) + + +def text_width(space: tuple[int, int]) -> int: + """The columns a line of text has inside the widest box""" + return space[1] - TEXT_CHROME + + +def text_box(text: str, space: tuple[int, int]) -> tuple[int, int]: + """A message box for `text`: as wide as its longest line, as tall as + its rows once wrapped, at most the `space` the terminal leaves (a + longer text scrolls inside it). dialog's own autosize drew over the + backtitle on 24 rows and wrapped well short of the terminal's width. + """ + rows, cols = space + longest = max(len(line) for line in text.split("\n")) + width = min(cols, max(MIN_TEXT_WIDTH, longest + TEXT_CHROME)) + needed = keelbanner.text_rows(text, width - TEXT_CHROME) + BOX_CHROME + return min(rows, needed), width + + +def _widths(choices: list[tuple[str, str]]) -> tuple[int, int]: + tag = max((len(one) for one, _ in choices), default=0) + item = max((len(one) for _, one in choices), default=0) + return tag, item + + +def menu_width(choices: list[tuple[str, str]], width: int, + cols: int, text: str = "") -> int: + """At least `width`, as wide as the widest choice and the longest + line of `text` (a public key wraps badly), at most `cols`""" + needed = width + if choices: + tag, item = _widths(choices) + needed = max(needed, tag + TAG_GAP + item + MENU_CHROME) + line = max((len(one) for one in text.splitlines()), default=0) + if line: + needed = max(needed, line + TEXT_CHROME) + return min(cols, needed) + + +def clip(text: str, room: int) -> str: + """`text` in `room` columns, its end an ellipsis when it was cut""" + if len(text) <= room: + return text + if room <= len(ELLIPSIS): + return text[:max(room, 0)] + return text[:room - len(ELLIPSIS)] + ELLIPSIS + + +def fit_choices(choices: list[tuple[str, str]], + width: int) -> list[tuple[str, str]]: + """The items shortened to a box `width` wide; the tags as they are, + since dialog answers with the tag""" + tag, _ = _widths(choices) + space = width - MENU_CHROME - TAG_GAP - tag + return [(one, clip(item, space)) for one, item in choices] diff --git a/keelmenu.py b/keelmenu.py new file mode 100644 index 0000000..69bf882 --- /dev/null +++ b/keelmenu.py @@ -0,0 +1,200 @@ +"""Which Keel screens this machine shows, read from its appliance manifest. + +Handbook decision 0041: the appliance manifests under +/usr/share/keel/{appliances,overlays} say which overlays an appliance's +chain carries and which data services it has, and the instance spec +says which installation mode chose its defaults (``installation.mode``). +A screen that configures something the chain does not carry is not +offered: Keel Web and Keel Core have no database, so they have no +Database mode screen. + +The chain is resolved by keel itself (``keel.manifest.facts.gather``, +what ``keel spec validate`` and ``keel manifest show --resolved`` use), +so confconsole holds no second copy of the resolution rules. + +The rules, by the entry's path under plugins.d: + +- Instance/Database_mode: a mariadb or postgresql data service in the + chain, an overlay that provides that engine or a service the + application consumes. +- Instance/Overlay_network.py: the wireguard overlay in the chain. It is + in the Instance menu in the cloud modes, or once this node uses the + overlay; in a simple installation it is behind Advanced. +- Instance/Keel_Cloud.py: hidden until Keel Cloud exists, which is when + CLOUD_ENDPOINT holds the endpoint of the service. Nothing writes that + file yet; the screen, its tests and its first boot step stay, and + writing the file turns them on. + +Every other entry is shown. Where the chain cannot be read (no keel, no +appliance manifest, as on every machine of before 0041, or a spec that +names no appliance) the screens are offered as they were, the overlay +behind Advanced: hiding a screen a machine needs is worse than offering +one that says it has nothing to configure. +""" + +import os +from dataclasses import dataclass + +import keelcli +import wgcli + +HERE = os.path.dirname(os.path.realpath(__file__)) +PLUGINS = os.path.join(HERE, "plugins.d") +ROOT = "/" +CLOUD_ENDPOINT = "/etc/keel/cloud-endpoint" +CLOUD_MODES = ("cloud_simple", "cloud_advanced") +DATA_ENGINES = frozenset(["mariadb", "postgresql"]) +WIREGUARD = "wireguard" +ENABLED = "enabled" +OK = "ok" + +SHOW = "show" +ADVANCED = "advanced" +HIDE = "hide" + +ADVANCED_TAG = "Advanced" +ADVANCED_ITEM = (ADVANCED_TAG, "Screens for a set of nodes") +ADVANCED_TEXT = ( + "Screens a simple installation does not need: they join this node" + " to others.\n" +) +OVERLAY_NAME = "Overlay network" + + +@dataclass(frozen=True) +class Chain: + """What the resolved chain carries""" + + overlays: frozenset + engines: frozenset + + +@dataclass(frozen=True) +class Machine: + """The chain (None when it cannot be read) and the spec's choices""" + + chain: Chain | None + mode: str + uses_overlay: bool + + +def _engines(resolved) -> frozenset: + found = set() + for state in resolved.overlays: + found.add((state.manifest.get("provides") or {}).get("engine")) + application = resolved.application + services = (application.item.get("services") or {}) if application else {} + for service in services.values(): + engine = service.get("engine") + found.update(engine if isinstance(engine, list) else [engine]) + found.discard(None) + return frozenset(found) + + +def chain_of(name: str, root: str = ROOT) -> Chain | None: + """The overlays and data engines of the appliance NAME, or None""" + try: + from keel.manifest.facts import gather + except ImportError: + return None + try: + resolved = gather(root, name).resolved + except Exception: # a keel that fails is an unknown chain, not a + return None # menu that cannot open + if resolved is None: + return None + return Chain(frozenset(state.name for state in resolved.overlays), + _engines(resolved)) + + +def _section(document: dict, key: str) -> dict: + found = document.get(key) + return found if isinstance(found, dict) else {} + + +def machine() -> Machine: + """This machine, as its spec and its manifests describe it""" + document, _ = keelcli.load_description(keelcli.spec_path()) + name = _section(document, "appliance").get("name") + chain = chain_of(name) if name else None + mode = str(_section(document, "installation").get("mode") or "") + uses = (_section(document, "overlays").get(WIREGUARD) == ENABLED + or bool(wgcli.overlay_of(document))) + return Machine(chain, mode, uses) + + +def cloud_available() -> bool: + """Whether Keel Cloud exists for this node: an endpoint is set""" + try: + with open(CLOUD_ENDPOINT) as fob: + return bool(fob.read().strip()) + except OSError: + return False + + +def _database(found: Machine) -> str: + if found.chain is None or found.chain.engines & DATA_ENGINES: + return SHOW + return HIDE + + +def _overlay(found: Machine) -> str: + if found.chain is not None and WIREGUARD not in found.chain.overlays: + return HIDE + if found.mode in CLOUD_MODES or found.uses_overlay: + return SHOW + return ADVANCED + + +def _cloud(found: Machine) -> str: + return SHOW if cloud_available() else HIDE + + +RULES = { + os.path.join("Instance", "Database_mode"): _database, + os.path.join("Instance", "Overlay_network.py"): _overlay, + os.path.join("Instance", "Keel_Cloud.py"): _cloud, +} + + +def _key(path: str) -> str: + return os.path.relpath(path, PLUGINS) + + +def place(path: str, found: Machine) -> str: + """SHOW, ADVANCED or HIDE for the entry at `path`""" + rule = RULES.get(_key(path)) + return rule(found) if rule else SHOW + + +def arrange(plugins: list, machine_of=machine) -> tuple[list, list]: + """The entries of one menu: those shown, those behind Advanced + + The machine is read only for a menu that holds a Keel screen. + """ + if not any(_key(one.path) in RULES for one in plugins): + return list(plugins), [] + found = machine_of() + shown, behind = [], [] + for one in plugins: + where = place(one.path, found) + if where == SHOW: + shown.append(one) + elif where == ADVANCED: + behind.append(one) + return shown, behind + + +def advanced(console, items: list, paths: dict, back: str) -> str: + """The Advanced menu: the path of the screen chosen, else `back`""" + code, choice = console.menu(ADVANCED_TAG, ADVANCED_TEXT, items) + if code != OK: + return back + return paths[choice] + + +def overlay_where() -> str: + """Where the overlay screen is in the Instance menu, as a path""" + if _overlay(machine()) == ADVANCED: + return f"{ADVANCED_TAG} > {OVERLAY_NAME}" + return OVERLAY_NAME diff --git a/plugin.py b/plugin.py index 73d5144..2a2bb5a 100644 --- a/plugin.py +++ b/plugin.py @@ -10,6 +10,8 @@ from typing import Callable, Any, Iterable import typing +import keelmenu + class PluginError(Exception): pass @@ -161,24 +163,16 @@ def updateGlobals(self, newglobals: dict[str, Any]) -> None: def doOnce(self): ... def run(self) -> str | None: - items = [] - plugin_map: dict[str, Plugin | PluginDir] = {} - for plugin in self.plugins: - if isinstance(plugin, Plugin) and hasattr(plugin.module, "run"): - items.append( - ( - plugin.module_name.capitalize(), - str(plugin.module.__doc__), - ) - ) - plugin_map[plugin.module_name.capitalize()] = plugin - elif isinstance(plugin, PluginDir): - items.append( - (plugin.module_name.capitalize(), plugin.description) - ) - plugin_map[plugin.module_name.capitalize()] = plugin - - retcode, choice = self.module_globals["console"].menu( + # The Keel screens this machine has no use for are left out, and + # those a simple installation rarely needs go behind Advanced + # (keelmenu, from the appliance manifest) + shown, behind = keelmenu.arrange(self.plugins) + items, plugin_map = menu_items(shown) + if behind: + items.append(keelmenu.ADVANCED_ITEM) + + console = self.module_globals["console"] + retcode, choice = console.menu( self.module_name.capitalize(), self.module_name.capitalize() + "\n", items, @@ -193,9 +187,35 @@ def run(self) -> str | None: if choice in plugin_map: return plugin_map[choice].path - else: - v: str = "_adv_" + choice.lower() - return v + if behind and choice == keelmenu.ADVANCED_TAG: + more, more_map = menu_items(behind) + paths = {tag: one.path for tag, one in more_map.items()} + return keelmenu.advanced(console, more, paths, self.path) + v: str = "_adv_" + choice.lower() + return v + + +def menu_items( + plugins: list["Plugin | PluginDir"], +) -> tuple[list[tuple[str, str]], dict[str, "Plugin | PluginDir"]]: + """The menu lines of `plugins`, and the entry each tag opens""" + items = [] + plugin_map: dict[str, Plugin | PluginDir] = {} + for plugin in plugins: + if isinstance(plugin, Plugin) and hasattr(plugin.module, "run"): + items.append( + ( + plugin.module_name.capitalize(), + str(plugin.module.__doc__), + ) + ) + plugin_map[plugin.module_name.capitalize()] = plugin + elif isinstance(plugin, PluginDir): + items.append( + (plugin.module_name.capitalize(), plugin.description) + ) + plugin_map[plugin.module_name.capitalize()] = plugin + return items, plugin_map class PluginManager: diff --git a/plugins.d/Instance/Database_mode/01Standalone.py b/plugins.d/Instance/Database_mode/01Standalone.py index 37fc41f..579f7e0 100755 --- a/plugins.d/Instance/Database_mode/01Standalone.py +++ b/plugins.d/Instance/Database_mode/01Standalone.py @@ -1,4 +1,4 @@ -"""Standalone: one server, replicating nothing""" +"""One server, replicating nothing""" import dbscreen import keelcli diff --git a/plugins.d/Instance/Database_mode/Cloud/01Primary.py b/plugins.d/Instance/Database_mode/Cloud/01Primary.py index 3cd9696..574a0b0 100755 --- a/plugins.d/Instance/Database_mode/Cloud/01Primary.py +++ b/plugins.d/Instance/Database_mode/Cloud/01Primary.py @@ -1,4 +1,4 @@ -"""Primary: other nodes may replicate from this one""" +"""Other nodes replicate from this one""" import dbscreen import keelcli diff --git a/plugins.d/Instance/Database_mode/Cloud/02Replica.py b/plugins.d/Instance/Database_mode/Cloud/02Replica.py index 7139e87..79728ab 100755 --- a/plugins.d/Instance/Database_mode/Cloud/02Replica.py +++ b/plugins.d/Instance/Database_mode/Cloud/02Replica.py @@ -1,4 +1,4 @@ -"""Replica: this node replicates from another""" +"""This node replicates from another""" import dbscreen import keelcli diff --git a/plugins.d/Instance/Database_mode/Cloud/03Promote_this_replica.py b/plugins.d/Instance/Database_mode/Cloud/03Promote_this_replica.py index b1cd8da..ff57214 100755 --- a/plugins.d/Instance/Database_mode/Cloud/03Promote_this_replica.py +++ b/plugins.d/Instance/Database_mode/Cloud/03Promote_this_replica.py @@ -1,4 +1,4 @@ -"""Promote this replica to a primary (an explicit act)""" +"""Make this replica the primary""" import dbscreen import keelcli diff --git a/plugins.d/Instance/Keel_Cloud.py b/plugins.d/Instance/Keel_Cloud.py index d47a96e..14bb5a9 100755 --- a/plugins.d/Instance/Keel_Cloud.py +++ b/plugins.d/Instance/Keel_Cloud.py @@ -1,4 +1,4 @@ -"""Keel Cloud: this node's API key, optional (handbook decision 0020)""" +"""This node's Keel Cloud API key, optional""" import keelfirstboot diff --git a/plugins.d/Instance/Overlay_network.py b/plugins.d/Instance/Overlay_network.py index 10eae3c..8e21ed9 100755 --- a/plugins.d/Instance/Overlay_network.py +++ b/plugins.d/Instance/Overlay_network.py @@ -1,4 +1,4 @@ -"""Overlay network (WireGuard): this node's key, address and peers""" +"""WireGuard mesh: this node's key, address, peers""" import wgscreen from wgcli import TITLE # noqa: F401 (the title the menu tests read) diff --git a/plugins.d/Instance/Show_drift.py b/plugins.d/Instance/Show_drift.py index 126774d..d1fe6c0 100755 --- a/plugins.d/Instance/Show_drift.py +++ b/plugins.d/Instance/Show_drift.py @@ -1,6 +1,7 @@ """Show drift between the spec and this machine""" import keelcli +import keelfit TITLE = "Instance drift" @@ -12,4 +13,5 @@ def run(): except keelcli.KeelNotInstalled as error: console.msgbox(TITLE, str(error)) return - console.msgbox(TITLE, keelcli.drift_text(result), autosize=True) + width = keelfit.text_width(keelfit.room()) + console.msgbox(TITLE, keelcli.drift_text(result, width), autosize=True) diff --git a/plugins.d/Instance/description b/plugins.d/Instance/description index 3eacd54..dcd3a24 100644 --- a/plugins.d/Instance/description +++ b/plugins.d/Instance/description @@ -1 +1 @@ -Instance spec: view, apply, drift, export, database mode, overlay network, Keel Cloud +The instance spec, and what this node runs diff --git a/tests/conftest.py b/tests/conftest.py index 2839f40..99e9aea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -359,3 +359,68 @@ def _load(relative, **console_kwargs): return loaded, console return _load + + +# --- the manifest of the machine (test_keelmenu, test_instance_menu) + +CORE = ["installer", "wireguard", "etcd", "crowdsec"] +WEB = CORE + ["nginx", "coraza", "anubis"] + + +def overlay(name, engine=None): + manifest = {"name": name} + if engine: + manifest["provides"] = {"engine": engine} + return types.SimpleNamespace(name=name, manifest=manifest) + + +def resolved(overlays, services=None): + application = None + if services is not None: + application = types.SimpleNamespace(item={"services": services}) + return types.SimpleNamespace(overlays=tuple(overlays), + application=application) + + +@pytest.fixture +def chains(monkeypatch): + """keel.manifest.facts.gather over the chains a test puts here""" + table = {} + asked = [] + + def gather(root, name): + asked.append((root, name)) + found = table.get(name) + if isinstance(found, Exception): + raise found + return types.SimpleNamespace(resolved=found) + + package = types.ModuleType("keel") + manifest = types.ModuleType("keel.manifest") + facts = types.ModuleType("keel.manifest.facts") + facts.gather = gather + package.manifest = manifest + manifest.facts = facts + monkeypatch.setitem(sys.modules, "keel", package) + monkeypatch.setitem(sys.modules, "keel.manifest", manifest) + monkeypatch.setitem(sys.modules, "keel.manifest.facts", facts) + table["core"] = resolved(overlay(name) for name in CORE) + table["web"] = resolved(overlay(name) for name in WEB) + table["mariadb"] = resolved( + [overlay(name) for name in CORE] + [overlay("mariadb", "mariadb")]) + table["wordpress"] = resolved( + [overlay(name) for name in WEB] + [overlay("mariadb", "mariadb")], + services={"db": {"engine": "mariadb"}}) + table["asked"] = asked + return table + + +@pytest.fixture +def cloud(tmp_path_factory, monkeypatch): + """The Keel Cloud flag, off until a test writes it""" + import keelmenu + + # outside tmp_path, where the spec is: tests list that directory + flag = tmp_path_factory.mktemp("keel-cloud") / "cloud-endpoint" + monkeypatch.setattr(keelmenu, "CLOUD_ENDPOINT", str(flag)) + return flag diff --git a/tests/test_confconsole_boxes.py b/tests/test_confconsole_boxes.py new file mode 100644 index 0000000..860c3dc --- /dev/null +++ b/tests/test_confconsole_boxes.py @@ -0,0 +1,166 @@ +"""confconsole's boxes fit the terminal, and a menu is as wide as its text. + +The maintainer's screenshots of Keel Web on an 80x24 Proxmox console +showed menu descriptions cut at the box's right border and boxes drawn +over the backtitle: every box was 65 by 25 whatever the terminal. The +Console now asks keelfit for the room the terminal leaves. +""" + +import pytest + +import keelbanner +import keelfit + + +class Recorder: + """Stands for pythondialog's Dialog: records what each box was given""" + + OK = "ok" + + def __init__(self): + self.calls = [] + + def _record(self, name, text, *args, **kwargs): + self.calls.append((name, text, args, kwargs)) + return ("ok", "x") if name in ("menu", "inputbox", "form") else "ok" + + def menu(self, text, *args, **kwargs): + return self._record("menu", text, *args, **kwargs) + + def msgbox(self, text, *args, **kwargs): + return self._record("msgbox", text, *args, **kwargs) + + def inputbox(self, text, *args, **kwargs): + return self._record("inputbox", text, *args, **kwargs) + + def form(self, text, *args, **kwargs): + return self._record("form", text, *args, **kwargs) + + +@pytest.fixture +def console(confconsole, monkeypatch): + def _make(rows=24, cols=80): + monkeypatch.setattr(keelbanner, "terminal_size", lambda: (rows, cols)) + made = confconsole.Console("Keel Linux") + made.console = Recorder() + return made + + return _make + + +LONG = [ + ("Networking", "Configure appliance networking"), + ("Instance", "Instance spec: view, apply, drift, export, database mode," + " overlay network, Keel Cloud"), +] + + +class TestMenu: + def test_a_short_menu_keeps_the_usual_width(self, console): + made = console() + + made.menu("Title", "text", [("Quit", "Quit the console")]) + + (_, _, args, kwargs), = made.console.calls + assert args == (20, 65) + assert kwargs["choices"] == [("Quit", "Quit the console")] + + def test_a_long_description_widens_the_box_to_the_terminal( + self, console + ): + made = console(30, 120) + + made.menu("Title", "text", LONG) + + (_, _, (height, width), kwargs), = made.console.calls + assert height == 25 + assert width == len("Networking") + 2 + len(LONG[1][1]) + 8 + assert kwargs["choices"] == LONG + + def test_at_80_columns_a_description_too_long_ends_in_an_ellipsis( + self, console + ): + made = console() + + made.menu("Title", "text", LONG) + + (_, _, (_, width), kwargs), = made.console.calls + assert width == 76 + (_, short), (_, cut) = kwargs["choices"] + assert short == LONG[0][1] + assert cut.endswith("...") + assert len("Networking") + 2 + len(cut) + 8 == 76 + + def test_a_long_line_of_text_widens_the_box(self, console): + made = console() + + made.menu("Title", "key: " + "k" * 60, [("Quit", "Quit")]) + + (_, _, (_, width), _), = made.console.calls + assert width == 65 + 4 + + def test_the_menu_height_and_default_item_still_reach_dialog( + self, console + ): + made = console() + + made.menu("Title", "text", LONG, default_item="Instance") + + (_, _, _, kwargs), = made.console.calls + assert kwargs["menu_height"] == 3 + assert kwargs["default_item"] == "Instance" + + +class TestOtherBoxes: + def test_a_message_never_taller_than_the_terminal_leaves(self, console): + made = console() + + made.msgbox("Title", "text") + + (_, _, args, _), = made.console.calls + assert args == (20, 65) + + def test_a_message_given_its_size_keeps_it(self, console): + # the usage screen sizes its own box from the room + made = console() + + made.msgbox("Title", "text", height=22, width=70) + + (_, _, args, _), = made.console.calls + assert args == (22, 70) + + def test_an_autosized_message_is_sized_to_its_text_and_the_room( + self, console + ): + made = console() + + made.msgbox("Title", "x" * 100 + "\nshort", autosize=True) + + (_, _, args, _), = made.console.calls + assert args == (2 + 1 + 1 + 5, 76) + + def test_an_autosized_question_is_sized_the_same_way(self, console): + made = console() + made.console.yesno = lambda text, *args, **kwargs: ( + made.console._record("yesno", text, *args, **kwargs)) + + made.yesno("Apply it?", autosize=True) + + (_, _, args, _), = made.console.calls + assert args[1] == keelfit.MIN_TEXT_WIDTH + + def test_an_input_box_fits(self, console): + made = console(20, 60) + + made.inputbox("Title", "text") + + (_, _, args, _), = made.console.calls + assert args == (16, 56) + + def test_a_form_fits(self, console): + made = console(20, 60) + + made.form("Title", "text", []) + + (_, _, _, kwargs), = made.console.calls + assert (kwargs["height"], kwargs["width"]) == (16, 56) diff --git a/tests/test_first_boot.py b/tests/test_first_boot.py index c7f60f9..251a8ed 100644 --- a/tests/test_first_boot.py +++ b/tests/test_first_boot.py @@ -35,6 +35,14 @@ / "Keel_Cloud.py") +@pytest.fixture(autouse=True) +def cloud_on(cloud): + """Keel Cloud available, so its screens are the ones under test; the + tests of the gate turn it off again""" + cloud.write_text("https://cloud.example\n") + return cloud + + @pytest.fixture def keel(monkeypatch): """Replace keelcli.call; `answers` steer it by the command's first @@ -562,6 +570,23 @@ def test_a_replica_left_at_the_overlay_is_finished_later( assert steps["screens"] == [] assert "Overlay network" in boxes(console)[0][2] + @pytest.mark.parametrize("mode, where", [ + ("simple", "Advanced > Overlay network: this node's address"), + ("cloud_simple", " Overlay network: this node's address"), + ]) + def test_later_names_where_the_overlay_screen_is_in_this_mode( + self, spec, steps, chains, mode, where + ): + # in a simple installation the overlay is behind Advanced + spec({"version": 1, "appliance": {"name": "mariadb"}, + "installation": {"mode": mode}}) + steps["overlay"] = None + console = FakeConsole() + + keelfirstboot.primary(console, str(spec.path), {}, {}, "mariadb") + + assert where in boxes(console)[0][2] + class TestFinish: def test_the_role_in_the_description_is_done(self, spec): @@ -909,6 +934,63 @@ def test_the_entry_says_when_the_description_does_not_read( assert "not valid YAML" in boxes(console)[0][2] +class TestUntilKeelCloudExists: + """Hidden by default: nobody can generate a key for a service that + does not exist yet (the maintainer, step 8 review). keelmenu's + CLOUD_ENDPOINT turns it on; until then nothing is asked or stored.""" + + @pytest.fixture + def steps(self, monkeypatch, cloud_on): + cloud_on.unlink() + ran = [] + monkeypatch.setattr(keelfirstboot, "STEPS", { + "role": lambda *args: ran.append(("role", *args)), + "cloud": lambda *args: ran.append(("cloud", *args)), + }) + monkeypatch.setattr(keelfirstboot, "make_console", lambda: "console") + monkeypatch.setattr(keelfirstboot, "draw_on_terminal", lambda: True) + return ran + + @pytest.mark.parametrize("environ", [ + {}, {"_TURNKEY_INIT": "1"}, {"HUB_APIKEY": "KEY123"}, + ]) + def test_the_first_boot_asks_and_stores_no_key( + self, keel, spec, steps, key_file, capsys, environ + ): + spec({"version": 1}) + + assert keelfirstboot.main(["cloud"], environ) == 0 + + assert steps == [] + assert keel["calls"] == [] + assert not key_file.exists() + assert read(spec.path) == {"version": 1} + assert "Keel Cloud is not available" in capsys.readouterr().err + + def test_the_role_is_still_asked(self, keel, spec, steps): + spec({"version": 1}) + + keelfirstboot.main(["role"], {}) + + assert [one[0] for one in steps] == ["role"] + + def test_the_entry_run_by_name_says_so_and_asks_nothing( + self, keel, spec, key_file, cloud_on + ): + cloud_on.unlink() + spec({"version": 1}) + loaded = plugin.Plugin(str(ENTRY)) + console = FakeConsole() + loaded.updateGlobals({"console": console}) + + loaded.run() + + assert keel["calls"] == [] + (_, title, text), = boxes(console) + assert title == keelfirstboot.CLOUD_TITLE + assert "not available" in text + + @pytest.mark.skipif(keel_with_overlay() is None, reason="no keel 0.11 or later (set KEEL)") class TestWithTheRealKeel: diff --git a/tests/test_instance_menu.py b/tests/test_instance_menu.py index 91984fc..3f3129a 100644 --- a/tests/test_instance_menu.py +++ b/tests/test_instance_menu.py @@ -10,8 +10,12 @@ from pathlib import Path import pytest +import yaml +import keelbanner import keelcli +import keelfit +import keelmenu import plugin from conftest import FakeConsole @@ -120,7 +124,9 @@ def test_every_entry_has_a_docstring_for_the_menu(self, entry, name): @pytest.mark.parametrize("name", ENTRIES) -def test_missing_keel_shows_one_message_and_returns(entry, keel, name): +def test_missing_keel_shows_one_message_and_returns(entry, keel, name, + cloud): + cloud.write_text("https://cloud.example\n") # Keel Cloud's screen open keel["outcome"] = keelcli.KeelNotInstalled(keelcli.NOT_INSTALLED) loaded, console = entry( name, yesno=["ok"], inputs=[("ok", "/root/instance.yaml")] @@ -196,6 +202,25 @@ def test_an_invalid_spec_maps_to_its_message(self, entry, keel): class TestShowDrift: + @pytest.fixture(autouse=True) + def terminal(self, monkeypatch): + monkeypatch.setattr(keelbanner, "terminal_size", lambda: (24, 80)) + + def test_a_table_too_wide_for_80_columns_is_stacked(self, entry, keel): + long = "network.interfaces.eth0.ipv6.method" + document = {"fields": [{"field": long, "status": "drift", + "declared": "x" * 30, "observed": "auto", + "reason": ""}], + "counts": {"drift": 1}, "drift": True} + keel["outcome"] = (None, 14, json.dumps(document)) + loaded, console = entry("Show_drift.py") + + loaded.run() + + (_, _, text), = messages(console) + assert text.splitlines()[:2] == [ + f"{long}: drift", f" declared {'x' * 30}, observed auto"] + def test_renders_the_json_as_a_table(self, entry, keel): document = { "fields": [ @@ -266,3 +291,153 @@ def test_cancel_or_an_empty_path_runs_nothing(self, entry, keel, answer): assert keel["calls"] == [] assert messages(console) == [] + + +# --- what the Instance menu offers, from the appliance manifest (0041) + +PLAIN = ["View spec", "Apply spec", "Show drift", "Export spec"] + + +@pytest.fixture +def instance(tmp_path, monkeypatch, chains, cloud): + """The Instance menu, loaded as confconsole loads it, on a machine + whose spec names `appliance` in `mode`; returns (menu, console)""" + spec = tmp_path / "instance.yaml" + monkeypatch.setenv("KEEL_SPEC", str(spec)) + + def _load(appliance, mode="simple", menus=(("cancel", ""),), **more): + document = {"version": 1, "appliance": {"name": appliance}, + "installation": {"mode": mode}, **more} + spec.write_text(yaml.safe_dump(document)) + manager = plugin.PluginManager(str(INSTANCE_DIR), {}) + menu = plugin.PluginDir(str(INSTANCE_DIR)) + menu.plugins = list(manager.getByDir(manager.plugin_path)) + for one in menu.plugins: + one.parent = menu.path + console = FakeConsole(menus=list(menus)) + menu.updateGlobals({"console": console}) + return menu, console + + return _load + + +def tags(console, which=0): + menus = [call for call in console.calls if call[0] == "menu"] + return [tag for tag, _ in menus[which][3]] + + +class TestTheMenuThisMachineShows: + @pytest.mark.parametrize("appliance", ["web", "core"]) + def test_keel_web_and_core_have_no_database_mode( + self, instance, appliance + ): + menu, console = instance(appliance) + + assert menu.run() == "advanced" + + assert sorted(tags(console)) == sorted(PLAIN + ["Advanced"]) + + def test_behind_advanced_in_a_simple_installation_is_the_overlay( + self, instance + ): + menu, console = instance( + "web", menus=[("ok", "Advanced"), ("ok", "Overlay network")]) + + assert menu.run() == str(INSTANCE_DIR / "Overlay_network.py") + + assert tags(console, 1) == ["Overlay network"] + + def test_back_from_advanced_reopens_the_instance_menu(self, instance): + menu, console = instance( + "core", menus=[("ok", "Advanced"), ("cancel", "")]) + + assert menu.run() == menu.path + + @pytest.mark.parametrize("mode", ["cloud_simple", "cloud_advanced"]) + def test_a_cloud_mode_shows_the_overlay_and_no_advanced( + self, instance, mode + ): + menu, console = instance("web", mode) + + menu.run() + + assert sorted(tags(console)) == sorted(PLAIN + ["Overlay network"]) + + def test_a_database_appliance_shows_database_mode(self, instance): + menu, console = instance("mariadb") + + menu.run() + + assert "Database mode" in tags(console) + + def test_keel_cloud_appears_once_its_endpoint_is_set( + self, instance, cloud + ): + cloud.write_text("https://cloud.example\n") + menu, console = instance("web") + + menu.run() + + assert "Keel cloud" in tags(console) + + def test_a_choice_in_the_menu_opens_its_screen(self, instance): + menu, _ = instance("web", menus=[("ok", "View spec")]) + + assert menu.run() == str(INSTANCE_DIR / "View_spec.py") + + def test_a_tag_no_entry_has_is_confconsoles_own_action(self, instance): + menu, _ = instance("web", menus=[("ok", "Reboot")]) + + assert menu.run() == "_adv_reboot" + + def test_back_from_a_submenu_goes_to_its_parent(self, instance): + menu, _ = instance("web") + menu.parent = "/parent" + + assert menu.run() == "/parent" + + +def all_menus(): + """Every Keel menu: the Instance tree's, each with every entry""" + manager = plugin.PluginManager(str(INSTANCE_DIR), {}) + menus = {"Instance": manager.getByDir(manager.plugin_path)} + for path, item in manager.path_map.items(): + if isinstance(item, plugin.PluginDir): + menus[path] = manager.getByDir(path) + found = {} + for name, entries in menus.items(): + items, _ = plugin.menu_items(list(entries)) + found[name] = [(tag, text.strip()) for tag, text in items] + found["Instance"].append(keelmenu.ADVANCED_ITEM) + return found + + +class TestNothingIsCutAt80Columns: + """Every Keel menu fits the box an 80 column terminal leaves""" + + COLS = keelbanner.available(24, 80)[1] + + @pytest.mark.parametrize("name, items", sorted(all_menus().items())) + def test_every_instance_menu(self, name, items): + width = keelfit.menu_width(items, 65, self.COLS) + + assert keelfit.fit_choices(items, width) == items, name + + def test_the_overlay_screens_menus(self): + import keelfirstboot + import wgscreen + + wireguard = {"address": "fd00::1/64", "peers": [{ + "public_key": "k" * 44, "allowed_ips": ["fd00::2/128"], + "endpoint": "[2001:db8::20]:51820"}]} + menus = [ + wgscreen.choices(wireguard), + keelfirstboot.overlay_choices("primary", wireguard), + keelfirstboot.overlay_choices("replica", {}), + keelfirstboot.ROLE_CHOICES, + keelfirstboot.KEY_CHOICES, + [keelmenu.ADVANCED_ITEM], + ] + for items in menus: + width = keelfit.menu_width(items, 65, self.COLS) + assert keelfit.fit_choices(items, width) == items diff --git a/tests/test_keelcli.py b/tests/test_keelcli.py index 7e3b6c7..b0090b0 100644 --- a/tests/test_keelcli.py +++ b/tests/test_keelcli.py @@ -386,6 +386,35 @@ def test_apply_text_shows_output_and_verdict(self): " readable by somebody other than its owner" ) + def test_a_table_wider_than_the_box_is_stacked_two_lines_a_field(self): + # The four columns wrapped every row in two on an 80 column + # console, observed under field (cc-web, 2026-10-02) + got = result(14, json.dumps(DIFF_DOCUMENT)) + + lines = keelcli.drift_text(got, width=50).splitlines() + + assert lines[:6] == [ + "instance.hostname: same", + " blog", + "instance.fqdn: drift", + " declared blog.example.org, observed shop.example.org", + "network.nameservers: same", + " 2001:db8:1::53, 2001:db8:1::54", + ] + assert "security.updates: unknown" in lines + assert "diff: 3 same, 1 drift" in "\n".join(lines) + + def test_a_table_that_fits_the_box_stays_a_table(self): + got = result(14, json.dumps(DIFF_DOCUMENT)) + + assert keelcli.drift_text(got, width=200) == keelcli.drift_text(got) + + def test_text_that_is_not_json_is_shown_as_it_came_at_any_width(self): + got = result(1, "", "usage: keel diff") + + assert keelcli.drift_text(got, width=20).startswith( + "usage: keel diff") + def test_drift_text_renders_the_table_when_there_is_json(self): got = result(14, json.dumps(DIFF_DOCUMENT)) diff --git a/tests/test_keelfit.py b/tests/test_keelfit.py new file mode 100644 index 0000000..58c72e6 --- /dev/null +++ b/tests/test_keelfit.py @@ -0,0 +1,121 @@ +"""The size of a dialog box, so that no text is cut at its edge. + +The maintainer's screenshots of the Keel Web step 8 image showed menu +descriptions cut at the right border of confconsole's 65 column box. A +menu now widens to its widest line, up to what the terminal leaves, and +a description is shortened with an ellipsis only when even that is not +enough. The numbers are dialog 1.3's, measured on trixie. +""" + +import pytest + +import keelbanner +import keelfit + + +class TestRoom: + def test_the_terminal_less_the_backtitle_and_the_shadow( + self, monkeypatch + ): + monkeypatch.setattr(keelbanner, "terminal_size", lambda: (24, 80)) + + assert keelfit.room() == (20, 76) + + def test_a_larger_terminal_leaves_more(self, monkeypatch): + monkeypatch.setattr(keelbanner, "terminal_size", lambda: (30, 100)) + + assert keelfit.room() == (26, 96) + + +class TestBox: + def test_a_box_that_fits_is_left_as_it_is(self): + assert keelfit.box(20, 65, (26, 96)) == (20, 65) + + def test_a_box_taller_or_wider_than_the_room_is_cut_to_it(self): + # confconsole's 25 rows on a 24 row console drew over the + # backtitle + assert keelfit.box(25, 120, (20, 76)) == (20, 76) + + +CHOICES = [ + ("Networking", "Configure appliance networking"), + ("Instance", "The instance spec, and what this node runs"), +] + + +class TestMenuWidth: + def test_a_narrow_menu_keeps_the_box_width_it_was_given(self): + assert keelfit.menu_width([("Quit", "Quit")], 65, 76) == 65 + + def test_the_widest_tag_and_the_widest_item_set_the_width(self): + # dialog lines every item up after the widest tag + needed = (len("Networking") + keelfit.TAG_GAP + + len("The instance spec, and what this node runs") + + keelfit.MENU_CHROME) + + assert keelfit.menu_width(CHOICES, 40, 76) == needed + + def test_never_wider_than_the_room(self): + assert keelfit.menu_width([("Tag", "x" * 200)], 65, 76) == 76 + + def test_an_empty_menu_keeps_its_width(self): + assert keelfit.menu_width([], 65, 76) == 65 + + def test_the_longest_line_of_the_text_widens_it_too(self): + # a public key line of 68 columns wrapped in a 65 column box + text = "short\n" + "k" * 68 + "\nshort" + + assert keelfit.menu_width([("A", "b")], 65, 76, text) == ( + 68 + keelfit.TEXT_CHROME) + assert keelfit.menu_width([], 65, 70, text) == 70 + + +class TestTextBox: + """A message box sized to its text and to the terminal, where dialog's + own autosize drew over the backtitle and wrapped at its own width""" + + def test_as_wide_as_the_longest_line_and_as_tall_as_its_rows(self): + text = "a" * 50 + "\nb" + + assert keelfit.text_box(text, (20, 76)) == ( + 2 + keelfit.BOX_CHROME, 50 + keelfit.TEXT_CHROME) + + def test_never_larger_than_the_room(self): + text = "\n".join(["word " * 30] * 40) + + assert keelfit.text_box(text, (20, 76)) == (20, 76) + + def test_a_short_text_keeps_a_readable_width(self): + assert keelfit.text_box("ok", (20, 76)) == ( + 1 + keelfit.BOX_CHROME, keelfit.MIN_TEXT_WIDTH) + + def test_the_text_width_inside_the_box(self): + assert keelfit.text_width((20, 76)) == 72 + + +class TestFitChoices: + def test_choices_that_fit_are_unchanged(self): + assert keelfit.fit_choices(CHOICES, 76) == CHOICES + + def test_an_item_wider_than_the_box_ends_in_an_ellipsis(self): + fitted = keelfit.fit_choices([("Tag", "word " * 30)], 40) + + (tag, item), = fitted + assert tag == "Tag" + assert item.endswith(keelfit.ELLIPSIS) + room = 40 - keelfit.MENU_CHROME - keelfit.TAG_GAP - len("Tag") + assert len(item) == room + + def test_the_tags_are_never_changed_since_dialog_answers_with_them(self): + choices = [("A" * 30, "b" * 60)] + + assert keelfit.fit_choices(choices, 50)[0][0] == "A" * 30 + + @pytest.mark.parametrize("room, expected", [ + (3, "abc"), (2, "ab"), (0, ""), (-4, ""), + ]) + def test_no_room_for_an_ellipsis_cuts_plainly(self, room, expected): + assert keelfit.clip("abcdef", room) == expected + + def test_clip_leaves_a_short_text_alone(self): + assert keelfit.clip("abc", 10) == "abc" diff --git a/tests/test_keelmenu.py b/tests/test_keelmenu.py new file mode 100644 index 0000000..ccc74df --- /dev/null +++ b/tests/test_keelmenu.py @@ -0,0 +1,329 @@ +"""Which Keel screens a machine shows, read from its appliance manifest. + +Handbook decision 0041: the manifests under /usr/share/keel say which +overlays and data services an appliance's chain carries, and the spec +says the installation mode. The maintainer's screenshots of Keel Web +showed Database mode, which configures a database Keel Web does not +have, and the WireGuard overlay of a simple installation in the first +menu. These tests replace keel's resolution (keel.manifest.facts.gather) +with chains written here; TestWithTheRealKeel runs keel's own where it is +installed. +""" + +import importlib +import sys +import types +from pathlib import Path + +import pytest +import yaml + +import keelmenu +from conftest import CORE, WEB, overlay, resolved + +PLUGINS = Path(keelmenu.PLUGINS) +DATABASE = str(PLUGINS / "Instance" / "Database_mode") +OVERLAY = str(PLUGINS / "Instance" / "Overlay_network.py") +CLOUD = str(PLUGINS / "Instance" / "Keel_Cloud.py") +VIEW = str(PLUGINS / "Instance" / "View_spec.py") + + +@pytest.fixture +def spec(tmp_path, monkeypatch): + path = tmp_path / "instance.yaml" + monkeypatch.setenv("KEEL_SPEC", str(path)) + + def _write(name=None, mode="simple", **more): + document = {"version": 1, **more} + if name: + document["appliance"] = {"name": name} + if mode: + document["installation"] = {"mode": mode} + path.write_text(yaml.safe_dump(document)) + return path + + return _write + + +class TestTheChain: + def test_overlays_and_the_engines_they_provide(self, chains): + chain = keelmenu.chain_of("mariadb") + + assert chain.overlays == frozenset(CORE + ["mariadb"]) + assert chain.engines == frozenset(["mariadb"]) + assert chains["asked"] == [("/", "mariadb")] + + def test_the_services_an_application_consumes(self, chains): + chains["app"] = resolved( + [overlay("nginx")], + services={"db": {"engine": ["mariadb", "postgresql"]}, + "cache": {"engine": "redis"}}) + + assert keelmenu.chain_of("app").engines == frozenset( + ["mariadb", "postgresql", "redis"]) + + def test_an_application_with_no_services(self, chains): + chains["app"] = resolved([overlay("nginx")], services=None) + chains["app"].application = types.SimpleNamespace(item={}) + + assert keelmenu.chain_of("app").engines == frozenset() + + def test_a_chain_keel_cannot_resolve_is_unknown(self, chains): + assert keelmenu.chain_of("absent") is None + + def test_keel_failing_is_unknown_not_a_broken_menu(self, chains): + chains["broken"] = OSError("unreadable") + + assert keelmenu.chain_of("broken") is None + + def test_without_keel_the_chain_is_unknown(self, monkeypatch): + monkeypatch.setitem(sys.modules, "keel.manifest.facts", None) + + assert keelmenu.chain_of("web") is None + + +class TestTheMachine: + def test_the_spec_names_the_appliance_and_the_mode(self, chains, spec): + spec("web", "cloud_simple") + + found = keelmenu.machine() + + assert found.chain.overlays == frozenset(WEB) + assert found.mode == "cloud_simple" + assert found.uses_overlay is False + + def test_a_spec_that_names_no_appliance_leaves_the_chain_unknown( + self, chains, spec + ): + spec(None, None) + + found = keelmenu.machine() + + assert found.chain is None + assert found.mode == "" + assert chains["asked"] == [] + + @pytest.mark.parametrize("text", ["- a list\n", "appliance: web\n" + "installation: simple\n"]) + def test_sections_that_are_not_mappings_count_as_absent( + self, chains, spec, text + ): + spec().write_text(text) + + found = keelmenu.machine() + + assert found.chain is None + assert found.mode == "" + + def test_an_enabled_wireguard_overlay_is_in_use(self, chains, spec): + spec("web", overlays={"wireguard": "enabled"}) + + assert keelmenu.machine().uses_overlay is True + + def test_a_declared_overlay_address_is_in_use(self, chains, spec): + spec("web", network={"overlay": {"wireguard": { + "address": "fd00:6b65:1::1/64"}}}) + + assert keelmenu.machine().uses_overlay is True + + +def machine(name, mode="simple", uses_overlay=False, chains=None): + chain = keelmenu.chain_of(name) if name else None + return keelmenu.Machine(chain, mode, uses_overlay) + + +class TestDatabaseMode: + @pytest.mark.parametrize("name", ["web", "core"]) + def test_hidden_where_the_chain_has_no_database(self, chains, name): + assert keelmenu.place(DATABASE, machine(name)) == keelmenu.HIDE + + @pytest.mark.parametrize("name", ["mariadb", "wordpress"]) + def test_shown_where_a_mariadb_service_is_in_the_chain( + self, chains, name + ): + assert keelmenu.place(DATABASE, machine(name)) == keelmenu.SHOW + + def test_postgresql_counts_and_redis_does_not(self, chains): + chains["pg"] = resolved([overlay("postgresql", "postgresql")]) + chains["cache"] = resolved([overlay("redis", "redis")]) + + assert keelmenu.place(DATABASE, machine("pg")) == keelmenu.SHOW + assert keelmenu.place(DATABASE, machine("cache")) == keelmenu.HIDE + + def test_an_unknown_chain_shows_it_as_before(self): + # every machine of before 0041 has no manifest; hiding a screen + # it needs is worse than offering one that says it has no server + assert keelmenu.place(DATABASE, machine(None)) == keelmenu.SHOW + + +class TestOverlayNetwork: + @pytest.mark.parametrize("mode", ["cloud_simple", "cloud_advanced"]) + def test_in_the_menu_in_the_cloud_modes(self, chains, mode): + assert keelmenu.place(OVERLAY, machine("web", mode)) == keelmenu.SHOW + + @pytest.mark.parametrize("mode", ["simple", ""]) + def test_behind_advanced_in_a_simple_installation(self, chains, mode): + assert keelmenu.place(OVERLAY, machine("web", mode)) == ( + keelmenu.ADVANCED) + + def test_in_the_menu_once_this_node_uses_the_overlay(self, chains): + found = machine("web", "simple", uses_overlay=True) + + assert keelmenu.place(OVERLAY, found) == keelmenu.SHOW + + def test_hidden_where_the_chain_carries_no_wireguard(self, chains): + chains["bare"] = resolved([overlay("installer")]) + + assert keelmenu.place(OVERLAY, machine("bare", "cloud_simple")) == ( + keelmenu.HIDE) + + def test_an_unknown_chain_keeps_it_behind_advanced(self): + assert keelmenu.place(OVERLAY, machine(None, "")) == keelmenu.ADVANCED + + +class TestKeelCloud: + def test_hidden_by_default(self, chains, cloud): + assert keelmenu.cloud_available() is False + assert keelmenu.place(CLOUD, machine("web")) == keelmenu.HIDE + + def test_an_empty_flag_is_still_off(self, cloud): + cloud.write_text("\n") + + assert keelmenu.cloud_available() is False + + def test_shown_once_an_endpoint_is_configured(self, chains, cloud): + cloud.write_text("https://cloud.keellinux.org\n") + + assert keelmenu.cloud_available() is True + assert keelmenu.place(CLOUD, machine("web")) == keelmenu.SHOW + + +class TestOtherScreens: + def test_a_screen_with_no_rule_is_shown(self, chains): + assert keelmenu.place(VIEW, machine("core")) == keelmenu.SHOW + + +class Entry: + def __init__(self, path): + self.path = path + + +class TestArrange: + def test_a_menu_without_keel_screens_does_not_read_the_machine(self): + plugins = [Entry("/elsewhere/Lets_Encrypt/get_certificate.py")] + + def never(): + raise AssertionError("the machine was read") + + assert keelmenu.arrange(plugins, never) == (plugins, []) + + def test_keel_web_simple(self, chains, spec, cloud): + spec("web") + plugins = [Entry(path) for path in (DATABASE, CLOUD, OVERLAY, VIEW)] + + shown, advanced = keelmenu.arrange(plugins) + + assert [one.path for one in shown] == [VIEW] + assert [one.path for one in advanced] == [OVERLAY] + + def test_a_mariadb_appliance_in_a_cloud_mode(self, chains, spec, cloud): + spec("mariadb", "cloud_advanced") + cloud.write_text("https://cloud.example\n") + plugins = [Entry(path) for path in (DATABASE, CLOUD, OVERLAY, VIEW)] + + shown, advanced = keelmenu.arrange(plugins) + + assert [one.path for one in shown] == [DATABASE, CLOUD, OVERLAY, + VIEW] + assert advanced == [] + + +class FakeMenu: + def __init__(self, answer): + self.answer = answer + self.calls = [] + + def menu(self, title, text, choices, **kwargs): + self.calls.append((title, text, choices)) + return self.answer + + +class TestTheAdvancedMenu: + ITEMS = [("Overlay network", "WireGuard mesh: this node and peers")] + PATHS = {"Overlay network": OVERLAY} + + def test_a_choice_opens_its_screen(self): + console = FakeMenu(("ok", "Overlay network")) + + assert keelmenu.advanced(console, self.ITEMS, self.PATHS, + "/back") == OVERLAY + (title, text, choices), = console.calls + assert title == keelmenu.ADVANCED_TAG + assert choices == self.ITEMS + assert "simple installation" in text + + def test_back_returns_to_the_menu_it_came_from(self): + console = FakeMenu(("cancel", "")) + + assert keelmenu.advanced(console, self.ITEMS, self.PATHS, + "/back") == "/back" + + def test_the_entry_itself_says_what_is_behind_it(self): + tag, text = keelmenu.ADVANCED_ITEM + + assert tag == keelmenu.ADVANCED_TAG + assert text + + +class TestWhereTheOverlayScreenIs: + def test_in_the_instance_menu(self, chains, spec): + spec("web", "cloud_simple") + + assert keelmenu.overlay_where() == "Overlay network" + + def test_behind_advanced(self, chains, spec): + spec("web") + + assert keelmenu.overlay_where() == "Advanced > Overlay network" + + +class TestWithTheRealKeel: + """keel's own resolution, on manifests shaped like the images'""" + + @pytest.fixture + def root(self, tmp_path): + try: + importlib.import_module("keel.manifest.facts") + except ImportError: + pytest.skip("keel is not installed") + share = tmp_path / "usr" / "share" / "keel" + (share / "appliances").mkdir(parents=True) + (share / "overlays").mkdir() + + def write(kind, name, **fields): + doc = {"manifest_version": 1, "kind": kind, "name": name, + "title": name.title(), "summary": f"The {name}"} + doc.update(fields) + folder = "appliances" if kind == "appliance" else "overlays" + (share / folder / f"{name}.yaml").write_text(yaml.safe_dump(doc)) + + states = {"simple": "disabled", "cloud_simple": "enabled", + "cloud_advanced": "enabled"} + for name in ("wireguard", "nginx"): + write("overlay", name) + write("overlay", "mariadb", provides={"engine": "mariadb"}) + write("appliance", "core", base="none", + overlays={"wireguard": states}) + write("appliance", "web", base="core", overlays={"nginx": states}) + write("appliance", "db", base="core", overlays={"mariadb": states}) + return str(tmp_path) + + def test_web_has_wireguard_and_no_database(self, root): + chain = keelmenu.chain_of("web", root) + + assert chain.overlays == frozenset(["wireguard", "nginx"]) + assert chain.engines == frozenset() + + def test_a_database_appliance_provides_mariadb(self, root): + assert keelmenu.chain_of("db", root).engines == frozenset( + ["mariadb"]) diff --git a/tests/test_overlay_screen.py b/tests/test_overlay_screen.py index 30ea53f..5692ca0 100644 --- a/tests/test_overlay_screen.py +++ b/tests/test_overlay_screen.py @@ -142,9 +142,12 @@ def test_removing_a_peer(self): assert wgcli.without_peer(OVERLAY, OTHER_KEY) == OVERLAY def test_the_remove_menu(self): + # the key and the address the peer routes, which name it and fit + # an 80 column console; the endpoint is on the first screen assert wgcli.peer_choices(OVERLAY) == [ - (PEER_KEY, "fd00:6b65:1::2/128 at [2001:db8::20]:51820")] - assert wgcli.peer_line({}) == "- at reaches this node itself" + (PEER_KEY, "fd00:6b65:1::2/128")] + assert wgcli.peer_choices({"peers": [{"public_key": PEER_KEY}]}) == [ + (PEER_KEY, "-")] def test_the_peers_addresses_are_what_a_primary_authorizes(self): document = wgcli.with_overlay({}, { @@ -176,14 +179,58 @@ def test_a_routed_range_or_a_typo_is_not_a_replica(self): class TestTexts: + def test_the_address_is_the_mesh_s_not_the_lan_s(self): + # The maintainer read the suggested fdXX::1/64 as "a private LAN + # address" (step 8 review): the text says whose address it is, + # that it is random, which node keeps it and what the port is. + text = wgscreen.ADDRESS_TEXT + assert "Keel's private mesh" in text + assert "not an address on your LAN" in text + assert "randomly generated" in text + assert "The first node keeps it" in text + assert "::2, ::3" in text and "same /64" in text + assert "UDP port the other nodes reach" in text + assert len(text) < 420 + + def test_the_add_peer_form_fits_a_24_row_console(self): + # its text and four fields filled all 24 rows, over the + # backtitle (cc-core, 2026-10-02): the box keeps within 20 + import keelbanner + + rows = keelbanner.text_rows(wgscreen.PEER_TEXT, 72) + + assert rows + 4 + 2 + keelbanner.BOX_CHROME + 1 <= 20 + assert "endpoint" in wgscreen.PEER_TEXT.lower() + assert "Keepalive" in wgscreen.PEER_TEXT + def test_the_first_screen(self): text = wgcli.overlay_text(THIS_KEY, OVERLAY) assert f"This node's public key: {THIS_KEY}" in text - assert "Overlay address: fd00:6b65:1::1/64" in text - assert "Listen port: 51820" in text - assert f"Peers (1):\n {PEER_KEY}" in text + assert "Mesh address (not your LAN): fd00:6b65:1::1/64" in text + assert "UDP port: 51820" in text + assert f"Peers (1):\n {PEER_KEY} fd00:6b65:1::2/128\n" in text assert "THIS node's side" in text + def test_the_first_screen_fits_a_24_row_console(self): + # Two peers on two lines each pushed the menu over its buttons + # on an 80x24 console (cc-core, 2026-10-02): one line a peer, at + # most three of them, every line within the 72 columns inside + # the widest box, and room left for the three choices + many = {**OVERLAY, "peers": [ + {"public_key": key, "allowed_ips": [f"fd00:6b65:1::{n}/128"]} + for n, key in enumerate([PEER_KEY, OTHER_KEY, THIS_KEY, + PEER_KEY[::-1], OTHER_KEY[::-1]], 2)]} + + lines = wgcli.overlay_text(THIS_KEY, many).splitlines() + + assert " and 3 more: Remove peer lists every one" in lines + assert sum(PEER_KEY in line or OTHER_KEY in line + for line in lines) == 2 + assert max(len(line) for line in lines + if line != wgcli.THIS_NODE) <= 72 + wrapped = sum(max(1, -(-len(line) // 72)) for line in lines) + assert wrapped <= 20 - 5 - 5 + def test_an_empty_overlay(self): text = wgcli.overlay_text(THIS_KEY, {}) assert wgcli.NO_ADDRESS in text diff --git a/wgcli.py b/wgcli.py index 602cf7f..bda258b 100644 --- a/wgcli.py +++ b/wgcli.py @@ -42,11 +42,11 @@ STILL_ARMED = "the revert timer will try again" NO_ADDRESS = "(none yet: choose Address first)" THIS_NODE = ( - "This screen configures THIS node's side of the overlay only. On the" - " other node, open this screen too and add this node as its peer:" - " this node's public key, its overlay address, and the address and" - " port it can be reached at." + "This screen sets THIS node's side only. On each other node, add this" + " node as a peer: its public key, mesh address and endpoint." ) +# the peers the first screen lists before it says how many more there are +LISTED = 3 CONFIRM_HOW = ( "The change REVERTS BY ITSELF when its window ends (120 seconds unless" " apply was told otherwise) unless it is confirmed. Confirm from a NEW" @@ -164,35 +164,46 @@ def without_peer(wireguard: dict, public_key: str) -> dict: return {**wireguard, "peers": kept} -def peer_line(peer: dict) -> str: - """How a peer is shown: its addresses and where it is reached""" - routed = ", ".join(str(one) for one in peer.get("allowed_ips") or []) - endpoint = peer.get("endpoint") or "reaches this node itself" - return f"{routed or '-'} at {endpoint}" +def routed(peer: dict) -> str: + """The addresses a peer routes, which name it on the overlay""" + return ", ".join(str(one) for one in peer.get("allowed_ips") or []) or "-" def peer_choices(wireguard: dict) -> list[tuple[str, str]]: - """The remove menu: the key is the tag, the addresses the text""" - return [(str(peer.get("public_key", "?")), peer_line(peer)) + """The remove menu: the key is the tag, the routed addresses the text + + No endpoint: a 44 column key beside it did not fit an 80 column + console. View spec shows the endpoints. + """ + return [(str(peer.get("public_key", "?")), routed(peer)) for peer in peers(wireguard)] def overlay_text(public_key: str, wireguard: dict) -> str: - """The first screen: this node, then its peers, then what it is for""" + """The first screen: this node, then its peers, then what it is for + + One line a peer, and no more than LISTED of them when there are + more, so that the menu under the text keeps its rows on an 80x24 + console; Remove peer lists every one. + """ port = wireguard.get("listen_port") or DEFAULT_PORT lines = [ f"This node's public key: {public_key}", - f"Overlay address: {wireguard.get('address') or NO_ADDRESS}", - f"Listen port: {port}", + "Mesh address (not your LAN):" + f" {wireguard.get('address') or NO_ADDRESS} UDP port: {port}", "", ] found = peers(wireguard) + shown = found if len(found) <= LISTED else found[:LISTED - 1] if found: lines.append(f"Peers ({len(found)}):") - lines += [f" {peer.get('public_key', '?')}\n {peer_line(peer)}" - for peer in found] + lines += [f" {peer.get('public_key', '?')} {routed(peer)}" + for peer in shown] else: lines.append("No peer yet.") + if len(shown) < len(found): + lines.append(f" and {len(found) - len(shown)} more: Remove peer" + " lists every one") return "\n".join(lines + ["", THIS_NODE]) diff --git a/wgscreen.py b/wgscreen.py index cc64055..6f6e001 100644 --- a/wgscreen.py +++ b/wgscreen.py @@ -25,18 +25,19 @@ ADD = "Add peer" REMOVE = "Remove peer" ADDRESS_TEXT = ( - "This node's address on the overlay: IPv6 with its /64, a unique local" - " address (fd00::/8). The first node of a set takes the suggested one," - " ::1 on a fresh /64; the others take ::2, ::3 on the SAME /64.\n\n" - "The port is where the other nodes reach this one (blank: 51820)." + "This node's address on Keel's private mesh, the WireGuard network" + " between the nodes of a set. It is not an address on your LAN.\n\n" + "The suggestion is randomly generated. The first node keeps it" + " (::1); each other node takes ::2, ::3 on the same /64.\n\n" + "UDP port: the UDP port the other nodes reach this one on (blank:" + " 51820)." ) PEER_TEXT = ( - "Another node this one accepts on the overlay, as that node's own" + "Another node this one accepts on the mesh, as that node's own" " screen shows it.\n\n" - "Endpoint: where that node is reached, host:port, an IPv6 address in" - " brackets ([2001:db8::20]:51820); blank when it reaches this node" - " instead. Keepalive: seconds, keeps a path through a firewall or NAT" - " open (blank: none).\n\n" + wgcli.THIS_NODE + "Endpoint: host:port where it is reached, IPv6 in brackets" + " ([2001:db8::20]:51820); blank when it reaches this node instead." + " Keepalive: seconds that keep a path through NAT open (blank: none)." ) @@ -63,7 +64,7 @@ def run(console) -> None: def choices(wireguard: dict) -> list[tuple[str, str]]: """No peer before an address; no removal without a peer""" - found = [(ADDRESS, "this node's overlay address and port")] + found = [(ADDRESS, "this node's mesh address and UDP port")] if not wireguard.get("address"): return found found.append((ADD, "accept another node: its key, address, endpoint")) @@ -104,8 +105,8 @@ def set_address(console, path: str, document: dict, wireguard: dict): address = str(wireguard.get("address") or suggested(console)) port = str(wireguard.get("listen_port") or "") values = ask(console, ADDRESS_TEXT, [ - ("Overlay address", address, 20, 44), - ("Listen port", port, 20, 44), + ("Mesh address", address, 20, 44), + ("UDP port", port, 20, 44), ]) if values is None: return @@ -116,7 +117,7 @@ def set_address(console, path: str, document: dict, wireguard: dict): def add_peer(console, path: str, document: dict, wireguard: dict): values = ask(console, PEER_TEXT, [ ("Its public key", "", 20, 46), - ("Its overlay address", "", 20, 46), + ("Its mesh address", "", 20, 46), ("Its endpoint", "", 20, 46), ("Keepalive (seconds)", wgcli.DEFAULT_KEEPALIVE, 20, 46), ]) From 9d80eda02711680110a95d879460664c1171fcaf Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 13:57:17 +0000 Subject: [PATCH 2/3] fix: keelfit never hands dialog a size of 0 On a terminal of 8 columns the box had 0 columns for its text, so text_rows was asked to wrap at 0, and a box height of 0 is dialog's own autosize, which draws over the backtitle. The text gets at least one column and box() at least one row and one column; text_box goes through box(). Review of confconsole#19. --- keelfit.py | 10 +++++++--- tests/test_keelfit.py | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/keelfit.py b/keelfit.py index 607a7a7..428a537 100644 --- a/keelfit.py +++ b/keelfit.py @@ -37,7 +37,9 @@ def room() -> tuple[int, int]: def box(height: int, width: int, space: tuple[int, int]) -> tuple[int, int]: """`height` by `width`, cut to the `space` the terminal leaves""" rows, cols = space - return min(height, rows), min(width, cols) + # never 0 or less: dialog takes 0 as "size it yourself" and draws + # over the backtitle again + return max(1, min(height, rows)), max(1, min(width, cols)) def text_width(space: tuple[int, int]) -> int: @@ -54,8 +56,10 @@ def text_box(text: str, space: tuple[int, int]) -> tuple[int, int]: rows, cols = space longest = max(len(line) for line in text.split("\n")) width = min(cols, max(MIN_TEXT_WIDTH, longest + TEXT_CHROME)) - needed = keelbanner.text_rows(text, width - TEXT_CHROME) + BOX_CHROME - return min(rows, needed), width + # a terminal of 8 columns leaves the text no column at all + inside = max(1, width - TEXT_CHROME) + needed = keelbanner.text_rows(text, inside) + BOX_CHROME + return box(needed, width, space) def _widths(choices: list[tuple[str, str]]) -> tuple[int, int]: diff --git a/tests/test_keelfit.py b/tests/test_keelfit.py index 58c72e6..f20c3b1 100644 --- a/tests/test_keelfit.py +++ b/tests/test_keelfit.py @@ -89,6 +89,21 @@ def test_a_short_text_keeps_a_readable_width(self): assert keelfit.text_box("ok", (20, 76)) == ( 1 + keelfit.BOX_CHROME, keelfit.MIN_TEXT_WIDTH) + def test_an_8_column_terminal_still_gets_a_box(self, monkeypatch): + # 8 columns leave 4 for a box, 0 for its text: text_rows was + # asked to wrap at 0, and a height of 0 made dialog autosize + monkeypatch.setattr(keelbanner, "terminal_size", lambda: (3, 8)) + space = keelfit.room() + + height, width = keelfit.text_box("a few words of text", space) + + assert space == (0, 4) + assert (height, width) == (1, 4) + assert keelfit.box(20, 65, space) == (1, 4) + + def test_a_text_too_wide_for_a_box_of_one_column_is_one_a_row(self): + assert keelfit.text_box("abc", (20, 4)) == (3 + keelfit.BOX_CHROME, 4) + def test_the_text_width_inside_the_box(self): assert keelfit.text_width((20, 76)) == 72 From 1f96eb97a45e354879758f355193ee7a78fd825e Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 13:57:17 +0000 Subject: [PATCH 3/3] fix: a valid Keel Cloud endpoint, and Database mode for every data engine Review of confconsole#19 and the maintainer's Redis request. /etc/keel/cloud-endpoint turns Keel Cloud on only when it holds one https URL with a host (a bracketed IPv6 literal allowed), in a regular file root owns that neither group nor others can write. Anything else keeps the screens hidden and logs why; no file stays quiet. docs/Instance.rst gives the format. Database mode shows for a mariadb, postgresql or redis data service, one set (keelmenu.DATA_ENGINES), and wherever the description declares database.server, even when the chain resolves with no engine. Its Cloud roles are offered only for an engine whose replication keel applies: keel validates a Redis or PostgreSQL primary and replica but converges MariaDB's alone (system/database.py notes the others and changes nothing), so those engines get Standalone, and the first boot asks them no role instead of writing one nothing makes. --- debian/changelog | 18 +++-- docs/Instance.rst | 32 +++++++-- keelfirstboot.py | 5 ++ keelmenu.py | 101 ++++++++++++++++++++++---- tests/conftest.py | 8 ++- tests/test_first_boot.py | 17 +++++ tests/test_keelmenu.py | 150 +++++++++++++++++++++++++++++++++++++-- 7 files changed, 300 insertions(+), 31 deletions(-) diff --git a/debian/changelog b/debian/changelog index 1f04a46..1594a39 100644 --- a/debian/changelog +++ b/debian/changelog @@ -3,20 +3,26 @@ confconsole (2.2.3+keel12) trixie; urgency=low * The Instance menu offers a screen only where what it configures is in the appliance, read from the appliance manifest (handbook decision 0041) through keel's own resolution: Database mode only where the - chain has a mariadb or postgresql data service, so not on Keel Core - or Keel Web (the maintainer's screenshots of the Keel Web step 8 - image); Overlay network in the menu in the cloud modes or once the + chain has a mariadb, postgresql or redis data service (one set, + keelmenu.DATA_ENGINES) or the description declares database.server, + so not on Keel Core or Keel Web (the maintainer's screenshots of the + Keel Web step 8 image); its Cloud roles only for MariaDB, the one + engine whose replication keel applies (Redis and PostgreSQL get + Standalone, and the first boot asks them no role); Overlay network in the menu in the cloud modes or once the overlay is in use, behind a new Advanced entry in a simple installation. A machine whose chain cannot be read keeps the screens it had. * Keel Cloud is hidden until it exists: the Instance entry and the first boot step (keelfirstboot.py cloud, run by inithooks' 80keel-cloud) ask - for no key until /etc/keel/cloud-endpoint names the service. The - screen, its tests and its first boot step stay. + for no key until /etc/keel/cloud-endpoint holds one https URL with a + host, in a file root owns and nobody else can write; anything else + keeps it hidden and is logged. The screen, its tests and its first + boot step stay. * No text is cut at the box edge: a menu is as wide as its widest line, up to the terminal, a message box is sized to its text up to the terminal, and no box is taller than the terminal (on 24 rows it was - drawn over the backtitle). Show drift stacks the table two lines a + drawn over the backtitle), nor sized 0, which dialog takes as its own + autosize, even on a terminal of 8 columns. Show drift stacks the table two lines a field when it is wider than the box, where every row wrapped in two at 80 columns. The Keel descriptions were shortened to fit 80 columns, and a test holds them to it. The overlay diff --git a/docs/Instance.rst b/docs/Instance.rst index 9ec9740..59b1d79 100644 --- a/docs/Instance.rst +++ b/docs/Instance.rst @@ -28,10 +28,18 @@ spec. | Show drift, | | | Export spec | | +-------------------+--------------------------------------------------------+ -| Database mode | a ``mariadb`` or ``postgresql`` data service is in the | -| | chain: an overlay that provides the engine, or a | -| | service the application consumes. Not on Keel Core or | -| | Keel Web | +| Database mode | a ``mariadb``, ``postgresql`` or ``redis`` data | +| | service is in the chain (an overlay that provides the | +| | engine, or a service the application consumes), or the | +| | spec declares ``database.server``. Not on Keel Core or | +| | Keel Web. The engines are one set, | +| | ``keelmenu.DATA_ENGINES`` | ++-------------------+--------------------------------------------------------+ +| Database mode > | only for an engine whose replication keel applies, | +| Cloud | MariaDB today (``keelmenu.REPLICATING_ENGINES``). keel | +| | validates a Redis or PostgreSQL primary and replica | +| | but converges neither, so those offer Standalone only, | +| | and the first boot asks them no role | +-------------------+--------------------------------------------------------+ | Overlay network | the ``wireguard`` overlay is in the chain. In the menu | | | in ``cloud_simple`` and ``cloud_advanced``, or once | @@ -45,6 +53,18 @@ spec. | | key either. Writing that file turns both on | +-------------------+--------------------------------------------------------+ +``/etc/keel/cloud-endpoint`` holds one URL and nothing else (surrounding +whitespace and a final newline are ignored):: + + https://cloud.example.org + https://[2001:db8::10]:8443/keel + +It must be ``https`` with a host, a bracketed IPv6 literal allowed and a +port optional, in a regular file owned by root and writable by neither +group nor others (``install -m 0644 -o root``). A missing file is the +default and is quiet; any other failure keeps Keel Cloud hidden and is +logged as ``Keel Cloud stays hidden: /etc/keel/cloud-endpoint: ``. + When the chain cannot be read (keel or the manifests missing, as on a machine of before 0041, or a spec that names no appliance) the screens are offered as before, the overlay behind Advanced. @@ -169,7 +189,9 @@ network confirm`` from a new session. Database mode ------------- -Handbook decision 0013. MariaDB only for now. Every screen configures +Handbook decision 0013. Replication is MariaDB's only for now: keel +validates a Redis or PostgreSQL primary and replica but applies neither, +so for those engines the menu offers Standalone alone. Every screen configures THIS node and nothing else, writes ``database.server`` of the instance description, checks it with ``keel spec validate`` before it replaces the file, and runs ``keel spec apply --system-only --non-interactive diff --git a/keelfirstboot.py b/keelfirstboot.py index 8b57dc5..0d02b01 100644 --- a/keelfirstboot.py +++ b/keelfirstboot.py @@ -328,6 +328,11 @@ def choose_role(console, path: str, document: dict) -> None: say(ROLE, "no database server on this machine, so no role to" " choose: it stays standalone") return + if not keelmenu.replicates(engine): + # keel validates a primary or a replica of it, and applies none + say(ROLE, f"keel does not apply {engine} replication yet, so no" + " role to choose: it stays standalone") + return text = ROLE_TEXT if server.get("role"): text += f"\n\nThe description says: {server['role']}." diff --git a/keelmenu.py b/keelmenu.py index 69bf882..1d31712 100644 --- a/keelmenu.py +++ b/keelmenu.py @@ -14,16 +14,23 @@ The rules, by the entry's path under plugins.d: -- Instance/Database_mode: a mariadb or postgresql data service in the - chain, an overlay that provides that engine or a service the - application consumes. +- Instance/Database_mode: a data service of DATA_ENGINES in the chain, + an overlay that provides that engine or a service the application + consumes, or a server the spec declares (database.server). +- Instance/Database_mode/Cloud: only for an engine whose replication + keel applies (REPLICATING_ENGINES). keel validates primary and + replica for every engine but converges MariaDB's alone; for Redis and + PostgreSQL it notes that and changes nothing, so their Database mode + offers Standalone only rather than roles nothing would make. - Instance/Overlay_network.py: the wireguard overlay in the chain. It is in the Instance menu in the cloud modes, or once this node uses the overlay; in a simple installation it is behind Advanced. - Instance/Keel_Cloud.py: hidden until Keel Cloud exists, which is when - CLOUD_ENDPOINT holds the endpoint of the service. Nothing writes that - file yet; the screen, its tests and its first boot step stay, and - writing the file turns them on. + CLOUD_ENDPOINT holds the endpoint of the service: one https URL with a + host (a bracketed IPv6 literal allowed), in a regular file root owns + and neither group nor others can write. Anything else keeps it hidden + and is logged. Nothing writes that file yet; the screen, its tests and + its first boot step stay, and writing the file turns them on. Every other entry is shown. Where the chain cannot be read (no keel, no appliance manifest, as on every machine of before 0041, or a spec that @@ -32,18 +39,29 @@ one that says it has nothing to configure. """ +import logging import os +import stat from dataclasses import dataclass +from urllib.parse import urlsplit import keelcli import wgcli +log = logging.getLogger("keelmenu") + HERE = os.path.dirname(os.path.realpath(__file__)) PLUGINS = os.path.join(HERE, "plugins.d") ROOT = "/" CLOUD_ENDPOINT = "/etc/keel/cloud-endpoint" +CLOUD_OWNER = 0 +CLOUD_SCHEME = "https" CLOUD_MODES = ("cloud_simple", "cloud_advanced") -DATA_ENGINES = frozenset(["mariadb", "postgresql"]) +# The data services Database mode configures: adding an engine (mongodb, +# couchdb) is one entry here +DATA_ENGINES = frozenset({"mariadb", "postgresql", "redis"}) +# Those whose primary and replica keel applies, not only validates +REPLICATING_ENGINES = frozenset({"mariadb"}) WIREGUARD = "wireguard" ENABLED = "enabled" OK = "ok" @@ -76,6 +94,8 @@ class Machine: chain: Chain | None mode: str uses_overlay: bool + server_engine: str = "" + has_server: bool = False def _engines(resolved) -> frozenset: @@ -120,20 +140,72 @@ def machine() -> Machine: mode = str(_section(document, "installation").get("mode") or "") uses = (_section(document, "overlays").get(WIREGUARD) == ENABLED or bool(wgcli.overlay_of(document))) - return Machine(chain, mode, uses) + server = keelcli.server_of(document) + return Machine(chain, mode, uses, str(server.get("engine") or ""), + bool(server)) -def cloud_available() -> bool: - """Whether Keel Cloud exists for this node: an endpoint is set""" +def replicates(engine: str) -> bool: + """Whether keel applies a primary and a replica of `engine`""" + return engine in REPLICATING_ENGINES + + +def _read(path: str) -> str: + with open(path) as fob: + return fob.read() + + +def endpoint_problem(path: str) -> str: + """Why `path` does not turn Keel Cloud on, or "" when it does""" try: - with open(CLOUD_ENDPOINT) as fob: - return bool(fob.read().strip()) - except OSError: + info = os.stat(path) + if not stat.S_ISREG(info.st_mode): + return "not a regular file" + if info.st_uid != CLOUD_OWNER: + return "not owned by root" + if info.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + return "writable by group or others" + words = _read(path).split() + except OSError as error: + return error.strerror or str(error) + if len(words) != 1: + return "must hold one URL" if words else "empty" + try: + url = urlsplit(words[0]) + url.port # a port out of range raises here + except ValueError as error: + return f"not a URL: {error}" + if url.scheme != CLOUD_SCHEME: + return f"must be an {CLOUD_SCHEME} URL" + if not url.hostname: + return "no host in the URL" + return "" + + +def cloud_available() -> bool: + """Whether Keel Cloud exists for this node: a valid endpoint is set. + No file is the default and is quiet; a file that does not hold a + valid endpoint keeps Keel Cloud hidden and says why in the log.""" + if not os.path.lexists(CLOUD_ENDPOINT): return False + problem = endpoint_problem(CLOUD_ENDPOINT) + if problem: + log.warning("Keel Cloud stays hidden: %s: %s", CLOUD_ENDPOINT, + problem) + return False + return True def _database(found: Machine) -> str: - if found.chain is None or found.chain.engines & DATA_ENGINES: + if (found.chain is None or found.has_server + or found.chain.engines & DATA_ENGINES): + return SHOW + return HIDE + + +def _replication(found: Machine) -> str: + if found.chain is None or replicates(found.server_engine) or ( + found.chain.engines & REPLICATING_ENGINES): return SHOW return HIDE @@ -152,6 +224,7 @@ def _cloud(found: Machine) -> str: RULES = { os.path.join("Instance", "Database_mode"): _database, + os.path.join("Instance", "Database_mode", "Cloud"): _replication, os.path.join("Instance", "Overlay_network.py"): _overlay, os.path.join("Instance", "Keel_Cloud.py"): _cloud, } diff --git a/tests/conftest.py b/tests/conftest.py index 99e9aea..4c152a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ """ import importlib +import os import sys import types from pathlib import Path @@ -423,4 +424,9 @@ def cloud(tmp_path_factory, monkeypatch): # outside tmp_path, where the spec is: tests list that directory flag = tmp_path_factory.mktemp("keel-cloud") / "cloud-endpoint" monkeypatch.setattr(keelmenu, "CLOUD_ENDPOINT", str(flag)) - return flag + # the tests write it as themselves; on a machine root must own it + monkeypatch.setattr(keelmenu, "CLOUD_OWNER", os.getuid()) + # and writable by its owner alone, whatever the umask of the run + before = os.umask(0o022) + yield flag + os.umask(before) diff --git a/tests/test_first_boot.py b/tests/test_first_boot.py index 251a8ed..8b169a6 100644 --- a/tests/test_first_boot.py +++ b/tests/test_first_boot.py @@ -355,6 +355,23 @@ def test_the_menu_offers_standalone_first(self, keel, spec, flows): assert "Standalone is the default" in text assert flows == [("standalone", {"engine": "mariadb"}, "mariadb")] + @pytest.mark.parametrize("engine", ["redis", "postgresql"]) + def test_an_engine_keel_does_not_replicate_is_not_asked( + self, keel, spec, flows, capsys, engine + ): + # keel validates primary and replica for Redis and PostgreSQL but + # applies neither; offering them would write a role nothing makes + spec({"version": 1, "database": {"server": {"engine": engine}}}) + console = FakeConsole() + + keelfirstboot.choose_role(console, str(spec.path), read(spec.path)) + + assert console.calls == [] + assert flows == [] + err = capsys.readouterr().err + assert f"keel does not apply {engine} replication yet" in err + assert "stays standalone" in err + def test_the_declared_role_is_named_when_keel_init_asks_again( self, keel, spec, flows ): diff --git a/tests/test_keelmenu.py b/tests/test_keelmenu.py index ccc74df..348623c 100644 --- a/tests/test_keelmenu.py +++ b/tests/test_keelmenu.py @@ -11,6 +11,8 @@ """ import importlib +import logging +import os import sys import types from pathlib import Path @@ -23,6 +25,7 @@ PLUGINS = Path(keelmenu.PLUGINS) DATABASE = str(PLUGINS / "Instance" / "Database_mode") +REPLICATION = str(PLUGINS / "Instance" / "Database_mode" / "Cloud") OVERLAY = str(PLUGINS / "Instance" / "Overlay_network.py") CLOUD = str(PLUGINS / "Instance" / "Keel_Cloud.py") VIEW = str(PLUGINS / "Instance" / "View_spec.py") @@ -102,6 +105,16 @@ def test_a_spec_that_names_no_appliance_leaves_the_chain_unknown( assert found.chain is None assert found.mode == "" assert chains["asked"] == [] + assert found.has_server is False + + def test_a_declared_server_and_its_engine(self, chains, spec): + spec("web", database={"server": {"engine": "redis", + "role": "standalone"}}) + + found = keelmenu.machine() + + assert found.has_server is True + assert found.server_engine == "redis" @pytest.mark.parametrize("text", ["- a list\n", "appliance: web\n" "installation: simple\n"]) @@ -143,12 +156,62 @@ def test_shown_where_a_mariadb_service_is_in_the_chain( ): assert keelmenu.place(DATABASE, machine(name)) == keelmenu.SHOW - def test_postgresql_counts_and_redis_does_not(self, chains): - chains["pg"] = resolved([overlay("postgresql", "postgresql")]) - chains["cache"] = resolved([overlay("redis", "redis")]) + @pytest.mark.parametrize("engine", ["postgresql", "redis"]) + def test_postgresql_and_redis_count(self, chains, engine): + chains["db"] = resolved([overlay(engine, engine)]) + + assert keelmenu.place(DATABASE, machine("db")) == keelmenu.SHOW + + def test_an_engine_outside_the_list_does_not(self, chains): + chains["search"] = resolved([overlay("opensearch", "opensearch")]) + + assert keelmenu.place(DATABASE, machine("search")) == keelmenu.HIDE + + def test_the_engines_are_one_set(self): + # adding mongodb or couchdb is one line here + assert keelmenu.DATA_ENGINES == {"mariadb", "postgresql", "redis"} + assert keelmenu.REPLICATING_ENGINES <= keelmenu.DATA_ENGINES + + def test_a_declared_server_shows_it_whatever_the_chain(self, chains): + # a description that holds database.server is a server to + # configure, even where the manifests name no engine + found = keelmenu.Machine(keelmenu.chain_of("web"), "simple", False, + server_engine="mariadb", has_server=True) + + assert keelmenu.place(DATABASE, found) == keelmenu.SHOW + + +class TestReplication: + """Database mode > Cloud: only for an engine whose replication keel + applies. keel validates primary and replica for every engine of + DATABASE_ENGINES but converges MariaDB's alone (system/database.py: + another engine is a note), so Redis and PostgreSQL get Standalone""" - assert keelmenu.place(DATABASE, machine("pg")) == keelmenu.SHOW - assert keelmenu.place(DATABASE, machine("cache")) == keelmenu.HIDE + def test_mariadb_replicates(self, chains): + assert keelmenu.place(REPLICATION, machine("mariadb")) == ( + keelmenu.SHOW) + + @pytest.mark.parametrize("engine", ["postgresql", "redis"]) + def test_an_engine_keel_does_not_replicate_has_no_cloud( + self, chains, engine + ): + chains["db"] = resolved([overlay(engine, engine)]) + + assert keelmenu.place(REPLICATION, machine("db")) == keelmenu.HIDE + + def test_a_declared_mariadb_server_replicates(self, chains): + found = keelmenu.Machine(keelmenu.chain_of("web"), "simple", False, + server_engine="mariadb", has_server=True) + + assert keelmenu.place(REPLICATION, found) == keelmenu.SHOW + + def test_an_unknown_chain_keeps_it(self): + assert keelmenu.place(REPLICATION, machine(None)) == keelmenu.SHOW + + @pytest.mark.parametrize("engine, expected", [ + ("mariadb", True), ("redis", False), ("", False)]) + def test_replicates(self, engine, expected): + assert keelmenu.replicates(engine) is expected def test_an_unknown_chain_shows_it_as_before(self): # every machine of before 0041 has no manifest; hiding a screen @@ -197,6 +260,83 @@ def test_shown_once_an_endpoint_is_configured(self, chains, cloud): assert keelmenu.cloud_available() is True assert keelmenu.place(CLOUD, machine("web")) == keelmenu.SHOW + @pytest.mark.parametrize("text", [ + "https://cloud.keellinux.org", "https://[2001:db8::1]:8443/api", + "https://cloud.example/v1\n"]) + def test_an_https_url_with_a_host_turns_it_on(self, cloud, text): + cloud.write_text(text) + + assert keelmenu.cloud_available() is True + + @pytest.mark.parametrize("text, why", [ + ("http://cloud.example", "https"), + ("cloud.example", "https"), + ("https://", "no host"), + ("https:///path", "no host"), + ("https://[2001:db8::1/", "not a URL"), + ("https://cloud.example:99999", "not a URL"), + ("https://a.example https://b.example", "one URL"), + ("https://a.example\nhttps://b.example", "one URL"), + ]) + def test_anything_else_keeps_it_hidden_and_says_why( + self, cloud, caplog, text, why + ): + cloud.write_text(text) + + with caplog.at_level(logging.WARNING, logger="keelmenu"): + assert keelmenu.cloud_available() is False + + assert why in caplog.text + assert keelmenu.CLOUD_ENDPOINT in caplog.text + + def test_a_file_root_does_not_own_is_refused( + self, cloud, caplog, monkeypatch + ): + cloud.write_text("https://cloud.example\n") + monkeypatch.setattr(keelmenu, "CLOUD_OWNER", os.getuid() + 1) + + with caplog.at_level(logging.WARNING, logger="keelmenu"): + assert keelmenu.cloud_available() is False + + assert "not owned by root" in caplog.text + + @pytest.mark.parametrize("mode", [0o664, 0o646, 0o666]) + def test_a_file_others_can_write_is_refused(self, cloud, caplog, mode): + cloud.write_text("https://cloud.example\n") + cloud.chmod(mode) + + with caplog.at_level(logging.WARNING, logger="keelmenu"): + assert keelmenu.cloud_available() is False + + assert "writable by group or others" in caplog.text + + def test_a_directory_is_refused(self, cloud, caplog): + cloud.mkdir() + + with caplog.at_level(logging.WARNING, logger="keelmenu"): + assert keelmenu.cloud_available() is False + + assert "not a regular file" in caplog.text + + def test_an_unreadable_file_is_refused(self, cloud, caplog, + monkeypatch): + cloud.write_text("https://cloud.example\n") + + def deny(*args, **kwargs): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(keelmenu, "_read", deny) + with caplog.at_level(logging.WARNING, logger="keelmenu"): + assert keelmenu.cloud_available() is False + + assert "Permission denied" in caplog.text + + def test_no_file_is_the_quiet_default(self, cloud, caplog): + with caplog.at_level(logging.WARNING, logger="keelmenu"): + assert keelmenu.cloud_available() is False + + assert caplog.text == "" + class TestOtherScreens: def test_a_screen_with_no_rule_is_shown(self, chains):