diff --git a/.github/workflows/sync-docker-installer.yml b/.github/workflows/sync-docker-installer.yml new file mode 100644 index 00000000..eb042150 --- /dev/null +++ b/.github/workflows/sync-docker-installer.yml @@ -0,0 +1,61 @@ +name: Sync Docker installer mirror + +on: + push: + branches: + - main + paths: + - "apps/site/public/install.sh" + - "apps/site/public/compose.yaml" + - ".github/workflows/sync-docker-installer.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + sync: + if: github.repository == 'tianma-if/edgeever' + name: Publish Tencent COS mirror + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Tencent COSCMD + run: | + python3 -m venv "${RUNNER_TEMP}/coscmd" + "${RUNNER_TEMP}/coscmd/bin/pip" install --disable-pip-version-check 'coscmd==1.9.0.6' + echo "${RUNNER_TEMP}/coscmd/bin" >> "${GITHUB_PATH}" + + - name: Configure COSCMD + env: + COS_BUCKET: ${{ vars.TENCENT_COS_INSTALLER_BUCKET }} + COS_REGION: ${{ vars.TENCENT_COS_INSTALLER_REGION }} + COS_SECRET_ID: ${{ secrets.TENCENT_COS_INSTALLER_SECRET_ID }} + COS_SECRET_KEY: ${{ secrets.TENCENT_COS_INSTALLER_SECRET_KEY }} + run: | + test -n "${COS_BUCKET}" + test -n "${COS_REGION}" + test -n "${COS_SECRET_ID}" + test -n "${COS_SECRET_KEY}" + coscmd config \ + -a "${COS_SECRET_ID}" \ + -s "${COS_SECRET_KEY}" \ + -b "${COS_BUCKET}" \ + -r "${COS_REGION}" + + - name: Upload installer files + run: | + coscmd upload -f apps/site/public/install.sh install.sh + coscmd upload -f apps/site/public/compose.yaml compose.yaml + + - name: Verify public mirror + env: + COS_BUCKET: ${{ vars.TENCENT_COS_INSTALLER_BUCKET }} + COS_REGION: ${{ vars.TENCENT_COS_INSTALLER_REGION }} + run: | + base_url="https://${COS_BUCKET}.cos.${COS_REGION}.myqcloud.com" + curl --fail --silent --show-error "${base_url}/install.sh" | cmp apps/site/public/install.sh - + curl --fail --silent --show-error "${base_url}/compose.yaml" | cmp apps/site/public/compose.yaml - diff --git a/README.md b/README.md index 0187c537..098f587c 100644 --- a/README.md +++ b/README.md @@ -95,12 +95,16 @@ Complete setup in 5 simple web steps: ### Option C: Docker on a VPS or NAS ```sh -export EDGE_EVER_VERSION=vX.Y.Z -export EDGE_EVER_AUTH_PASSWORD='replace-with-a-long-random-password' -docker compose up -d +curl -fsSL https://edgeever.org/install.sh | bash ``` -Docker persists SQLite and local attachments in `/data` and supports S3-compatible storage. Users in mainland China can select the Tencent Cloud mirror with `EDGE_EVER_IMAGE=ccr.ccs.tencentyun.com/edgeever/edgeever`. See the [Docker deployment guide](docs/deploy-docker.md) for details. +The script pulls the latest image, generates an administrator password, and starts EdgeEver with Docker Compose. Users in mainland China can use the Tencent COS and TCR mirrors: + +```sh +curl -fsSL https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/install.sh | bash -s -- --mirror tcr +``` + +See the [Docker deployment guide](docs/deploy-docker.md) for manual deployment and configuration. --- diff --git a/README.zh-CN.md b/README.zh-CN.md index a370f961..24e3dcf4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -95,12 +95,10 @@ Cloudflare 在线部署可以选择以下两种方式之一: ### 方案三:在 VPS 或 NAS 上使用 Docker ```sh -export EDGE_EVER_VERSION=vX.Y.Z -export EDGE_EVER_AUTH_PASSWORD='请替换为足够长的随机密码' -docker compose up -d +curl -fsSL https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/install.sh | bash -s -- --mirror tcr ``` -Docker 将 SQLite 与本地附件持久化到 `/data`,并支持 S3 兼容存储。中国大陆用户可通过 `EDGE_EVER_IMAGE=ccr.ccs.tencentyun.com/edgeever/edgeever` 选择腾讯云镜像。详见 [Docker 部署文档](docs/deploy-docker.zh-CN.md)。 +脚本和 Compose 配置由腾讯云 COS 提供中国大陆入口,并自动从腾讯云 TCR 拉取最新镜像、生成管理员密码及启动 EdgeEver。手动部署与配置说明见 [Docker 部署文档](docs/deploy-docker.zh-CN.md)。 --- diff --git a/apps/site/public/compose.yaml b/apps/site/public/compose.yaml new file mode 100644 index 00000000..bdb417a9 --- /dev/null +++ b/apps/site/public/compose.yaml @@ -0,0 +1,20 @@ +services: + edgeever: + image: "${EDGE_EVER_IMAGE:-ghcr.io/tianma-if/edgeever}:${EDGE_EVER_VERSION:-latest}" + restart: unless-stopped + init: true + ports: + - "${EDGE_EVER_PORT:-8787}:8787" + environment: + EDGE_EVER_AUTH_USERNAME: "${EDGE_EVER_AUTH_USERNAME:-admin}" + EDGE_EVER_AUTH_PASSWORD: "${EDGE_EVER_AUTH_PASSWORD:?Set EDGE_EVER_AUTH_PASSWORD before starting EdgeEver}" + volumes: + - edgeever-data:/data + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + stop_grace_period: 30s + +volumes: + edgeever-data: diff --git a/apps/site/public/install.sh b/apps/site/public/install.sh new file mode 100755 index 00000000..8c9f0e44 --- /dev/null +++ b/apps/site/public/install.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail +umask 077 + +readonly GHCR_IMAGE="ghcr.io/tianma-if/edgeever" +readonly TCR_IMAGE="ccr.ccs.tencentyun.com/edgeever/edgeever" +readonly GLOBAL_COMPOSE_URL="https://edgeever.org/compose.yaml" +readonly TENCENT_COMPOSE_URL="https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/compose.yaml" + +install_dir="${EDGE_EVER_INSTALL_DIR:-${HOME:-}/edgeever}" +compose_url="${EDGE_EVER_COMPOSE_URL:-}" +image="${EDGE_EVER_IMAGE:-}" +version="${EDGE_EVER_VERSION:-}" +port="${EDGE_EVER_PORT:-}" +username="${EDGE_EVER_AUTH_USERNAME:-}" +password="${EDGE_EVER_AUTH_PASSWORD:-}" +project_name="${EDGE_EVER_PROJECT_NAME:-edgeever}" +generated_password=false +temporary_compose="" +temporary_env="" + +usage() { + cat <<'EOF' +Install or upgrade EdgeEver with Docker Compose. + +Usage: + curl -fsSL https://edgeever.org/install.sh | bash + curl -fsSL https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/install.sh | bash -s -- --mirror tcr + +Options: + --mirror ghcr|tcr Select the image registry (default: ghcr) + --image IMAGE Use a custom image repository + --version TAG Deploy an image tag (default: latest) + --compose-url URL Download Compose configuration from URL + --install-dir DIR Store Compose configuration in DIR (default: ~/edgeever) + --port PORT Publish EdgeEver on PORT (default: 8787) + -h, --help Show this help + +The same options can be provided with EDGE_EVER_IMAGE, EDGE_EVER_VERSION, +EDGE_EVER_COMPOSE_URL, EDGE_EVER_INSTALL_DIR, EDGE_EVER_PORT, and +EDGE_EVER_AUTH_PASSWORD. +EOF +} + +fail() { + printf 'EdgeEver installer: %s\n' "$*" >&2 + exit 1 +} + +on_error() { + local exit_code=$? + printf 'EdgeEver installer: command failed at line %s\n' "${BASH_LINENO[0]}" >&2 + exit "$exit_code" +} + +trap on_error ERR + +require_value() { + [[ $# -ge 2 && -n "$2" ]] || fail "$1 requires a value" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --mirror) + require_value "$@" + case "$2" in + ghcr) image="$GHCR_IMAGE" ;; + tcr) image="$TCR_IMAGE" ;; + *) fail "--mirror must be ghcr or tcr" ;; + esac + shift 2 + ;; + --image) + require_value "$@" + image="$2" + shift 2 + ;; + --version) + require_value "$@" + version="$2" + shift 2 + ;; + --compose-url) + require_value "$@" + compose_url="$2" + shift 2 + ;; + --install-dir) + require_value "$@" + install_dir="$2" + shift 2 + ;; + --port) + require_value "$@" + port="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) fail "unknown option: $1" ;; + esac +done + +[[ -n "$install_dir" ]] || fail "set HOME or EDGE_EVER_INSTALL_DIR" +[[ "$project_name" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || fail "invalid EDGE_EVER_PROJECT_NAME" + +command -v curl >/dev/null 2>&1 || fail "curl is required" +command -v docker >/dev/null 2>&1 || fail "Docker is required: https://docs.docker.com/engine/install/" +docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 is required" +docker info >/dev/null 2>&1 || fail "cannot connect to the Docker daemon" + +mkdir -p "$install_dir" +env_file="$install_dir/.env" +compose_file="$install_dir/compose.yaml" + +read_env_value() { + local key="$1" + local line value + [[ -f "$env_file" ]] || return 1 + while IFS= read -r line; do + case "$line" in + "$key="*) + value="${line#*=}" + if [[ "$value" == "'"*"'" && ${#value} -ge 2 ]]; then + value="${value:1:${#value}-2}" + fi + printf '%s' "$value" + return 0 + ;; + esac + done < "$env_file" + return 1 +} + +if [[ -z "$image" ]]; then + image="$(read_env_value EDGE_EVER_IMAGE || true)" + image="${image:-$GHCR_IMAGE}" +fi + +if [[ -z "$compose_url" ]]; then + if [[ "$image" == "$TCR_IMAGE" ]]; then + compose_url="$TENCENT_COMPOSE_URL" + else + compose_url="$GLOBAL_COMPOSE_URL" + fi +fi + +if [[ -z "$version" ]]; then + version="$(read_env_value EDGE_EVER_VERSION || true)" + version="${version:-latest}" +fi + +if [[ -z "$port" ]]; then + port="$(read_env_value EDGE_EVER_PORT || true)" + port="${port:-8787}" +fi + +if [[ -z "$username" ]]; then + username="$(read_env_value EDGE_EVER_AUTH_USERNAME || true)" + username="${username:-admin}" +fi + +if [[ -z "$password" ]]; then + password="$(read_env_value EDGE_EVER_AUTH_PASSWORD || true)" +fi + +if [[ -z "$password" ]]; then + if command -v openssl >/dev/null 2>&1; then + password="$(openssl rand -hex 16)" + elif command -v od >/dev/null 2>&1; then + password="$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')" + else + fail "openssl or od is required to generate a password" + fi + generated_password=true +fi + +[[ "$image" != *[[:space:]]* ]] || fail "image must not contain whitespace" +[[ "$version" != *[[:space:]]* ]] || fail "version must not contain whitespace" +[[ "$port" =~ ^[0-9]+$ ]] && ((port >= 1 && port <= 65535)) || fail "port must be between 1 and 65535" +[[ "$username" != *$'\n'* && "$username" != *"'"* ]] || fail "invalid administrator username" +[[ "$password" != *$'\n'* && "$password" != *"'"* ]] || fail "password must not contain a newline or single quote" + +cleanup() { + [[ -z "$temporary_compose" || ! -e "$temporary_compose" ]] || rm -f "$temporary_compose" + [[ -z "$temporary_env" || ! -e "$temporary_env" ]] || rm -f "$temporary_env" + return 0 +} +trap cleanup EXIT + +temporary_compose="$(mktemp "$install_dir/.compose.yaml.XXXXXX")" +curl --fail --silent --show-error --location --output "$temporary_compose" "$compose_url" +grep -q '^services:' "$temporary_compose" || fail "downloaded Compose file is invalid" +chmod 0644 "$temporary_compose" +mv "$temporary_compose" "$compose_file" +temporary_compose="" + +temporary_env="$(mktemp "$install_dir/.env.XXXXXX")" +{ + printf "EDGE_EVER_IMAGE='%s'\n" "$image" + printf "EDGE_EVER_VERSION='%s'\n" "$version" + printf "EDGE_EVER_PORT='%s'\n" "$port" + printf "EDGE_EVER_AUTH_USERNAME='%s'\n" "$username" + printf "EDGE_EVER_AUTH_PASSWORD='%s'\n" "$password" +} > "$temporary_env" +chmod 0600 "$temporary_env" +mv "$temporary_env" "$env_file" +temporary_env="" + +compose=( + docker compose + --project-name "$project_name" + --project-directory "$install_dir" + --env-file "$env_file" + --file "$compose_file" +) + +printf 'Pulling %s:%s...\n' "$image" "$version" +"${compose[@]}" pull +"${compose[@]}" up -d --remove-orphans + +container_id="" +health="" +for _ in {1..60}; do + container_id="$("${compose[@]}" ps -q edgeever 2>/dev/null || true)" + if [[ -n "$container_id" ]]; then + health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || true)" + [[ "$health" == "healthy" ]] && break + [[ "$health" == "exited" || "$health" == "dead" ]] && break + fi + sleep 2 +done + +if [[ "$health" != "healthy" ]]; then + "${compose[@]}" logs --tail 80 edgeever >&2 || true + fail "container did not become healthy (status: ${health:-unknown})" +fi + +printf '\nEdgeEver is ready.\n' +printf 'URL: http://:%s\n' "$port" +printf 'Username: %s\n' "$username" +if [[ "$generated_password" == true ]]; then + printf 'Password: %s\n' "$password" +else + printf 'Password: unchanged (stored in %s)\n' "$env_file" +fi +printf 'Install directory: %s\n' "$install_dir" diff --git a/docs/deploy-docker.md b/docs/deploy-docker.md index 7ca6762f..b5f4bd14 100644 --- a/docs/deploy-docker.md +++ b/docs/deploy-docker.md @@ -13,7 +13,30 @@ Cloudflare uses Workers with D1 and R2. - A reverse proxy with HTTPS when the instance is reachable outside a trusted local network. -## Start with Compose +## One-command install + +With Docker Compose v2 already installed: + +```sh +curl -fsSL https://edgeever.org/install.sh | bash +``` + +Use Tencent Cloud TCR in mainland China: + +```sh +curl -fsSL https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/install.sh | bash -s -- --mirror tcr +``` + +The installer creates `~/edgeever`, generates an administrator password, pulls +`latest`, starts the container, and waits for it to become healthy. Run the same +command again to upgrade without replacing the password or `/data` volume. The +mainland command downloads the installer and Compose configuration from Tencent +COS and pulls the image from Tencent TCR. + +Use `--version vX.Y.Z`, `--port PORT`, or `--install-dir DIR` when needed. Run +`curl -fsSL https://edgeever.org/install.sh | bash -s -- --help` for all options. + +## Manual Compose Download `compose.yaml`, choose the release you want to run, and provide a strong instance password: diff --git a/docs/deploy-docker.zh-CN.md b/docs/deploy-docker.zh-CN.md index b6ba9165..43255d87 100644 --- a/docs/deploy-docker.zh-CN.md +++ b/docs/deploy-docker.zh-CN.md @@ -11,7 +11,26 @@ Cloudflare 使用 Workers + D1 + R2。 - `amd64` 或 `arm64` Linux 主机。 - 实例离开可信局域网时,必须使用带 HTTPS 的反向代理。 -## 使用 Compose 启动 +## 一键安装 + +已安装 Docker Compose v2 的主机可直接执行: + +```sh +curl -fsSL https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/install.sh | bash -s -- --mirror tcr +``` + +脚本会创建 `~/edgeever`、生成管理员密码、拉取 `latest`、启动容器并等待健康 +检查通过。中国大陆命令从腾讯云 COS 获取脚本与 Compose 配置,并从腾讯云 TCR +拉取镜像。再次执行同一命令即可升级,已有密码和 `/data` 卷保持不变。 + +海外服务器可去掉 `--mirror tcr` 使用 GHCR。按需使用 `--version vX.Y.Z`、 +`--port PORT` 或 `--install-dir DIR`;执行以下命令可查看全部参数: + +```sh +curl -fsSL https://edgeever-installer-1256854452.cos.ap-guangzhou.myqcloud.com/install.sh | bash -s -- --help +``` + +## 手动使用 Compose 下载 `compose.yaml`,选择要运行的正式版本,并设置高强度实例密码: diff --git a/package.json b/package.json index 9d4b4e0e..086d1955 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dev:api:demo": "bun scripts/local-dev.mjs api demo", "dev:self-hosted": "bun --watch scripts/self-hosted-server.mjs", "start:self-hosted": "bun scripts/self-hosted-server.mjs", - "test:docker-release": "bun test scripts/docker-release.test.mjs scripts/build-cloudflare-worker.test.mjs tests/storage-adapter.test.ts", + "test:docker-release": "bun test scripts/docker-release.test.mjs scripts/docker-installer.test.mjs scripts/build-cloudflare-worker.test.mjs tests/storage-adapter.test.ts", "dev:remote": "concurrently -k -n api,web -c cyan,magenta \"bun run dev:api:remote\" \"bun run dev:web\"", "dev:api:remote": "bun scripts/run-wrangler.mjs dev --remote --port 8787", "dev:web": "vite --config apps/web/vite.config.ts --host 127.0.0.1 --port 5173", diff --git a/scripts/docker-installer.test.mjs b/scripts/docker-installer.test.mjs new file mode 100644 index 00000000..e9fb565c --- /dev/null +++ b/scripts/docker-installer.test.mjs @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +const projectRoot = resolve(import.meta.dir, ".."); +const installerPath = resolve(projectRoot, "apps/site/public/install.sh"); +const composePath = resolve(projectRoot, "compose.yaml"); +const hostedComposePath = resolve(projectRoot, "apps/site/public/compose.yaml"); + +describe("Docker installer", () => { + test("publishes the repository Compose file unchanged", async () => { + expect(await readFile(hostedComposePath, "utf8")).toBe(await readFile(composePath, "utf8")); + }); + + test("installs with TCR and preserves the generated password on rerun", async () => { + const fixture = await mkdtemp(resolve(tmpdir(), "edgeever-installer-")); + const fakeBin = resolve(fixture, "bin"); + const installDirectory = resolve(fixture, "edgeever"); + const dockerLog = resolve(fixture, "docker.log"); + const curlLog = resolve(fixture, "curl.log"); + const fakeDocker = resolve(fakeBin, "docker"); + const fakeCurl = resolve(fakeBin, "curl"); + + try { + await mkdir(fakeBin, { recursive: true }); + await writeFile( + fakeDocker, + `#!/usr/bin/env bash +set -eu +printf '%s\\n' "$*" >> "$EDGE_EVER_TEST_DOCKER_LOG" +if [[ "$1" == "inspect" ]]; then + printf 'healthy\\n' +elif [[ "$1" == "compose" && "$*" == *" ps -q edgeever"* ]]; then + printf 'edgeever-test-container\\n' +fi +`, + ); + await writeFile( + fakeCurl, + `#!/usr/bin/env bash +set -eu +printf '%s\n' "$*" >> "$EDGE_EVER_TEST_CURL_LOG" +output='' +while [[ $# -gt 0 ]]; do + if [[ "$1" == "--output" ]]; then + output="$2" + shift 2 + else + shift + fi +done +cp "$EDGE_EVER_TEST_COMPOSE_SOURCE" "$output" +`, + ); + await chmod(fakeDocker, 0o755); + await chmod(fakeCurl, 0o755); + + const environment = { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH}`, + EDGE_EVER_INSTALL_DIR: installDirectory, + EDGE_EVER_TEST_COMPOSE_SOURCE: composePath, + EDGE_EVER_TEST_CURL_LOG: curlLog, + EDGE_EVER_TEST_DOCKER_LOG: dockerLog, + }; + const first = spawnSync("bash", [installerPath, "--mirror", "tcr", "--port", "18789"], { + env: environment, + encoding: "utf8", + stdio: ["ignore", "ignore", "pipe"], + }); + if (first.status !== 0) { + const commands = await readFile(dockerLog, "utf8").catch(() => "no Docker commands"); + throw new Error( + `installer failed (exit=${first.status}, signal=${first.signal}):\n${first.stderr}\n${commands}`, + ); + } + expect(first.status).toBe(0); + + const firstEnvironment = await readFile(resolve(installDirectory, ".env"), "utf8"); + expect(firstEnvironment).toContain( + "EDGE_EVER_IMAGE='ccr.ccs.tencentyun.com/edgeever/edgeever'", + ); + expect(firstEnvironment).toContain("EDGE_EVER_VERSION='latest'"); + expect(firstEnvironment).toContain("EDGE_EVER_PORT='18789'"); + expect(firstEnvironment).toMatch(/EDGE_EVER_AUTH_PASSWORD='[a-f0-9]{32}'/); + + const second = spawnSync("bash", [installerPath], { + env: environment, + encoding: "utf8", + stdio: ["ignore", "ignore", "pipe"], + }); + if (second.status !== 0) { + throw new Error( + `installer rerun failed (exit=${second.status}, signal=${second.signal}):\n${second.stdout}\n${second.stderr}`, + ); + } + expect(second.status).toBe(0); + expect(await readFile(resolve(installDirectory, ".env"), "utf8")).toBe(firstEnvironment); + + const log = await readFile(dockerLog, "utf8"); + expect(log).toContain("compose version"); + expect(log.match(/ pull$/gm)).toHaveLength(2); + expect(log.match(/ up -d --remove-orphans$/gm)).toHaveLength(2); + expect(log).toContain("inspect --format"); + const curlRequests = await readFile(curlLog, "utf8"); + expect( + curlRequests.match( + /edgeever-installer-1256854452\.cos\.ap-guangzhou\.myqcloud\.com\/compose\.yaml/g, + ), + ).toHaveLength(2); + } finally { + await rm(fixture, { recursive: true, force: true }); + } + }); +});