From f0f100cc990a6c0fcc6fba4245d48772536f61c1 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 30 Aug 2026 10:44:48 +0000 Subject: [PATCH] feat: add one-click installer --- Dockerfile.bootstrap | 6 ++ README.md | 8 ++- compose.yaml | 52 +++++++++++++++ config.yaml | 33 ++++++++++ install.sh | 152 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 Dockerfile.bootstrap create mode 100644 compose.yaml create mode 100644 config.yaml create mode 100644 install.sh diff --git a/Dockerfile.bootstrap b/Dockerfile.bootstrap new file mode 100644 index 0000000..17de056 --- /dev/null +++ b/Dockerfile.bootstrap @@ -0,0 +1,6 @@ +FROM debian:bookworm-slim +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends tini openjdk-17-jre-headless fuse3 zstd curl ca-certificates bash procps && rm -rf /var/lib/apt/lists/* +WORKDIR /app +ENTRYPOINT ["/usr/bin/tini", "--", "/releases/current/bin/xymedia-supervisor"] diff --git a/README.md b/README.md index 81b11cf..74d7859 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ -# xymediavault-releases +# XyMediaVault public installer -Public XyMediaVault release catalog and downloadable assets \ No newline at end of file +Run `sudo bash install.sh` on a Linux host with Docker and Compose. The installer downloads `catalog-v1.json` and the public Generic Package assets over HTTPS, selects `linux-amd64` or `linux-arm64`, safely extracts the app archive, and stores TMM and Title archives in the local component directory. + +The app archive must contain `bin/xymediavault`, `bin/xymedia-supervisor`, `web/dist`, and `release.json`. The installer rejects legacy app packages missing the supervisor and waits for the current public catalog entry containing this complete layout. + +Options include `--install-dir`, PostgreSQL connection options, `--postgres-password-file`, `--skip-components`, and `--existing-db`. An external database requires `--existing-db`; the migration command is explicit and never runs against an unconfirmed external database. No source repository, token, signature, or release hash is used by this v1 installer. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..b164b7e --- /dev/null +++ b/compose.yaml @@ -0,0 +1,52 @@ +services: + postgres: + image: postgres:17-bookworm + profiles: [local-db] + environment: + POSTGRES_DB: ${XYMEDIA_POSTGRES_DATABASE} + POSTGRES_USER: ${XYMEDIA_POSTGRES_USER} + POSTGRES_PASSWORD_FILE: /run/secrets/xymedia-postgres-password + secrets: [xymedia-postgres-password] + volumes: ["${XYMEDIA_INSTALL_DIR}/data/postgres:/var/lib/postgresql/data"] + healthcheck: + test: [CMD-SHELL, "pg_isready -U ${XYMEDIA_POSTGRES_USER} -d ${XYMEDIA_POSTGRES_DATABASE}"] + interval: 5s + timeout: 5s + retries: 24 + app: + build: {context: ., dockerfile: Dockerfile.bootstrap} + image: xymediavault-bootstrap:local + environment: + XYMEDIA_POSTGRES_MODE: external + XYMEDIA_COMPONENT_RUNTIME: local + XYMEDIA_WEB_DIR: /releases/current/web/dist + ports: + - "${XYMEDIA_API_PORT:-18080}:8080" + - "${XYMEDIA_WEBDAV_PORT:-18081}:8081" + - "${XYMEDIA_TVBOX_PORT:-18082}:8082" + volumes: + - "${XYMEDIA_INSTALL_DIR}/data:/app/data" + - "${XYMEDIA_INSTALL_DIR}/components:/app/components" + - "${XYMEDIA_INSTALL_DIR}/releases:/releases" + - "${XYMEDIA_INSTALL_DIR}/config.yaml:/app/config.yaml:ro" + secrets: [xymedia-postgres-password] + restart: unless-stopped + healthcheck: + test: [CMD, /releases/current/bin/xymediavault, healthcheck, --url, http://127.0.0.1:8080/api/health] + app-migrate: + image: xymediavault-bootstrap:local + entrypoint: [/releases/current/bin/xymediavault] + command: [migrate, --new, --config, /app/config.yaml] + environment: + XYMEDIA_POSTGRES_MODE: external + XYMEDIA_EXTERNAL_MIGRATION_CONFIRM: ${XYMEDIA_EXTERNAL_MIGRATION_CONFIRM:-YES} + volumes: + - "${XYMEDIA_INSTALL_DIR}/data:/app/data" + - "${XYMEDIA_INSTALL_DIR}/releases:/releases:ro" + - "${XYMEDIA_INSTALL_DIR}/config.yaml:/app/config.yaml:ro" + secrets: [xymedia-postgres-password] + profiles: [migration] + restart: "no" +secrets: + xymedia-postgres-password: + file: ${XYMEDIA_INSTALL_DIR}/secrets/postgres-password diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..7e4e8d3 --- /dev/null +++ b/config.yaml @@ -0,0 +1,33 @@ +server: + host: 0.0.0.0 + port: 8080 + web_dir: /releases/current/web/dist +database: + mode: external + driver: postgres + host: ${XYMEDIA_POSTGRES_HOST} + port: ${XYMEDIA_POSTGRES_PORT} + name: ${XYMEDIA_POSTGRES_DATABASE} + user: ${XYMEDIA_POSTGRES_USER} + password_file: /run/secrets/xymedia-postgres-password + sslmode: disable +virtual: + enable_remote_file_mapping: false +webdav: + enabled: true + listen_addr: 0.0.0.0 + port: 8081 + public_port: 18081 + base_path: /dav + read_only: true +tvbox: + enabled: true + listen_addr: 0.0.0.0 + port: 8082 + public_port: 18082 + token_key_file: /app/data/tvbox-token.key +fuse: + auto_mount: false + mount_path: /mnt/xymediavault +cache: + directory: /app/data/cache diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..155f534 --- /dev/null +++ b/install.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +set -euo pipefail + +CATALOG_URL=${XYMEDIA_CATALOG_URL:-https://git.keeper.work/admin/xymediavault-releases/raw/branch/main/catalog-v1.json} +SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd -P) +INSTALL_DIR=/opt/xymediavault +PG_HOST=postgres +PG_PORT=5432 +PG_DB=xymedia +PG_USER=xymedia_app +PG_PASSWORD_FILE= +SKIP_COMPONENTS=0 +EXISTING_DB=0 +API_PORT=${XYMEDIA_API_PORT:-18080} +WEBDAV_PORT=${XYMEDIA_WEBDAV_PORT:-18081} +TVBOX_PORT=${XYMEDIA_TVBOX_PORT:-18082} +die() { printf 'xymediavault installer: %s\n' "$*" >&2; exit 1; } +usage() { sed -n '1,20p' "$0"; } +while (($#)); do + case "$1" in + --install-dir) INSTALL_DIR=${2:?missing value}; shift 2;; + --postgres-host) PG_HOST=${2:?missing value}; shift 2;; + --postgres-port) PG_PORT=${2:?missing value}; shift 2;; + --postgres-db) PG_DB=${2:?missing value}; shift 2;; + --postgres-user) PG_USER=${2:?missing value}; shift 2;; + --postgres-password-file) PG_PASSWORD_FILE=${2:?missing value}; shift 2;; + --skip-components) SKIP_COMPONENTS=1; shift;; + --existing-db) EXISTING_DB=1; shift;; + -h|--help) usage; exit 0;; + *) die "unknown option: $1";; + esac +done +[[ $INSTALL_DIR = /* && $INSTALL_DIR != / ]] || die 'install directory must be an absolute non-root path' +command -v docker >/dev/null || die 'Docker is required' +if docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose); elif command -v docker-compose >/dev/null; then COMPOSE=(docker-compose); else die 'Docker Compose is required'; fi +docker info >/dev/null 2>&1 || die 'cannot connect to Docker' +command -v curl >/dev/null || die 'curl is required' +command -v zstd >/dev/null || die 'zstd is required for release archives' +command -v python3 >/dev/null || die 'python3 is required for safe archive extraction' +if [[ $PG_HOST != postgres && $EXISTING_DB != 1 ]]; then die 'an external database requires --existing-db confirmation'; fi +if ! [[ $API_PORT =~ ^[0-9]+$ && $WEBDAV_PORT =~ ^[0-9]+$ && $TVBOX_PORT =~ ^[0-9]+$ ]]; then die 'ports must be numeric'; fi +if ! [[ $PG_PORT =~ ^[0-9]+$ && $PG_DB =~ ^[A-Za-z0-9_]+$ && $PG_USER =~ ^[A-Za-z0-9_]+$ && $PG_HOST =~ ^[A-Za-z0-9._-]+$ ]]; then die 'invalid PostgreSQL connection values'; fi +mkdir -p "$INSTALL_DIR" "$INSTALL_DIR"/{data/postgres,data,state,components,releases,secrets,config} +chmod 700 "$INSTALL_DIR/secrets" +if [[ -n $PG_PASSWORD_FILE ]]; then + [[ -r $PG_PASSWORD_FILE ]] || die 'password file is not readable' + if [[ "$PG_PASSWORD_FILE" != "$INSTALL_DIR/secrets/postgres-password" ]]; then + cp "$PG_PASSWORD_FILE" "$INSTALL_DIR/secrets/postgres-password" + PG_PASSWORD_FILE="$INSTALL_DIR/secrets/postgres-password" + fi +else + PG_PASSWORD_FILE="$INSTALL_DIR/secrets/postgres-password" + if [[ ! -s $PG_PASSWORD_FILE ]]; then + umask 077 + if command -v openssl >/dev/null; then openssl rand -hex 32 >"$PG_PASSWORD_FILE"; else od -An -N32 -tx1 /dev/urandom | tr -d ' \n' >"$PG_PASSWORD_FILE"; fi + fi +fi +chmod 600 "$PG_PASSWORD_FILE" +umask 077 +printf 'XYMEDIA_INSTALL_DIR=%s\nXYMEDIA_POSTGRES_HOST=%s\nXYMEDIA_POSTGRES_PORT=%s\nXYMEDIA_POSTGRES_DATABASE=%s\nXYMEDIA_POSTGRES_USER=%s\nXYMEDIA_API_PORT=%s\nXYMEDIA_WEBDAV_PORT=%s\nXYMEDIA_TVBOX_PORT=%s\n' "$INSTALL_DIR" "$PG_HOST" "$PG_PORT" "$PG_DB" "$PG_USER" "$API_PORT" "$WEBDAV_PORT" "$TVBOX_PORT" >"$INSTALL_DIR/.env" +curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 "$CATALOG_URL" -o "$INSTALL_DIR/catalog-v1.json" +python3 - "$INSTALL_DIR/catalog-v1.json" <<'PY' || die 'invalid public catalog' +import json, sys +catalog = json.load(open(sys.argv[1])) +assert catalog.get("schema_version") == 1 +for component in ("app", "tmm", "title"): + assert isinstance(catalog.get("components", {}).get(component, {}).get("artifacts"), dict) +PY +case "$(uname -m)" in x86_64|amd64) PLATFORM=linux-amd64;; aarch64|arm64) PLATFORM=linux-arm64;; *) die "unsupported host architecture: $(uname -m)";; esac +artifact() { + python3 - "$INSTALL_DIR/catalog-v1.json" "$1" "$2" <<'PY' +import json, sys +catalog, component, platform = sys.argv[1:] +artifacts = json.load(open(catalog))["components"][component]["artifacts"] +entry = artifacts.get(platform) or artifacts.get("linux-any") +if not isinstance(entry, dict) or not isinstance(entry.get("version"), str) or not isinstance(entry.get("asset_url"), str): + raise SystemExit(1) +print(entry["version"] + "\t" + entry["asset_url"]) +PY +} +download_extract() { + local component=$1 version=$2 url=$3 target=$4 archive="$INSTALL_DIR/state/$component-$version.archive" + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 "$url" -o "$archive" + local tarball="$archive.tar" + if [[ $url == *.tar.gz || $url == *.tgz ]]; then gzip -dc "$archive" >"$tarball"; else zstd -dc "$archive" >"$tarball"; fi + python3 - "$tarball" "$target" <<'PY' +import pathlib, sys, tarfile +archive, target = sys.argv[1:] +pathlib.Path(target).mkdir(parents=True, exist_ok=True) +with tarfile.open(archive, 'r:*') as tf: + members = tf.getmembers() + for m in members: + name = m.name.lstrip('./') + if not name or name.startswith('/') or '..' in pathlib.PurePosixPath(name).parts: + raise SystemExit('archive contains traversal path') + 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) +PY + rm -f "$tarball" +} +IFS=$'\t' read -r APP_VERSION APP_URL < <(artifact app "$PLATFORM") +APP_STAGE="$INSTALL_DIR/state/app-$APP_VERSION" +download_extract app "$APP_VERSION" "$APP_URL" "$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" +mkdir -p "$INSTALL_DIR/releases/releases" +[[ ! -e "$INSTALL_DIR/releases/releases/$APP_VERSION" ]] && mv "$APP_ROOT" "$INSTALL_DIR/releases/releases/$APP_VERSION" +ln -sfn "releases/$APP_VERSION" "$INSTALL_DIR/releases/current" +if (( ! SKIP_COMPONENTS )); then + 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=$(find "$INSTALL_DIR/state/$component-$version" -mindepth 1 -maxdepth 1 -type d | head -n 1) + [[ -n $component_root ]] || die "$component archive has no root directory" + if [[ $component == tmm ]]; then + [[ -f "$component_root/payload/xymedia-api.jar" ]] || die 'TMM archive is missing payload/xymedia-api.jar' + compgen -G "$component_root/payload/lib/*" >/dev/null || die 'TMM archive is missing payload/lib files' + 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" + done +fi +PG_HOST="$PG_HOST" PG_PORT="$PG_PORT" PG_DB="$PG_DB" PG_USER="$PG_USER" python3 - "$SCRIPT_DIR/config.yaml" "$INSTALL_DIR/config.yaml" <<'PY' +import os, pathlib, sys +template, output = map(pathlib.Path, sys.argv[1:]) +values = { + "${XYMEDIA_POSTGRES_HOST}": os.environ["PG_HOST"], + "${XYMEDIA_POSTGRES_PORT}": os.environ["PG_PORT"], + "${XYMEDIA_POSTGRES_DATABASE}": os.environ["PG_DB"], + "${XYMEDIA_POSTGRES_USER}": os.environ["PG_USER"], +} +content = template.read_text() +for placeholder, value in values.items(): + content = content.replace(placeholder, value) +output.write_text(content) +PY +cp "$SCRIPT_DIR/compose.yaml" "$SCRIPT_DIR/Dockerfile.bootstrap" "$INSTALL_DIR/" +if [[ ! -f "$INSTALL_DIR/.bootstrap-dockerfile.sha256" || "$(sha256sum "$INSTALL_DIR/Dockerfile.bootstrap" | cut -d' ' -f1)" != "$(<"$INSTALL_DIR/.bootstrap-dockerfile.sha256")" ]]; then + "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" build app + sha256sum "$INSTALL_DIR/Dockerfile.bootstrap" | cut -d' ' -f1 >"$INSTALL_DIR/.bootstrap-dockerfile.sha256" +fi +if [[ $PG_HOST == postgres ]]; then "${COMPOSE[@]}" --profile local-db --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d postgres; fi +if [[ $PG_HOST == postgres ]]; then for _ in {1..60}; do "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" exec -T postgres pg_isready -U "$PG_USER" -d "$PG_DB" >/dev/null 2>&1 && break; sleep 2; done; fi +if (( EXISTING_DB )); then export XYMEDIA_EXTERNAL_MIGRATION_CONFIRM=YES; "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" run --rm --no-deps app-migrate migrate --config /app/config.yaml; else "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" run --rm --no-deps app-migrate migrate --new --config /app/config.yaml; fi +"${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d app +for _ in {1..60}; do curl --fail --silent "http://127.0.0.1:$API_PORT/api/health" >/dev/null && printf 'XyMediaVault is ready at http://127.0.0.1:%s\n' "$API_PORT" && exit 0; sleep 2; done