fix: align container packaging with m4b-tool 0.5.2

This commit is contained in:
drockhollaback
2026-08-26 21:59:28 -04:00
parent 78b3f599f8
commit 06c081f338
11 changed files with 161 additions and 26 deletions
+27 -12
View File
@@ -24,6 +24,8 @@ confirmation.
- The container runs as the numeric `PUID:PGID` supplied by Compose, drops all
Linux capabilities, enables `no-new-privileges`, is not privileged, and does
not mount the Docker socket.
- UID 0 and GID 0 are refused. The application must never run as root, even if
root IDs are supplied accidentally in `.env`.
This safety model is not a backup. Keep independent backups of source audio and
the ready library.
@@ -86,7 +88,8 @@ id -g
```
Edit `.env`: set `PUID` to the `id -u` result and `PGID` to the `id -g`
result. Confirm all four `AUDIOBOOK_*_HOST` values and
result. Both results must be nonzero; the entrypoint refuses root UID or GID.
Confirm all four `AUDIOBOOK_*_HOST` values and
`AUDIOBOOK_STATE_HOST`. All are required host-side deployment settings.
Do not commit `.env`.
@@ -109,10 +112,13 @@ container smoke test passed
```
The smoke test uses only a temporary directory inside the container. It prints
the installed `m4b-tool`, `ffmpeg`, and `ffprobe` versions, creates two synthetic
one-second MP3 tracks, runs the application's real conversion services, and
checks the final duration and two chapter titles. It does not touch host
audiobook paths.
the installed `m4b-tool`, `ffmpeg`, and `ffprobe` versions and checks the pinned
`m4b-tool 0.5.2` merge help before conversion. This application maps Narrator
to the supported `--writer` option and Year to `--year`; the smoke test fails if
either option is absent. It creates two synthetic one-second MP3 tracks, uses a
nonblank smoke narrator and year, runs the application's real conversion
services, and checks the final duration and two chapter titles. It does not
touch host audiobook paths.
## Launch over SSH
@@ -203,8 +209,12 @@ ready and work roots.
Persistent conversion logs are written beneath the directory mounted at
`/app/state`, normally `./state/logs` on Enterprise-D. Back up logs if they are
part of your audit trail. Temporary merge data lives beneath
`/tmp/audiobook-tui` in the ephemeral container and is not a backup.
part of your audit trail. Conversion work and generated chapter metadata live
beneath persistent `/app/state/work`, normally `./state/work` on Enterprise-D.
The entrypoint creates that work directory only after confirming the mounted
state directory is writable by `PUID:PGID`. Interrupted work therefore survives
container removal and remains available to the TUI's explicit stale-recovery
flow. It is operational recovery data, not a backup.
## Permissions troubleshooting
@@ -216,6 +226,9 @@ id -u
id -g
```
Both IDs must be nonzero. Do not work around the root-ID refusal by adding
privileges; use the ordinary account that owns the host directories.
If `doctor` reports an unreadable or unwritable root, inspect every host path
and the state directory with `ls -ld`. Correct ownership or group permissions
on the host; do not add privileged mode, capabilities, or a Docker socket.
@@ -233,7 +246,9 @@ to `latest`. To upgrade intentionally:
2. Change `M4B_TOOL_IMAGE` in `.env` (and `.env.example` when adopting the pin
for everyone) to a stable version tag or immutable digest.
3. Run Compose configuration, `build --pull`, `doctor`, and the synthetic smoke
command from the Enterprise-D verification section.
command from the Enterprise-D verification section. The smoke gate also
confirms that merge help still exposes `--writer` and `--year` before using
them with nonblank metadata.
4. Run the manual acceptance checklist with disposable or backed-up test data.
5. Keep the old pin recorded so rollback is a one-line `.env` change and rebuild.
@@ -251,6 +266,7 @@ Never promote a new pin solely because the image builds.
- [ ] Output, moved source, and log ownership match `PUID:PGID`.
- [ ] An existing output collision is refused without changing either file.
- [ ] Ctrl-C leaves the source pending and stale cleanup lists only expected paths.
- [ ] Removing the interrupted container leaves stale work under `./state/work`.
- [ ] Successful conversion yields the expected pending → ready + done transition.
- [ ] `REVIEW` yields the expected pending → review transition.
- [ ] Independent source and ready-library backups have been confirmed.
@@ -258,10 +274,9 @@ Never promote a new pin solely because the image builds.
## Development verification status
Local Python tests are the only verified checks from the authoring desktop.
Docker Compose configuration, image build, container diagnostics, and container
smoke conversion are **not verified there** because neither the Docker CLI nor
daemon is installed. Run the Enterprise-D commands above and retain their output
before declaring deployment acceptance.
Docker config/build/doctor/smoke are **NOT RUN locally because Docker was
absent**. Run the Enterprise-D commands above and retain their output before
declaring deployment acceptance.
## License
+1 -1
View File
@@ -35,7 +35,7 @@ def load_settings(path: Path, environ: Mapping[str, str]) -> Settings:
settings = Settings(
roots=roots,
state_dir=Path(_path_value(paths, "state_dir", environ, "AUDIOBOOK_STATE_DIR", errors, "/app/state")),
work_dir=Path(_path_value(paths, "work_dir", environ, "AUDIOBOOK_WORK_DIR", errors, "/tmp/audiobook-tui")),
work_dir=Path(_path_value(paths, "work_dir", environ, "AUDIOBOOK_WORK_DIR", errors, "/app/state/work")),
jobs=_integer_value(conversion, "jobs", errors, 2),
audnexus_enabled=_boolean_value(audnexus, "enabled", errors, True),
audnexus_base_url=_string_value(audnexus, "base_url", errors, "https://api.audnex.us"),
+1 -1
View File
@@ -69,7 +69,7 @@ class Roots:
class Settings:
roots: Roots
state_dir: Path = Path("/app/state")
work_dir: Path = Path("/tmp/audiobook-tui")
work_dir: Path = Path("/app/state/work")
jobs: int = 2
audnexus_enabled: bool = True
audnexus_base_url: str = "https://api.audnex.us"
+2 -2
View File
@@ -119,10 +119,10 @@ def build_merge_argv(
metadata_options = (
("name", "--name", metadata.title),
("author", "--artist", metadata.author),
("narrator", "--narrator", metadata.narrator),
("narrator", "--writer", metadata.narrator),
("series", "--series", metadata.series),
("series position", "--series-part", metadata.series_position),
("year", "--published-date", metadata.year),
("year", "--year", metadata.year),
("description", "--description", metadata.description),
)
argv: list[str] = [
+1 -1
View File
@@ -13,7 +13,7 @@ services:
AUDIOBOOK_DONE: /data/done
AUDIOBOOK_REVIEW: /data/review
AUDIOBOOK_STATE_DIR: /app/state
AUDIOBOOK_WORK_DIR: /tmp/audiobook-tui
AUDIOBOOK_WORK_DIR: /app/state/work
stdin_open: true
tty: true
init: true
+1 -1
View File
@@ -4,7 +4,7 @@ ready = "/data/ready"
done = "/data/done"
review = "/data/review"
state_dir = "/app/state"
work_dir = "/tmp/audiobook-tui"
work_dir = "/app/state/work"
[conversion]
jobs = 2
+22 -7
View File
@@ -2,7 +2,18 @@
set -eu
m4b-tool --version
m4b-tool merge --help >/dev/null
merge_help=$(m4b-tool merge --help 2>&1)
for required_option in --writer --year
do
case "$merge_help" in
*"$required_option"*) ;;
*)
printf 'm4b-tool 0.5.2 merge help is missing required option: %s\n' \
"$required_option" >&2
exit 1
;;
esac
done
ffmpeg -version
ffprobe -version
@@ -15,8 +26,7 @@ mkdir -p \
"$smoke_root/ready" \
"$smoke_root/done" \
"$smoke_root/review" \
"$smoke_root/state" \
"$smoke_root/work"
"$smoke_root/state"
ffmpeg -nostdin -hide_banner -loglevel error \
-f lavfi -i 'sine=frequency=440:duration=1' \
@@ -34,8 +44,7 @@ import subprocess
from app.chapters import chapters_from_embedded
from app.converter import Converter
from app.metadata import infer_metadata
from app.models import Roots, Settings
from app.models import BookMetadata, Roots, Settings
from app.planner import build_plan
from app.scanner import scan_book
from app.workflow import finalize_success
@@ -50,7 +59,7 @@ roots = Roots(
settings = Settings(
roots=roots,
state_dir=root / "state",
work_dir=root / "work",
work_dir=root / "state" / "work",
jobs=1,
audnexus_enabled=False,
)
@@ -61,7 +70,13 @@ def run_probe(argv: tuple[str, ...]) -> subprocess.CompletedProcess[str]:
source = roots.pending / "Synthetic Book"
book = scan_book(source, run_probe)
chapters = chapters_from_embedded(book)
plan = build_plan(book, infer_metadata(book), chapters, "Synthetic Book.m4b", settings)
metadata = BookMetadata(
title="Synthetic Book",
author="Smoke Author",
narrator="Smoke Narrator",
year="2026",
)
plan = build_plan(book, metadata, chapters, "Synthetic Book.m4b", settings)
validation = Converter(settings).run(plan, settings.state_dir / "smoke.log")
if not validation.ok:
raise SystemExit("conversion validation failed: " + "; ".join(validation.errors))
+10
View File
@@ -12,6 +12,7 @@ require_numeric() {
case "$variable_value" in
''|*[!0-9]*) fail "$variable_name must be a non-negative numeric ID" ;;
esac
[ "$variable_value" -ne 0 ] || fail "$variable_name must not be 0; root IDs are refused"
}
require_directory() {
@@ -43,4 +44,13 @@ do
[ -w "$destination" ] || fail "directory is not writable by $PUID:$PGID: $destination"
done
work_directory=${AUDIOBOOK_WORK_DIR:-/app/state/work}
if [ ! -e "$work_directory" ]; then
mkdir -p "$work_directory" \
|| fail "could not create persistent work directory: $work_directory"
fi
require_directory work "$work_directory"
[ -w "$work_directory" ] \
|| fail "work directory is not writable by $PUID:$PGID: $work_directory"
exec audiobook-tui "$@"
+12
View File
@@ -16,6 +16,18 @@ def test_environment_overrides_all_host_roots(tmp_path: Path) -> None:
assert settings.roots.ready == Path("/data/ready")
def test_default_work_directory_is_persistent_under_state(tmp_path: Path) -> None:
config = tmp_path / "config.toml"
config.write_text(
'[paths]\npending="/pending"\nready="/ready"\ndone="/done"\nreview="/review"\n',
encoding="utf-8",
)
settings = load_settings(config, {})
assert settings.work_dir == Path("/app/state/work")
def test_validation_reports_duplicate_roots_and_invalid_jobs(tmp_path: Path) -> None:
config = tmp_path / "config.toml"
config.write_text(
+78
View File
@@ -1,5 +1,9 @@
import os
from pathlib import Path
import subprocess
import tomllib
import pytest
import yaml
@@ -35,6 +39,75 @@ def test_config_defaults_match_container_roots() -> None:
assert root in text
def test_work_directory_is_persistent_in_the_state_mount() -> None:
config = tomllib.loads(Path("config.toml").read_text(encoding="utf-8"))
service = _service()
state_mount = next(
item for item in service["volumes"] if item["target"] == "/app/state"
)
assert config["paths"]["work_dir"] == "/app/state/work"
assert service["environment"]["AUDIOBOOK_WORK_DIR"] == "/app/state/work"
assert state_mount["source"] == "${AUDIOBOOK_STATE_HOST:-./state}"
@pytest.mark.parametrize(
("puid", "pgid", "rejected"),
(("0", "1", "PUID"), ("1", "0", "PGID")),
)
def test_entrypoint_refuses_root_numeric_ids(
puid: str, pgid: str, rejected: str
) -> None:
result = subprocess.run(
("sh", "scripts/entrypoint.sh"),
env={**os.environ, "PUID": puid, "PGID": pgid},
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 1
assert f"{rejected} must not be 0" in result.stderr
def test_entrypoint_creates_persistent_work_directory_as_runtime_user(
tmp_path: Path,
) -> None:
roots = tuple(tmp_path / name for name in ("pending", "ready", "done", "review"))
for root in roots:
root.mkdir()
state = tmp_path / "state"
state.mkdir()
work = state / "work"
binary_directory = tmp_path / "bin"
binary_directory.mkdir()
executable = binary_directory / "audiobook-tui"
executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
executable.chmod(0o755)
result = subprocess.run(
("sh", "scripts/entrypoint.sh", "doctor"),
env={
**os.environ,
"PATH": f"{binary_directory}:{os.environ['PATH']}",
"PUID": str(os.getuid()),
"PGID": str(os.getgid()),
"AUDIOBOOK_PENDING": str(roots[0]),
"AUDIOBOOK_READY": str(roots[1]),
"AUDIOBOOK_DONE": str(roots[2]),
"AUDIOBOOK_REVIEW": str(roots[3]),
"AUDIOBOOK_STATE_DIR": str(state),
"AUDIOBOOK_WORK_DIR": str(work),
},
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert work.is_dir()
def test_image_pin_and_application_entrypoint_are_explicit() -> None:
dockerfile = Path("Dockerfile").read_text(encoding="utf-8")
environment = Path(".env.example").read_text(encoding="utf-8")
@@ -47,6 +120,11 @@ def test_image_pin_and_application_entrypoint_are_explicit() -> None:
def test_container_smoke_script_is_copied_to_its_documented_path() -> None:
dockerfile = Path("Dockerfile").read_text(encoding="utf-8")
smoke = Path("scripts/container-smoke.sh").read_text(encoding="utf-8")
assert "COPY scripts/container-smoke.sh /app/scripts/container-smoke.sh" in dockerfile
assert Path("scripts/container-smoke.sh").is_file()
assert "--writer" in smoke
assert "--year" in smoke
assert 'narrator="Smoke Narrator"' in smoke
assert 'year="2026"' in smoke
+6 -1
View File
@@ -110,7 +110,12 @@ def test_plan_uses_argument_vector_without_shell(tmp_path: Path) -> None:
f"--output-file={tmp_path / 'work' / 'A Book.work' / 'merged.m4b'}",
"--name=A Book",
"--artist=An Author",
"--narrator=A Narrator",
"--writer=A Narrator",
)
assert "--year=2024" in plan.argv
assert not any(
argument.startswith(("--narrator=", "--published-date="))
for argument in plan.argv
)
assert "shell=True" not in plan.argv
assert plan.argv[-2:] == tuple(str(track.path) for track in plan.book.tracks)