diff --git a/apps/site/public/install.sh b/apps/site/public/install.sh index 976e28e0..d250754d 100755 --- a/apps/site/public/install.sh +++ b/apps/site/public/install.sh @@ -39,6 +39,69 @@ step() { log "STEP $1/6" "$2" } +print_storage_diagnostics() { + local container_id container_user mount_info mount_type mount_name mount_source probe_output + container_id="$("${compose[@]}" ps -q edgeever 2>/dev/null || true)" + container_user="" + mount_info="" + + if [[ -n "$container_id" ]]; then + container_user="$(docker inspect --format '{{.Config.User}}' "$container_id" 2>/dev/null || true)" + mount_info="$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Type}}|{{.Name}}|{{.Source}}{{end}}{{end}}' "$container_id" 2>/dev/null || true)" + fi + + log INFO "Persistent storage diagnostics:" + log INFO "Container user: ${container_user:-unknown} (EdgeEver expects UID/GID 1000:1000)" + if [[ -n "$mount_info" ]]; then + IFS='|' read -r mount_type mount_name mount_source <<< "$mount_info" + log INFO "/data mount: type=${mount_type:-unknown}, source=${mount_source:-unknown}${mount_name:+, volume=$mount_name}" + else + mount_type="" + mount_name="" + mount_source="" + log WARN "Could not identify the /data mount; inspect it with: docker inspect $container_id" + fi + + if probe_output="$("${compose[@]}" run --rm --no-deps --entrypoint /bin/sh edgeever -c ' +probe="/data/.edgeever-write-test-$$" +if (umask 077 && : > "$probe" && rm -f "$probe"); then + exit 0 +fi +printf "EDGEEVER_DATA_WRITE_FAILED\n" >&2 +printf "Runtime identity: " >&2 +id >&2 || true +printf "Data directory: " >&2 +ls -ldn /data >&2 || true +exit 73 +' 2>&1)"; then + log INFO "/data write test: passed" + return 0 + fi + + if [[ "$probe_output" != *"EDGEEVER_DATA_WRITE_FAILED"* ]]; then + log WARN "/data write test: unavailable (the diagnostic container could not run)" + [[ -z "$probe_output" ]] || printf '%s\n' "$probe_output" | sed 's/^/ /' >&2 + return 0 + fi + + log ERROR "/data write test: failed" + printf '%s\n' "$probe_output" | sed '/^EDGEEVER_DATA_WRITE_FAILED$/d; s/^/ /' >&2 + if [[ "$mount_type" == "bind" && -n "$mount_source" ]]; then + log INFO "The NAS/host directory must be writable by UID/GID 1000:1000. Suggested repair:" + printf ' sudo chown -R 1000:1000 -- %q\n' "$mount_source" >&2 + printf ' sudo chmod -R u+rwX -- %q\n' "$mount_source" >&2 + log INFO "If the NAS uses ACL permissions, grant UID 1000 read/write access in its control panel too." + elif [[ "$mount_type" == "volume" ]]; then + log INFO "The Docker volume must be writable by UID/GID 1000:1000. Suggested repair:" + printf ' cd %q\n' "$install_dir" >&2 + printf " docker compose --project-name %q run --rm --no-deps --user 0 --entrypoint /bin/sh edgeever -c 'chown -R 1000:1000 /data'\n" "$project_name" >&2 + else + log INFO "Check the Compose /data mount and grant UID/GID 1000:1000 read/write access." + printf ' cd %q\n' "$install_dir" >&2 + printf ' docker compose --project-name %q config\n' "$project_name" >&2 + fi +} + usage() { cat <<'EOF' Install or upgrade EdgeEver with Docker Compose. @@ -75,6 +138,7 @@ print_diagnostics() { "${compose[@]}" ps -a >&2 || true log INFO "Recent container logs:" "${compose[@]}" logs --tail 80 edgeever >&2 || true + print_storage_diagnostics || true log INFO "Troubleshooting commands:" printf ' cd %q\n' "$install_dir" >&2 printf ' docker compose --project-name %q --env-file .env --file compose.yaml ps -a\n' "$project_name" >&2 @@ -342,6 +406,67 @@ log() { printf '[%s] [EdgeEver update] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" } +print_storage_diagnostics() { + local container_id container_user mount_info mount_type mount_name mount_source probe_output + container_id="$("${compose[@]}" ps -q edgeever 2>/dev/null || true)" + container_user="" + mount_info="" + + if [[ -n "$container_id" ]]; then + container_user="$("$docker_command" inspect --format '{{.Config.User}}' "$container_id" 2>/dev/null || true)" + mount_info="$("$docker_command" inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Type}}|{{.Name}}|{{.Source}}{{end}}{{end}}' "$container_id" 2>/dev/null || true)" + fi + + log "Persistent storage diagnostics:" + log "Container user: ${container_user:-unknown} (EdgeEver expects UID/GID 1000:1000)." + if [[ -n "$mount_info" ]]; then + IFS='|' read -r mount_type mount_name mount_source <<< "$mount_info" + log "/data mount: type=${mount_type:-unknown}, source=${mount_source:-unknown}${mount_name:+, volume=$mount_name}." + else + mount_type="" + mount_name="" + mount_source="" + log "Could not identify the /data mount." + fi + + if probe_output="$("${compose[@]}" run --rm --no-deps --entrypoint /bin/sh edgeever -c ' +probe="/data/.edgeever-write-test-$$" +if (umask 077 && : > "$probe" && rm -f "$probe"); then + exit 0 +fi +printf "EDGEEVER_DATA_WRITE_FAILED\n" >&2 +printf "Runtime identity: " >&2 +id >&2 || true +printf "Data directory: " >&2 +ls -ldn /data >&2 || true +exit 73 +' 2>&1)"; then + log "/data write test: passed." + return 0 + fi + + if [[ "$probe_output" != *"EDGEEVER_DATA_WRITE_FAILED"* ]]; then + log "/data write test: unavailable (the diagnostic container could not run)." + [[ -z "$probe_output" ]] || printf '%s\n' "$probe_output" | sed 's/^/ /' + return 0 + fi + + log "/data write test: failed." + printf '%s\n' "$probe_output" | sed '/^EDGEEVER_DATA_WRITE_FAILED$/d; s/^/ /' + if [[ "$mount_type" == "bind" && -n "$mount_source" ]]; then + log "Grant the NAS/host directory to UID/GID 1000:1000:" + printf ' sudo chown -R 1000:1000 -- %q\n' "$mount_source" + printf ' sudo chmod -R u+rwX -- %q\n' "$mount_source" + log "For NAS ACLs, also grant UID 1000 read/write access in the control panel." + elif [[ "$mount_type" == "volume" ]]; then + log "Repair the Docker volume ownership:" + printf ' cd %q\n' "$install_dir" + printf " %q compose --project-name %q run --rm --no-deps --user 0 --entrypoint /bin/sh edgeever -c 'chown -R 1000:1000 /data'\n" "$docker_command" "$project_name" + else + log "Inspect the Compose /data mount and grant UID/GID 1000:1000 read/write access." + fi +} + on_error() { local exit_code=$? local line_number="${BASH_LINENO[0]}" @@ -350,6 +475,7 @@ on_error() { if declare -p compose >/dev/null 2>&1; then "${compose[@]}" ps -a || true "${compose[@]}" logs --tail 80 edgeever || true + print_storage_diagnostics || true fi exit "$exit_code" } diff --git a/docs/deploy-docker.md b/docs/deploy-docker.md index 00ab00b3..72e6c7ea 100644 --- a/docs/deploy-docker.md +++ b/docs/deploy-docker.md @@ -88,6 +88,11 @@ replacement is under `/data`: The image runs as the non-root `bun` user (UID/GID `1000`). If a NAS requires a host bind mount instead of the named volume, create the directory first and grant UID/GID `1000` read/write access. +When an installation or automatic update fails, the script performs a real +write test against `/data` and reports the mount type, source, container user, +and directory state. If permissions are the cause, it prints a repair command +tailored to a Docker named volume or NAS bind mount; it never changes existing +data permissions automatically. ## Configuration diff --git a/docs/deploy-docker.zh-CN.md b/docs/deploy-docker.zh-CN.md index d618a45c..35a60b72 100644 --- a/docs/deploy-docker.zh-CN.md +++ b/docs/deploy-docker.zh-CN.md @@ -75,6 +75,9 @@ Compose 会创建一个命名卷。所有需要在容器替换后保留的数据 镜像以非 root 的 `bun` 用户运行(UID/GID 均为 `1000`)。如果 NAS 必须使用主机 目录绑定而不是命名卷,请先创建目录,并为 UID/GID `1000` 授予读写权限。 +安装或自动更新失败时,脚本会对 `/data` 执行实际写入测试,输出挂载类型、来源、 +容器用户和目录状态。确认是权限问题后,还会根据 Docker 命名卷或 NAS 目录绑定 +给出对应的修复命令;脚本不会擅自修改现有数据的权限。 ## 配置 diff --git a/scripts/docker-installer.test.mjs b/scripts/docker-installer.test.mjs index 87444277..487d5fbc 100644 --- a/scripts/docker-installer.test.mjs +++ b/scripts/docker-installer.test.mjs @@ -192,6 +192,8 @@ fi const updaterSource = await readFile(resolve(installDirectory, "update.sh"), "utf8"); expect(updaterSource).toContain("Checking for updates"); expect(updaterSource).toContain("Update complete. Running image:"); + expect(updaterSource).toContain("Persistent storage diagnostics:"); + expect(updaterSource).toContain("/data write test: failed."); expect(updaterSource).not.toContain(generatedPassword); const installedCrontab = await readFile(crontabStore, "utf8"); expect(installedCrontab).toContain("17 4 * * *"); @@ -299,4 +301,83 @@ exit 1 await rm(fixture, { recursive: true, force: true }); } }); + + test("identifies a NAS bind-mount permission failure and prints a targeted repair", async () => { + const fixture = await mkdtemp(resolve(tmpdir(), "edgeever-installer-permission-")); + const fakeBin = resolve(fixture, "bin"); + const installDirectory = resolve(fixture, "edgeever"); + const nasDataDirectory = "/volume1/docker/edgeever data"; + 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 +if [[ "$1" == "version" ]]; then + printf '27.5.1\\n' +elif [[ "$1" == "compose" && "$2" == "version" && "\${3:-}" == "--short" ]]; then + printf '2.32.4\\n' +elif [[ "$1" == "compose" && "$*" == *" ps -q edgeever"* ]]; then + printf 'edgeever-permission-container\\n' +elif [[ "$1" == "inspect" && "$*" == *".Config.User"* ]]; then + printf 'bun\\n' +elif [[ "$1" == "inspect" && "$*" == *".Mounts"* ]]; then + printf 'bind||${nasDataDirectory}\\n' +elif [[ "$1" == "inspect" ]]; then + printf 'exited\\n' +elif [[ "$1" == "compose" && "$*" == *" run --rm --no-deps --entrypoint /bin/sh edgeever"* ]]; then + printf '/bin/sh: cannot create /data/.edgeever-write-test: Permission denied\\n' >&2 + printf 'EDGEEVER_DATA_WRITE_FAILED\\n' >&2 + printf 'Runtime identity: uid=1000(bun) gid=1000(bun)\\n' >&2 + printf 'Data directory: drwxr-xr-x 2 0 0 4096 /data\\n' >&2 + exit 73 +elif [[ "$1" == "compose" && "$*" == *" logs --tail 80 edgeever"* ]]; then + printf 'SQLITE_CANTOPEN: unable to open database file\\n' +fi +`, + ); + await writeFile( + fakeCurl, + `#!/usr/bin/env bash +set -eu +while [[ $# -gt 0 ]]; do + if [[ "$1" == "--output" ]]; then + cp "$EDGE_EVER_TEST_COMPOSE_SOURCE" "$2" + exit 0 + fi + shift +done +exit 1 +`, + ); + await chmod(fakeDocker, 0o755); + await chmod(fakeCurl, 0o755); + + const result = runInstaller( + ["--no-auto-update"], + { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH}`, + EDGE_EVER_INSTALL_DIR: installDirectory, + EDGE_EVER_TEST_COMPOSE_SOURCE: composePath, + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Persistent storage diagnostics:"); + expect(result.stderr).toContain("Container user: bun (EdgeEver expects UID/GID 1000:1000)"); + expect(result.stderr).toContain(`/data mount: type=bind, source=${nasDataDirectory}`); + expect(result.stderr).toContain("/data write test: failed"); + expect(result.stderr).toContain("Permission denied"); + expect(result.stderr).toContain("Runtime identity: uid=1000(bun) gid=1000(bun)"); + expect(result.stderr).toContain("The NAS/host directory must be writable by UID/GID 1000:1000"); + expect(result.stderr).toContain("sudo chown -R 1000:1000 -- /volume1/docker/edgeever\\ data"); + expect(result.stderr).toContain("NAS uses ACL permissions"); + } finally { + await rm(fixture, { recursive: true, force: true }); + } + }); });