Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 28 additions & 8 deletions confconsole.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import ifutil
import conf
import keelbanner
import keelfit
import plugin

from typing import NoReturn, Iterable, Mapping, Any
Expand Down Expand Up @@ -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?"
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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,
Expand All @@ -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,
)
Expand All @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,41 @@
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, 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 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), 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
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 <mendez.foto@gmail.com> 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:
Expand Down
78 changes: 72 additions & 6 deletions docs/Instance.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,69 @@ 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``, ``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 |
| | 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 |
+-------------------+--------------------------------------------------------+

``/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: <why>``.

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
--------------------------------------

Expand Down Expand Up @@ -78,11 +141,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
Expand Down Expand Up @@ -125,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
Expand Down
32 changes: 25 additions & 7 deletions keelcli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)])
Expand Down
Loading
Loading