diff --git a/bin/deploy-box-web.sh b/bin/deploy-box-web.sh new file mode 100755 index 0000000..c09254c --- /dev/null +++ b/bin/deploy-box-web.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# deploy-box-web.sh — Atomically package and deploy Box Web Console assets to the Google Cloud VM +# +# Assets: web/box/www/{index.html, box.js, box.css} +# Destination: /srv/box/www/ on 34.139.37.135 (box.muse-dev.online) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SRC_DIR="$REPO_ROOT/web/box/www" + +# Default configuration +DEST_HOST="${BOX_WEB_HOST:-34.139.37.135}" +DEST_USER="${BOX_WEB_USER:-super}" +DEST_DIR="${BOX_WEB_DEST:-/srv/box/www}" +DRY_RUN=0 +BUNDLE_ONLY=0 + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=1 + shift + ;; + --bundle-only) + BUNDLE_ONLY=1 + shift + ;; + --host) + DEST_HOST="$2" + shift 2 + ;; + --user) + DEST_USER="$2" + shift 2 + ;; + --dest) + DEST_DIR="$2" + shift 2 + ;; + -h|--help) + echo "Usage: $0 [options]" + echo "" + echo "Options:" + echo " --dry-run Preview assets, hashes, and destination without deploying" + echo " --bundle-only Package the atomic tarball into /tmp and exit" + echo " --host Target host (default: 34.139.37.135)" + echo " --user SSH user on target (default: super)" + echo " --dest Remote directory path (default: /srv/box/www)" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +# Pre-flight asset verification +if [[ ! -d "$SRC_DIR" ]]; then + echo "Error: Source directory missing at $SRC_DIR" >&2 + exit 1 +fi + +REQUIRED_FILES=("index.html" "box.js" "box.css") +for f in "${REQUIRED_FILES[@]}"; do + if [[ ! -f "$SRC_DIR/$f" ]]; then + echo "Error: Missing required asset: $SRC_DIR/$f" >&2 + exit 1 + fi +done + +echo "=== Box Web Console Deployment Plan ===" +echo "Source: $SRC_DIR" +echo "Target: $DEST_USER@$DEST_HOST:$DEST_DIR" +echo "Mode: $([ "$DRY_RUN" -eq 1 ] && echo "DRY-RUN (no changes)" || echo "LIVE")" +echo "" +echo "Asset Manifest & SHA256 Checksums:" +for f in "${REQUIRED_FILES[@]}"; do + csum=$(sha256sum "$SRC_DIR/$f" | cut -d' ' -f1) + size=$(wc -c < "$SRC_DIR/$f" | tr -d ' ') + printf " • %-12s %6s bytes sha256:%s\n" "$f" "$size" "$csum" +done +echo "" + +# Dry-run exit +if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[dry-run] Validation complete. No files transferred." + exit 0 +fi + +# Create atomic bundle in temp directory +BUNDLE_PATH="/tmp/box-web-bundle-$(date +%s).tar.gz" +echo "Creating atomic archive: $BUNDLE_PATH..." +tar -czf "$BUNDLE_PATH" -C "$SRC_DIR" index.html box.js box.css +echo "Archive created ($(wc -c < "$BUNDLE_PATH" | tr -d ' ') bytes)." + +if [[ "$BUNDLE_ONLY" -eq 1 ]]; then + echo "Bundle generated at $BUNDLE_PATH. Skipping network deployment." + exit 0 +fi + +# Deploy to remote target +echo "Deploying to $DEST_USER@$DEST_HOST:$DEST_DIR..." +REMOTE_TMP="/tmp/box-web-bundle-$$.tar.gz" + +scp -o ConnectTimeout=10 -o BatchMode=yes "$BUNDLE_PATH" "$DEST_USER@$DEST_HOST:$REMOTE_TMP" +ssh -o ConnectTimeout=10 -o BatchMode=yes "$DEST_USER@$DEST_HOST" " + mkdir -p '$DEST_DIR' && + tar -xzf '$REMOTE_TMP' -C '$DEST_DIR' && + rm -f '$REMOTE_TMP' +" +rm -f "$BUNDLE_PATH" + +echo "✓ Deployment succeeded! Assets live at https://box.muse-dev.online/" diff --git a/tests/test_deploy_box_web.py b/tests/test_deploy_box_web.py new file mode 100644 index 0000000..0bc1bd0 --- /dev/null +++ b/tests/test_deploy_box_web.py @@ -0,0 +1,61 @@ +"""Tests for bin/deploy-box-web.sh.""" +import os +import subprocess +import tarfile +import unittest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCRIPT_PATH = os.path.join(REPO_ROOT, "bin", "deploy-box-web.sh") + +class TestDeployBoxWeb(unittest.TestCase): + def test_script_exists_and_executable(self): + self.assertTrue(os.path.exists(SCRIPT_PATH), f"Missing script: {SCRIPT_PATH}") + self.assertTrue(os.access(SCRIPT_PATH, os.X_OK), "Script not executable") + + def test_bash_syntax(self): + proc = subprocess.run(["bash", "-n", SCRIPT_PATH], capture_output=True, text=True) + self.assertEqual(proc.returncode, 0, f"Syntax error in script: {proc.stderr}") + + def test_dry_run_output(self): + proc = subprocess.run([SCRIPT_PATH, "--dry-run"], capture_output=True, text=True) + self.assertEqual(proc.returncode, 0, f"Dry-run failed: {proc.stderr}") + self.assertIn("Box Web Console Deployment Plan", proc.stdout) + self.assertIn("34.139.37.135", proc.stdout) + self.assertIn("/srv/box/www", proc.stdout) + self.assertIn("index.html", proc.stdout) + self.assertIn("box.js", proc.stdout) + self.assertIn("box.css", proc.stdout) + self.assertIn("DRY-RUN (no changes)", proc.stdout) + self.assertIn("sha256:", proc.stdout) + + def test_custom_parameters(self): + cmd = [ + SCRIPT_PATH, + "--host", "custom.domain.net", + "--user", "deployer", + "--dest", "/opt/box/web", + "--dry-run" + ] + proc = subprocess.run(cmd, capture_output=True, text=True) + self.assertEqual(proc.returncode, 0) + self.assertIn("deployer@custom.domain.net:/opt/box/web", proc.stdout) + + def test_bundle_only(self): + proc = subprocess.run([SCRIPT_PATH, "--bundle-only"], capture_output=True, text=True) + self.assertEqual(proc.returncode, 0, f"Bundle creation failed: {proc.stderr}") + self.assertIn("Archive created", proc.stdout) + # Extract bundle path from output + for line in proc.stdout.splitlines(): + if "Bundle generated at" in line: + bundle_path = line.split("Bundle generated at")[1].strip().split()[0].rstrip(".") + self.assertTrue(os.path.exists(bundle_path)) + with tarfile.open(bundle_path, "r:gz") as tar: + names = tar.getnames() + self.assertIn("index.html", names) + self.assertIn("box.js", names) + self.assertIn("box.css", names) + os.remove(bundle_path) + break + +if __name__ == "__main__": + unittest.main()