#!/usr/bin/env bash
# Install or update Libris from the licensed registry, with no archive and no Git checkout (#145).
#
# This file travels inside every application image as /app/deploy/install.sh, beside the Compose file
# (/app/deploy/docker-compose.yml) and the configuration template (/app/deploy/env.example) it installs.
# Whatever copy is started — the one served by https://libris-translate.com/install.sh, or one taken
# from an image — it pulls the requested image, resolves it to an immutable digest and, when it is not
# byte for byte the installer of that digest, hands over to it. So the installer, the Compose file and
# the configuration template that end up on the machine always come from the very image they start.
#
#   curl --fail --location --silent --show-error https://libris-translate.com/install.sh | sudo bash
#   sudo docker run --rm --entrypoint cat registry.libris-translate.com/libris/libris:0.15.0 \
#     /app/deploy/install.sh > install-libris.sh && sudo env LIBRIS_TAG=0.15.0 bash install-libris.sh
#
# Settings (environment): LIBRIS_TAG (default latest) or LIBRIS_IMAGE (a full reference, digests
# accepted), LIBRIS_HOME (default /opt/libris), LIBRIS_REGISTRY, and LIBRIS_REGISTRY_USERNAME with
# LIBRIS_REGISTRY_TOKEN for a non-interactive registry login (read-only credentials of the licence).
#
# A first installation is staged beside its final directory and renamed into place in one step: an
# interrupted run leaves either nothing or a complete configuration, never a half-written .env. A
# later run keeps the secrets and the data, and only moves the pinned images forward.
set -Eeuo pipefail

readonly default_registry="registry.libris-translate.com"
readonly default_repository="libris/libris"
# The keys this installer writes into a new .env; everything else comes from the image's template.
readonly generated_keys=(LIBRIS_IMAGE LIBRIS_CODEX_IMAGE SECRET_KEY BOOTSTRAP_PASSWORD POSTGRES_PASSWORD CODEX_BRIDGE_TOKEN)
# The Compose project, which names the network and the volumes, and the name earlier releases gave it.
readonly project_name="libris" historical_project="epub-translator"
readonly project_volumes=(database books codex-state logs)

fail() {
  printf 'Libris installation: %s\n' "$*" >&2
  exit 1
}

require_command() {
  command -v "$1" >/dev/null 2>&1 || fail "$1 is required."
}

# 32 random bytes as 64 hexadecimal characters: long enough for every secret Libris checks (SECRET_KEY
# needs 32 characters, the first password 12), and safe in a .env file without any quoting.
random_secret() {
  local value
  value="$(od -An -vtx1 -N32 /dev/urandom | tr -d ' \n')"
  [[ "$value" =~ ^[0-9a-f]{64}$ ]] || fail "could not read random bytes from /dev/urandom."
  printf '%s\n' "$value"
}

env_value() {
  local name="$1" value
  value="$(sed -n "s/^${name}=//p" "$install_dir/.env" | head -n 1)"
  [[ -n "$value" && "$value" != *[[:space:]]* ]] || fail "the existing .env has no safe ${name} value."
  printf '%s\n' "$value"
}

oci_label() {
  local source_image="$1" label="$2" value
  value="$(docker image inspect --format "{{ index .Config.Labels \"${label}\" }}" "$source_image")" ||
    fail "could not inspect the ${label} label of ${source_image}."
  [[ -n "$value" && "$value" != "<no value>" ]] || fail "${source_image} has no ${label} label."
  printf '%s\n' "$value"
}

# The repository of a reference, without its tag or digest: registry:5050/libris/libris:1.0 → registry:5050/libris/libris.
repository_of() {
  local reference="${1%%@*}" last
  last="${reference##*/}"
  [[ "$last" == *:* ]] && reference="${reference%:*}"
  printf '%s\n' "$reference"
}

# The digest reference Docker recorded for this repository. An image pulled from several repositories
# has several digests: take the one of the repository it was asked from, never merely the first.
immutable_image() {
  local source_image="$1" repository digest
  if [[ "$source_image" == *@sha256:* ]]; then
    printf '%s\n' "$source_image"
    return
  fi
  repository="$(repository_of "$source_image")"
  digest="$(docker image inspect --format '{{ range .RepoDigests }}{{ println . }}{{ end }}' "$source_image" |
    awk -v prefix="${repository}@sha256:" 'index($0, prefix) == 1 { print; exit }')" ||
    fail "could not inspect ${source_image}."
  [[ "$digest" == "${repository}@sha256:"* ]] || fail "Docker did not report an immutable digest for ${source_image}."
  printf '%s\n' "$digest"
}

interactive_registry_login() {
  local username token
  if [[ ! -t 2 || ! -r /dev/tty || ! -w /dev/tty ]]; then
    fail "the registry pull failed and no terminal is available. Run 'sudo docker login ${registry}' first, or set LIBRIS_REGISTRY_USERNAME and LIBRIS_REGISTRY_TOKEN."
  fi
  printf 'Registry username: ' >/dev/tty
  IFS= read -r username </dev/tty || fail "could not read the registry username."
  printf 'Registry token: ' >/dev/tty
  if ! IFS= read -r -s token </dev/tty; then
    printf '\n' >/dev/tty
    fail "could not read the registry token."
  fi
  printf '\n' >/dev/tty
  [[ -n "$username" && -n "$token" ]] || fail "the registry username and token must not be empty."
  if ! printf '%s' "$token" | docker login "$registry" --username "$username" --password-stdin; then
    token=""
    fail "the registry rejected those credentials."
  fi
  token=""
}

# Pull the requested image, logging in first when explicit credentials are given, or when the stored
# login cannot pull. A digest already present locally needs no registry at all (a hand-over lands here).
pull_application() {
  if [[ "$image" == *@sha256:* ]] && docker image inspect "$image" >/dev/null 2>&1; then
    return
  fi
  if [[ -n "${LIBRIS_REGISTRY_USERNAME:-}" || -n "${LIBRIS_REGISTRY_TOKEN:-}" ]]; then
    [[ -n "${LIBRIS_REGISTRY_USERNAME:-}" && -n "${LIBRIS_REGISTRY_TOKEN:-}" ]] ||
      fail "set both LIBRIS_REGISTRY_USERNAME and LIBRIS_REGISTRY_TOKEN, or neither."
    printf '%s' "$LIBRIS_REGISTRY_TOKEN" | docker login "$registry" --username "$LIBRIS_REGISTRY_USERNAME" --password-stdin ||
      fail "the registry rejected LIBRIS_REGISTRY_USERNAME and LIBRIS_REGISTRY_TOKEN."
    docker pull "$image" || fail "could not download ${image}."
  elif docker pull "$image"; then
    :
  else
    printf 'The stored Docker registry login could not pull Libris; use the read-only credentials supplied with your licence.\n' >&2
    interactive_registry_login
    docker pull "$image" || fail "could not download ${image}."
  fi
}

# Copy /app/deploy/NAME out of an image into DESTINATION/NAME for each NAME, without running anything:
# the container is created, read and removed. A file the image does not carry is simply not copied
# (older releases carry no installer); the caller decides whether that is an error.
extract_deploy_files() {
  local source_image="$1" destination="$2" container_id name
  shift 2
  container_id="$(docker create "$source_image")" || fail "could not open ${source_image} to read its deployment files."
  for name in "$@"; do
    docker cp "${container_id}:/app/deploy/${name}" "${destination}/${name}" >/dev/null 2>&1 || rm -f "${destination}/${name}"
  done
  docker rm --force "$container_id" >/dev/null 2>&1 || true
}

# Built-in configuration for releases that carried no template (before 0.15); the same keys as
# .env.example, with its defaults.
fallback_template() {
  cat <<'TEMPLATE'
LIBRIS_IMAGE=
LIBRIS_CODEX_IMAGE=
COMPOSE_PROFILES=codex
LIBRIS_PROJECT=libris
SECRET_KEY=
BOOTSTRAP_USERNAME=admin
BOOTSTRAP_PASSWORD=
POSTGRES_PASSWORD=
BIND_ADDRESS=127.0.0.1
PORT=8088
ALLOWED_ORIGINS=http://localhost:8088,http://127.0.0.1:8088
COOKIE_SECURE=true
CODEX_BRIDGE_TOKEN=
TEMPLATE
}

# Fill the generated keys of TEMPLATE into OUTPUT: the first line of each key receives its value, later
# duplicates are dropped, and a key the template lacks is appended. Values reach awk through the
# environment, never through its command line (visible to every user in the process list).
render_env() {
  local template="$1" output="$2"
  LIBRIS_GENERATED_KEYS="${generated_keys[*]}" awk '
    BEGIN { count = split(ENVIRON["LIBRIS_GENERATED_KEYS"], keys, " "); for (i = 1; i <= count; i++) wanted[keys[i]] = 1 }
    {
      key = $0; sub(/=.*/, "", key)
      if (index($0, "=") && (key in wanted)) {
        if (!(key in written)) { print key "=" ENVIRON["LIBRIS_VALUE_" key]; written[key] = 1 }
        next
      }
      print
    }
    END { for (i = 1; i <= count; i++) if (!(keys[i] in written)) print keys[i] "=" ENVIRON["LIBRIS_VALUE_" keys[i]] }
  ' "$template" >"$output"
}

update_env_images() {
  local app_image="$1" codex_image="$2" temporary
  temporary="$(mktemp "${install_dir}/.env.XXXXXX")"
  chmod 600 "$temporary"
  if ! awk -v app_image="$app_image" -v codex_image="$codex_image" '
    index($0, "LIBRIS_IMAGE=") == 1 {
      app_count++
      if (app_count == 1) print "LIBRIS_IMAGE=" app_image
      next
    }
    index($0, "LIBRIS_CODEX_IMAGE=") == 1 {
      codex_count++
      if (codex_count == 1) print "LIBRIS_CODEX_IMAGE=" codex_image
      next
    }
    index($0, "COMPOSE_PROFILES=") == 1 { profiles_count++ }
    { print }
    END {
      if (app_count != 1 || codex_count > 1) exit 1
      if (codex_count == 0) print "LIBRIS_CODEX_IMAGE=" codex_image
      # Earlier installations lack it: without it, a plain `docker compose pull` or `up`
      # silently leaves the Codex bridge on its old image. A value the operator chose is kept.
      if (profiles_count == 0) print "COMPOSE_PROFILES=codex"
    }
  ' "$install_dir/.env" >"$temporary"; then
    rm -f "$temporary"
    fail "the existing .env must contain exactly one LIBRIS_IMAGE and at most one LIBRIS_CODEX_IMAGE value."
  fi
  mv -f "$temporary" "$install_dir/.env"
}

env_has() {
  grep -q "^$1=" "$install_dir/.env"
}

append_env() {
  printf '%s\n' "$1" >>"$install_dir/.env"
}

volume_exists() {
  docker volume inspect "$1" >/dev/null 2>&1
}

# Size in bytes of a volume, measured from a throwaway container of the pinned image.
volume_bytes() {
  docker run --rm --network none --user 0:0 --volume "$1:/from:ro" --entrypoint du "$image" -sb /from | cut -f1
}

# What a copy must reproduce: the number of directories, of other entries, and the bytes of the files.
# Not `du`: a directory's own size depends on what it once held, and differs between two exact copies.
volume_content() {
  docker run --rm --network none --user 0:0 --volume "$1:/from:ro" --entrypoint sh "$image" \
    -c 'cd /from && find . -printf "%y %s\n" | awk '"'"'$1 == "d" { d++; next } { n++; if ($1 == "f") s += $2 } END { print d + 0, n + 0, s + 0 }'"'"''
}

# Installations made by earlier releases run as the Compose project `epub-translator`: its network and volumes
# (`epub-translator_database`, `_books`…) carry that name. Move them to `libris`, once: the stack stops,
# each volume is copied into its `libris_*` twin and checked, the old containers and network go, and
# LIBRIS_PROJECT in the .env records the new name. The old volumes are never written to nor removed.
# A LIBRIS_PROJECT already in the .env is the operator's choice and is left alone.
adopt_project_name() {
  local suffix present=() source_bytes source_content copied_content needed=0 free docker_root
  local legacy=(docker compose --project-name "$historical_project" --project-directory "$install_dir" --env-file "$install_dir/.env" --profile codex)
  env_has LIBRIS_PROJECT && return
  if ! volume_exists "${historical_project}_database"; then
    append_env "LIBRIS_PROJECT=$project_name"
    return
  fi
  volume_exists "${project_name}_database" &&
    fail "both ${historical_project}_database and ${project_name}_database exist. Add LIBRIS_PROJECT=<the project holding your data> to ${install_dir}/.env, then run the same command again."
  for suffix in "${project_volumes[@]}"; do
    volume_exists "${historical_project}_${suffix}" || continue
    present+=("$suffix")
    source_bytes="$(volume_bytes "${historical_project}_${suffix}")" ||
      fail "could not measure the volume ${historical_project}_${suffix}."
    needed=$((needed + source_bytes))
  done
  docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null)" || docker_root=""
  free="$(df -PB1 "$docker_root" 2>/dev/null | awk 'NR == 2 { print $4 }')" || free=""
  # A copy needs the data twice for a while, plus a margin of 1 GiB.
  if [[ ! "$free" =~ ^[0-9]+$ ]] || ((free < needed + 1073741824)); then
    append_env "LIBRIS_PROJECT=$historical_project"
    printf 'Keeping the Compose project name %s: renaming it copies %s bytes of volumes, and the free space of %s could not hold them.\n' \
      "$historical_project" "$needed" "${docker_root:-the Docker data directory}"
    printf 'To rename it later, free some space, delete the LIBRIS_PROJECT line of %s/.env and run the installer again.\n' "$install_dir"
    return
  fi
  printf 'Renaming the Compose project %s to %s: Libris stops while its volumes (%s bytes) are copied.\n' \
    "$historical_project" "$project_name" "$needed"
  "${legacy[@]}" stop || fail "could not stop the ${historical_project} stack; nothing was changed."
  for suffix in "${present[@]}"; do
    printf '  %s_%s -> %s_%s\n' "$historical_project" "$suffix" "$project_name" "$suffix"
    if ! volume_exists "${project_name}_${suffix}"; then
      docker volume create --label "com.docker.compose.project=${project_name}" \
        --label "com.docker.compose.volume=${suffix}" "${project_name}_${suffix}" >/dev/null ||
        abandon_rename "could not create the volume ${project_name}_${suffix}."
    fi
    docker run --rm --network none --user 0:0 --volume "${historical_project}_${suffix}:/from:ro" \
      --volume "${project_name}_${suffix}:/to" --entrypoint sh "$image" \
      -c 'cd /from && tar --numeric-owner -cf - . | tar --numeric-owner -xpf - -C /to' ||
      abandon_rename "copying ${historical_project}_${suffix} failed."
    if ! source_content="$(volume_content "${historical_project}_${suffix}")" ||
      ! copied_content="$(volume_content "${project_name}_${suffix}")"; then
      abandon_rename "the copy of ${historical_project}_${suffix} could not be checked."
    fi
    [[ -n "$source_content" && "$source_content" == "$copied_content" ]] ||
      abandon_rename "${project_name}_${suffix} holds (${copied_content}) against (${source_content}) in ${historical_project}_${suffix} (directories, entries, bytes)."
  done
  # The name is recorded first, so a second run after a failed `down` resumes with the copies. Then the
  # containers and the network of the old project go; `down` without --volumes keeps every volume.
  append_env "LIBRIS_PROJECT=$project_name"
  "${legacy[@]}" down || fail "the volumes were copied, but the ${historical_project} containers could not be removed. Remove them with 'docker compose --project-name ${historical_project} down' in ${install_dir}, then run the same command again."
  printf 'The volumes %s_* are kept untouched. Once Libris has proved itself under its new name, remove them with:\n' "$historical_project"
  printf '  docker volume rm%s\n' "$(printf " ${historical_project}_%s" "${present[@]}")"
}

# A failed copy removes the new volumes (they only hold a partial copy), restarts the old stack
# unchanged and stops the installation: running it again retries the rename.
abandon_rename() {
  local suffix
  for suffix in "${project_volumes[@]}"; do
    if volume_exists "${historical_project}_${suffix}"; then
      docker volume rm "${project_name}_${suffix}" >/dev/null 2>&1 || true
    fi
  done
  docker compose --project-name "$historical_project" --project-directory "$install_dir" --env-file "$install_dir/.env" --profile codex start || true
  fail "$1 Libris was restarted under ${historical_project}, whose volumes are untouched; run the same command again to retry."
}

registry="${LIBRIS_REGISTRY:-$default_registry}"
tag="${LIBRIS_TAG:-latest}"
install_dir="${LIBRIS_HOME:-/opt/libris}"
image="${LIBRIS_IMAGE:-${registry}/${default_repository}:${tag}}"
existing_installation=false
restored_env=false
previous_image=""
work_dir=""

no_start="${LIBRIS_NO_START:-0}"
if [[ "${1:-}" == "--no-start" && $# -eq 1 ]]; then
  no_start=1
elif [[ $# -ne 0 ]]; then
  fail "usage: install.sh [--no-start]"
fi
[[ "$no_start" == 0 || "$no_start" == 1 ]] || fail "LIBRIS_NO_START must be 0 or 1."

[[ "$registry" =~ ^[A-Za-z0-9][A-Za-z0-9.:-]*$ ]] || fail "LIBRIS_REGISTRY is not a registry host."
[[ "$tag" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || fail "LIBRIS_TAG is not a valid image tag."
[[ "$image" =~ ^[A-Za-z0-9][A-Za-z0-9._/:@-]*$ ]] || fail "LIBRIS_IMAGE is not an image reference."
install_dir="${install_dir%/}"
[[ "$install_dir" == /* && -n "$install_dir" && "$install_dir" != /opt ]] || fail "LIBRIS_HOME must be an absolute directory other than / or /opt."
[[ ! -L "$install_dir" ]] || fail "LIBRIS_HOME must not be a symbolic link."

require_command docker
require_command od
require_command cmp
docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 is required."

if [[ -e "$install_dir/.env" || -e "$install_dir/docker-compose.yml" ]]; then
  [[ -f "$install_dir/.env" ]] || fail "${install_dir} contains an incomplete installation; do not overwrite it."
  [[ ! -L "$install_dir/.env" && ! -L "$install_dir/docker-compose.yml" ]] || fail "the existing configuration must not use symbolic links."
  if [[ -e "$install_dir/docker-compose.yml" ]]; then
    [[ -f "$install_dir/docker-compose.yml" ]] || fail "${install_dir} contains an incomplete installation; do not overwrite it."
    existing_installation=true
    previous_image="$(env_value LIBRIS_IMAGE)"
  else
    [[ "$no_start" == 1 ]] || fail "a restored .env without Compose requires --no-start."
    [[ -z "$(find "$install_dir" -mindepth 1 -maxdepth 1 ! -name .env -print -quit)" ]] ||
      fail "${install_dir} contains unexpected files; keep only the restored .env before installation."
    restored_env=true
    # The saved digest is the version used to create the backup. Keep the entire restored .env intact.
    image="$(env_value LIBRIS_IMAGE)"
    [[ "$image" == "${registry}/${default_repository}@sha256:"* && "${image##*@sha256:}" =~ ^[0-9a-f]{64}$ ]] ||
      fail "the restored .env must pin LIBRIS_IMAGE to ${registry}/${default_repository}@sha256:<64 hexadecimal characters>."
  fi
else
  [[ ! -e "$install_dir" || -d "$install_dir" ]] || fail "LIBRIS_HOME exists but is not a directory."
  if [[ -d "$install_dir" ]] && [[ -n "$(find "$install_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
    fail "LIBRIS_HOME is not empty; choose an empty directory so no data is overwritten."
  fi
fi

parent_dir="$(dirname "$install_dir")"
[[ -d "$parent_dir" ]] || fail "the parent directory ${parent_dir} does not exist."
if [[ "$install_dir" == /opt/* && "$EUID" -ne 0 ]]; then
  fail "installing under /opt requires root; run this command through sudo."
fi

pull_application
image="$(immutable_image "$image")"

# Everything below reads files of this one digest. Work in the parent of the installation, so that the
# final rename never crosses a file system, and remove the work directory whatever happens.
work_dir="$(mktemp -d "${parent_dir}/.libris-install.XXXXXX")"
cleanup() { [[ -z "$work_dir" ]] || rm -rf "$work_dir"; }
trap cleanup EXIT
chmod 700 "$work_dir"
extract_deploy_files "$image" "$work_dir" install.sh docker-compose.yml env.example
[[ -s "$work_dir/docker-compose.yml" ]] || fail "${image} does not contain the Libris Compose definition."

# Hand over to the installer of the image when this one is another version (or was piped, and cannot be
# compared). The hand-over carries the digest: the next installer pulls nothing and resolves no tag.
if [[ -s "$work_dir/install.sh" && "${LIBRIS_INSTALLER_IMAGE:-}" != "$image" ]]; then
  self="${BASH_SOURCE[0]:-}"
  if [[ -z "$self" || ! -f "$self" ]] || ! cmp -s "$self" "$work_dir/install.sh"; then
    printf 'Continuing with the installer of %s.\n' "$image"
    installer="$(cat "$work_dir/install.sh")"
    rm -rf "$work_dir"
    trap - EXIT
    export LIBRIS_IMAGE="$image" LIBRIS_INSTALLER_IMAGE="$image"
    export LIBRIS_NO_START="$no_start"
    exec bash -c "$installer" libris-install
  fi
elif [[ ! -s "$work_dir/install.sh" ]]; then
  printf '%s carries no installer (a release before 0.15); installing it with this one.\n' "$image"
fi

# The bridge and application share internal protocols that are not a public compatibility surface.
# Pin and compare both build labels: a moving `latest` tag must never pair application code with a
# bridge built from another revision.
app_version="$(oci_label "$image" org.opencontainers.image.version)"
app_revision="$(oci_label "$image" org.opencontainers.image.revision)"
[[ "$app_version" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || fail "the Libris image has an invalid OCI version label."
requested_codex_image="$(repository_of "$image"):codex-${app_version}"
docker pull "$requested_codex_image" || fail "could not download the Codex bridge matching Libris ${app_version}."
codex_version="$(oci_label "$requested_codex_image" org.opencontainers.image.version)"
codex_revision="$(oci_label "$requested_codex_image" org.opencontainers.image.revision)"
if [[ "$codex_version" != "$app_version" || "$codex_revision" != "$app_revision" ]]; then
  fail "the Codex bridge does not match Libris (application ${app_version}/${app_revision}, bridge ${codex_version}/${codex_revision})."
fi
codex_image="$(immutable_image "$requested_codex_image")"

backup_files=(libris-backup libris-restore libris-backup.service libris-backup.timer libris-backup.conf.example)
mkdir "$work_dir/backup"
extract_deploy_files "$image" "$work_dir/backup" "${backup_files[@]}"
for name in "${backup_files[@]}"; do
  if [[ ! -s "$work_dir/backup/$name" ]]; then
    printf '%s carries no backup tools; this is expected before 0.24.0.\n' "$image"
    backup_files=()
    break
  fi
done
backup_project="$project_name"
if [[ "$restored_env" == true || "$existing_installation" == true ]]; then
  backup_project="$(sed -n 's/^LIBRIS_PROJECT=//p' "$install_dir/.env" | head -n 1)"
  if [[ -z "$backup_project" ]] && volume_exists "${historical_project}_database"; then
    backup_project="$historical_project"
  fi
  [[ -n "$backup_project" ]] || backup_project="$project_name"
fi
[[ "$backup_project" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || fail "LIBRIS_PROJECT is not a safe Compose project name."
if ((${#backup_files[@]})); then
  LIBRIS_BACKUP_ENV_PATH="$install_dir/.env" awk -v project="$backup_project" '
    /^#?LIBRIS_BACKUP_PROJECT=/ { print "LIBRIS_BACKUP_PROJECT=" project; next }
    /^#?LIBRIS_BACKUP_SECRET_ENV=/ { print "LIBRIS_BACKUP_SECRET_ENV=" ENVIRON["LIBRIS_BACKUP_ENV_PATH"]; next }
    { print }
  ' "$work_dir/backup/libris-backup.conf.example" > "$work_dir/backup/libris-backup.conf.rendered"
  mv "$work_dir/backup/libris-backup.conf.rendered" "$work_dir/backup/libris-backup.conf.example"
fi

if [[ "$existing_installation" == false ]]; then
  printf 'Installing Libris %s in %s.\n' "$app_version" "$install_dir"
  staging_dir="$work_dir/libris"
  mkdir -m 750 "$staging_dir"
  template="$work_dir/env.example"
  [[ -s "$template" ]] || fallback_template >"$template"
  export LIBRIS_VALUE_LIBRIS_IMAGE="$image" LIBRIS_VALUE_LIBRIS_CODEX_IMAGE="$codex_image"
  LIBRIS_VALUE_SECRET_KEY="$(random_secret)"
  LIBRIS_VALUE_BOOTSTRAP_PASSWORD="$(random_secret)"
  LIBRIS_VALUE_POSTGRES_PASSWORD="$(random_secret)"
  LIBRIS_VALUE_CODEX_BRIDGE_TOKEN="$(random_secret)"
  export LIBRIS_VALUE_SECRET_KEY LIBRIS_VALUE_BOOTSTRAP_PASSWORD LIBRIS_VALUE_POSTGRES_PASSWORD LIBRIS_VALUE_CODEX_BRIDGE_TOKEN
  # The secrets file is created 0600 before a single byte is written into it.
  (umask 077 && render_env "$template" "$staging_dir/.env")
  chmod 600 "$staging_dir/.env"
  unset LIBRIS_VALUE_SECRET_KEY LIBRIS_VALUE_BOOTSTRAP_PASSWORD LIBRIS_VALUE_POSTGRES_PASSWORD LIBRIS_VALUE_CODEX_BRIDGE_TOKEN
  install -m 644 "$work_dir/docker-compose.yml" "$staging_dir/docker-compose.yml"
  # One rename publishes the whole configuration. -T replaces an empty LIBRIS_HOME instead of moving the
  # staging directory inside it, and fails, touching nothing, if anything appeared there meanwhile.
  if [[ "$restored_env" == true ]]; then
    install -m 644 "$work_dir/docker-compose.yml" "$install_dir/docker-compose.yml"
  else
    mv -T "$staging_dir" "$install_dir" || fail "${install_dir} changed during the installation; nothing was written."
  fi
else
  printf 'Updating the existing Libris configuration in %s to %s.\n' "$install_dir" "$app_version"
  # A release can change Compose as well as application code. Replace it only when the installed file
  # is still exactly the file of the installed image: silently erasing a proxy or a local mount would be
  # worse than asking its owner to merge a new template once.
  # An image that no registry serves any more (the repository moved, as dev/libris did in 0.16.4) cannot
  # vouch for the installed file: then only a file identical to the new template is replaced.
  previous_known=true
  if ! docker image inspect "$previous_image" >/dev/null 2>&1; then
    printf 'Downloading the installed image to protect a customised Compose file.\n'
    if ! docker pull "$previous_image"; then
      printf 'The installed image %s can no longer be downloaded; docker-compose.yml is only compared with the new template.\n' "$previous_image"
      previous_known=false
    fi
  fi
  if [[ "$previous_known" == true ]]; then
    mkdir "$work_dir/previous"
    extract_deploy_files "$previous_image" "$work_dir/previous" docker-compose.yml
  fi
  if { [[ "$previous_known" == false ]] || ! cmp -s "$install_dir/docker-compose.yml" "$work_dir/previous/docker-compose.yml"; } &&
    ! cmp -s "$install_dir/docker-compose.yml" "$work_dir/docker-compose.yml"; then
    install -m 644 "$work_dir/docker-compose.yml" "$install_dir/docker-compose.yml.libris-new"
    fail "docker-compose.yml has local changes. The new template is at ${install_dir}/docker-compose.yml.libris-new; merge it before retrying."
  fi
  install -m 644 "$work_dir/docker-compose.yml" "$install_dir/.docker-compose.yml.libris-staged"
  mv -f "$install_dir/.docker-compose.yml.libris-staged" "$install_dir/docker-compose.yml"
  update_env_images "$image" "$codex_image"
fi
if ((${#backup_files[@]})); then
  mkdir -p -m 700 "$install_dir/backup"
  for name in "${backup_files[@]}"; do
    install -m 600 "$work_dir/backup/$name" "$install_dir/backup/$name"
  done
  chmod 755 "$install_dir/backup/libris-backup" "$install_dir/backup/libris-restore"
fi
rm -rf "$work_dir"
work_dir=""
trap - EXIT

# From here a failure leaves a complete configuration: running the same command again resumes.
compose=(docker compose --project-directory "$install_dir" --env-file "$install_dir/.env" --profile codex)
"${compose[@]}" pull database migrate api worker codex ||
  fail "could not download the images of the stack. The configuration is kept; run the same command again to resume."
if [[ "$no_start" == 1 ]]; then
  printf 'Libris %s is installed in %s without starting the stack.\n' "$app_version" "$install_dir"
  exit 0
fi
[[ "$existing_installation" == false ]] || adopt_project_name
if ((${#backup_files[@]})); then
  actual_project="$(sed -n 's/^LIBRIS_PROJECT=//p' "$install_dir/.env" | head -n 1)"
  [[ -n "$actual_project" ]] || actual_project="$project_name"
  if [[ "$actual_project" != "$backup_project" ]]; then
    sed -i "s/^LIBRIS_BACKUP_PROJECT=.*/LIBRIS_BACKUP_PROJECT=${actual_project}/" \
      "$install_dir/backup/libris-backup.conf.example"
  fi
fi
"${compose[@]}" up -d --no-build --wait database migrate api worker codex ||
  fail "the stack did not start. The configuration is kept; inspect 'docker compose logs' in ${install_dir}, then run the same command again to resume."

printf '\nLibris %s is ready at http://localhost:8088\n' "$app_version"
printf 'Sign in as admin. Display the generated password with:\n  sudo grep "^BOOTSTRAP_" %s/.env\n' "$install_dir"
printf 'On a remote server, first create a tunnel: ssh -L 8088:127.0.0.1:8088 user@server\n'
