From cf79c6a1acb0f06e3a29ee5c14b0444a88de6640 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 3 Sep 2026 08:06:56 +0000 Subject: [PATCH] release: sync public installer asset install.sh --- install.sh | 238 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 195 insertions(+), 43 deletions(-) diff --git a/install.sh b/install.sh index 3e2285b..a408015 100644 --- a/install.sh +++ b/install.sh @@ -287,8 +287,75 @@ persist_installer_log() { chmod 600 "$INSTALL_DIR/install.log" 2>/dev/null || true } die() { printf '\n\033[31mxymediavault installer: %s\033[0m\n' "$*" >&2; persist_installer_log; [[ -f ${INSTALL_DIR:-}/install.log ]] && printf '详情日志:%s/install.log\n' "$INSTALL_DIR" >&2 || { [[ -n $INSTALL_LOG ]] && printf '详情日志:%s\n' "$INSTALL_LOG" >&2; }; exit 1; } +component_cache_test_mode() { + local catalog=${XYMEDIA_INSTALL_TEST_CATALOG:?} component platform version url sha archive marker_version marker_sha stage downloaded tarball actual + mkdir -p "$INSTALL_DIR/components" "$INSTALL_DIR/state" + for component in tmm title; do + if [[ $component == tmm ]]; then platform=linux-any + elif [[ -n ${XYMEDIA_INSTALL_TEST_PLATFORM:-} ]]; then platform=$XYMEDIA_INSTALL_TEST_PLATFORM + else case "$(uname -m)" in x86_64|amd64) platform=linux-amd64;; aarch64|arm64) platform=linux-arm64;; *) die "unsupported host architecture: $(uname -m)";; esac + fi + IFS=$'\t' read -r version url sha < <(python3 - "$catalog" "$component" "$platform" <<'PY' +import json, re, sys +from urllib.parse import urlsplit +c = json.load(open(sys.argv[1])); name, platform = sys.argv[2:] +e = c['components'][name]['artifacts'].get(platform) or c['components'][name]['artifacts'].get('linux-any') +if not re.fullmatch(r'[0-9a-fA-F]{64}', e.get('sha256', '')): raise SystemExit(1) +url = e.get('asset_url', '') +parsed = urlsplit(url) +try: port = parsed.port +except ValueError: raise SystemExit(1) +if (parsed.scheme != 'https' or parsed.netloc.lower() != 'git.keeper.work' or + parsed.username is not None or parsed.password is not None or port is not None or + parsed.query or parsed.fragment or '%' in (parsed.path or '') or + re.fullmatch(r'/api/packages/[^/]+/generic/[^/]+/[^/]+/[^/]+', parsed.path or '') is None): + raise SystemExit(1) +print(e['version'], e['asset_url'], e['sha256'].lower(), sep='\t') +PY +) || die '公开目录缺少带 SHA-256 的组件制品' + archive="$INSTALL_DIR/components/$component.tar.zst"; marker_version="$INSTALL_DIR/components/$component.version"; marker_sha="$INSTALL_DIR/components/$component.sha256" + if [[ -s $archive && -f $marker_version && -f $marker_sha && $(<"$marker_version") == "$version" && $(<"$marker_sha") == "$sha" && $(sha256sum "$archive" | cut -d' ' -f1) == "$sha" ]]; then + printf '%s %s 已安装且完整,跳过下载\n' "$([[ $component == tmm ]] && printf TMM || printf Title)" "$version"; continue + fi + stage=$(mktemp -d "$INSTALL_DIR/state/.$component.XXXXXX") + downloaded="$stage/$component.tar.zst"; tarball="$stage/$component.tar" + curl --connect-timeout 10 --max-time 300 --fail --silent --show-error --location --proto '=https' --tlsv1.2 -o "$downloaded" "$url" || die "$component 下载失败" + actual=$(sha256sum "$downloaded" | cut -d' ' -f1); [[ $actual == "$sha" ]] || die "$component SHA256 校验失败" + zstd -dc "$downloaded" >"$tarball" || die "$component 解压失败" + python3 - "$tarball" "$stage/root" <<'PY' +import pathlib, sys, tarfile +archive, target = sys.argv[1:]; pathlib.Path(target).mkdir() +with tarfile.open(archive) as tf: + members = tf.getmembers() + for member in members: + name = member.name.lstrip('./') + if not name or name.startswith('/') or '..' in pathlib.PurePosixPath(name).parts or member.issym() or member.islnk() or not (member.isfile() or member.isdir()): raise SystemExit(1) + member.name = name + tf.extractall(target, members=members, filter='data') +PY + cp "$downloaded" "$archive.tmp"; printf '%s\n' "$version" >"$marker_version.tmp"; printf '%s\n' "$sha" >"$marker_sha.tmp" + mv -f "$archive.tmp" "$archive"; mv -f "$marker_version.tmp" "$marker_version"; mv -f "$marker_sha.tmp" "$marker_sha"; rm -rf "$stage" + done + exit 0 +} +if [[ ${XYMEDIA_INSTALL_TEST_COMPONENT_CACHE_ONLY:-0} == 1 ]]; then + INSTALL_DIR=${XYMEDIA_INSTALL_DIR:?XYMEDIA_INSTALL_DIR is required}; component_cache_test_mode +fi INSTALL_TMP_DIR= -cleanup_installer_tmp() { persist_installer_log; [[ -z ${INSTALL_TMP_DIR:-} ]] || rm -rf -- "$INSTALL_TMP_DIR"; } +RELEASE_ROLLBACK_ACTIVE=0 +RELEASE_TARGET= +RELEASE_BACKUP= +cleanup_installer_tmp() { + if (( RELEASE_ROLLBACK_ACTIVE )) && [[ -n $RELEASE_BACKUP && -d $RELEASE_BACKUP ]]; then + rm -rf -- "$RELEASE_TARGET" 2>/dev/null || true + mv -- "$RELEASE_BACKUP" "$RELEASE_TARGET" 2>/dev/null || true + ln -sfn "releases/$(basename "$RELEASE_TARGET")" "$INSTALL_DIR/releases/current" 2>/dev/null || true + printf '%s\n' '应用 release 失败,已尝试恢复旧 release' >>"${INSTALL_LOG:-/dev/null}" + "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" stop app >/dev/null 2>&1 || true + "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d app >/dev/null 2>&1 || true + fi + persist_installer_log; [[ -z ${INSTALL_TMP_DIR:-} ]] || rm -rf -- "$INSTALL_TMP_DIR" +} trap cleanup_installer_tmp EXIT init_installer_log() { local tmp_parent=${TMPDIR:-/tmp} mode @@ -356,7 +423,6 @@ tty_out=${XYMEDIA_INSTALL_TEST_TTY_OUTPUT:-/dev/tty} emit() { printf '%s\n' "$1" printf '%s\n' "$1" >>"$install_log" 2>/dev/null || true - printf '%s\n' "$1" >>"$tty_out" 2>/dev/null || true } emit '正在启动 XyMediaVault 应用...' @@ -390,8 +456,13 @@ if [[ ${XYMEDIA_INSTALL_TEST_POST_MIGRATION_ONLY:-0} == 1 ]]; then printf '[安装流程] 已完成数据库迁移,继续启动应用\n' >>"$INSTALL_LOG" printf '数据库初始化完成\n' printf '数据库初始化完成\n' >>"$INSTALL_LOG" + if [[ $MENU_PROFILES == *xiaoya-control* ]]; then + printf '正在将本机小雅 Controller 同步到管理平台数据库...\n' + printf '正在将本机小雅 Controller 同步到管理平台数据库...\n' >>"$INSTALL_LOG" + run_visible_logged "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" run --rm -T --no-deps app-migrate bootstrap-controller --config /app/config.yaml "$tarball"; else zstd -dc "$archive" >"$tarball"; fi @@ -998,9 +1119,11 @@ with tarfile.open(archive, 'r:*') as tf: if m.issym() or m.islnk() or not (m.isfile() or m.isdir()): raise SystemExit('archive contains link or special file') m.name = name - tf.extractall(target, members=members) + tf.extractall(target, members=members, filter='data') PY + cp "$archive" "$target/.download.archive" rm -f "$tarball" + rm -rf "$work" } install_controller_binary() { local source=$1 target="$INSTALL_DIR/controller/xymedia-edge" staged @@ -1017,9 +1140,9 @@ install_controller_binary() { chmod 755 "$target" } if (( CONTROLLER_ONLY )); then - IFS=$'\t' read -r controller_version controller_url < <(artifact controller "$PLATFORM") || die '公开目录没有当前架构的 controller 制品' + IFS=$'\t' read -r controller_version controller_url controller_sha < <(artifact controller "$PLATFORM") || die '公开目录没有当前架构的 controller 制品' progress "下载小雅控制器 $controller_version ($PLATFORM)" - download_extract controller "$controller_version" "$controller_url" "$INSTALL_DIR/controller-stage" + download_extract controller "$controller_version" "$controller_url" "$controller_sha" "$INSTALL_DIR/controller-stage" edge=$(find "$INSTALL_DIR/controller-stage" -type f -name xymedia-edge -perm -u+x -print -quit) [[ -n $edge ]] || die 'controller archive lacks executable xymedia-edge' install_controller_binary "$edge" @@ -1030,27 +1153,50 @@ if (( CONTROLLER_ONLY )); then printf '小雅控制器已安装,监听地址:%s:%s;请按防火墙配置访问,控制器密钥不会显示。\n' "$CONTROLLER_BIND_ADDRESS" "$CONTROLLER_PORT" exit 0 fi -IFS=$'\t' read -r APP_VERSION APP_URL < <(artifact app "$PLATFORM") -APP_STAGE="$INSTALL_DIR/state/app-$APP_VERSION" +IFS=$'\t' read -r APP_VERSION APP_URL APP_SHA < <(artifact app "$PLATFORM") +APP_STAGE=$(mktemp -d "$INSTALL_TMP_DIR/app.XXXXXX") progress "下载应用包 $APP_VERSION ($PLATFORM)" -download_extract app "$APP_VERSION" "$APP_URL" "$APP_STAGE" +download_extract app "$APP_VERSION" "$APP_URL" "$APP_SHA" "$APP_STAGE" APP_ROOT="$APP_STAGE/xymediavault-$APP_VERSION-$PLATFORM" [[ -x $APP_ROOT/bin/xymediavault && -x $APP_ROOT/bin/xymedia-supervisor && -d $APP_ROOT/web/dist && -f $APP_ROOT/release.json ]] || die "app package $APP_VERSION lacks the required xymedia-supervisor; use a newer public app package" if [[ -x $APP_ROOT/bin/xymedia-edge ]]; then install_controller_binary "$APP_ROOT/bin/xymedia-edge" fi mkdir -p "$INSTALL_DIR/releases/releases" -[[ ! -e "$INSTALL_DIR/releases/releases/$APP_VERSION" ]] && mv "$APP_ROOT" "$INSTALL_DIR/releases/releases/$APP_VERSION" +if [[ -e "$INSTALL_DIR/releases/releases/$APP_VERSION" ]]; then + progress '停止旧版本应用以替换同版本 release' + run_logged "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" stop app || die '无法停止旧版本应用' +fi +release_target="$INSTALL_DIR/releases/releases/$APP_VERSION" +release_backup="$INSTALL_DIR/releases/.${APP_VERSION}.old.$$" +RELEASE_TARGET=$release_target +RELEASE_BACKUP=$release_backup +[[ ! -L "$release_target" ]] || die 'release 目录不能是符号链接' +mv "$APP_ROOT" "$release_target.new.$$" || die '无法准备 release 替换' +if [[ -e "$release_target" ]]; then + mv "$release_target" "$release_backup" || die '无法备份旧 release' +fi +mv "$release_target.new.$$" "$release_target" || { mv "$release_backup" "$release_target" 2>/dev/null || true; die '无法安装新 release'; } +RELEASE_ROLLBACK_ACTIVE=1 ln -sfn "releases/$APP_VERSION" "$INSTALL_DIR/releases/current" if (( ! SKIP_COMPONENTS )); then progress '下载 TMM 与 Title 组件包' for component in tmm title; do platform=$([[ $component == tmm ]] && printf linux-any || printf "$PLATFORM") - IFS=$'\t' read -r version url < <(artifact "$component" "$platform") - download_extract "$component" "$version" "$url" "$INSTALL_DIR/state/$component-$version" - archive="$INSTALL_DIR/state/$component-$version.archive" - [[ -s $archive ]] || die "$component archive was not downloaded" - component_root="$INSTALL_DIR/state/$component-$version" + IFS=$'\t' read -r version url sha < <(artifact "$component" "$platform") + archive="$INSTALL_DIR/components/$component.tar.zst" + marker_version="$INSTALL_DIR/components/$component.version" + marker_sha="$INSTALL_DIR/components/$component.sha256" + if [[ -s $archive && -f $marker_version && -f $marker_sha ]] && [[ $(<"$marker_version") == "$version" ]] && [[ $(<"$marker_sha") == "$sha" ]] && [[ $(sha256sum "$archive" | cut -d' ' -f1) == "$sha" ]]; then + printf '%s %s 已安装且完整,跳过下载\n' "$([[ $component == tmm ]] && printf TMM || printf Title)" "$version" + continue + fi + component_stage=$(mktemp -d "$INSTALL_TMP_DIR/$component.XXXXXX") + download_extract "$component" "$version" "$url" "$sha" "$component_stage" + archive_tmp="$INSTALL_TMP_DIR/$component.archive" + cp "$component_stage/.download.archive" "$archive_tmp" + [[ -s $archive_tmp ]] || die "$component archive was not retained" + component_root="$component_stage" if [[ ! -d "$component_root/payload" ]]; then component_root=$(find "$component_root" -mindepth 1 -maxdepth 1 -type d | head -n 1) [[ -n $component_root && -d "$component_root/payload" ]] || die "$component archive has no payload directory" @@ -1061,7 +1207,10 @@ if (( ! SKIP_COMPONENTS )); then else for required in payload/python/bin/python payload/service/entrypoint.py payload/service/app.py payload/service/engine.json payload/NOTICE payload/licenses/guessit-LICENSE.txt payload/licenses/flask-LICENSE.txt payload/licenses/gunicorn-LICENSE.txt; do [[ -f "$component_root/$required" ]] || die "Title archive is missing $required"; done fi - cp "$archive" "$INSTALL_DIR/components/$component.tar.zst" + cp "$archive_tmp" "$archive.tmp" + printf '%s\n' "$version" >"$marker_version.tmp" + printf '%s\n' "$sha" >"$marker_sha.tmp" + mv -f "$archive.tmp" "$archive"; mv -f "$marker_version.tmp" "$marker_version"; mv -f "$marker_sha.tmp" "$marker_sha" done fi CONTROLLER_URL="$CONTROLLER_URL" XIAOYA_CONTAINER="$XIAOYA_CONTAINER" PG_HOST="$PG_HOST" PG_PORT="$PG_PORT" PG_DB="$PG_DB" PG_USER="$PG_USER" python3 - "$CONFIG_TEMPLATE" "$INSTALL_DIR/config.yaml" <<'PY' @@ -1245,10 +1394,6 @@ run_migration() { set +e run_migration migration_rc=$? -printf '数据库迁移退出码:%s\n' "$migration_rc" -printf '数据库迁移退出码:%s\n' "$migration_rc" >>"$INSTALL_LOG" -printf '[安装流程] 已完成数据库迁移,继续启动应用\n' -printf '[安装流程] 已完成数据库迁移,继续启动应用\n' >>"$INSTALL_LOG" if [[ $migration_rc -ne 0 ]]; then exit_code=1 persist_installer_log || true @@ -1259,10 +1404,28 @@ if [[ $migration_rc -ne 0 ]]; then fi exit "$exit_code" fi +printf '[安装流程] 已完成数据库迁移,继续启动应用\n' +printf '[安装流程] 已完成数据库迁移,继续启动应用\n' >>"$INSTALL_LOG" printf '数据库初始化完成\n' printf '数据库初始化完成\n' >>"$INSTALL_LOG" +bootstrap_local_controller() { + [[ $MENU_PROFILES == *xiaoya-control* ]] || return 0 + (( REMOTE_MODE == 0 )) || return 0 + printf '正在将本机小雅 Controller 同步到管理平台数据库...\n' + printf '正在将本机小雅 Controller 同步到管理平台数据库...\n' >>"$INSTALL_LOG" + if ! run_visible_logged "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" run --rm -T --no-deps app-migrate bootstrap-controller --config /app/config.yaml &2 + return 1 + fi +} +if bootstrap_local_controller; then + : +else + persist_installer_log || true + exit 1 +fi progress '启动 XyMediaVault 服务' || true -start_app_after_migration "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" up -d app +start_app_after_migration "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" up -d --force-recreate app app_start_rc=$? if (( app_start_rc != 0 )); then exit_code=1 @@ -1305,24 +1468,13 @@ else persist_installer_log || true exit 1 fi -bootstrap_local_controller() { - [[ $MENU_PROFILES == *xiaoya-control* ]] || return 0 - (( REMOTE_MODE == 0 )) || return 0 - printf '正在将本机小雅 Controller 同步到管理平台数据库...\n' - if ! run_visible_logged docker exec xymedia-app /releases/current/bin/xymediavault bootstrap-controller --config /app/config.yaml; then - printf '本机小雅 Controller 数据库同步失败,安装未完成。\n' >&2 - return 1 - fi -} -if bootstrap_local_controller; then - : -else - persist_installer_log || true - exit 1 -fi +if (( RELEASE_ROLLBACK_ACTIVE )); then rm -rf -- "$release_backup"; RELEASE_ROLLBACK_ACTIVE=0; fi cp "$INSTALL_LOG" "$INSTALL_DIR/install.log" 2>/dev/null || true printf '\033[2K\r[%d/%d] 安装完成:XyMediaVault 已就绪\n' "$TOTAL_STEPS" "$TOTAL_STEPS" - printf '管理地址:http://127.0.0.1:%s\n' "$API_PORT" + management_address=$(printf '%s\n' "$HOST_ADDRESSES_INITIAL" | head -n 1) + [[ -n $management_address ]] || management_address=127.0.0.1 + if [[ $management_address == *:* ]]; then management_address="[$management_address]"; fi + printf '管理地址:http://%s:%s\n' "$management_address" "$API_PORT" if [[ $MENU_PROFILES == *xiaoya-control* ]]; then if print_controller_info; then :