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


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