From 8768f9d7151c37f7fccecb2e76944e258ffcd537 Mon Sep 17 00:00:00 2001 From: navigator Date: Wed, 30 Sep 2026 04:35:20 +0000 Subject: [PATCH] fix: wordpress.py draws its password boxes on the terminal, not in the hook's pipe 40wordpress reads wordpress.py's standard output for APP_PASS= and DB_PASS=, and dialog draws on standard output, so an interactive first boot froze with the box drawn into the pipe (the same bug as keel-mariadb#20, keel-wordpress 19.0-3 on Proxmox, 2026-09-30). The answers go to a duplicate of that pipe and standard output becomes the terminal the hook checked on standard input, else /dev/tty; without either the script names the instance description fields instead of a traceback. tests/dialog.bats runs the script as the hook does inside script(1) and fails on the old code. --- COVERAGE.md | 2 +- changelog | 13 +++++ overlay/usr/lib/inithooks/bin/wordpress.py | 40 +++++++++++++- tests/README.md | 5 ++ tests/dialog.bats | 61 ++++++++++++++++++++++ 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 tests/dialog.bats diff --git a/COVERAGE.md b/COVERAGE.md index 5497876..ab96a3b 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -16,7 +16,7 @@ acceptance test of a recipe, docs/org-plan.md section 1). | `conf.d/zzz-keel-archive` | `tests/keel-archive.bats` (13 tests) | 100 percent (26/26) under kcov | every way it enables and every way it refuses, including a staging keyring left in the image | | `conf.d/zz-project-packages` | `tests/project-packages.bats` (14 tests) | 100 percent (31/31) under kcov | shared with keel-nodebb, where the pattern is maintained | | `bin/keel-archive-check` | `tests/archive-check.bats` (27 tests) | 100 percent (54/54) under kcov | the build time check of tracker#7: the copy is the live archive, the entry names the keyring through signed-by, nothing says trusted=yes, and the copied InRelease verifies against the staging key | -| `overlay/usr/lib/inithooks/bin/wordpress.py` | none | 0 | dialog wrapper, only reached with a terminal attached | +| `overlay/usr/lib/inithooks/bin/wordpress.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 answers reach the hook and the boxes are drawn on the terminal | | `overlay/usr/lib/inithooks/lib/*.php` | the boot test | integration only | two PHP files `wp eval-file` runs; `conf.d/main` has PHP lint them | | `conf.d/main` | the build | 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 d9ad656..d511a65 100644 --- a/changelog +++ b/changelog @@ -1,3 +1,16 @@ +turnkey-wordpress-19.0 (5) turnkey; urgency=medium + + * bin/wordpress.py draws its password boxes on the terminal. + firstboot.d/40wordpress reads the script's standard output for + APP_PASS= and DB_PASS=, and dialog draws on standard output, so an + interactive first boot would freeze at the WordPress password with the + box drawn into the hook's pipe, as it did at the MariaDB password + (keel-wordpress 19.0-3 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-wordpress-19.0 (4) turnkey; urgency=low * The two operator commands this appliance writes itself now carry the diff --git a/overlay/usr/lib/inithooks/bin/wordpress.py b/overlay/usr/lib/inithooks/bin/wordpress.py index afe4688..2b57c90 100755 --- a/overlay/usr/lib/inithooks/bin/wordpress.py +++ b/overlay/usr/lib/inithooks/bin/wordpress.py @@ -11,10 +11,13 @@ Syntax: wordpress.py NAME [NAME ...] NAME is APP_PASS or DB_PASS """ +import os import sys from libinithooks.dialog_wrapper import Dialog +TTY = "/dev/tty" + TITLE = "Keel - First boot configuration" PROMPTS = { @@ -38,14 +41,49 @@ def ask(name: str, dialog: Dialog) -> str: return dialog.get_password(title, text) +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"wordpress.py: no terminal to draw the dialog on ({path}:" + f" {e.strerror}); declare secrets.app_password and" + " secrets.db_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: """Print one KEY=value line per requested name.""" 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 8f1cb43..d8e4d05 100644 --- a/tests/README.md +++ b/tests/README.md @@ -25,6 +25,10 @@ the spec. - `wordpress.bats`: unit tests of `overlay/usr/lib/inithooks/lib/wordpress.sh`, the logic behind the first boot hook `40wordpress`. +- `dialog.bats`: `overlay/usr/lib/inithooks/bin/wordpress.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. - `hook.bats`: the hook itself, executed for real against scratch directories with `systemctl`, `mysqladmin`, `mysql`, `php`, `wp`, `chown` and `openssl` as `PATH` stubs that log every call, and a scratch `INITHOOKS_PATH` whose @@ -63,6 +67,7 @@ Debian packages `bats` (1.11) and `kcov` (43); no root: bats tests/wrappers.bats bats tests/boot-test.bats bats tests/keel-archive.bats + bats tests/dialog.bats COVERAGE_THRESHOLD=95 tests/coverage.sh `COVERAGE_DIR=coverage tests/coverage.sh` keeps the kcov reports, one directory diff --git a/tests/dialog.bats b/tests/dialog.bats new file mode 100644 index 0000000..f7f2ec3 --- /dev/null +++ b/tests/dialog.bats @@ -0,0 +1,61 @@ +#!/usr/bin/env bats +# bin/wordpress.py draws its dialog on the terminal, not in the hook's pipe. +# +# firstboot.d/40wordpress 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 that pipe. 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)" + WORDPRESS="$here/../overlay/usr/lib/inithooks/bin/wordpress.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 '$WORDPRESS' $* > '$OUT'" /dev/null +} + +@test "the answers reach the hook while the dialog draws on the terminal" { + run run_as_the_hook APP_PASS DB_PASS + [ "$status" -eq 0 ] + [ "$(cat "$OUT")" = "APP_PASS=typed-at-the-console +DB_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 "$WORDPRESS" 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 "$WORDPRESS" + [ "$status" -eq 1 ] + [[ "$output" == *"Syntax: wordpress.py NAME"* ]] +}