From 75d2494ec6c3e6f030d8f9224d537e5648f9bea1 Mon Sep 17 00:00:00 2001 From: navigator Date: Wed, 30 Sep 2026 04:35:25 +0000 Subject: [PATCH] fix: redispass.py draws its password box on the terminal, not in the hook's pipe 35redispass captures redispass.py's standard output for DB_PASS=, and dialog draws on standard output, so an interactive first boot would freeze with the box drawn into the command substitution (the bug keel-wordpress 19.0-3 hit on Proxmox on 2026-09-30, keel-mariadb#20). 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 secrets.db_password instead of a traceback. tests/dialog.bats runs the script as the hook does inside script(1) and fails on the old code. Version 1.0.3. --- .github/workflows/tests.yml | 2 +- COVERAGE.md | 4 +- changelog | 13 +++++ overlay/usr/lib/inithooks/bin/redispass.py | 40 ++++++++++++++- tests/README.md | 1 + tests/dialog.bats | 60 ++++++++++++++++++++++ version | 2 +- 7 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 tests/dialog.bats diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 45b73ae..5bdd378 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,7 +20,7 @@ permissions: jobs: tests: # COVERAGE.md: the conf script at 100 percent (42/42), the first boot - # hook at 100 (31/31) and its library at 100 (56/56), 70 bats tests. The + # hook at 100 (31/31) and its library at 100 (56/56), 73 bats tests. The # threshold is the measured number and is only ever raised (decision # 0006). This job produces the check # "tests / coverage", the required status on main; the job id is part of diff --git a/COVERAGE.md b/COVERAGE.md index e362256..961ce70 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -14,10 +14,10 @@ repository does not carry a boot test of its own. | overlay/usr/lib/inithooks/firstboot.d/35redispass | tests/hook.bats (18 tests) | 100 percent (31/31) under kcov | every path, including the four failures that matter | | conf | tests/conf.bats (20 tests) | 100 percent (42/42) under kcov | the include appended once and last, the fragments, the running server on both families, every refusal, the CRLF an INFO reply really carries, the server log a failure prints, and the packaged log file the check must not leave behind | | overlay/etc/redis/redis.conf.d/\* | tests/conf.bats (2 tests) | not executable | asserted as content: two literal bind addresses and no name, the default account restricted to `+info`, the administrative account published `off`, and no `requirepass` anywhere | -| overlay/usr/lib/inithooks/bin/redispass.py | none | 0 | dialog wrapper, only reached with a terminal attached | +| overlay/usr/lib/inithooks/bin/redispass.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 | Total over the three measured shell files: **100 percent (129/129)**, -70 bats tests over four files (conf, hook, library, unit shape). +73 bats tests over five files (conf, hook, library, unit shape, dialog). `tests/coverage.sh` fails below `COVERAGE_THRESHOLD`, which the workflow sets to 100, the measured number. It is only ever raised (decision 0006). diff --git a/changelog b/changelog index 5c70fce..5f529c4 100644 --- a/changelog +++ b/changelog @@ -1,3 +1,16 @@ +unit-redis-1.0.3 (1) keel; urgency=low + + * bin/redispass.py draws its password box on the terminal. + firstboot.d/35redispass captures the script's standard output for + DB_PASS=, and dialog draws on standard output, so an interactive first + boot would freeze at the Redis password with the box drawn into the + hook's command substitution, 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 + unit-redis-1.0.2 (1) keel; urgency=low * Declare the administrative account once. 20-keel-acl.conf published it diff --git a/overlay/usr/lib/inithooks/bin/redispass.py b/overlay/usr/lib/inithooks/bin/redispass.py index 4a2acec..cee7c19 100755 --- a/overlay/usr/lib/inithooks/bin/redispass.py +++ b/overlay/usr/lib/inithooks/bin/redispass.py @@ -10,10 +10,13 @@ Syntax: redispass.py NAME [NAME ...] NAME is DB_PASS """ +import os import sys from libinithooks.dialog_wrapper import Dialog +TTY = "/dev/tty" + TITLE = "Keel - First boot configuration" @@ -27,13 +30,48 @@ def ask(name: str, dialog: Dialog) -> str: raise SystemExit(f"redispass.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"redispass.py: no terminal to draw the dialog on ({path}:" + f" {e.strerror}); declare 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: 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 d1c8f24..9c1c6d6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -11,6 +11,7 @@ organization's `test-shell.yml` and the check is `tests / coverage`. | File | What it covers | | --- | --- | | `conf.bats` | the build time conf script, run for real against a scratch tree, with `redis-server` and `redis-cli` as PATH stubs. Also the two configuration fragments, asserted as content | +| `dialog.bats` | `bin/redispass.py` run as the hook runs it, output redirected, inside a pseudo terminal (`script -qec`), with a stand-in for libinithooks whose dialog refuses to draw anywhere but a terminal: the answer goes to the hook and the box to the screen | | `hook.bats` | the first boot hook, run for real against a scratch tree, with `systemctl` and `redis-cli` as PATH stubs and a stub dialog on a scratch `INITHOOKS_PATH` whose `lib` is a symlink to the real library, so kcov measures the file the component ships | | `redis.bats` | the library behind the hook, function by function | | `unit.bats` | the shape fab and `bt-layer` require of a unit: the executable conf, the plan, the version against the changelog, the overlay's exact file list, and the conf script as POSIX shell under shellcheck | diff --git a/tests/dialog.bats b/tests/dialog.bats new file mode 100644 index 0000000..54bb811 --- /dev/null +++ b/tests/dialog.bats @@ -0,0 +1,60 @@ +#!/usr/bin/env bats +# bin/redispass.py draws its dialog on the terminal, not in the hook's pipe. +# +# firstboot.d/35redispass 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)" + REDISPASS="$here/../overlay/usr/lib/inithooks/bin/redispass.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 command substitution makes it a pipe + script -qec "PYTHONPATH='$FAKE' python3 '$REDISPASS' $* > '$OUT'" /dev/null +} + +@test "the answer reaches the hook while the dialog draws on the terminal" { + run run_as_the_hook DB_PASS + [ "$status" -eq 0 ] + [ "$(cat "$OUT")" = "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 "$REDISPASS" DB_PASS < /dev/null + [ "$status" -ne 0 ] + [[ "$output" == *"no terminal to draw the dialog on"* ]] + [[ "$output" == *"secrets.db_password"* ]] + [[ "$output" != *"Traceback"* ]] +} + +@test "without a name it prints its usage and fails" { + PYTHONPATH="$FAKE" run python3 "$REDISPASS" + [ "$status" -eq 1 ] + [[ "$output" == *"Syntax: redispass.py NAME"* ]] +} diff --git a/version b/version index 6d7de6e..21e8796 100644 --- a/version +++ b/version @@ -1 +1 @@ -1.0.2 +1.0.3