diff --git a/library/.gitignore b/library/.gitignore new file mode 100644 index 0000000..46f8659 --- /dev/null +++ b/library/.gitignore @@ -0,0 +1,4 @@ +# Generated by install.sh - contains passwords +.env +# Extra CA certificates dropped in for building behind a proxy +koha/certs/*.crt diff --git a/library/docker-compose.yml b/library/docker-compose.yml new file mode 100644 index 0000000..576a9b1 --- /dev/null +++ b/library/docker-compose.yml @@ -0,0 +1,48 @@ +name: library + +services: + db: + image: mariadb:10.11 + container_name: library-db + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MARIADB_DATABASE: koha_library + MARIADB_USER: koha_library + MARIADB_PASSWORD: ${DB_PASSWORD} + command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci", "--innodb-buffer-pool-size=128M"] + volumes: + - db-data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 20 + + koha: + build: + context: ./koha + args: + KOHA_SUITE: ${KOHA_SUITE:-stable} + image: library-koha:local + container_name: library-koha + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + TZ: ${TZ:-UTC} + KOHA_DB_HOST: db + KOHA_DB_PASS: ${DB_PASSWORD} + KOHA_ADMIN_USER: ${ADMIN_USER:-admin} + KOHA_ADMIN_PASS: ${ADMIN_PASSWORD} + KOHA_LIBRARY_NAME: ${LIBRARY_NAME:-My Library} + ports: + - "${OPAC_PORT:-8080}:80" + - "${STAFF_PORT:-8081}:8081" + volumes: + - koha-data:/var/lib/koha + +volumes: + db-data: + koha-data: diff --git a/library/install.sh b/library/install.sh new file mode 100755 index 0000000..5f7c849 --- /dev/null +++ b/library/install.sh @@ -0,0 +1,126 @@ +#!/bin/bash +# One-command setup for a self-hosted home library (Koha) on Ubuntu/Debian. +# +# sudo ./install.sh # install Docker if needed, build, start +# sudo ./install.sh --status # show URLs and container health +# sudo ./install.sh --update # rebuild with the latest Koha and restart +# sudo ./install.sh --stop | --start +# +# Optional environment variables: +# LIBRARY_NAME="Smith Family Library" name shown on the catalogue +# OPAC_PORT=8080 STAFF_PORT=8081 preferred host ports (next free is used) +# EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$HERE" + +c_info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; } +c_ok() { printf '\033[0;32m[ OK ]\033[0m %s\n' "$*"; } +c_warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; } +c_err() { printf '\033[0;31m[FAIL]\033[0m %s\n' "$*" >&2; } + +[ "$(id -u)" = 0 ] || { c_err "Run with sudo: sudo $0 $*"; exit 1; } +RUN_USER="${SUDO_USER:-root}" + +# --- Docker ------------------------------------------------------------------- +ensure_docker() { + if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + c_ok "Docker $(docker --version | awk '{print $3}' | tr -d ,) with Compose is installed" + else + c_info "Installing Docker (official get.docker.com script)..." + command -v curl >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq curl ca-certificates; } + curl -fsSL https://get.docker.com | sh + docker compose version >/dev/null 2>&1 || apt-get install -y -qq docker-compose-plugin + c_ok "Docker installed" + fi + systemctl enable --now docker >/dev/null 2>&1 || true + if [ "$RUN_USER" != root ] && ! id -nG "$RUN_USER" | tr ' ' '\n' | grep -qx docker; then + usermod -aG docker "$RUN_USER" && c_info "Added $RUN_USER to the docker group (log out/in to use docker without sudo)" + fi + docker info >/dev/null 2>&1 || { c_err "The Docker daemon is not running."; exit 1; } +} + +port_in_use() { ss -tlnH "sport = :$1" 2>/dev/null | grep -q .; } +free_port() { local p="$1"; while port_in_use "$p"; do p=$((p + 1)); done; echo "$p"; } +rand() { head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "$1"; } +lan_ip() { hostname -I 2>/dev/null | awk '{print $1}'; } + +dc() { docker compose --project-directory "$HERE" "$@"; } + +show_status() { + [ -f .env ] || { c_err "Not installed yet - run: sudo ./install.sh"; exit 1; } + # shellcheck disable=SC1091 + . ./.env + local ip; ip="$(lan_ip)"; ip="${ip:-localhost}" + dc ps + echo + echo " Catalogue (public): http://$ip:$OPAC_PORT" + echo " Staff interface: http://$ip:$STAFF_PORT" + echo " Staff login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)" + echo +} + +case "${1:-}" in + --status) show_status; exit 0 ;; + --stop) dc stop; exit 0 ;; + --start) dc start; exit 0 ;; + --update) + ensure_docker + dc build --pull --no-cache koha + dc up -d + show_status; exit 0 ;; + "" ) ;; + *) c_err "Unknown option: $1"; exit 1 ;; +esac + +ensure_docker + +# --- Settings (.env is created once and never overwritten) ------------------------ +if [ ! -f .env ]; then + c_info "Generating settings and passwords..." + TZ_NOW="$(cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone --value 2>/dev/null || echo UTC)" + umask 077 + cat > .env << ENVEOF +# Generated by install.sh - keep this file private. +LIBRARY_NAME="${LIBRARY_NAME:-My Library}" +TZ=${TZ_NOW} +OPAC_PORT=$(free_port "${OPAC_PORT:-8080}") +STAFF_PORT=$(free_port "${STAFF_PORT:-8081}") +ADMIN_USER=admin +ADMIN_PASSWORD=$(rand 16) +DB_ROOT_PASSWORD=$(rand 32) +DB_PASSWORD=$(rand 32) +KOHA_SUITE=stable +ENVEOF + umask 022 + [ "$RUN_USER" = root ] || chown "$RUN_USER" .env +else + c_info "Re-using existing .env" +fi + +if [ -n "${EXTRA_CA:-}" ]; then + cp "$EXTRA_CA" koha/certs/extra-ca.crt + c_info "Trusting extra CA during the build: $EXTRA_CA" +fi + +# --- Build and start --------------------------------------------------------------- +c_info "Building the Koha image (first time: 5-10 minutes, downloads ~1 GB)..." +dc build +c_info "Starting..." +dc up -d + +# --- Wait until the site answers --------------------------------------------------- +# shellcheck disable=SC1091 +. ./.env +c_info "First start installs the database - waiting for the staff interface..." +for i in $(seq 1 120); do + if curl -fsS -o /dev/null "http://127.0.0.1:$STAFF_PORT/" 2>/dev/null; then READY=1; break; fi + if ! docker ps --format '{{.Names}}' | grep -qx library-koha; then c_err "library-koha stopped - see: docker logs library-koha"; exit 1; fi + sleep 5 +done +[ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; } + +c_ok "Your library is running." +echo +show_status diff --git a/library/koha/Dockerfile b/library/koha/Dockerfile new file mode 100644 index 0000000..50555b5 --- /dev/null +++ b/library/koha/Dockerfile @@ -0,0 +1,40 @@ +# Koha (open-source library system) built from the Koha project's own Debian +# packages - no third-party Koha images involved. +# +# KOHA_SUITE stable (default) | oldstable | unstable +# KEEP_TRANSLATIONS no (default) trims ~650 MB of non-English .po files; +# set "yes" if you want to install other languages later +FROM debian:bookworm-slim + +ARG KOHA_SUITE=stable +ARG KEEP_TRANSLATIONS=no +ENV DEBIAN_FRONTEND=noninteractive \ + LANG=C.UTF-8 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl gnupg tzdata + +# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if +# you are behind a TLS-inspecting proxy. Empty by default. +COPY certs/ /usr/local/share/ca-certificates/extra/ +RUN update-ca-certificates + +RUN curl -fsSL https://debian.koha-community.org/koha/gpg.asc | gpg --dearmor -o /usr/share/keyrings/koha.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/koha.gpg] https://debian.koha-community.org/koha ${KOHA_SUITE} main bookworm" \ + > /etc/apt/sources.list.d/koha.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends koha-common mariadb-client \ + && a2enmod rewrite headers proxy_http cgi deflate >/dev/null \ + && a2dissite 000-default >/dev/null 2>&1 || true \ + && if [ "$KEEP_TRANSLATIONS" != "yes" ]; then rm -rf /usr/share/koha/misc/translator/po; fi \ + && rm -rf /var/lib/apt/lists/* /usr/share/doc/* + +COPY bootstrap.pl entrypoint.sh /opt/koha-init/ +COPY seed/ /opt/koha-init/seed/ +RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init + +EXPOSE 80 8081 +VOLUME ["/var/lib/koha"] +HEALTHCHECK --interval=30s --timeout=10s --start-period=300s --retries=5 \ + CMD curl -fsS -o /dev/null http://127.0.0.1:80/ && curl -fsS -o /dev/null http://127.0.0.1:8081/ || exit 1 +ENTRYPOINT ["/opt/koha-init/entrypoint.sh"] diff --git a/library/koha/bootstrap.pl b/library/koha/bootstrap.pl new file mode 100755 index 0000000..c3ca66c --- /dev/null +++ b/library/koha/bootstrap.pl @@ -0,0 +1,111 @@ +#!/usr/bin/perl +# Headless replacement for Koha's web installer + onboarding wizard. +# Run as the instance user: koha-shell -c "perl bootstrap.pl" +# +# Mirrors what /cgi-bin/koha/installer/install.pl does step by step (so a +# future Koha upgrade keeps working), then seeds a home-library setup. +use Modern::Perl; +use C4::Context; +use C4::Installer; +use Koha; +use Koha::Libraries; +use Koha::Patrons; +use Koha::Patron::Categories; + +$| = 1; +my $seed_dir = $ENV{KOHA_SEED_DIR} || '/opt/koha-init/seed'; +my $admin_pass = $ENV{KOHA_ADMIN_PASS}; +die "KOHA_ADMIN_PASS not set\n" unless defined $admin_pass && length $admin_pass; +my %env = ( + admin_user => $ENV{KOHA_ADMIN_USER} || 'admin', + admin_pass => $admin_pass, + library => $ENV{KOHA_LIBRARY_NAME} || 'My Library', +); + +my $installer = C4::Installer->new(); +my $dbh = C4::Context->dbh; +my $intranet = C4::Context->config('intranetdir'); +my $data = "$intranet/installer/data/mysql"; + +# Already installed? (the entrypoint checks too; this is a safety net) +my ($tables) = $dbh->selectrow_array( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()"); +if ($tables) { say "bootstrap: database already has $tables tables - nothing to do."; exit 0; } + +say "bootstrap: loading database schema..."; +if ( my $err = $installer->load_db_schema() ) { die "schema load failed: $err\n"; } + +$installer->set_marcflavour_syspref('marc21'); + +# Same ordering rules the web installer applies (sysprefs first, userflags last). +my @files = ( + sort( glob("$data/en/mandatory/*.sql"), glob("$data/en/mandatory/*.yml") ), + sort( glob("$data/en/marcflavour/marc21/mandatory/*.yml"), glob("$data/en/marcflavour/marc21/mandatory/*.sql") ), +); +# Optional data worth having: matching rules, "Fast add" framework (a short +# cataloguing form - title/author/ISBN/call number/barcode), MARC-coded values. +for my $f (qw( + marcflavour/marc21/optional/marc21_default_matching_rules.yml + marcflavour/marc21/optional/marc21_sample_fastadd_framework.yml + optional/marc21_holdings_coded_values.yml + optional/marc21_relatorterms.yml + optional/auth_val.yml + optional/csv_profiles.yml +)) { + push @files, "$data/en/$f" if -f "$data/en/$f"; +} + +say "bootstrap: loading ", scalar(@files), " framework / data files..."; +my ( undef, $list ) = $installer->load_sql_in_order( 'en', [], @files ); +for my $lvl (@$list) { + for my $f ( @{ $lvl->{fwklist} } ) { + say " ! $f->{fwkname}: $f->{error}" if $f->{error}; + } +} + +$installer->set_version_syspref(); +$installer->set_languages_syspref('en'); + +# ---- Home-library seed data (YAML in the same format Koha ships) ---------- +for my $y (sort glob("$seed_dir/*.yml")) { + my $err = $installer->load_sql($y); + say " seed $y: ", ( $err ? "ERROR $err" : 'ok' ); +} + +# ---- Library, patron categories, owner account ---------------------------- +Koha::Library->new({ + branchcode => 'HOME', branchname => $env{library}, +})->store unless Koha::Libraries->find('HOME'); + +for my $c ( [ 'OWNER', 'Owner / librarian', 'S' ], [ 'FRIEND', 'Friend / borrower', 'A' ] ) { + Koha::Patron::Category->new({ + categorycode => $c->[0], description => $c->[1], category_type => $c->[2], + enrolmentperiod => 999, overduenoticerequired => 0, + })->store unless Koha::Patron::Categories->find( $c->[0] ); +} + +my $admin = Koha::Patron->new({ + cardnumber => 'ADMIN', surname => 'Librarian', userid => $env{admin_user}, + branchcode => 'HOME', categorycode => 'OWNER', flags => 1, # 1 = superlibrarian +})->store; +$admin->set_password({ password => $env{admin_pass}, skip_validation => 1 }); + +# ---- Preferences ---------------------------------------------------------- +my %prefs = ( + autoBarcode => 'incremental', # new items get a barcode automatically + itemcallnumber => '082ab', # Dewey call number copied from the MARC record + OpenLibraryCovers => 1, # cover images in the catalogue + OPACSearchForTitleIn => '', + BiblioDefaultView => 'normal', + OpacPublic => 1, + opacuserlogin => 0, # catalogue is browse-only; no patron logins needed + OpacMaintenance => 0, + ExpireReservesMaxPickUpDelay => 0, + AllowOfflineCirculation => 0, + marcflavour => 'MARC21', + LibraryName => $env{library}, + timezone => $ENV{TZ} || 'UTC', +); +C4::Context->set_preference( $_, $prefs{$_} ) for sort keys %prefs; + +say "bootstrap: done. Staff login: $env{admin_user}"; diff --git a/library/koha/certs/.gitkeep b/library/koha/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/library/koha/entrypoint.sh b/library/koha/entrypoint.sh new file mode 100755 index 0000000..f395132 --- /dev/null +++ b/library/koha/entrypoint.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Container entrypoint: (re)creates the Koha instance config from environment +# variables on every start, installs the database on the first start, then runs +# Apache in the foreground. All durable state lives in MariaDB and /var/lib/koha. +set -euo pipefail + +INST="${KOHA_INSTANCE:-library}" +DB_HOST="${KOHA_DB_HOST:-db}" +DB_NAME="${KOHA_DB_NAME:-koha_library}" +DB_USER="${KOHA_DB_USER:-koha_library}" +: "${KOHA_DB_PASS:?KOHA_DB_PASS is required}" +: "${KOHA_ADMIN_PASS:?KOHA_ADMIN_PASS is required}" +STAFF_PORT=8081 +export TZ="${TZ:-UTC}" + +log() { echo "[koha-init] $*"; } + +# --- 1. memcached (Koha caches sysprefs and sessions in it) ----------------- +memcached -u memcache -m 64 -d +cron + +# --- 2. wait for MariaDB ---------------------------------------------------- +log "waiting for database at $DB_HOST..." +for i in $(seq 1 120); do + mariadb -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" -e 'select 1' >/dev/null 2>&1 && break + [ "$i" = 120 ] && { log "database never became ready"; exit 1; } + sleep 2 +done + +# --- 3. instance config (regenerated each time a container is created) ------ +if [ ! -d "/etc/koha/sites/$INST" ]; then + log "creating Koha instance '$INST'..." + sed -i \ + -e 's|^DOMAIN=.*|DOMAIN=""|' \ + -e 's|^INTRASUFFIX=.*|INTRASUFFIX=""|' \ + -e "s|^INTRAPORT=.*|INTRAPORT=\"$STAFF_PORT\"|" \ + /etc/koha/koha-sites.conf + echo "$INST:$DB_USER:$KOHA_DB_PASS:$DB_NAME:$DB_HOST" > /etc/koha/passwd + koha-create --use-db --dbhost "$DB_HOST" --timezone "$TZ" \ + --memcached-servers 127.0.0.1:11211 "$INST" 2>&1 | grep -v AH00558 || true + + # No RabbitMQ in this stack: workers fall back to polling the database. + CONF="/etc/koha/sites/$INST/koha-conf.xml" + perl -0pi -e 's|\s*.*?||s' "$CONF" + + # Serve both interfaces through Plack (much faster than plain CGI). + SITE="/etc/apache2/sites-available/$INST.conf" + sed -i \ + -e 's|^\(\s*\)Include /etc/koha/apache-shared-opac.conf|\1Include /etc/koha/apache-shared-opac-plack.conf|' \ + -e 's|^\(\s*\)Include /etc/koha/apache-shared-intranet.conf|\1Include /etc/koha/apache-shared-intranet-plack.conf|' \ + -e 's|^#\s*Include /etc/koha/apache-shared-opac-plack.conf||' \ + -e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf||' "$SITE" + koha-plack --enable "$INST" >/dev/null + grep -q '^ServerName' /etc/apache2/apache2.conf || echo 'ServerName localhost' >> /etc/apache2/apache2.conf +fi +chown -R "$INST-koha:$INST-koha" "/var/lib/koha/$INST" "/var/log/koha/$INST" 2>/dev/null || true + +# --- 4. install or upgrade the database ------------------------------------- +TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \ + -e "select count(*) from information_schema.tables where table_schema='$DB_NAME'") +if [ "$TABLES" = "0" ]; then + log "first start: installing Koha (about a minute)..." + koha-shell -c "KOHA_ADMIN_USER='${KOHA_ADMIN_USER:-admin}' KOHA_ADMIN_PASS='$KOHA_ADMIN_PASS' KOHA_LIBRARY_NAME='${KOHA_LIBRARY_NAME:-My Library}' TZ='$TZ' perl /opt/koha-init/bootstrap.pl" "$INST" + FIRST_RUN=1 +else + log "existing database found - applying any pending upgrades..." + koha-upgrade-schema "$INST" || log "WARNING: koha-upgrade-schema reported a problem (see output above)" + FIRST_RUN=0 +fi + +# --- 5. search index (Zebra) ------------------------------------------------ +koha-plack --restart "$INST" >/dev/null 2>&1 || koha-plack --start "$INST" >/dev/null 2>&1 || true +koha-worker --restart "$INST" >/dev/null 2>&1 || true +if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then + log "building search index..." + koha-rebuild-zebra -f -b -a -v "$INST" >/dev/null 2>&1 || koha-rebuild-zebra -f -v "$INST" || true +fi + +log "ready - library catalogue on :80, staff interface on :$STAFF_PORT" +exec apache2ctl -D FOREGROUND diff --git a/library/koha/seed/10-itemtypes.yml b/library/koha/seed/10-itemtypes.yml new file mode 100644 index 0000000..cdfc0fd --- /dev/null +++ b/library/koha/seed/10-itemtypes.yml @@ -0,0 +1,13 @@ +--- +description: + - "Item types for a home library: books, movies, music, games" +tables: + - itemtypes: + translatable: [ description ] + multiline: [] + rows: + - { itemtype: BOOK, description: "Book", rentalcharge: 0, notforloan: 0, imageurl: "bridge/book.png", summary: "" } + - { itemtype: DVD, description: "Movie (DVD/Blu-ray)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/dvd.png", summary: "" } + - { itemtype: CD, description: "Music (CD/vinyl)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/sound.png", summary: "" } + - { itemtype: GAME, description: "Game", rentalcharge: 0, notforloan: 0, imageurl: "bridge/computer_file.png", summary: "" } + - { itemtype: OTHER, description: "Other", rentalcharge: 0, notforloan: 0, imageurl: "bridge/kit.png", summary: "" } diff --git a/library/koha/seed/20-z3950.yml b/library/koha/seed/20-z3950.yml new file mode 100644 index 0000000..50975fd --- /dev/null +++ b/library/koha/seed/20-z3950.yml @@ -0,0 +1,9 @@ +--- +description: + - "Public Z39.50 servers used by Cataloging > Z39.50/SRU search" +tables: + - z3950servers: + translatable: [ servername ] + multiline: [] + rows: + - { host: "lx2.loc.gov", port: 210, db: LCDB, userid: "", password: "", servername: "LIBRARY OF CONGRESS", checked: 1, rank: 1, syntax: USMARC, encoding: utf8, recordtype: biblio }