mirror of
https://github.com/samuelncui/yatm.git
synced 2026-11-08 12:26:33 +00:00
Upgrades keep customized Tape scripts, so outdated local copies stayed silent after template fixes. The installer's check now compares each configured script with the candidate template of the same name (ignoring a .local suffix) and prints a unified diff, and lists differing template-named helpers beside them. The report is a manual check; preserved scripts are never replaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
898 lines
41 KiB
Bash
Executable File
898 lines
41 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
|
|
INSTALL_DIRECTORY=/opt/yatm
|
|
SERVICE_NAME=yatm-httpd.service
|
|
SKILLS_VERSION=1.5.0
|
|
STAGE=preflight
|
|
BACKUP_DIRECTORY=
|
|
BACKUP_COMPLETE=0
|
|
INSTALLATION_CHANGED=0
|
|
SERVICE_STOPPED=0
|
|
CONFIG_CHANGED=false
|
|
CONFIG_PLAN=
|
|
WORK_DIRECTORY=
|
|
REPORT_FILE=
|
|
ATTEMPT_DIRECTORY=
|
|
REPORT_DIRECTORY=
|
|
SERVICE_ACTIVE=0
|
|
SERVICE_PID=
|
|
RELEASE_VERSION=
|
|
LOCAL_ARCHIVE=
|
|
CHECKSUM_FILE=
|
|
FRESH_CONFIG=
|
|
PREVIEW_CHOICE=ask
|
|
PREVIEW_ARCHIVE=
|
|
PREVIEW_CHECKSUM=
|
|
INSTALL_PREVIEW=0
|
|
CHECK_ONLY=0
|
|
SHOW_HELP=0
|
|
LEGACY=0
|
|
REQUIRED_TOOLS=(curl jq tar sha256sum systemctl cp du df readlink mktemp find flock tee awk grep sed mv rm mkdir rmdir chmod date sleep id uname cat diff)
|
|
MANAGED_ITEMS=(yatm-httpd yatm-cli yatm-export-library yatm-lto-info yatm-migrate install-release.sh frontend README.md CONTEXT.md docs VERSION COMMIT LICENSE licenses skills templates)
|
|
OBSOLETE_MANAGED_ITEMS=(config.example.yaml)
|
|
|
|
fail() { echo "error: $*" >&2; return 1; }
|
|
confirm_action() {
|
|
local reply
|
|
read -r -p "$1 [y/N] " reply || return 1
|
|
[[ "$reply" == y || "$reply" == Y ]]
|
|
}
|
|
|
|
usage() {
|
|
echo 'Usage: install-release.sh [--version v1.0.0-alpha.1] [--check]'
|
|
echo ' [--archive FILE --checksum FILE] [--install-dir DIR] [--service NAME]'
|
|
echo ' [--config FILE]'
|
|
echo ' [--with-preview | --without-preview] [--preview-archive FILE --preview-checksum FILE]'
|
|
echo "Linux amd64/systemd only. Required tools: ${REQUIRED_TOOLS[*]}."
|
|
echo 'Stable is the default. Alpha and local candidates require an explicit version.'
|
|
echo '--check performs read-only checks; it does not stop or quiesce YATM.'
|
|
echo '--config supplies the configuration for a fresh installation only.'
|
|
}
|
|
|
|
parse_options() {
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--version|--archive|--checksum|--install-dir|--service|--config|--preview-archive|--preview-checksum)
|
|
[[ $# -ge 2 && "$2" != --* && -n "$2" ]] || { fail "Missing value for $1."; return 1; } ;;
|
|
esac
|
|
case "$1" in
|
|
--version) RELEASE_VERSION="v${2#v}"; shift 2 ;;
|
|
--archive) LOCAL_ARCHIVE="${2:?Missing archive}"; shift 2 ;;
|
|
--checksum) CHECKSUM_FILE="${2:?Missing checksum}"; shift 2 ;;
|
|
--install-dir) INSTALL_DIRECTORY="${2:?Missing installation directory}"; shift 2 ;;
|
|
--service) SERVICE_NAME="${2:?Missing service name}"; shift 2 ;;
|
|
--config) FRESH_CONFIG="${2:?Missing configuration}"; shift 2 ;;
|
|
--with-preview) PREVIEW_CHOICE=yes; shift ;;
|
|
--without-preview) PREVIEW_CHOICE=no; shift ;;
|
|
--preview-archive) PREVIEW_ARCHIVE="$2"; PREVIEW_CHOICE=yes; shift 2 ;;
|
|
--preview-checksum) PREVIEW_CHECKSUM="$2"; shift 2 ;;
|
|
--check) CHECK_ONLY=1; shift ;;
|
|
-h|--help) SHOW_HELP=1; shift ;;
|
|
*) fail "Unknown option: $1"; return 1 ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
prepare_preview() {
|
|
# Optional dependencies are reviewed and staged before service admission or a complete backup.
|
|
[[ "$LEGACY" == 0 ]] || return 0
|
|
if [[ "$PREVIEW_CHOICE" == ask ]]; then
|
|
if [[ "$CHECK_ONLY" == 0 && -t 0 ]] && confirm_action 'Install the optional Image/Video Preview helper? Generation stays disabled until enabled in Settings.'; then
|
|
PREVIEW_CHOICE=yes
|
|
else
|
|
echo 'Optional Preview helper not selected. Use --with-preview to install it later.'
|
|
return 0
|
|
fi
|
|
fi
|
|
[[ "$PREVIEW_CHOICE" == yes ]] || return 0
|
|
local name="yatm-preview-linux-amd64-$RELEASE_VERSION.tar.gz"
|
|
local archive="$TMP_DIRECTORY/$name" checksum="$TMP_DIRECTORY/preview-checksum"
|
|
local url="https://github.com/samuelncui/yatm/releases/download/$RELEASE_VERSION/$name"
|
|
if [[ -n "$PREVIEW_ARCHIVE" ]]; then
|
|
[[ -n "$PREVIEW_CHECKSUM" ]] || { fail '--preview-archive requires --preview-checksum.'; return 1; }
|
|
cp "$PREVIEW_ARCHIVE" "$archive"
|
|
else
|
|
fetch "$url" -o "$archive"
|
|
fi
|
|
if [[ -n "$PREVIEW_CHECKSUM" ]]; then cp "$PREVIEW_CHECKSUM" "$checksum"; else fetch "$url.sha256" -o "$checksum"; fi
|
|
local -a lines
|
|
local expected actual ignored member
|
|
mapfile -t lines < "$checksum"
|
|
[[ "${#lines[@]}" == 1 ]] || { fail 'Expected one Preview checksum.'; return 1; }
|
|
read -r expected ignored <<< "${lines[0]}"
|
|
[[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || { fail 'Invalid Preview checksum.'; return 1; }
|
|
actual="$(sha256sum "$archive")"; actual="${actual%% *}"
|
|
[[ "${expected,,}" == "$actual" ]] || { fail 'Preview checksum mismatch.'; return 1; }
|
|
|
|
# This package can only replace its three owned resources, never configuration or main executables.
|
|
tar -tzf "$archive" > "$TMP_DIRECTORY/preview-members"
|
|
while IFS= read -r member; do
|
|
member="${member#./}"
|
|
[[ "$member" != /* && "$member" != '..' && "$member" != ../* && "$member" != */../* && "$member" != */.. ]] || {
|
|
fail 'Unsafe Preview archive member.'; return 1;
|
|
}
|
|
case "$member" in ''|yatm-preview|preview-lib|preview-lib/*|preview-support|preview-support/*) ;;
|
|
*) fail "Unexpected Preview archive member: $member"; return 1 ;;
|
|
esac
|
|
done < "$TMP_DIRECTORY/preview-members"
|
|
tar -tvzf "$archive" > "$TMP_DIRECTORY/preview-member-types"
|
|
if LC_ALL=C grep -qvE '^[-d]' "$TMP_DIRECTORY/preview-member-types"; then
|
|
fail 'Preview archives may only contain ordinary files and directories.'; return 1
|
|
fi
|
|
tar -xzf "$archive" --no-same-owner --preserve-permissions -C "$RELEASE_DIRECTORY"
|
|
[[ -x "$RELEASE_DIRECTORY/yatm-preview" && -d "$RELEASE_DIRECTORY/preview-lib" && -f "$RELEASE_DIRECTORY/preview-support/VERSION" ]] || {
|
|
fail 'Incomplete Preview package.'; return 1;
|
|
}
|
|
[[ "$(< "$RELEASE_DIRECTORY/preview-support/VERSION")" == "$RELEASE_VERSION" ]] || { fail 'Preview package version mismatch.'; return 1; }
|
|
[[ -f "$RELEASE_DIRECTORY/preview-support/COMMIT" && "$(< "$RELEASE_DIRECTORY/preview-support/COMMIT")" == "$(< "$RELEASE_DIRECTORY/COMMIT")" ]] || {
|
|
fail 'Preview package commit mismatch.'; return 1;
|
|
}
|
|
if ! "$RELEASE_DIRECTORY/yatm-preview" --capabilities | jq -e --arg version "$RELEASE_VERSION" \
|
|
'.protocol == 1 and .type == "capabilities" and .capabilities.version == $version' >/dev/null; then
|
|
fail 'Preview helper cannot run or has an incompatible protocol/version.'; return 1
|
|
fi
|
|
local item
|
|
for item in yatm-preview preview-lib preview-support; do
|
|
if [[ -e "$INSTALL_DIRECTORY/$item" || -L "$INSTALL_DIRECTORY/$item" ]]; then
|
|
[[ -f "$INSTALL_DIRECTORY/preview-support/VERSION" && -f "$INSTALL_DIRECTORY/preview-support/COMMIT" && ! -L "$INSTALL_DIRECTORY/$item" ]] || {
|
|
fail "Unrecognized local Preview resource: $item. Preserve it before installing the managed helper."; return 1;
|
|
}
|
|
fi
|
|
done
|
|
MANAGED_ITEMS+=(yatm-preview preview-lib preview-support)
|
|
INSTALL_PREVIEW=1
|
|
echo 'Optional Preview helper verified; existing generation preferences will be retained.'
|
|
}
|
|
|
|
version_key() {
|
|
local version="$1" rank=3 sequence=0 number
|
|
[[ "$version" =~ ^v([0-9]{1,5})\.([0-9]{1,5})\.([0-9]{1,5})(-(alpha|beta|rc)\.([0-9]{1,5}(\.[0-9]{1,5})*))?$ ]] || return 1
|
|
local major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}" patch="${BASH_REMATCH[3]}" pre="${BASH_REMATCH[5]:-}"
|
|
sequence="${BASH_REMATCH[6]:-0}"
|
|
case "$pre" in alpha) rank=0 ;; beta) rank=1 ;; rc) rank=2 ;; esac
|
|
printf '%05d.%05d.%05d.%d' "$((10#$major))" "$((10#$minor))" "$((10#$patch))" "$rank"
|
|
local -a numbers
|
|
IFS=. read -r -a numbers <<< "$sequence"
|
|
for number in "${numbers[@]}"; do printf '.%05d' "$((10#$number))"; done
|
|
}
|
|
|
|
fetch() {
|
|
command curl -q --fail --location --silent --show-error --connect-timeout 15 --max-time 180 \
|
|
--retry 2 --retry-delay 2 --proto '=https' --proto-redir '=https' "$@"
|
|
}
|
|
|
|
identify_platform() {
|
|
[[ "$(uname -s)" == Linux && "$(uname -m)" =~ ^(x86_64|amd64)$ ]] || {
|
|
fail 'Automatic installation supports Linux amd64/systemd. Use the manual guide on other platforms.'; return 1;
|
|
}
|
|
local dependency
|
|
for dependency in "${REQUIRED_TOOLS[@]}"; do
|
|
command -v "$dependency" >/dev/null || { fail "Install the required tool '$dependency' first."; return 1; }
|
|
done
|
|
[[ "$(tar --version)" == *'GNU tar'* ]] || { fail 'GNU tar is required for complete backup verification.'; return 1; }
|
|
[[ "$INSTALL_DIRECTORY" =~ ^/[a-zA-Z0-9_./-]+$ && "$INSTALL_DIRECTORY" != */../* && "$INSTALL_DIRECTORY" != */.. ]] || {
|
|
fail 'Installation directory must be an absolute path without whitespace or parent traversal.'; return 1;
|
|
}
|
|
INSTALL_DIRECTORY="$(readlink -m "$INSTALL_DIRECTORY")"
|
|
case "$INSTALL_DIRECTORY" in /|/opt|/usr|/usr/local|/var|/tmp|/home|/root) fail 'Choose a dedicated YATM installation directory.'; return 1 ;; esac
|
|
[[ "$SERVICE_NAME" =~ ^[a-zA-Z0-9_-]+\.service$ ]] || { fail 'Invalid systemd service name.'; return 1; }
|
|
}
|
|
|
|
begin_attempt() {
|
|
[[ "$CHECK_ONLY" == 0 ]] || return 0
|
|
local upgrades="$INSTALL_DIRECTORY/.backup"
|
|
umask 077
|
|
mkdir -p -m 755 "$INSTALL_DIRECTORY"
|
|
if [[ -e "$upgrades" || -L "$upgrades" ]]; then
|
|
[[ -d "$upgrades" && ! -L "$upgrades" ]] || {
|
|
fail 'Reserved .backup path is not a directory.'; return 1;
|
|
}
|
|
else
|
|
mkdir -m 700 "$upgrades"
|
|
fi
|
|
chmod 700 "$upgrades"
|
|
[[ ! -L "$upgrades/lock" && ( ! -e "$upgrades/lock" || -f "$upgrades/lock" ) ]] || {
|
|
fail 'Backup lock is not a regular file.'; return 1;
|
|
}
|
|
exec 9> "$upgrades/lock"
|
|
flock -n 9 || { fail 'Another installer is using this installation.'; return 1; }
|
|
# Honor a running older installer before replacing its retained-artifact convention.
|
|
local old_lock="$INSTALL_DIRECTORY/.yatm-upgrades/lock"
|
|
if [[ -f "$old_lock" && ! -L "$old_lock" && ! -L "$INSTALL_DIRECTORY/.yatm-upgrades" ]]; then
|
|
exec 8<> "$old_lock"
|
|
flock -n 8 || { fail 'An older installer is using this installation.'; return 1; }
|
|
fi
|
|
reject_pending_migration
|
|
ATTEMPT_DIRECTORY="$(mktemp -d "$upgrades/$(date +%Y%m%d%H%M%S).XXXXXX")"
|
|
REPORT_DIRECTORY="$ATTEMPT_DIRECTORY"
|
|
WORK_DIRECTORY="$ATTEMPT_DIRECTORY/.work"
|
|
mkdir -m 700 "$WORK_DIRECTORY"
|
|
TMP_DIRECTORY="$WORK_DIRECTORY"
|
|
RELEASE_DIRECTORY="$WORK_DIRECTORY/release"
|
|
REPORT_FILE="$REPORT_DIRECTORY/upgrade.log"
|
|
exec > >(tee -a "$REPORT_FILE") 2>&1
|
|
echo "Installation report: $REPORT_FILE"
|
|
}
|
|
|
|
reject_ambiguous_fresh_root() {
|
|
[[ ! -e "$INSTALL_DIRECTORY" && ! -L "$INSTALL_DIRECTORY" ]] && return 0
|
|
[[ -d "$INSTALL_DIRECTORY" && ! -L "$INSTALL_DIRECTORY" ]] || {
|
|
fail 'Installation path exists but is not a directory.'; return 1;
|
|
}
|
|
[[ -f "$INSTALL_DIRECTORY/VERSION" ]] && return 0
|
|
local backups="$INSTALL_DIRECTORY/.backup"
|
|
if [[ -e "$backups" || -L "$backups" ]]; then
|
|
[[ -d "$backups" && ! -L "$backups" ]] || {
|
|
fail 'Reserved .backup path is not a directory.'; return 1;
|
|
}
|
|
fi
|
|
# A cancelled fresh installation may retain reports without having installed a VERSION.
|
|
if [[ -n "$(find "$INSTALL_DIRECTORY" -mindepth 1 -maxdepth 1 ! -name .backup -print -quit)" ]]; then
|
|
fail 'Refusing to use a non-empty installation directory without a recognized VERSION. Inspect or move its contents first.'; return 1
|
|
fi
|
|
}
|
|
|
|
resolve_version() {
|
|
if [[ -z "$RELEASE_VERSION" ]]; then
|
|
[[ -z "$LOCAL_ARCHIVE" ]] || { fail '--archive requires --version.'; return 1; }
|
|
fetch https://api.github.com/repos/samuelncui/yatm/releases/latest > "$TMP_DIRECTORY/latest.json"
|
|
RELEASE_VERSION="$(jq -er '.tag_name | select(type == "string")' "$TMP_DIRECTORY/latest.json")"
|
|
fi
|
|
TARGET_KEY="$(version_key "$RELEASE_VERSION")" || { fail "Unsupported version: $RELEASE_VERSION"; return 1; }
|
|
if [[ "$RELEASE_VERSION" =~ ^v0\.1\.([0-9]+)$ ]] && (( 10#${BASH_REMATCH[1]} <= 21 )); then LEGACY=1; fi
|
|
if [[ -f "$INSTALL_DIRECTORY/VERSION" ]]; then
|
|
CURRENT_VERSION="$(< "$INSTALL_DIRECTORY/VERSION")"
|
|
CURRENT_VERSION="v${CURRENT_VERSION#v}"
|
|
local current_key
|
|
current_key="$(version_key "$CURRENT_VERSION")" || { fail 'Installed VERSION is unrecognized; inspect the installation manually.'; return 1; }
|
|
if [[ "$TARGET_KEY" < "$current_key" ]]; then
|
|
fail "Refusing downgrade from $CURRENT_VERSION to $RELEASE_VERSION. Select an explicit compatible version; rollback requires its complete backup."; return 1
|
|
fi
|
|
else
|
|
CURRENT_VERSION=none
|
|
[[ ! -f "$INSTALL_DIRECTORY/config.yaml" ]] || { fail 'Existing installation has no VERSION. Use the manual upgrade guide.'; return 1; }
|
|
fi
|
|
}
|
|
|
|
download_release() {
|
|
local name="yatm-linux-amd64-$RELEASE_VERSION.tar.gz" url
|
|
ARCHIVE="$TMP_DIRECTORY/$name"
|
|
url="https://github.com/samuelncui/yatm/releases/download/$RELEASE_VERSION/$name"
|
|
if [[ -n "$LOCAL_ARCHIVE" ]]; then
|
|
[[ -n "$CHECKSUM_FILE" ]] || { fail 'A local candidate requires --checksum.'; return 1; }
|
|
cp "$LOCAL_ARCHIVE" "$ARCHIVE"
|
|
else
|
|
fetch "$url" -o "$ARCHIVE"
|
|
fi
|
|
if [[ -n "$CHECKSUM_FILE" ]]; then
|
|
cp "$CHECKSUM_FILE" "$TMP_DIRECTORY/checksum"
|
|
elif [[ "$LEGACY" == 0 ]]; then
|
|
fetch "$url.sha256" -o "$TMP_DIRECTORY/checksum"
|
|
else
|
|
echo "Manual check: legacy release $RELEASE_VERSION has no publisher checksum. Source: $url"
|
|
fi
|
|
if [[ -f "$TMP_DIRECTORY/checksum" ]]; then
|
|
local expected actual extra
|
|
local -a checksum_lines
|
|
mapfile -t checksum_lines < "$TMP_DIRECTORY/checksum"
|
|
[[ "${#checksum_lines[@]}" == 1 ]] || { fail 'Expected one SHA-256 checksum.'; return 1; }
|
|
read -r expected extra <<< "${checksum_lines[0]}"
|
|
[[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || { fail 'Expected one SHA-256 checksum.'; return 1; }
|
|
actual="$(sha256sum "$ARCHIVE")"; actual="${actual%% *}"
|
|
[[ "${expected,,}" == "$actual" ]] || { fail 'Release checksum mismatch.'; return 1; }
|
|
fi
|
|
|
|
# Inspect names and entry types before extracting or executing candidate programs.
|
|
tar -tzf "$ARCHIVE" > "$TMP_DIRECTORY/members"
|
|
local member
|
|
while IFS= read -r member; do
|
|
[[ "$member" != /* && "$member" != '..' && "$member" != ../* && "$member" != */../* && "$member" != */.. ]] || {
|
|
fail 'Unsafe archive member.'; return 1;
|
|
}
|
|
done < "$TMP_DIRECTORY/members"
|
|
tar -tvzf "$ARCHIVE" > "$TMP_DIRECTORY/member-types"
|
|
if LC_ALL=C grep -qvE '^[-d]' "$TMP_DIRECTORY/member-types"; then
|
|
fail 'Release archives may only contain ordinary files and directories.'; return 1
|
|
fi
|
|
mkdir "$RELEASE_DIRECTORY"
|
|
tar -xzf "$ARCHIVE" --no-same-owner --preserve-permissions -C "$RELEASE_DIRECTORY"
|
|
local archive_version
|
|
[[ -f "$RELEASE_DIRECTORY/VERSION" ]] || { fail 'Archive VERSION is missing.'; return 1; }
|
|
archive_version="$(< "$RELEASE_DIRECTORY/VERSION")"
|
|
[[ "v${archive_version#v}" == "$RELEASE_VERSION" ]] || { fail 'Archive VERSION does not match the requested release.'; return 1; }
|
|
local program
|
|
for program in yatm-httpd yatm-export-library yatm-lto-info; do
|
|
[[ -x "$RELEASE_DIRECTORY/$program" ]] || { fail "Missing executable $program."; return 1; }
|
|
done
|
|
[[ -f "$RELEASE_DIRECTORY/frontend/index.html" ]] || { fail 'Missing frontend.'; return 1; }
|
|
if [[ "$LEGACY" == 1 ]]; then return; fi
|
|
local item
|
|
for item in "${MANAGED_ITEMS[@]}"; do
|
|
[[ -e "$RELEASE_DIRECTORY/$item" ]] || { fail "Candidate is missing managed resource $item."; return 1; }
|
|
done
|
|
for program in yatm-httpd yatm-cli yatm-migrate yatm-export-library yatm-lto-info; do
|
|
[[ -x "$RELEASE_DIRECTORY/$program" ]] || { fail "Missing executable $program."; return 1; }
|
|
"$RELEASE_DIRECTORY/$program" --version | jq -e --arg program "$program" --arg version "$RELEASE_VERSION" \
|
|
--arg commit "$(< "$RELEASE_DIRECTORY/COMMIT")" '.program == $program and .version == $version and .commit == $commit' >/dev/null
|
|
done
|
|
[[ -f "$RELEASE_DIRECTORY/templates/config.example.yaml" && -f "$RELEASE_DIRECTORY/templates/yatm-httpd.service" && -f "$RELEASE_DIRECTORY/skills/yatm/SKILL.md" ]] || {
|
|
fail 'Candidate is missing installation templates or its bundled Skill.'; return 1;
|
|
}
|
|
}
|
|
|
|
run_migrator() {
|
|
(cd "$INSPECTION_DIRECTORY" && "$RELEASE_DIRECTORY/yatm-migrate" -config ./config.yaml "$@")
|
|
}
|
|
|
|
service_property() {
|
|
systemctl show "$SERVICE_NAME" --property "$1" --value
|
|
}
|
|
|
|
service_main_pid() {
|
|
local pid
|
|
pid="$(service_property MainPID)" || return 1
|
|
[[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1
|
|
printf '%s\n' "$pid"
|
|
}
|
|
|
|
verify_service_ownership() {
|
|
local load_state fragment working_directory
|
|
load_state="$(service_property LoadState)" || return 1
|
|
fragment="$(service_property FragmentPath)" || return 1
|
|
working_directory="$(service_property WorkingDirectory)" || return 1
|
|
if [[ "$CURRENT_VERSION" == none ]]; then
|
|
[[ "$load_state" == not-found && -z "$fragment" && -z "$working_directory" ]] || {
|
|
fail "Service $SERVICE_NAME already exists and is not owned by this fresh installation."; return 1;
|
|
}
|
|
return 0
|
|
fi
|
|
[[ "$load_state" != not-found ]] || { fail "Installed service unit $SERVICE_NAME was not found."; return 1; }
|
|
local installation_root
|
|
installation_root="$(readlink -m "$INSTALL_DIRECTORY")"
|
|
[[ -n "$fragment" && "$(readlink -f "$fragment")" == "$installation_root/$SERVICE_NAME" ]] || {
|
|
fail "The service unit is outside the installation backup: ${fragment:-missing}. Use manual upgrade."; return 1;
|
|
}
|
|
[[ -n "$working_directory" && "$(readlink -m "$working_directory")" == "$installation_root" ]] || {
|
|
fail 'The configured service working directory differs from the installation. Use manual upgrade.'; return 1;
|
|
}
|
|
}
|
|
|
|
prepare_fresh_config() {
|
|
INSPECTION_DIRECTORY="$TMP_DIRECTORY/fresh"
|
|
mkdir "$INSPECTION_DIRECTORY"
|
|
local templates="$RELEASE_DIRECTORY/templates"
|
|
[[ "$LEGACY" == 0 ]] || templates="$RELEASE_DIRECTORY"
|
|
cp -a "$templates/scripts" "$INSPECTION_DIRECTORY/scripts"
|
|
cp "${FRESH_CONFIG:-$templates/config.example.yaml}" "$INSPECTION_DIRECTORY/config.yaml"
|
|
if [[ -z "$FRESH_CONFIG" && "$CHECK_ONLY" == 0 ]]; then
|
|
[[ -t 0 ]] || { fail 'A noninteractive fresh installation requires --config.'; return 1; }
|
|
"${EDITOR:-vi}" "$INSPECTION_DIRECTORY/config.yaml"
|
|
fi
|
|
}
|
|
|
|
inspect_installation() {
|
|
INSPECTION_DIRECTORY="$INSTALL_DIRECTORY"
|
|
if [[ "$CURRENT_VERSION" == none ]]; then
|
|
prepare_fresh_config
|
|
else
|
|
[[ -z "$FRESH_CONFIG" ]] || { fail '--config is only for a fresh installation; existing configuration is preserved.'; return 1; }
|
|
if systemctl is-active --quiet "$SERVICE_NAME"; then SERVICE_ACTIVE=1; fi
|
|
fi
|
|
verify_service_ownership
|
|
if [[ "$LEGACY" == 1 ]]; then
|
|
[[ "$CURRENT_VERSION" == none || "$CURRENT_VERSION" == "$RELEASE_VERSION" ]] || {
|
|
fail 'This legacy candidate has no read-only migration inspector. Use the legacy manual update guide or explicitly select current Alpha.'; return 1;
|
|
}
|
|
return
|
|
fi
|
|
local stopped=()
|
|
[[ "$SERVICE_ACTIVE" == 1 ]] || stopped=(-service-stopped)
|
|
if [[ "$SERVICE_ACTIVE" == 1 ]]; then
|
|
SERVICE_PID="$(service_main_pid)" || { fail 'The active systemd service has no stable MainPID.'; return 1; }
|
|
stopped+=(-service-pid "$SERVICE_PID")
|
|
fi
|
|
local inspection_root="$INSPECTION_DIRECTORY"
|
|
if [[ "$CURRENT_VERSION" == none ]]; then
|
|
stopped+=(-fresh-install)
|
|
inspection_root="$INSTALL_DIRECTORY"
|
|
fi
|
|
local preflight_file="$TMP_DIRECTORY/preflight.json"
|
|
run_migrator -phase preflight -json -install-root "$inspection_root" "${stopped[@]}" > "$preflight_file"
|
|
if [[ "$SERVICE_ACTIVE" == 1 ]]; then
|
|
[[ "$(service_main_pid)" == "$SERVICE_PID" ]] || { fail 'The systemd service changed during preflight.'; return 1; }
|
|
fi
|
|
jq . "$TMP_DIRECTORY/preflight.json"
|
|
SCHEMA="$(jq -er .schema "$TMP_DIRECTORY/preflight.json")"
|
|
SERVER_URL="$(jq -er .server_url "$TMP_DIRECTORY/preflight.json")"
|
|
if [[ "$CURRENT_VERSION" != none && "$SCHEMA" != legacy && "$SCHEMA" != current ]]; then
|
|
fail 'Existing installation has no recognized catalog. Inspect its configured database before upgrading.'; return 1
|
|
fi
|
|
if [[ "$CURRENT_VERSION" != none ]]; then
|
|
local needed available candidate
|
|
(cd "$INSTALL_DIRECTORY" && find . -mindepth 1 -maxdepth 1 ! -name .backup -print0) > "$TMP_DIRECTORY/backup.entries"
|
|
needed="$(cd "$INSTALL_DIRECTORY" && du -skc --files0-from="$TMP_DIRECTORY/backup.entries" | awk 'END {print $1}')"
|
|
candidate="$(du -sk "$RELEASE_DIRECTORY" | awk '{print $1}')"
|
|
available="$(df -Pk "$INSTALL_DIRECTORY" | awk 'END {print $4}')"
|
|
(( available > needed * 2 + candidate + 10240 )) || {
|
|
fail 'Insufficient installation-filesystem space for the complete backup, migration and candidate.'; return 1;
|
|
}
|
|
fi
|
|
echo 'Passed: configuration, backup scope and current service activity checks.'
|
|
jq -r '.warnings[]? | "Manual check: " + .' "$TMP_DIRECTORY/preflight.json"
|
|
report_script_templates
|
|
echo 'Tape scripts were not executed; installation checks do not establish Tape readiness.'
|
|
}
|
|
|
|
report_script_templates() {
|
|
# Preserved scripts are compared, never replaced; a local name may append .local to its template's.
|
|
[[ "$CURRENT_VERSION" != none ]] || return 0
|
|
local templates="$RELEASE_DIRECTORY/templates/scripts" script path name
|
|
local -A configured=() directories=()
|
|
[[ -d "$templates" ]] || return 0
|
|
while IFS= read -r script; do
|
|
path="$script"
|
|
[[ "$path" == /* ]] || path="$INSTALL_DIRECTORY/${path#./}"
|
|
configured[$path]=1
|
|
directories[${path%/*}]=1
|
|
name="${path##*/}"
|
|
name="${name%.local}"
|
|
if [[ ! -f "$templates/$name" ]]; then
|
|
echo "Manual check: configured Tape script $script has no bundled template to compare."
|
|
elif ! diff -q -- "$templates/$name" "$path" > /dev/null 2>&1; then
|
|
echo "Manual check: configured Tape script $script differs from bundled template templates/scripts/$name:"
|
|
diff -u --label "templates/scripts/$name" --label "$script" -- "$templates/$name" "$path" || true
|
|
fi
|
|
done < <(jq -r '.tape_scripts[]?' "$TMP_DIRECTORY/preflight.json")
|
|
|
|
# Helpers beside the configured scripts, such as get_device, are compared by their template name.
|
|
local directory template
|
|
for directory in "${!directories[@]}"; do
|
|
for template in "$templates"/*; do
|
|
path="$directory/${template##*/}"
|
|
[[ -f "$path" && -z "${configured[$path]:-}" ]] || continue
|
|
diff -q -- "$template" "$path" > /dev/null 2>&1 ||
|
|
echo "Manual check: unconfigured file ${path#"$INSTALL_DIRECTORY"/} differs from bundled template templates/scripts/${template##*/}; it is a helper or an unused copy."
|
|
done
|
|
done
|
|
}
|
|
|
|
show_migration_guide() {
|
|
echo "Requested installation: $CURRENT_VERSION → $RELEASE_VERSION"
|
|
[[ "$CURRENT_VERSION" != none ]] || return 0
|
|
if [[ "$RELEASE_VERSION" == v1.* ]]; then
|
|
echo 'Tape adapter check required before the first Tape Job: v1 needs the completed final Index at TAPE_DIR/<barcode>.schema.'
|
|
echo 'Custom scripts are retained. Keep legacy captures through migration, then adapt mount output and wait for final Index output and device release in unmount.'
|
|
echo 'Read the verified release guide: docs/operations/migration.md, Tape Script Adaptation.'
|
|
fi
|
|
local current_major="${CURRENT_VERSION#v}" target_major="${RELEASE_VERSION#v}"
|
|
[[ "${current_major%%.*}" != "${target_major%%.*}" ]] || return 0
|
|
[[ "$CURRENT_VERSION" =~ ^v0\.1\.[0-9]+$ && "$RELEASE_VERSION" == v1.* ]] || {
|
|
fail 'This installer has no supported migration procedure for the requested major-version change.'; return 1;
|
|
}
|
|
local guide="$RELEASE_DIRECTORY/docs/operations/migration.md"
|
|
[[ -s "$guide" && ! -L "$guide" ]] || { fail 'The verified release is missing its migration guide.'; return 1; }
|
|
echo "Migration guide from the verified release: $guide"
|
|
if [[ "$CHECK_ONLY" == 1 ]]; then
|
|
echo 'Read this guide before the mutating run; that run displays it once before consent.'
|
|
return 0
|
|
fi
|
|
command cat "$guide"
|
|
}
|
|
|
|
backup_installation() {
|
|
STAGE=backup
|
|
BACKUP_DIRECTORY="$ATTEMPT_DIRECTORY"
|
|
BACKUP_COMPLETE=0
|
|
echo "Installation backup destination: $BACKUP_DIRECTORY/yatm.tar.gz"
|
|
# Archive the root itself to retain its metadata; only prior backups are excluded.
|
|
local partial="$BACKUP_DIRECTORY/yatm.tar.gz.partial"
|
|
local -a archive_options=(--format=pax --pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime
|
|
--sort=name --sparse --acls --xattrs --xattrs-include='*' --numeric-owner --exclude=./.backup)
|
|
if ! tar "${archive_options[@]}" \
|
|
-C "$INSTALL_DIRECTORY" -czf "$partial" .; then
|
|
fail "Installation backup failed; incomplete archive remains at $partial."
|
|
return 1
|
|
fi
|
|
if ! tar --acls --xattrs --xattrs-include='*' --numeric-owner -C "$INSTALL_DIRECTORY" -dzf "$partial"; then
|
|
fail "Installation backup comparison failed; unverified archive remains at $partial."
|
|
return 1
|
|
fi
|
|
# tar --compare does not verify extended attributes or detect newly added files.
|
|
# A deterministic second stream checks the entire frozen tree, including ACL/xattr payloads.
|
|
local saved observed
|
|
saved="$(sha256sum "$partial")"
|
|
observed="$(tar "${archive_options[@]}" -C "$INSTALL_DIRECTORY" -czf - . | sha256sum)" || {
|
|
fail 'Could not verify the complete backup tree.'; return 1;
|
|
}
|
|
[[ "${saved%% *}" == "${observed%% *}" ]] || {
|
|
fail 'Installation changed while verifying the backup; the archive remains unverified.'; return 1;
|
|
}
|
|
mv "$partial" "$BACKUP_DIRECTORY/yatm.tar.gz"
|
|
(cd "$BACKUP_DIRECTORY" && sha256sum yatm.tar.gz > yatm.tar.gz.sha256)
|
|
BACKUP_COMPLETE=1
|
|
echo 'Passed: complete backup content and metadata comparison.'
|
|
}
|
|
|
|
reject_pending_migration() {
|
|
local marker="$INSTALL_DIRECTORY/.yatm-upgrades/migration.pending.json"
|
|
[[ ! -e "$marker" && ! -L "$marker" ]] || {
|
|
fail 'An earlier upgrade is unfinished. Complete it using its original installer or recover its complete backup before this upgrade.'; return 1;
|
|
}
|
|
}
|
|
|
|
plan_configuration() {
|
|
[[ "$LEGACY" == 0 && "$CURRENT_VERSION" != none ]] || return 0
|
|
CONFIG_PLAN="$TMP_DIRECTORY/config-plan.json"
|
|
run_migrator -phase config-plan -plan-file "$CONFIG_PLAN" > "$TMP_DIRECTORY/config-summary.json"
|
|
CONFIG_CHANGED="$(jq -er '.changed | tostring' "$TMP_DIRECTORY/config-summary.json")"
|
|
[[ "$CONFIG_CHANGED" == true || "$CONFIG_CHANGED" == false ]] || return 1
|
|
jq -r '.diff' "$TMP_DIRECTORY/config-summary.json" > "${REPORT_DIRECTORY:-$TMP_DIRECTORY}/config.diff"
|
|
cat "${REPORT_DIRECTORY:-$TMP_DIRECTORY}/config.diff"
|
|
jq -r '.settings[]? | "Settings: " + .' "$TMP_DIRECTORY/config-summary.json"
|
|
}
|
|
|
|
clean_work_directory() {
|
|
[[ -n "$WORK_DIRECTORY" && "$WORK_DIRECTORY" == "$ATTEMPT_DIRECTORY/.work" ]] || return 0
|
|
rm -rf -- "$WORK_DIRECTORY"
|
|
}
|
|
|
|
clean_old_upgrades() {
|
|
local old="$INSTALL_DIRECTORY/.yatm-upgrades" attempt entry report recognized
|
|
[[ "$BACKUP_COMPLETE" == 1 && -d "$old" && ! -L "$old" ]] || return 0
|
|
[[ -f "$old/OWNER" && ! -L "$old/OWNER" && "$(< "$old/OWNER")" == yatm-installer-upgrades ]] || {
|
|
echo "Preserved unrecognized historical directory: $old"; return 0;
|
|
}
|
|
# Ownership comes from the known layout and log, not the success of an old backup.
|
|
# Cancelled and failed attempts are safe to remove once the new whole-tree backup is verified.
|
|
while IFS= read -r -d '' attempt; do
|
|
[[ "${attempt##*/}" =~ ^[0-9]{14}\.[a-zA-Z0-9]{6}$ && -d "$attempt" && ! -L "$attempt" ]] || continue
|
|
[[ -d "$attempt/reports" && ! -L "$attempt/reports" && \
|
|
-f "$attempt/reports/upgrade.log" && ! -L "$attempt/reports/upgrade.log" ]] || continue
|
|
recognized=1
|
|
while IFS= read -r -d '' entry; do
|
|
case "${entry##*/}" in
|
|
reports|release) [[ -d "$entry" && ! -L "$entry" ]] || recognized=0 ;;
|
|
v*.backup) [[ "${entry##*/}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-z]+\.[0-9]+)?\.backup$ && -d "$entry" && ! -L "$entry" ]] || recognized=0 ;;
|
|
*) recognized=0 ;;
|
|
esac
|
|
done < <(find "$attempt" -mindepth 1 -maxdepth 1 -print0)
|
|
while IFS= read -r -d '' report; do
|
|
case "${report##*/}" in
|
|
backup.status)
|
|
if [[ ! -f "$report" || -L "$report" ]]; then
|
|
recognized=0
|
|
elif [[ "$(< "$report")" != complete && "$(< "$report")" != incomplete ]]; then
|
|
recognized=0
|
|
fi ;;
|
|
upgrade.log|preflight.json|release.sha256|backup.entries|migration.json)
|
|
[[ -f "$report" && ! -L "$report" ]] || recognized=0 ;;
|
|
*) recognized=0 ;;
|
|
esac
|
|
done < <(find "$attempt/reports" -mindepth 1 -maxdepth 1 -print0)
|
|
if [[ "$recognized" == 1 ]]; then
|
|
rm -rf -- "$attempt"
|
|
else
|
|
echo "Preserved unrecognized historical attempt: $attempt"
|
|
fi
|
|
done < <(find "$old" -mindepth 1 -maxdepth 1 -print0)
|
|
if [[ -z "$(find "$old" -mindepth 1 -maxdepth 1 ! -name OWNER ! -name lock -print -quit)" && \
|
|
! -L "$old/lock" && ( ! -e "$old/lock" || -f "$old/lock" ) ]]; then
|
|
rm -f -- "$old/OWNER" "$old/lock"
|
|
rmdir "$old"
|
|
else
|
|
echo "Preserved historical artifacts requiring manual review: $old"
|
|
fi
|
|
}
|
|
|
|
reverse_prepare() {
|
|
if run_migrator -phase abort --confirm; then
|
|
STAGE=reversed
|
|
INSTALLATION_CHANGED=0
|
|
if [[ "$SERVICE_ACTIVE" == 1 ]]; then systemctl start "$SERVICE_NAME"; fi
|
|
SERVICE_STOPPED=0
|
|
echo 'Prepared migration aborted; original service state restored.'
|
|
return 0
|
|
fi
|
|
echo 'Abort failed. Keep YATM stopped and recover the complete backup.' >&2
|
|
return 1
|
|
}
|
|
|
|
restore_previous_service() {
|
|
[[ "$SERVICE_ACTIVE" == 1 ]] || return 0
|
|
if ! systemctl restart "$SERVICE_NAME"; then
|
|
fail 'The previous service could not be restarted; keep the installation unchanged and recover it manually.'
|
|
return 1
|
|
fi
|
|
local attempt recovered=0
|
|
for attempt in {1..15}; do
|
|
if systemctl is-active --quiet "$SERVICE_NAME" && \
|
|
{ [[ "$SCHEMA" != current ]] || ready_process_id >/dev/null; }; then
|
|
recovered=1
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
[[ "$recovered" == 1 ]] || {
|
|
fail 'The restarted service did not recover its active state and local admission endpoint.'
|
|
return 1
|
|
}
|
|
echo 'Previous service admission and active state restored.'
|
|
SERVICE_STOPPED=0
|
|
}
|
|
|
|
quiesce_and_stop() {
|
|
[[ "$SERVICE_ACTIVE" == 1 ]] || return 0
|
|
local expected output
|
|
expected="$(service_main_pid)" || { fail 'The active systemd service has no stable MainPID.'; return 1; }
|
|
if ! output="$(run_migrator -phase quiesce --confirm -install-root "$INSTALL_DIRECTORY" -service-pid "$expected" 2>&1)"; then
|
|
printf '%s\n' "$output" >&2
|
|
if [[ "$output" != *'running Jobs must finish'* ]]; then
|
|
restore_previous_service || true
|
|
fi
|
|
return 1
|
|
fi
|
|
printf '%s\n' "$output"
|
|
if [[ "$(service_main_pid)" != "$expected" ]]; then
|
|
restore_previous_service || true
|
|
fail 'The systemd service changed while quiescing.'
|
|
return 1
|
|
fi
|
|
|
|
STAGE=stopping
|
|
if ! systemctl stop "$SERVICE_NAME"; then
|
|
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
|
restore_previous_service || true
|
|
fail 'Stopping the service failed; its admission state was restored if possible.'
|
|
return 1
|
|
fi
|
|
fi
|
|
SERVICE_STOPPED=1
|
|
}
|
|
|
|
complete_legacy_migration() {
|
|
STAGE=commit
|
|
run_migrator -phase commit --confirm
|
|
# Only the locally generated and verified archive is extracted for legacy evidence.
|
|
local evidence="$WORK_DIRECTORY/backup"
|
|
mkdir -m 700 "$evidence"
|
|
(cd "$BACKUP_DIRECTORY" && sha256sum --check yatm.tar.gz.sha256)
|
|
tar --acls --xattrs --xattrs-include='*' --numeric-owner -xzf "$BACKUP_DIRECTORY/yatm.tar.gz" -C "$evidence"
|
|
# Cleanup fully validates the migration against the backup before removing obsolete artifacts.
|
|
STAGE=cleanup
|
|
run_migrator -phase cleanup -backup-root "$evidence" -install-root "$INSTALL_DIRECTORY" --confirm
|
|
}
|
|
|
|
upgrade_existing() {
|
|
# Recheck after consent, then close current admission at the actual activation boundary.
|
|
if [[ "$SERVICE_ACTIVE" == 1 ]]; then
|
|
quiesce_and_stop
|
|
else
|
|
run_migrator -phase quiesce --confirm -install-root "$INSTALL_DIRECTORY" -service-stopped
|
|
fi
|
|
STAGE=config-check
|
|
run_migrator -phase config-check -plan-file "$CONFIG_PLAN" -service-stopped
|
|
if ! backup_installation; then
|
|
restore_previous_service || true
|
|
return 1
|
|
fi
|
|
if [[ "$SCHEMA" == legacy ]]; then
|
|
STAGE=prepare
|
|
INSTALLATION_CHANGED=1
|
|
if ! run_migrator -phase prepare -report-file "$REPORT_DIRECTORY/migration.json"; then
|
|
reverse_prepare
|
|
return 1
|
|
fi
|
|
if ! confirm_action 'Approve this migration report and commit the migration?'; then
|
|
reverse_prepare
|
|
exit 0
|
|
fi
|
|
complete_legacy_migration
|
|
fi
|
|
if [[ "$CONFIG_CHANGED" == true ]]; then
|
|
STAGE=config-apply
|
|
INSTALLATION_CHANGED=1
|
|
run_migrator -phase config-apply -plan-file "$CONFIG_PLAN" -service-stopped --confirm
|
|
fi
|
|
}
|
|
|
|
install_managed_files() {
|
|
# This allowlist does not overwrite active configuration, scripts or unknown local files.
|
|
STAGE=replace-programs
|
|
INSTALLATION_CHANGED=1
|
|
mkdir -p "$INSTALL_DIRECTORY"
|
|
local item
|
|
for item in "${MANAGED_ITEMS[@]}"; do
|
|
if [[ ! -e "$RELEASE_DIRECTORY/$item" ]]; then
|
|
[[ "$LEGACY" == 1 ]] || { fail "Candidate is missing managed resource $item."; return 1; }
|
|
continue
|
|
fi
|
|
# Replace owned trees in full so obsolete release files cannot survive an overlay.
|
|
rm -rf -- "$INSTALL_DIRECTORY/$item"
|
|
cp -a "$RELEASE_DIRECTORY/$item" "$INSTALL_DIRECTORY/"
|
|
done
|
|
if [[ "$CURRENT_VERSION" != none ]]; then
|
|
[[ "$BACKUP_COMPLETE" == 1 ]] || { fail 'Owned obsolete files may only be removed after a complete backup.'; return 1; }
|
|
for item in "${OBSOLETE_MANAGED_ITEMS[@]}"; do
|
|
rm -rf -- "$INSTALL_DIRECTORY/$item"
|
|
done
|
|
fi
|
|
local templates="$RELEASE_DIRECTORY/templates"
|
|
[[ "$LEGACY" == 0 ]] || templates="$RELEASE_DIRECTORY"
|
|
if [[ "$CURRENT_VERSION" == none ]]; then
|
|
cp "$INSPECTION_DIRECTORY/config.yaml" "$INSTALL_DIRECTORY/config.yaml"
|
|
cp -a "$templates/scripts" "$INSTALL_DIRECTORY/"
|
|
sed "s|/opt/yatm|$INSTALL_DIRECTORY|g" "$templates/yatm-httpd.service" > "$INSTALL_DIRECTORY/$SERVICE_NAME"
|
|
fi
|
|
[[ -f "$INSTALL_DIRECTORY/$SERVICE_NAME" ]] || { fail 'Missing preserved systemd unit.'; return 1; }
|
|
}
|
|
|
|
managed_install_matches_candidate() {
|
|
local installed candidate installed_version item
|
|
for item in "${OBSOLETE_MANAGED_ITEMS[@]}"; do
|
|
[[ ! -e "$INSTALL_DIRECTORY/$item" && ! -L "$INSTALL_DIRECTORY/$item" ]] || return 1
|
|
done
|
|
[[ -f "$INSTALL_DIRECTORY/VERSION" && -f "$INSTALL_DIRECTORY/COMMIT" ]] || return 1
|
|
installed_version="$(< "$INSTALL_DIRECTORY/VERSION")"
|
|
[[ "v${installed_version#v}" == "v${RELEASE_VERSION#v}" ]] || return 1
|
|
[[ "$(< "$INSTALL_DIRECTORY/COMMIT")" == "$(< "$RELEASE_DIRECTORY/COMMIT")" ]] || return 1
|
|
installed="$(cd "$INSTALL_DIRECTORY" && tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner -cf - "${MANAGED_ITEMS[@]}" 2>/dev/null | sha256sum)" || return 1
|
|
candidate="$(cd "$RELEASE_DIRECTORY" && tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner -cf - "${MANAGED_ITEMS[@]}" 2>/dev/null | sha256sum)" || return 1
|
|
[[ "${installed%% *}" == "${candidate%% *}" ]]
|
|
}
|
|
|
|
ready_process_id() {
|
|
local expected="${1:-}" actual
|
|
actual="$(systemctl show "$SERVICE_NAME" --property MainPID --value)" || return 1
|
|
[[ "$actual" =~ ^[1-9][0-9]*$ && ( -z "$expected" || "$actual" == "$expected" ) ]] || return 1
|
|
systemctl is-active --quiet "$SERVICE_NAME" || return 1
|
|
command curl -q --noproxy '*' --fail --silent --show-error --connect-timeout 2 --max-time 2 \
|
|
"$SERVER_URL/files/_upgrade/status" | jq -e --argjson pid "$actual" '.process_id == $pid' >/dev/null || return 1
|
|
[[ "$(systemctl show "$SERVICE_NAME" --property MainPID --value)" == "$actual" ]] || return 1
|
|
systemctl is-active --quiet "$SERVICE_NAME" || return 1
|
|
printf '%s\n' "$actual"
|
|
}
|
|
|
|
check_readiness() {
|
|
local attempt process_id=
|
|
if [[ "$LEGACY" == 1 ]]; then
|
|
systemctl is-active --quiet "$SERVICE_NAME"
|
|
echo 'Legacy release started; validate its UI before use. This release has no CLI readiness probe.'
|
|
return
|
|
fi
|
|
for attempt in {1..15}; do
|
|
if process_id="$(ready_process_id 2>/dev/null)"; then break; fi
|
|
sleep 1
|
|
done
|
|
[[ -n "$process_id" ]] || { fail 'Readiness endpoint does not belong to the running systemd service.'; return 1; }
|
|
"$INSTALL_DIRECTORY/yatm-cli" --server "$SERVER_URL" --timeout 3s status
|
|
"$INSTALL_DIRECTORY/yatm-cli" --server "$SERVER_URL" --timeout 3s ls > "$TMP_DIRECTORY/library.json"
|
|
"$INSTALL_DIRECTORY/yatm-cli" --server "$SERVER_URL" --timeout 3s job list --limit 1 > "$TMP_DIRECTORY/jobs.json"
|
|
(cd "$INSTALL_DIRECTORY" && ./yatm-migrate -config ./config.yaml -phase frontend-check)
|
|
ready_process_id "$process_id" >/dev/null || { fail 'The systemd service changed or stopped during readiness checks.'; return 1; }
|
|
if [[ "$INSTALL_PREVIEW" == 1 ]]; then
|
|
"$INSTALL_DIRECTORY/yatm-preview" --capabilities | jq -e --arg version "$RELEASE_VERSION" \
|
|
'.protocol == 1 and .type == "capabilities" and .capabilities.version == $version' >/dev/null
|
|
fi
|
|
}
|
|
|
|
offer_skill() {
|
|
local skill_path="$INSTALL_DIRECTORY/skills/yatm" skill_user="${SUDO_USER:-$(id -un)}"
|
|
local -a command_prefix=()
|
|
[[ -f "$skill_path/SKILL.md" ]] || { echo 'Optional Skill: this release does not include one.'; return 0; }
|
|
if [[ -z "$skill_user" || "$skill_user" == root ]] || ! id "$skill_user" >/dev/null 2>&1; then
|
|
echo "Optional Skill: run as your ordinary user: DISABLE_TELEMETRY=1 DO_NOT_TRACK=1 npx --yes --registry=https://registry.npmjs.org skills@$SKILLS_VERSION add '$skill_path' --global --copy"
|
|
return 0
|
|
fi
|
|
if [[ "$(id -un)" != "$skill_user" ]]; then command_prefix=(sudo -H -u "$skill_user"); fi
|
|
if ! "${command_prefix[@]}" sh -c 'command -v node >/dev/null && command -v npx >/dev/null'; then
|
|
echo "Optional Skill skipped: Node/npm are not available for $skill_user. They were not installed."
|
|
return 0
|
|
fi
|
|
if [[ ! -t 0 ]]; then
|
|
echo "Optional Skill: run as $skill_user: DISABLE_TELEMETRY=1 DO_NOT_TRACK=1 npx --yes --registry=https://registry.npmjs.org skills@$SKILLS_VERSION add '$skill_path' --global --copy"
|
|
return 0
|
|
fi
|
|
confirm_action "Install this release's YATM Skill for $skill_user? Review agents and confirm in the Skills CLI." || return 0
|
|
# sudo changes identity, not the caller's directory (which may be an unreadable /root).
|
|
if ! "${command_prefix[@]}" sh -c 'cd && exec "$@"' sh env DISABLE_TELEMETRY=1 DO_NOT_TRACK=1 \
|
|
npx --yes --registry=https://registry.npmjs.org "skills@$SKILLS_VERSION" add "$skill_path" --global --copy; then
|
|
echo 'Optional Skill installation cancelled or failed. YATM installation remains successful.'
|
|
fi
|
|
}
|
|
|
|
on_failure() {
|
|
local status="$1"
|
|
trap - ERR
|
|
if [[ "$INSTALLATION_CHANGED" == 1 ]]; then
|
|
systemctl stop "$SERVICE_NAME" || true
|
|
elif [[ "$SERVICE_STOPPED" == 1 ]]; then
|
|
restore_previous_service || true
|
|
fi
|
|
echo "Installation did not complete. Stage: $STAGE" >&2
|
|
if [[ -n "$BACKUP_DIRECTORY" && "$BACKUP_COMPLETE" == 1 ]]; then
|
|
echo "Complete backup: $BACKUP_DIRECTORY. Recover the complete installation, not only its executables." >&2
|
|
elif [[ -n "$BACKUP_DIRECTORY" ]]; then
|
|
echo "Incomplete backup: $BACKUP_DIRECTORY. Do not use it as a complete recovery source." >&2
|
|
fi
|
|
[[ -z "$REPORT_FILE" ]] || echo "Report: $REPORT_FILE" >&2
|
|
exit "$status"
|
|
}
|
|
|
|
main() {
|
|
parse_options "$@"
|
|
if [[ "$SHOW_HELP" == 1 ]]; then usage; return; fi
|
|
identify_platform
|
|
TMP_DIRECTORY="$(mktemp -d -t yatm-install.XXXXXX)"
|
|
RELEASE_DIRECTORY="$TMP_DIRECTORY/release"
|
|
INITIAL_SCRATCH="$TMP_DIRECTORY"
|
|
trap '[[ -z "${INITIAL_SCRATCH:-}" ]] || rm -rf -- "$INITIAL_SCRATCH"' EXIT
|
|
trap 'on_failure "$?"' ERR
|
|
trap 'on_failure 130' INT TERM
|
|
reject_ambiguous_fresh_root
|
|
reject_pending_migration
|
|
resolve_version
|
|
if [[ "$CURRENT_VERSION" == none ]]; then
|
|
verify_service_ownership
|
|
fi
|
|
download_release
|
|
prepare_preview
|
|
inspect_installation
|
|
# Reject unsafe backup layouts before creating reports or locks inside the installation.
|
|
# Keep verified inputs in the retained work directory after acquiring the installer lock.
|
|
local inspected_directory="$TMP_DIRECTORY"
|
|
begin_attempt
|
|
if [[ "$CHECK_ONLY" == 0 ]]; then
|
|
cp -a "$inspected_directory/." "$WORK_DIRECTORY/"
|
|
if [[ "$CURRENT_VERSION" == none ]]; then INSPECTION_DIRECTORY="$WORK_DIRECTORY/fresh"; fi
|
|
fi
|
|
show_migration_guide
|
|
plan_configuration
|
|
if [[ "$CHECK_ONLY" == 1 ]]; then echo 'Read-only installation checks passed; review any manual checks above. No service or installation changes were made.'; return; fi
|
|
if [[ "$CURRENT_VERSION" == "$RELEASE_VERSION" && "$CONFIG_CHANGED" == false ]] && \
|
|
{ [[ "$LEGACY" == 1 ]] || managed_install_matches_candidate; }; then
|
|
if [[ "$LEGACY" == 0 ]]; then
|
|
local program
|
|
for program in yatm-httpd yatm-cli yatm-migrate yatm-export-library yatm-lto-info; do
|
|
"$INSTALL_DIRECTORY/$program" --version | jq -e --arg program "$program" --arg version "$RELEASE_VERSION" \
|
|
--arg commit "$(< "$RELEASE_DIRECTORY/COMMIT")" '.program == $program and .version == $version and .commit == $commit' >/dev/null
|
|
done
|
|
STAGE=readiness
|
|
if [[ "$SERVICE_ACTIVE" == 0 ]]; then systemctl start "$SERVICE_NAME"; fi
|
|
check_readiness
|
|
fi
|
|
echo "YATM $RELEASE_VERSION is already installed; no replacement is needed."
|
|
offer_skill
|
|
clean_work_directory
|
|
return
|
|
fi
|
|
echo "Install $RELEASE_VERSION (currently $CURRENT_VERSION). Review configuration changes above; local scripts and files are retained."
|
|
if [[ "$CURRENT_VERSION" != none ]]; then
|
|
echo "The service will stop after confirmation. Its complete backup will remain inside $ATTEMPT_DIRECTORY."
|
|
fi
|
|
confirm_action 'Continue with installation?' || { echo 'Installation cancelled; existing service and data are unchanged.'; clean_work_directory; return; }
|
|
if [[ "$CURRENT_VERSION" != none ]]; then upgrade_existing; fi
|
|
install_managed_files
|
|
STAGE=readiness
|
|
systemctl daemon-reload
|
|
systemctl enable "$INSTALL_DIRECTORY/$SERVICE_NAME"
|
|
systemctl start "$SERVICE_NAME"
|
|
check_readiness
|
|
clean_old_upgrades
|
|
clean_work_directory
|
|
STAGE=complete
|
|
echo "Installed $RELEASE_VERSION."
|
|
if [[ "$LEGACY" == 1 ]]; then
|
|
echo "UI: use the address configured in $INSTALL_DIRECTORY/config.yaml."
|
|
else
|
|
echo "UI: $SERVER_URL"
|
|
fi
|
|
echo "Configuration: $INSTALL_DIRECTORY/config.yaml"
|
|
echo "Backup: ${BACKUP_DIRECTORY:-not needed for a fresh installation}"
|
|
echo "Report: $REPORT_FILE"
|
|
echo 'Before the first physical Tape Job, validate the preserved customized Tape scripts for this environment.'
|
|
offer_skill
|
|
}
|
|
|
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@"; fi
|