Skip to content
Open
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
2 changes: 1 addition & 1 deletion COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
13 changes: 13 additions & 0 deletions changelog
Original file line number Diff line number Diff line change
@@ -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 <admin@keellinux.org> 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
Expand Down
40 changes: 39 additions & 1 deletion overlay/usr/lib/inithooks/bin/nodebb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand All @@ -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


Expand Down
5 changes: 5 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
60 changes: 60 additions & 0 deletions tests/dialog.bats
Original file line number Diff line number Diff line change
@@ -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"* ]]
}
Loading