427 lines
12 KiB
Bash
Executable File
427 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# SHD2YPP2 remote bootstrapper.
|
|
#
|
|
# Supported entrypoints:
|
|
# curl -fsSL http://<host>/install.sh | bash
|
|
# curl -fsSL http://<host>/install.sh | bash -s -- --profile all
|
|
# sudo bash install.sh --profile hub
|
|
#
|
|
# The heavy OS/service installation stays in scripts/linux/install_stack.sh.
|
|
# This file owns the production bootstrap contract: source acquisition, locking,
|
|
# logging, privilege handling, profile dispatch, and post-install verification.
|
|
set -Eeuo pipefail
|
|
|
|
PRODUCT_NAME="SHD2YPP2"
|
|
DEFAULT_REPO_URL="http://gitlab.nomadro.com/gitlab-instance-0a899031/sh.git"
|
|
DEFAULT_REPO_REF="best"
|
|
DEFAULT_REPO_SUBDIR="d2ypp2"
|
|
DEFAULT_INSTALL_DIR="/opt/live/d2ypp"
|
|
DEFAULT_LOG_DIR="/var/log/shd2ypp2"
|
|
DEFAULT_LOCK_FILE="/run/lock/shd2ypp2-install.lock"
|
|
|
|
PROFILE="all"
|
|
REPO_URL="${LIVE_REPO_URL:-$DEFAULT_REPO_URL}"
|
|
REPO_REF="${LIVE_REPO_REF:-$DEFAULT_REPO_REF}"
|
|
REPO_SUBDIR="${LIVE_REPO_SUBDIR:-$DEFAULT_REPO_SUBDIR}"
|
|
INSTALL_DIR="${LIVE_INSTALL_DIR:-$DEFAULT_INSTALL_DIR}"
|
|
LOG_DIR="${LIVE_INSTALL_LOG_DIR:-$DEFAULT_LOG_DIR}"
|
|
LOCK_FILE="${LIVE_INSTALL_LOCK_FILE:-$DEFAULT_LOCK_FILE}"
|
|
VERIFY=1
|
|
LOCAL_ONLY=0
|
|
KEEP_SOURCE=0
|
|
ASSUME_YES=1
|
|
|
|
SELF_PATH="${BASH_SOURCE[0]:-$0}"
|
|
START_TS="$(date +%Y%m%d-%H%M%S)"
|
|
LOG_FILE="$LOG_DIR/install-$START_TS.log"
|
|
|
|
log() {
|
|
printf '[install] %s\n' "$*" >&2
|
|
}
|
|
|
|
die() {
|
|
log "ERROR: $*"
|
|
exit 1
|
|
}
|
|
|
|
usage() {
|
|
cat <<USAGE
|
|
$PRODUCT_NAME one-click installer
|
|
|
|
Usage:
|
|
curl -fsSL <url>/install.sh | bash
|
|
bash install.sh [options]
|
|
|
|
Options:
|
|
--profile all|hub|core|business|live Install profile. Default: all
|
|
--repo URL Git repository URL. Default: $DEFAULT_REPO_URL
|
|
--ref REF Git branch/tag/commit. Default: $DEFAULT_REPO_REF
|
|
--subdir PATH Project subdir inside repo. Default: $DEFAULT_REPO_SUBDIR
|
|
--dir PATH Runtime source directory. Default: $DEFAULT_INSTALL_DIR
|
|
--no-verify Skip post-install health probes
|
|
--local-only Require running from an existing project checkout
|
|
--keep-source Keep temporary clone directory for debugging
|
|
-h, --help Show this help
|
|
|
|
Environment overrides:
|
|
LIVE_REPO_URL, LIVE_REPO_REF, LIVE_REPO_SUBDIR, LIVE_INSTALL_DIR
|
|
HOSTNAME_ALIAS, PORT and other config/system-stack.env values
|
|
USAGE
|
|
}
|
|
|
|
parse_args() {
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--profile)
|
|
PROFILE="${2:-}"
|
|
shift 2
|
|
;;
|
|
--profile=*)
|
|
PROFILE="${1#*=}"
|
|
shift
|
|
;;
|
|
--repo)
|
|
REPO_URL="${2:-}"
|
|
shift 2
|
|
;;
|
|
--repo=*)
|
|
REPO_URL="${1#*=}"
|
|
shift
|
|
;;
|
|
--ref)
|
|
REPO_REF="${2:-}"
|
|
shift 2
|
|
;;
|
|
--ref=*)
|
|
REPO_REF="${1#*=}"
|
|
shift
|
|
;;
|
|
--subdir)
|
|
REPO_SUBDIR="${2:-}"
|
|
shift 2
|
|
;;
|
|
--subdir=*)
|
|
REPO_SUBDIR="${1#*=}"
|
|
shift
|
|
;;
|
|
--dir)
|
|
INSTALL_DIR="${2:-}"
|
|
shift 2
|
|
;;
|
|
--dir=*)
|
|
INSTALL_DIR="${1#*=}"
|
|
shift
|
|
;;
|
|
--no-verify)
|
|
VERIFY=0
|
|
shift
|
|
;;
|
|
--local-only)
|
|
LOCAL_ONLY=1
|
|
shift
|
|
;;
|
|
--keep-source)
|
|
KEEP_SOURCE=1
|
|
shift
|
|
;;
|
|
-y|--yes)
|
|
ASSUME_YES=1
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
die "Unknown argument: $1"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
case "$PROFILE" in
|
|
all|full) PROFILE="all" ;;
|
|
hub|core|sh) PROFILE="hub" ;;
|
|
business|live|app|sh2) PROFILE="business" ;;
|
|
*) die "Unsupported profile: $PROFILE" ;;
|
|
esac
|
|
[ -n "$REPO_URL" ] || die "--repo cannot be empty"
|
|
[ -n "$REPO_REF" ] || die "--ref cannot be empty"
|
|
[ -n "$INSTALL_DIR" ] || die "--dir cannot be empty"
|
|
}
|
|
|
|
have() {
|
|
command -v "$1" >/dev/null 2>&1
|
|
}
|
|
|
|
root_prefix() {
|
|
if [ "$(id -u)" -eq 0 ]; then
|
|
return 0
|
|
fi
|
|
if ! have sudo; then
|
|
die "sudo is required when not running as root. Use: curl -fsSL <url>/install.sh | sudo -E bash"
|
|
fi
|
|
sudo -n true 2>/dev/null || sudo -v
|
|
}
|
|
|
|
run_root() {
|
|
if [ "$(id -u)" -eq 0 ]; then
|
|
"$@"
|
|
else
|
|
sudo -E "$@"
|
|
fi
|
|
}
|
|
|
|
run_root_shell() {
|
|
if [ "$(id -u)" -eq 0 ]; then
|
|
bash -lc "$*"
|
|
else
|
|
sudo -E bash -lc "$*"
|
|
fi
|
|
}
|
|
|
|
setup_logging() {
|
|
root_prefix
|
|
run_root mkdir -p "$LOG_DIR" "$(dirname "$LOCK_FILE")"
|
|
run_root chmod 0755 "$LOG_DIR"
|
|
if [ "$(id -u)" -eq 0 ]; then
|
|
exec > >(tee -a "$LOG_FILE") 2>&1
|
|
else
|
|
exec > >(sudo -E tee -a "$LOG_FILE") 2>&1
|
|
fi
|
|
log "$PRODUCT_NAME bootstrap started at $START_TS"
|
|
log "Log file: $LOG_FILE"
|
|
}
|
|
|
|
detect_platform() {
|
|
[ -r /etc/os-release ] || die "/etc/os-release not found; Debian/Ubuntu/Armbian are supported"
|
|
# shellcheck source=/dev/null
|
|
. /etc/os-release
|
|
local family="${ID:-} ${ID_LIKE:-}"
|
|
if ! printf '%s' "$family" | grep -qiE 'debian|ubuntu'; then
|
|
die "Unsupported distribution: ${PRETTY_NAME:-unknown}. Use Debian, Ubuntu, Armbian, Raspberry Pi OS, or a compatible derivative."
|
|
fi
|
|
local arch
|
|
arch="$(uname -m 2>/dev/null || true)"
|
|
case "$arch" in
|
|
x86_64|amd64|aarch64|arm64|armv7l|armv6l) ;;
|
|
*) log "WARN: untested architecture: ${arch:-unknown}; continuing with best-effort install" ;;
|
|
esac
|
|
log "Platform: ${PRETTY_NAME:-$family}; arch=$arch; kernel=$(uname -r)"
|
|
}
|
|
|
|
apt_install_bootstrap_deps() {
|
|
local need_apt=0
|
|
for cmd in git curl rsync flock; do
|
|
have "$cmd" || need_apt=1
|
|
done
|
|
[ "$need_apt" = 1 ] || return 0
|
|
log "Installing bootstrap dependencies"
|
|
run_root env DEBIAN_FRONTEND=noninteractive apt-get update
|
|
run_root env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
|
ca-certificates curl git rsync util-linux sudo
|
|
}
|
|
|
|
acquire_lock() {
|
|
run_root touch "$LOCK_FILE"
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
run_root chown "$(id -u):$(id -g)" "$LOCK_FILE"
|
|
fi
|
|
exec 9>"$LOCK_FILE"
|
|
if ! flock -n 9; then
|
|
die "Another installer is already running. Lock: $LOCK_FILE"
|
|
fi
|
|
}
|
|
|
|
is_project_dir() {
|
|
local dir="$1"
|
|
[ -f "$dir/scripts/linux/install_stack.sh" ] && [ -f "$dir/web.py" ] && [ -d "$dir/web-console" ]
|
|
}
|
|
|
|
local_project_dir() {
|
|
local candidate
|
|
if [ -n "$SELF_PATH" ] && [ -f "$SELF_PATH" ]; then
|
|
candidate="$(cd "$(dirname "$SELF_PATH")" && pwd)"
|
|
if is_project_dir "$candidate"; then
|
|
printf '%s\n' "$candidate"
|
|
return 0
|
|
fi
|
|
fi
|
|
candidate="$(pwd)"
|
|
if is_project_dir "$candidate"; then
|
|
printf '%s\n' "$candidate"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
clone_source() {
|
|
local clone_root project_dir
|
|
clone_root="/opt/live/src/shd2ypp2-$START_TS"
|
|
log "Fetching source: repo=$REPO_URL ref=$REPO_REF subdir=$REPO_SUBDIR"
|
|
run_root mkdir -p "$(dirname "$clone_root")"
|
|
run_root git clone --depth 1 --branch "$REPO_REF" "$REPO_URL" "$clone_root" || {
|
|
log "Shallow clone failed; retrying full fetch"
|
|
run_root rm -rf "$clone_root"
|
|
run_root git clone "$REPO_URL" "$clone_root"
|
|
run_root git -C "$clone_root" checkout "$REPO_REF"
|
|
}
|
|
if [ -n "$REPO_SUBDIR" ]; then
|
|
project_dir="$clone_root/$REPO_SUBDIR"
|
|
else
|
|
project_dir="$clone_root"
|
|
fi
|
|
is_project_dir "$project_dir" || die "Fetched source does not contain SHD2YPP2 project at: $project_dir"
|
|
printf '%s\n' "$project_dir"
|
|
}
|
|
|
|
sync_to_install_dir() {
|
|
local source_dir="$1"
|
|
local target="$INSTALL_DIR"
|
|
log "Syncing source to $target"
|
|
run_root mkdir -p "$target"
|
|
run_root rsync -a --delete \
|
|
--exclude '.git/' \
|
|
--exclude '.venv/' \
|
|
--exclude '.next/' \
|
|
--exclude 'node_modules/' \
|
|
--exclude 'web-console/node_modules/' \
|
|
--exclude 'web-console/.next/' \
|
|
--exclude 'logs/' \
|
|
--exclude 'downloads/' \
|
|
--exclude 'backup_config/' \
|
|
--exclude '.pm2/' \
|
|
--exclude '.process_runner/' \
|
|
--exclude 'services/neko/data/' \
|
|
--exclude 'config/system-stack.env' \
|
|
"$source_dir/" "$target/"
|
|
run_root chown -R live:live "$target" 2>/dev/null || true
|
|
printf '%s\n' "$target"
|
|
}
|
|
|
|
resolve_project_dir() {
|
|
local project=""
|
|
if project="$(local_project_dir 2>/dev/null)"; then
|
|
log "Using local project checkout: $project"
|
|
project="$(sync_to_install_dir "$project")"
|
|
printf '%s\n' "$project"
|
|
return 0
|
|
fi
|
|
if [ "$LOCAL_ONLY" = 1 ]; then
|
|
die "--local-only was set, but current directory is not a SHD2YPP2 checkout"
|
|
fi
|
|
apt_install_bootstrap_deps
|
|
local fetched
|
|
fetched="$(clone_source)"
|
|
sync_to_install_dir "$fetched"
|
|
if [ "$KEEP_SOURCE" != 1 ]; then
|
|
run_root rm -rf "$(dirname "$fetched")"
|
|
else
|
|
log "Keeping fetched source at $(dirname "$fetched")"
|
|
fi
|
|
}
|
|
|
|
install_profile() {
|
|
local project_dir="$1"
|
|
local stack="$project_dir/scripts/linux/install_stack.sh"
|
|
[ -f "$stack" ] || die "Missing installer: $stack"
|
|
log "Dispatching install profile: $PROFILE"
|
|
run_root chmod +x "$stack" "$project_dir"/install-*.sh "$project_dir"/start.sh 2>/dev/null || true
|
|
run_root env \
|
|
LIVE_STACK_REPO_ROOT="$INSTALL_DIR" \
|
|
LIVE_INSTALL_LOG_FILE="$LOG_FILE" \
|
|
ASSUME_YES="$ASSUME_YES" \
|
|
bash "$stack" "$PROFILE"
|
|
}
|
|
|
|
post_verify() {
|
|
[ "$VERIFY" = 1 ] || {
|
|
log "Post-install verification skipped by --no-verify"
|
|
return 0
|
|
}
|
|
local port="${PORT:-8001}"
|
|
if [ -f "$INSTALL_DIR/config/system-stack.env" ]; then
|
|
# shellcheck source=/dev/null
|
|
. "$INSTALL_DIR/config/system-stack.env" || true
|
|
port="${PORT:-8001}"
|
|
fi
|
|
log "Waiting for control plane on 127.0.0.1:$port"
|
|
local ok=0 i
|
|
for i in $(seq 1 90); do
|
|
if curl -fsS -m 3 "http://127.0.0.1:$port/health" >/dev/null 2>&1; then
|
|
ok=1
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
if [ "$ok" != 1 ]; then
|
|
run_root journalctl -u live-console.service -n 120 --no-pager || true
|
|
die "Control plane did not become healthy on 127.0.0.1:$port"
|
|
fi
|
|
log "Control plane health OK"
|
|
|
|
if curl -fsS -m 8 "http://127.0.0.1:$port/deploy/check" >/tmp/shd2ypp2-deploy-check.json 2>/dev/null; then
|
|
python3 - <<'PY'
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
data = json.loads(Path("/tmp/shd2ypp2-deploy-check.json").read_text(encoding="utf-8"))
|
|
status = "READY" if data.get("ready") else "DEGRADED"
|
|
print(f"[install] deploy/check: {status} ({data.get('pass_count', 0)}/{data.get('total_count', 0)})")
|
|
for item in data.get("failing_checks") or []:
|
|
label = item.get("label") or item.get("id") or "check"
|
|
detail = item.get("detail") or item.get("error") or ""
|
|
print(f"[install] FAIL {label}: {detail}")
|
|
for hint in data.get("hints") or []:
|
|
print(f"[install] hint: {hint}")
|
|
PY
|
|
else
|
|
log "WARN: /deploy/check unavailable; /health passed"
|
|
fi
|
|
|
|
local verifier="$INSTALL_DIR/tools/verify_local_install.py"
|
|
if [ -f "$verifier" ]; then
|
|
log "Running local production verifier"
|
|
run_root chmod +x "$verifier" 2>/dev/null || true
|
|
run_root python3 "$verifier" --install-dir "$INSTALL_DIR" --timeout 6
|
|
else
|
|
log "WARN: local verifier not found at $verifier"
|
|
fi
|
|
}
|
|
|
|
print_finish() {
|
|
local host_alias="${HOSTNAME_ALIAS:-live}"
|
|
local port="${PORT:-8001}"
|
|
if [ -f "$INSTALL_DIR/config/system-stack.env" ]; then
|
|
# shellcheck source=/dev/null
|
|
. "$INSTALL_DIR/config/system-stack.env" || true
|
|
host_alias="${HOSTNAME_ALIAS:-live}"
|
|
port="${PORT:-8001}"
|
|
fi
|
|
cat <<EOF
|
|
|
|
[install] $PRODUCT_NAME install finished.
|
|
[install] Web console: http://${host_alias}.local
|
|
[install] Direct URL: http://${host_alias}.local:${port}
|
|
[install] Runtime dir: $INSTALL_DIR
|
|
[install] Log file: $LOG_FILE
|
|
|
|
EOF
|
|
}
|
|
|
|
main() {
|
|
parse_args "$@"
|
|
setup_logging
|
|
acquire_lock
|
|
detect_platform
|
|
apt_install_bootstrap_deps
|
|
local project_dir
|
|
project_dir="$(resolve_project_dir)"
|
|
install_profile "$project_dir"
|
|
post_verify
|
|
print_finish
|
|
}
|
|
|
|
main "$@"
|