fix: align container packaging with m4b-tool 0.5.2
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user