diff --git a/COVERAGE.md b/COVERAGE.md index 7c8bf2f..0b30fe0 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -14,7 +14,7 @@ acceptance test of an appliance recipe, docs/org-plan.md section 1). | tests/lib/boot-test-lib.sh | tests/boot-test.bats (41 tests) | 100 percent (137/137) under kcov | the logic of the boot test: argument parsing, address discovery, deadlines, the HTTP and diff verdicts | | bin/keel-archive-check | tests/archive-check.bats (27 tests) | 100 percent (54/54) under kcov | the build time check: the archive copy in the build tree is the live archive, the source entry names the keyring through signed-by, nothing says trusted=yes, and the signature on the copied InRelease verifies against the staging key (tracker#7) | | conf.d/zz-project-packages | tests/project-packages.bats (14 tests) | 100 percent (31/31) under kcov | the build time check that each project package is the candidate of the archive and a project build, and that the archive copy, its source entry and the staging keyring leave the image | -| overlay/usr/lib/inithooks/bin/nodebb.py | none | 0 | dialog wrapper, only reached with a terminal attached | +| overlay/usr/lib/inithooks/bin/nodebb.py | tests/dialog.bats (3 tests) | not measured (kcov measures the shell) | dialog wrapper, run as the hook runs it inside a pseudo terminal: the answer reaches the hook and the box is drawn on the terminal | | conf.d/main | tests/boot-test.sh (build step) | integration only | build time script, 0004 pragmatic limits | | tests/boot-test.sh | itself | integration only | the thin main of the acceptance test: keel and LXC as root | diff --git a/changelog b/changelog index cf5ebd4..4631cf9 100644 --- a/changelog +++ b/changelog @@ -1,3 +1,16 @@ +turnkey-nodebb-19.0 (5) turnkey; urgency=medium + + * bin/nodebb.py draws its password box on the terminal. firstboot.d/ + 40nodebb reads the script's standard output for APP_PASS=, and dialog + draws on standard output, so an interactive first boot would freeze at + the admin password with the box drawn into the hook's pipe, as + keel-wordpress 19.0-3 did at the MariaDB password on Proxmox + (2026-09-30). The answers now go to a copy of that pipe and standard + output is the terminal. tests/dialog.bats runs the script as the hook + does, inside a pseudo terminal. + + -- Keel Linux maintainers Wed, 30 Sep 2026 04:30:00 +0000 + turnkey-nodebb-19.0 (4) turnkey; urgency=low * bin/keel-archive-check refuses a trusted=yes on the project archive rather diff --git a/overlay/usr/lib/inithooks/bin/nodebb.py b/overlay/usr/lib/inithooks/bin/nodebb.py index 616d223..a88745f 100755 --- a/overlay/usr/lib/inithooks/bin/nodebb.py +++ b/overlay/usr/lib/inithooks/bin/nodebb.py @@ -8,10 +8,13 @@ Syntax: nodebb.py NAME [NAME ...] NAME is APP_PASS """ +import os import sys from libinithooks.dialog_wrapper import Dialog +TTY = "/dev/tty" + TITLE = "Keel - First boot configuration" @@ -23,13 +26,48 @@ def ask(name: str, dialog: Dialog) -> str: raise SystemExit(f"nodebb.py: unknown value name {name!r}") +def terminal_path(tty: str = TTY) -> str: + """The terminal to draw on: the one the hook checked on standard input, + else the controlling terminal""" + try: + return os.ttyname(sys.stdin.fileno()) + except OSError: + return tty + + +def answers_out(tty: str = TTY): + """The hook's pipe for the answers, with standard output on the terminal + + The hook reads this script's standard output, and dialog draws its + screen on standard output: left there, the password box is drawn into + the hook's pipe, and the console shows a frozen screen waiting for a + password nobody can see (2026-09-30, keel-wordpress on Proxmox). So + the pipe is kept on a new descriptor for the KEY=value lines, and + standard output, which dialog inherits, becomes the terminal. + """ + path = terminal_path(tty) + try: + terminal = os.open(path, os.O_WRONLY) + except OSError as e: + raise SystemExit( + f"nodebb.py: no terminal to draw the dialog on ({path}:" + f" {e.strerror}); declare secrets.app_password in the instance" + " description instead") + answers = os.fdopen(os.dup(sys.stdout.fileno()), "w") + os.dup2(terminal, sys.stdout.fileno()) + os.close(terminal) + return answers + + def main(names: list[str]) -> int: if not names: print(__doc__, file=sys.stderr) return 1 + answers = answers_out() dialog = Dialog(TITLE) for name in names: - print(f"{name}={ask(name, dialog)}") + print(f"{name}={ask(name, dialog)}", file=answers) + answers.close() return 0 diff --git a/tests/README.md b/tests/README.md index 9fea202..c9e5061 100644 --- a/tests/README.md +++ b/tests/README.md @@ -23,6 +23,10 @@ matches the spec. - `nodebb.bats`: unit tests of `overlay/usr/lib/inithooks/lib/nodebb.sh`, the logic behind the first boot hook `40nodebb`. +- `dialog.bats`: `overlay/usr/lib/inithooks/bin/nodebb.py` run as the hook + runs it, output redirected, inside a pseudo terminal (`script`), with a + stand-in for libinithooks whose dialog refuses to draw anywhere but a + terminal: the answers go to the hook and the box to the screen. - `archive-check.bats`: unit tests of `bin/keel-archive-check`, which the Makefile runs twice per build so that the copy of the project archive inside the build tree is the archive as it is at build time. @@ -41,6 +45,7 @@ Debian packages `bats` (1.11) and `kcov` (43); no root: bats tests/nodebb.bats bats tests/boot-test.bats + bats tests/dialog.bats COVERAGE_THRESHOLD=100 tests/coverage.sh `COVERAGE_DIR=coverage tests/coverage.sh` keeps the kcov reports, one diff --git a/tests/dialog.bats b/tests/dialog.bats new file mode 100644 index 0000000..02f271b --- /dev/null +++ b/tests/dialog.bats @@ -0,0 +1,60 @@ +#!/usr/bin/env bats +# bin/nodebb.py draws its dialog on the terminal, not in the hook's pipe. +# +# firstboot.d/40nodebb reads the script's standard output for KEY=value, and +# dialog draws its screen on standard output. On 2026-09-30 a keel-wordpress +# first boot on Proxmox froze at a password box because the box was drawn +# into such a pipe, and this script had the same shape. These tests run the +# script as the hook does, output redirected, inside a real pseudo terminal +# (script(1)), with a stand-in for libinithooks whose dialog refuses to draw +# anywhere but a terminal. + +setup() { + here="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + NODEBB="$here/../overlay/usr/lib/inithooks/bin/nodebb.py" + FAKE="$BATS_TEST_TMPDIR/lib" + mkdir -p "$FAKE/libinithooks" + : > "$FAKE/libinithooks/__init__.py" + cat > "$FAKE/libinithooks/dialog_wrapper.py" << 'EOF' +import os + + +class Dialog: + def __init__(self, title): + self.title = title + + def get_password(self, title, text): + # what dialog needs to be seen: a terminal on standard output + if not os.isatty(1): + raise SystemExit("dialog would draw into a pipe") + return "typed-at-the-console" +EOF + OUT="$BATS_TEST_TMPDIR/answers" +} + +run_as_the_hook() { + # stdin and /dev/tty are the pseudo terminal; stdout is a file, as the + # hook's process substitution makes it a pipe + script -qec "PYTHONPATH='$FAKE' python3 '$NODEBB' $* > '$OUT'" /dev/null +} + +@test "the answer reaches the hook while the dialog draws on the terminal" { + run run_as_the_hook APP_PASS + [ "$status" -eq 0 ] + [ "$(cat "$OUT")" = "APP_PASS=typed-at-the-console" ] +} + +@test "without any terminal it says so instead of a traceback" { + # setsid: no controlling terminal, and standard input is not one either + PYTHONPATH="$FAKE" run setsid -w python3 "$NODEBB" APP_PASS < /dev/null + [ "$status" -ne 0 ] + [[ "$output" == *"no terminal to draw the dialog on"* ]] + [[ "$output" == *"secrets.app_password"* ]] + [[ "$output" != *"Traceback"* ]] +} + +@test "without a name it prints its usage and fails" { + PYTHONPATH="$FAKE" run python3 "$NODEBB" + [ "$status" -eq 1 ] + [[ "$output" == *"Syntax: nodebb.py NAME"* ]] +}