From 24c95c91f24a7957afc19441bbbc26e71b44760c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:29:22 +0000 Subject: [PATCH] Add Library Catalog addon, lib/docker.sh and tests - lib/docker.sh: shared Docker/Compose tooling (on-demand install via apt or get.docker.com, compose wrapper, container state/health, HTTP wait) - menus/addon_library.sh: install/update/uninstall the Koha + Quick Add stack from library/ to /opt/kiosk-library, add the catalogue as a kiosk page (duration 0), choose kiosk-only vs LAN access, show addresses and login - wire into install.sh menu, complete_uninstall and clone_settings - tests/addon_library.test.sh: 41 checks against a stub sudo/docker and a fixture stack (no root, Docker or network needed) Note: the 'network access' choice relies on a BIND_ADDR setting in the library stack, added in a follow-up commit. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- install.sh | 14 +- lib/config.sh | 5 + lib/docker.sh | 106 ++++++++++++ menus/addon_library.sh | 319 ++++++++++++++++++++++++++++++++++++ menus/clone_settings.sh | 8 + menus/complete_uninstall.sh | 4 +- tests/addon_library.test.sh | 203 +++++++++++++++++++++++ 7 files changed, 655 insertions(+), 4 deletions(-) create mode 100644 lib/docker.sh create mode 100644 menus/addon_library.sh create mode 100755 tests/addon_library.test.sh diff --git a/install.sh b/install.sh index 1ac658a..9026446 100755 --- a/install.sh +++ b/install.sh @@ -38,7 +38,11 @@ # Display/Lockout, the webui/ Node app installed as a systemd # service; no login of its own, put it behind your own reverse proxy # with Authelia forward-auth if it needs to be reachable beyond a -# trusted LAN - see the file header). +# trusted LAN - see the file header), Library Catalog (menus/ +# addon_library.sh - a self-hosted Koha library catalogue in Docker with +# Z39.50 lookup, label printing and a scan-to-add page, shown on the +# kiosk as a public search terminal; installs Docker on demand via +# lib/docker.sh). # Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/ # audio/network), Electron Maintenance (menus/advanced_electron.sh - # manual update, fix blank screen), Factory Reset @@ -67,6 +71,8 @@ source "$SCRIPT_DIR/lib/menu.sh" source "$SCRIPT_DIR/lib/config.sh" # shellcheck source=lib/electron.sh source "$SCRIPT_DIR/lib/electron.sh" +# shellcheck source=lib/docker.sh +source "$SCRIPT_DIR/lib/docker.sh" # shellcheck source=menus/sites.sh source "$SCRIPT_DIR/menus/sites.sh" # shellcheck source=menus/display.sh @@ -97,6 +103,8 @@ source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh" source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh" # shellcheck source=menus/addon_webui.sh source "$SCRIPT_DIR/menus/addon_webui.sh" +# shellcheck source=menus/addon_library.sh +source "$SCRIPT_DIR/menus/addon_library.sh" # shellcheck source=menus/advanced_electron.sh source "$SCRIPT_DIR/menus/advanced_electron.sh" # shellcheck source=menus/advanced_upgrade.sh @@ -179,8 +187,8 @@ core_settings_menu() { } addons_menu_builder() { - MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI") - MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu) + MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI" "Library Catalog (Koha)") + MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu addon_library_menu) } addons_menu() { diff --git a/lib/config.sh b/lib/config.sh index cf3d7d1..464e13e 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -42,6 +42,11 @@ # other addon's own scripts. : "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}" +# Library Catalog addon (menus/addon_library.sh): the Koha + Quick Add Docker +# stack is deployed here from this checkout's library/ directory. Root-owned +# on purpose - it holds the generated database/admin passwords in .env. +: "${LIBRARY_DIR:=/opt/kiosk-library}" + # Offline install bundle - only present on systems installed from an ISO # built with `iso/build-iso.sh --offline`: a local apt repository (every # package provisioning and the CUPS addon install) plus prebuilt diff --git a/lib/docker.sh b/lib/docker.sh new file mode 100644 index 0000000..93fe831 --- /dev/null +++ b/lib/docker.sh @@ -0,0 +1,106 @@ +#!/bin/bash +################################################################################ +# lib/docker.sh - Docker / Compose tooling shared by every addon that runs a +# containerised service (the Library Catalog addon is the first). +# +# Everything here goes through `sudo docker ...`: the kiosk admin runs +# install.sh as a regular user and is deliberately NOT put in the `docker` +# group (docker-group membership is root-equivalent). +# +# Depends on: lib/menu.sh (log_* helpers) being sourced first. +################################################################################ + +# Overridable so tests can substitute stubs for the real binaries. +: "${DOCKER_BIN:=docker}" + +docker_is_installed() { + command -v "$DOCKER_BIN" >/dev/null 2>&1 \ + && sudo "$DOCKER_BIN" compose version >/dev/null 2>&1 +} + +docker_daemon_running() { + sudo "$DOCKER_BIN" info >/dev/null 2>&1 +} + +# Install Docker Engine + the Compose plugin if they are missing. +# +# Ubuntu 24.04+ ships both in its own archive (docker.io, docker-compose-v2): +# preferred because it also works through the offline apt bundle's mirror and +# gets security updates with everything else. On releases without +# docker-compose-v2 (22.04), fall back to Docker's official install script. +docker_ensure_installed() { + if docker_is_installed; then + docker_daemon_running || sudo systemctl enable --now docker >/dev/null 2>&1 || true + log_success "Docker $(sudo "$DOCKER_BIN" --version 2>/dev/null | awk '{print $3}' | tr -d ,) is installed" + return 0 + fi + + log_info "Installing Docker..." + if ! sudo apt-get update -qq; then + log_error "apt update failed - check the network/package sources and try again" + return 1 + fi + if apt-cache show docker-compose-v2 >/dev/null 2>&1; then + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io docker-compose-v2 \ + || { log_error "Installing docker.io / docker-compose-v2 failed"; return 1; } + else + log_info "This Ubuntu release has no docker-compose-v2 package - using Docker's install script" + have_internet 2>/dev/null || { log_error "No internet connection - can't download Docker"; return 1; } + local script + script=$(mktemp) + if ! curl -fsSL https://get.docker.com -o "$script"; then + rm -f "$script" + log_error "Could not download Docker's install script" + return 1 + fi + sudo sh "$script" || { rm -f "$script"; log_error "Docker's install script failed"; return 1; } + rm -f "$script" + fi + + sudo systemctl enable --now docker >/dev/null 2>&1 || true + if ! docker_is_installed || ! docker_daemon_running; then + log_error "Docker was installed but is not working - check: sudo systemctl status docker" + return 1 + fi + log_success "Docker installed" +} + +# docker_compose DIR args... -> sudo docker compose --project-directory DIR args... +docker_compose() { + local dir="$1"; shift + sudo "$DOCKER_BIN" compose --project-directory "$dir" "$@" +} + +# docker_container_state NAME -> running | exited | restarting | ... | missing +docker_container_state() { + sudo "$DOCKER_BIN" inspect -f '{{.State.Status}}' "$1" 2>/dev/null || echo "missing" +} + +# docker_container_health NAME -> healthy | unhealthy | starting | none | missing +docker_container_health() { + sudo "$DOCKER_BIN" inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$1" 2>/dev/null \ + || echo "missing" +} + +# docker_wait_http URL [TIMEOUT_SECONDS] +# Polls until URL answers 2xx/3xx; returns 1 on timeout. +docker_wait_http() { + local url="$1" timeout="${2:-300}" waited=0 + while (( waited < timeout )); do + if curl -fsS -o /dev/null -m 5 "$url" 2>/dev/null; then + return 0 + fi + sleep 5 + waited=$((waited + 5)) + done + return 1 +} + +# docker_free_port START -> prints the first TCP port >= START nothing listens on. +docker_free_port() { + local port="$1" + while ss -tlnH "sport = :${port}" 2>/dev/null | grep -q .; do + port=$((port + 1)) + done + echo "$port" +} diff --git a/menus/addon_library.sh b/menus/addon_library.sh new file mode 100644 index 0000000..fbe9b66 --- /dev/null +++ b/menus/addon_library.sh @@ -0,0 +1,319 @@ +#!/bin/bash +################################################################################ +# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library +# catalogue (Koha) running in Docker, shown on the kiosk as a public +# search terminal. +# +# What gets installed (all from this checkout's library/ directory, deployed to +# $LIBRARY_DIR - see library/README.md for the stack itself): +# - Koha: the catalogue ("OPAC", public search, no login) and the staff +# interface (cataloguing, Z39.50 lookup, label printing). +# - Quick Add: a one-box "scan an ISBN, the book is added" page. +# - MariaDB. +# Docker itself is installed on demand (lib/docker.sh). +# +# On the kiosk the catalogue is added as one of the Sites (tabs) with +# duration 0 (stay put - it's a search terminal, not a rotating display). +# Staff and Quick Add are meant for the librarian's own device; whether they +# are reachable from the LAN or only from this kiosk is the "Network access" +# choice below. NOTE: Docker publishes ports straight through iptables and +# bypasses ufw, so "this kiosk only" is enforced by binding the published +# ports to 127.0.0.1 (BIND_ADDR in library/.env), not by firewall rules. +# +# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first. +################################################################################ + +LIBRARY_CATALOGUE_TAB_NAME="Library catalogue" + +################################################################################ +# Detection / state +################################################################################ + +library_env_get() { + sudo grep -m1 "^$1=" "$LIBRARY_DIR/.env" 2>/dev/null | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//' +} + +library_is_installed() { + [[ -f "$LIBRARY_DIR/docker-compose.yml" ]] || return 1 + [[ "$(docker_container_state library-koha)" != "missing" ]] +} + +library_is_running() { + [[ "$(docker_container_state library-koha)" == "running" ]] +} + +library_catalogue_url() { + echo "http://localhost:$(library_env_get OPAC_PORT)/" +} + +# True when the kiosk's page list already shows the catalogue. +library_tab_exists() { + local url idx + url="$(library_catalogue_url)" + for idx in "${!URLS[@]}"; do + [[ "${URLS[$idx]}" == "$url" ]] && return 0 + done + return 1 +} + +addon_library_status() { + if ! library_is_installed; then + echo "Library Catalog: not installed" + return + fi + local state health ip + state="$(docker_container_state library-koha)" + health="$(docker_container_health library-koha)" + ip="$(get_ip_address)" + echo "Library Catalog: ${state}${health:+ ($health)}" + echo " Catalogue: http://localhost:$(library_env_get OPAC_PORT)/ (shown on the kiosk)" + if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then + echo " Network: this kiosk only" + else + echo " Network: other devices on the LAN can reach it at http://${ip}:$(library_env_get OPAC_PORT)/" + fi +} + +addon_library_menu_builder() { + if library_is_installed; then + load_existing_config + local tab_label="Show the catalogue on the kiosk" + library_tab_exists && tab_label="Stop showing the catalogue on the kiosk" + MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall") + MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall) + else + MENU_LABELS=("Install Library Catalog") + MENU_HANDLERS=(action_library_install) + fi +} + +addon_library_menu() { + run_menu "LIBRARY CATALOG" addon_library_menu_builder addon_library_status +} + +################################################################################ +# Helpers +################################################################################ + +# Copy library/ from this checkout to $LIBRARY_DIR. Never overwrites the +# deployed .env (passwords) even if a stray one exists in the checkout. +library_sync_files() { + local src="${SCRIPT_DIR}/library" + if [[ ! -f "$src/docker-compose.yml" ]]; then + log_error "library/ not found in this checkout ($src) - re-clone or git pull" + return 1 + fi + sudo mkdir -p "$LIBRARY_DIR" || return 1 + tar -C "$src" --exclude=./.env --exclude='*.crt' -cf - . | sudo tar -C "$LIBRARY_DIR" -xf - || return 1 + sudo chmod 755 "$LIBRARY_DIR/install.sh" +} + +library_add_tab() { + load_existing_config + if library_tab_exists; then + log_info "The catalogue is already one of the kiosk's pages." + return 0 + fi + URLS+=("$(library_catalogue_url)") + DURS+=("0") + USERS+=("") + PASSES+=("") + NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME") + save_config +} + +# Drop every page that points at the catalogue's local address. +library_remove_tab() { + load_existing_config + local url idx + url="$(library_catalogue_url)" + local -a nu=() nd=() nus=() np=() nn=() + for idx in "${!URLS[@]}"; do + [[ "${URLS[$idx]}" == "$url" ]] && continue + nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}") + np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}") + done + if [[ "${#nu[@]}" -eq "${#URLS[@]}" ]]; then + return 0 + fi + URLS=("${nu[@]}"); DURS=("${nd[@]}"); USERS=("${nus[@]}"); PASSES=("${np[@]}"); NAMES=("${nn[@]}") + HOME_TAB_INDEX=-1 + save_config +} + +# library_set_bind ADDR - persist BIND_ADDR in .env and recreate the containers. +library_set_bind() { + local addr="$1" + if sudo grep -q '^BIND_ADDR=' "$LIBRARY_DIR/.env" 2>/dev/null; then + sudo sed -i "s|^BIND_ADDR=.*|BIND_ADDR=${addr}|" "$LIBRARY_DIR/.env" + else + echo "BIND_ADDR=${addr}" | sudo tee -a "$LIBRARY_DIR/.env" >/dev/null + fi + docker_compose "$LIBRARY_DIR" up -d +} + +library_warn_if_low_memory() { + local total_mb + total_mb=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0) + if [[ "$total_mb" -gt 0 && "$total_mb" -lt 3000 ]]; then + log_warning "This machine has about ${total_mb} MB of RAM. The catalogue needs roughly 2 GB on top of the kiosk itself - it may be slow." + fi +} + +################################################################################ +# Actions +################################################################################ + +action_library_install() { + echo + echo "This installs a library catalogue (Koha) with Z39.50 book lookup, label" + echo "printing and a scan-to-add page. It needs Docker (installed for you if" + echo "missing), about 3 GB of disk space and ~2 GB of RAM. The first build takes" + echo "5-10 minutes." + echo + library_warn_if_low_memory + + local library_name + library_name=$(ask_text "Library name (shown on the catalogue)" "My Library") + [[ -z "$library_name" ]] && library_name="My Library" + + local bind="0.0.0.0" + echo + echo "Who should be able to open the catalogue, staff interface and Quick Add" + echo "page besides this kiosk?" + echo " - Yes: any device on your network (use it from your own laptop/phone;" + echo " the staff pages are protected by a generated password)." + echo " - No: only this kiosk. Safest, but you can only manage the library here." + if ! ask_yes_no "Allow other devices on the network?" "y"; then + bind="127.0.0.1" + fi + + docker_ensure_installed || { pause; return 1; } + library_sync_files || { pause; return 1; } + + echo + log_info "Building and starting the stack - this takes a while on the first run..." + if ! sudo env LIBRARY_NAME="$library_name" BIND_ADDR="$bind" "$LIBRARY_DIR/install.sh"; then + log_error "The library stack failed to start - see: sudo docker logs library-koha" + pause + return 1 + fi + + if ask_yes_no "Show the catalogue on the kiosk screen?" "y"; then + library_add_tab + fi + echo + log_success "Library Catalog installed" + pause +} + +action_library_show_access() { + local ip user pass + ip="$(get_ip_address)" + user="$(library_env_get ADMIN_USER)" + pass="$(library_env_get ADMIN_PASSWORD)" + echo + if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then + ip="localhost" + echo "(Network access is limited to this kiosk.)" + fi + echo " Catalogue (public search): http://${ip}:$(library_env_get OPAC_PORT)/" + echo " Staff interface: http://${ip}:$(library_env_get STAFF_PORT)/" + echo " Add books (scan ISBNs): http://${ip}:$(library_env_get QUICKADD_PORT)/" + echo " Login (staff and add books): ${user} / ${pass}" + echo + echo " Passwords live in $LIBRARY_DIR/.env (root only)." + pause +} + +action_library_toggle_tab() { + load_existing_config + if library_tab_exists; then + library_remove_tab + else + library_add_tab + fi + pause +} + +action_library_network() { + local current + current="$(library_env_get BIND_ADDR)" + echo + echo "Currently: $([[ "$current" == "127.0.0.1" ]] && echo "this kiosk only" || echo "other devices on the network can connect")" + echo + if ask_yes_no "Allow other devices on the network?" "$([[ "$current" == "127.0.0.1" ]] && echo n || echo y)"; then + library_set_bind "0.0.0.0" && log_success "Other devices on the network can now connect" + else + library_set_bind "127.0.0.1" && log_success "Only this kiosk can connect now" + fi + pause +} + +action_library_start() { + docker_compose "$LIBRARY_DIR" start && log_success "Started" + pause +} + +action_library_stop() { + docker_compose "$LIBRARY_DIR" stop && log_success "Stopped (the kiosk page will show an error until you start it again)" + pause +} + +action_library_update() { + echo + echo "This rebuilds the images with the latest Koha release. Your catalogue data is kept." + if ! ask_yes_no "Update now?" "y"; then + return 0 + fi + docker_ensure_installed || { pause; return 1; } + library_sync_files || { pause; return 1; } + if sudo "$LIBRARY_DIR/install.sh" --update; then + log_success "Updated" + else + log_error "Update failed - see: sudo docker logs library-koha" + fi + pause +} + +# library_do_uninstall [purge] +# no argument: stop and remove the containers but KEEP the data volumes and +# $LIBRARY_DIR/.env - the database password is baked into the +# database volume, so a later reinstall needs the same .env. +# purge: also delete the data (all books!), the images and $LIBRARY_DIR. +library_do_uninstall() { + local mode="${1:-}" + library_remove_tab + if [[ -f "$LIBRARY_DIR/docker-compose.yml" ]]; then + if [[ "$mode" == "purge" ]]; then + docker_compose "$LIBRARY_DIR" down -v --rmi local 2>/dev/null || true + else + docker_compose "$LIBRARY_DIR" down 2>/dev/null || true + fi + fi + if [[ "$mode" == "purge" ]]; then + sudo rm -rf "$LIBRARY_DIR" + log_success "Library Catalog and all its data removed" + else + log_success "Library Catalog stopped and removed (your books are kept)" + fi +} + +action_library_uninstall() { + echo + echo "This stops and removes the catalogue containers." + if ask_yes_no "Also DELETE all catalogue data (every book you have added)?" "n"; then + local confirm + confirm=$(ask_text "Type DELETE to confirm" "") + if [[ "$confirm" != "DELETE" ]]; then + echo "Cancelled" + pause + return 0 + fi + library_do_uninstall purge + else + library_do_uninstall + echo "Your books are kept; install again from this menu to bring them back." + fi + pause +} diff --git a/menus/clone_settings.sh b/menus/clone_settings.sh index 9994193..dc41bf2 100644 --- a/menus/clone_settings.sh +++ b/menus/clone_settings.sh @@ -65,6 +65,7 @@ clone_detect_addons() { command -v netbird &>/dev/null && addons+=("netbird") baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom") webui_is_installed 2>/dev/null && addons+=("webui") + library_is_installed 2>/dev/null && addons+=("library") if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then local authelia_url @@ -243,6 +244,13 @@ action_apply_clone_settings() { fi ;; asterisk_intercom) echo " [!] Asterisk Intercom - needs its own SIP extension on this device" ;; + library) + if library_is_installed; then + echo " [x] Library Catalog - already installed (the books themselves are not cloned)" + else + echo " [ ] Library Catalog - not installed, install via Addons (the catalogue page is cloned, the books are not)" + missing_any=true + fi ;; authelia) echo " [!] Authelia - needs a fresh login on this device" ;; *) diff --git a/menus/complete_uninstall.sh b/menus/complete_uninstall.sh index c0f3747..beb5037 100644 --- a/menus/complete_uninstall.sh +++ b/menus/complete_uninstall.sh @@ -7,7 +7,7 @@ # (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall, # tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall, # squeezelite_do_uninstall, asterisk_intercom_do_uninstall, -# webui_do_uninstall, power_schedule_do_remove_all, +# webui_do_uninstall, library_do_uninstall, power_schedule_do_remove_all, # emergency_hotspot_do_disable, time_sync_do_reset) instead of # re-implementing removal logic for each addon a second time here - if an # addon's uninstall logic changes, this picks it up automatically. Only @@ -59,6 +59,7 @@ action_complete_uninstall() { echo " • Remote access (VNC, WireGuard, Tailscale, Netbird)" echo " • Asterisk Intercom (Baresip)" echo " • Web UI" + echo " • Library Catalog (Koha) and ALL the books in it" echo " • LightDM and Openbox" echo " • All kiosk schedules and services" echo " • Emergency hotspot configuration" @@ -90,6 +91,7 @@ action_complete_uninstall() { squeezelite_do_uninstall asterisk_intercom_do_uninstall purge webui_do_uninstall + library_do_uninstall purge echo "[3/12] Removing schedules, emergency hotspot and time server setting..." power_schedule_do_remove_all diff --git a/tests/addon_library.test.sh b/tests/addon_library.test.sh new file mode 100755 index 0000000..b2f6a76 --- /dev/null +++ b/tests/addon_library.test.sh @@ -0,0 +1,203 @@ +#!/bin/bash +################################################################################ +# tests/addon_library.test.sh - tests for menus/addon_library.sh + lib/docker.sh +# +# Runs entirely in a scratch directory with a stub `sudo`, a stub `docker` and +# a fixture stack standing in for library/, so it needs no root, no Docker and +# no network - and cannot touch a real kiosk. (The real stack is exercised by +# library/install.sh itself.) +# +# bash tests/addon_library.test.sh +################################################################################ +set -u + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT + +PASS=0 FAIL=0 +ok() { PASS=$((PASS + 1)); echo " ok - $1"; } +bad() { FAIL=$((FAIL + 1)); echo " FAIL - $1"; } +check() { # check "description" command... + local d="$1"; shift + if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi +} + +# ---- stubs -------------------------------------------------------------------- +mkdir -p "$T/bin" +cat > "$T/bin/sudo" << 'EOF' +#!/bin/bash +# Stub sudo: drop "-u USER" and run the command as-is. +if [[ "$1" == "-u" ]]; then shift 2; fi +exec "$@" +EOF +cat > "$T/bin/docker" << 'EOF' +#!/bin/bash +# Stub docker: logs every call; container state comes from $STUB_STATE. +echo "$*" >> "$STUB_LOG" +case "$1" in + --version) echo "Docker version 99.0.0, build stub"; exit 0 ;; + info) exit 0 ;; + compose) + [[ "$2" == "version" ]] && { echo "Docker Compose version v9"; exit 0; } + exit 0 ;; + inspect) + st="$(cat "$STUB_STATE" 2>/dev/null || echo missing)" + [[ "$st" == "missing" ]] && exit 1 + case "$*" in *Health*) echo healthy ;; *) echo "$st" ;; esac + exit 0 ;; +esac +exit 0 +EOF +chmod +x "$T/bin/sudo" "$T/bin/docker" +export PATH="$T/bin:$PATH" +export STUB_LOG="$T/docker.log" STUB_STATE="$T/state" +echo missing > "$STUB_STATE"; : > "$STUB_LOG" + +# ---- fixture "checkout" with a stub library stack ---------------------------- +FIX="$T/checkout" +mkdir -p "$FIX/library/koha/certs" +echo "name: library" > "$FIX/library/docker-compose.yml" +echo "SHOULD-NOT-BE-COPIED" > "$FIX/library/.env" +echo "cert" > "$FIX/library/koha/certs/proxy.crt" +echo "koha-file" > "$FIX/library/koha/Dockerfile" +cat > "$FIX/library/install.sh" << 'EOF' +#!/bin/bash +# Stub of library/install.sh: records how it was called and writes a .env. +HERE="$(cd "$(dirname "$0")" && pwd)" +echo "install.sh $* LIBRARY_NAME=${LIBRARY_NAME:-} BIND_ADDR=${BIND_ADDR:-}" >> "$STUB_LOG" +[[ "${1:-}" == "--update" ]] && exit 0 +cat > "$HERE/.env" << ENV +LIBRARY_NAME="${LIBRARY_NAME:-}" +OPAC_PORT=8080 +STAFF_PORT=8081 +QUICKADD_PORT=8082 +ADMIN_USER=admin +ADMIN_PASSWORD=s3cretPASS +BIND_ADDR=${BIND_ADDR:-0.0.0.0} +ENV +echo running > "$STUB_STATE" +EOF + +# ---- environment the addon expects ------------------------------------------- +export KIOSK_USER="$(id -un)" +export KIOSK_HOME="$T/home" KIOSK_DIR="$T/home/kiosk-app" CONFIG_PATH="$T/home/kiosk-app/config.json" +export LIBRARY_DIR="$T/opt-library" +SCRIPT_DIR="$FIX" +mkdir -p "$KIOSK_DIR" +cat > "$CONFIG_PATH" << 'EOF' +{"tabs":[{"url":"https://example.com","duration":60,"username":"","password":"","name":"Example"}], + "homeTabIndex":-1,"inactivityTimeout":120,"swipeMode":"dual","allowNavigation":"same-origin","somethingUntracked":"keep-me"} +EOF + +# shellcheck disable=SC1090 +source "$REPO/lib/menu.sh" +source "$REPO/lib/config.sh" +source "$REPO/lib/docker.sh" +source "$REPO/menus/addon_library.sh" +export KIOSK_UNATTENDED=0 +docker_ensure_installed() { return 0; } # the stub stands in for a working Docker + +tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)"' "$CONFIG_PATH"; } + +# ============================================================================ +echo "== lib/docker.sh" +check "docker_is_installed sees the stub" docker_is_installed +check "container state: missing" test "$(docker_container_state library-koha)" = missing +echo running > "$STUB_STATE" +check "container state: running" test "$(docker_container_state library-koha)" = running +check "container health: healthy" test "$(docker_container_health library-koha)" = healthy +echo missing > "$STUB_STATE" +check "docker_free_port returns a number" test -n "$(docker_free_port 20000)" + +# ============================================================================ +echo "== sync files" +mkdir -p "$LIBRARY_DIR"; echo "DEPLOYED-ENV" > "$LIBRARY_DIR/.env" +library_sync_files +check "copies the stack files" test -f "$LIBRARY_DIR/koha/Dockerfile" +check "never overwrites a deployed .env" test "$(cat "$LIBRARY_DIR/.env")" = DEPLOYED-ENV +check "does not copy certificates" test ! -e "$LIBRARY_DIR/koha/certs/proxy.crt" +rm -rf "$LIBRARY_DIR" + +# ============================================================================ +echo "== not installed" +if library_is_installed; then bad "library_is_installed false before install"; else ok "library_is_installed false before install"; fi +addon_library_menu_builder +check "offers only Install" test "${MENU_LABELS[*]}" = "Install Library Catalog" +check "status says not installed" test "$(addon_library_status)" = "Library Catalog: not installed" + +# ============================================================================ +echo "== install action (kiosk-only network, show on kiosk)" +# answers: library name, allow-network=n, show-on-kiosk=y, for pause +printf 'Smith Family Library\nn\ny\n\n' | action_library_install >"$T/install.out" 2>&1 +check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG" +check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG" +check "is_installed is now true" library_is_installed +check "catalogue tab added, duration 0" grep -qx 'http://localhost:8080/|0|Library catalogue' <(tabs) +check "existing tab preserved" grep -qx 'https://example.com|60|Example' <(tabs) +check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me +addon_library_status > "$T/status.out" +check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out" + +# ============================================================================ +echo "== menu while installed" +addon_library_menu_builder +check "offers 'stop showing' (tab exists)" grep -q 'Stop showing the catalogue' <<< "${MENU_LABELS[*]}" +check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}" + +echo "== tab toggling is idempotent" +library_add_tab >/dev/null 2>&1 +check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8080')" = 1 +printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it +check "toggle removes the catalogue tab" test "$(tabs | grep -c 'localhost:8080')" = 0 +check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example' <(tabs) +addon_library_menu_builder +check "menu now offers 'Show'" grep -q 'Show the catalogue on the kiosk' <<< "${MENU_LABELS[*]}" +printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back +check "toggle adds it back" test "$(tabs | grep -c 'localhost:8080')" = 1 + +# ============================================================================ +echo "== network access" +: > "$STUB_LOG" +printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network +check ".env BIND_ADDR is now 0.0.0.0" test "$(library_env_get BIND_ADDR)" = 0.0.0.0 +check "containers were recreated" grep -q 'compose --project-directory .* up -d' "$STUB_LOG" +printf 'n\n\n' | action_library_network >/dev/null 2>&1 # back to kiosk-only +check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR)" = 127.0.0.1 + +# ============================================================================ +echo "== access screen" +printf '\n' | action_library_show_access > "$T/access.out" 2>&1 +check "shows the staff login" grep -q 'admin / s3cretPASS' "$T/access.out" +check "shows quick-add address" grep -q 'Add books.*:8082' "$T/access.out" + +# ============================================================================ +echo "== update" +: > "$STUB_LOG" +printf 'y\n\n' | action_library_update >/dev/null 2>&1 +check "runs install.sh --update" grep -q 'install.sh --update' "$STUB_LOG" +check "update keeps the .env" test "$(library_env_get ADMIN_PASSWORD)" = s3cretPASS + +# ============================================================================ +echo "== uninstall keeps the books by default" +: > "$STUB_LOG" +printf 'n\n\n' | action_library_uninstall >/dev/null 2>&1 +check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG" +check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'" +check "install dir and .env kept" test -f "$LIBRARY_DIR/.env" +check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8080')" = 0 + +echo "== purge needs the typed confirmation" +printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1 +check "wrong confirmation cancels" test -f "$LIBRARY_DIR/.env" +: > "$STUB_LOG" +library_add_tab >/dev/null 2>&1 +printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1 +check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG" +check "install dir removed" test ! -e "$LIBRARY_DIR" +check "tab removed again" test "$(tabs | grep -c 'localhost:8080')" = 0 +check "other tab still there" grep -qx 'https://example.com|60|Example' <(tabs) + +echo +echo "passed: $PASS failed: $FAIL" +[[ "$FAIL" -eq 0 ]]