From c5223c7c163b0b8db2773899ca13573b0969695e Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 7 Apr 2026 09:30:32 +0800 Subject: [PATCH] Add remote build fallback to deploy script --- scripts/deploy-server.sh | 60 ++++++++++++++++++-- tests/deploy-server-script.test.ts | 90 ++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 6 deletions(-) diff --git a/scripts/deploy-server.sh b/scripts/deploy-server.sh index 0c18b25..d368ed7 100755 --- a/scripts/deploy-server.sh +++ b/scripts/deploy-server.sh @@ -8,6 +8,7 @@ REMOTE_HOST="${BOSS_REMOTE_HOST:-${REMOTE_USER}@${REMOTE_HOSTNAME}}" REMOTE_DIR="${BOSS_REMOTE_DIR:-/opt/boss}" SSH_OPTS="-o StrictHostKeyChecking=no" KEYCHAIN_SERVICE="${BOSS_KEYCHAIN_SERVICE:-boss-server-debug-ssh}" +BUILD_MODE="${BOSS_DEPLOY_BUILD_MODE:-auto}" resolve_password() { if [[ -n "${BOSS_SERVER_PASS:-}" ]]; then @@ -36,23 +37,70 @@ else SSH_PREFIX=(ssh ${=SSH_OPTS}) fi -BOSS_RUNTIME_ROOT="$ROOT_DIR" BOSS_STATE_FILE="$ROOT_DIR/data/boss-state.json" npm run build +run_local_build() { + BOSS_RUNTIME_ROOT="$ROOT_DIR" BOSS_STATE_FILE="$ROOT_DIR/data/boss-state.json" npm run build +} + +use_remote_build=false +build_log="$(mktemp)" +cleanup() { + rm -f "$build_log" +} +trap cleanup EXIT + +case "$BUILD_MODE" in + local) + run_local_build + ;; + remote) + use_remote_build=true + ;; + auto|"") + if ! run_local_build 2>&1 | tee "$build_log"; then + if grep -q "ENOSPC" "$build_log"; then + echo "Local build hit ENOSPC, falling back to remote build." >&2 + use_remote_build=true + else + exit 1 + fi + fi + ;; + *) + echo "Unsupported BOSS_DEPLOY_BUILD_MODE: $BUILD_MODE" >&2 + exit 1 + ;; +esac "${SSH_PREFIX[@]}" "$REMOTE_HOST" "sudo mkdir -p $REMOTE_DIR && sudo chown -R ${REMOTE_USER}:${REMOTE_USER} $REMOTE_DIR && sudo rm -rf $REMOTE_DIR/.next" +RSYNC_EXCLUDES=( + --exclude ".git" + --exclude "node_modules" + --exclude "data/" + --exclude "android/app/build" + --exclude ".DS_Store" +) + +if [[ "$use_remote_build" == true ]]; then + RSYNC_EXCLUDES+=(--exclude ".next") +fi + rsync -az --delete \ - --exclude ".git" \ - --exclude "node_modules" \ - --exclude "data/" \ - --exclude ".DS_Store" \ + "${RSYNC_EXCLUDES[@]}" \ -e "$RSYNC_RSH" \ "$ROOT_DIR/" "$REMOTE_HOST:$REMOTE_DIR/" +if [[ "$use_remote_build" == true ]]; then + REMOTE_INSTALL_AND_BUILD_CMD="npm install && BOSS_RUNTIME_ROOT=$REMOTE_DIR BOSS_STATE_FILE=$REMOTE_DIR/data/boss-state.json npm run build && npm prune --omit=dev" +else + REMOTE_INSTALL_AND_BUILD_CMD="npm install --omit=dev" +fi + POST_SYNC_REMOTE_CMD=" sudo bash $REMOTE_DIR/scripts/bootstrap-server.sh && sudo chown -R ${REMOTE_USER}:${REMOTE_USER} $REMOTE_DIR && cd $REMOTE_DIR && -npm install --omit=dev && +$REMOTE_INSTALL_AND_BUILD_CMD && sudo systemctl restart boss-web && sudo systemctl restart caddy && sleep 2 && diff --git a/tests/deploy-server-script.test.ts b/tests/deploy-server-script.test.ts index 378c572..7c97710 100644 --- a/tests/deploy-server-script.test.ts +++ b/tests/deploy-server-script.test.ts @@ -84,3 +84,93 @@ exec "$@" await rm(tempRoot, { recursive: true, force: true }); } }); + +test("deploy-server falls back to remote build when local build hits ENOSPC", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "boss-deploy-script-remote-build-")); + const binDir = path.join(tempRoot, "bin"); + const logDir = path.join(tempRoot, "logs"); + const repoRoot = "/Users/kris/code/boss"; + + await mkdir(binDir, { recursive: true }); + await mkdir(logDir, { recursive: true }); + + await writeExecutable( + path.join(binDir, "security"), + `#!/bin/sh +printf 'Asd123456.' +`, + ); + await writeExecutable( + path.join(binDir, "npm"), + `#!/bin/sh +COUNT_FILE="${path.join(logDir, "npm-count")}" +count=0 +if [ -f "$COUNT_FILE" ]; then + count="$(cat "$COUNT_FILE")" +fi +count=$((count + 1)) +printf '%s' "$count" > "$COUNT_FILE" +printf '%s\\0' "$*" >> "${path.join(logDir, "npm.log")}" +if [ "$count" -eq 1 ]; then + printf 'Error: ENOSPC: no space left on device\\n' >&2 + exit 1 +fi +exit 0 +`, + ); + await writeExecutable( + path.join(binDir, "rsync"), + `#!/bin/sh +printf '%s\\0' "$*" >> "${path.join(logDir, "rsync.log")}" +exit 0 +`, + ); + await writeExecutable( + path.join(binDir, "ssh"), + `#!/bin/sh +printf '%s\\0' "$*" >> "${path.join(logDir, "ssh.log")}" +exit 0 +`, + ); + await writeExecutable( + path.join(binDir, "sshpass"), + `#!/bin/sh +if [ "$1" = "-e" ]; then + shift +fi +exec "$@" +`, + ); + + try { + await execFileAsync("zsh", [path.join(repoRoot, "scripts/deploy-server.sh")], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + }, + }); + + const rsyncLog = await readFile(path.join(logDir, "rsync.log"), "utf8"); + const rsyncArgs = rsyncLog + .split("\0") + .map((line) => line.trim()) + .filter(Boolean) + .join(" "); + + assert.match(rsyncArgs, /--exclude \.next/); + + const sshLog = await readFile(path.join(logDir, "ssh.log"), "utf8"); + const sshCalls = sshLog + .split("\0") + .map((line) => line.trim()) + .filter(Boolean); + + assert.equal(sshCalls.length, 2); + assert.match(sshCalls[1] ?? "", /npm install && BOSS_RUNTIME_ROOT=\/opt\/boss BOSS_STATE_FILE=\/opt\/boss\/data\/boss-state\.json npm run build/); + assert.match(sshCalls[1] ?? "", /npm prune --omit=dev/); + assert.doesNotMatch(sshCalls[1] ?? "", /npm install --omit=dev/); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +});