Files
ghost-docker/scripts/migrate.sh
2025-11-03 16:26:11 +11:00

658 lines
23 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
# Constants
readonly GHOST_UID=1000
readonly GHOST_GID=1000
readonly MYSQL_TIMEOUT=120
readonly DISK_SPACE_SAFETY_FACTOR=1.5
readonly TEMP_SQL_FILE="${PWD}/data/ghost_import.sql"
readonly RECOVERY_SCRIPT="${PWD}/recovery_instructions.sh"
# Global variables
current_location=""
mysql_user=""
mysql_password=""
ghost_service_name=""
# Function to convert bytes to human readable format
human_readable() {
local bytes=$1
local units=("B" "KB" "MB" "GB" "TB")
local unit=0
local size=$bytes
while (( $(echo "$size > 1024" | bc -l) )) && (( unit < 4 )); do
size=$(echo "scale=2; $size / 1024" | bc -l)
((unit++))
done
echo "${size} ${units[$unit]}"
}
# Function to get size in bytes
get_size_bytes() {
local path=$1
if [[ -d "$path" ]]; then
du -sb "$path" 2>/dev/null | cut -f1
else
echo "0"
fi
}
# Cleanup function
cleanup() {
local exit_code=$?
if [[ -f "$TEMP_SQL_FILE" ]]; then
echo "Cleaning up temporary files..."
rm "$TEMP_SQL_FILE"
fi
if [[ $exit_code -ne 0 && -f "$RECOVERY_SCRIPT" ]]; then
echo ""
echo "════════════════════════════════════════════════════════════"
echo "❌ MIGRATION FAILED!"
echo "════════════════════════════════════════════════════════════"
echo ""
echo "Don't worry - your data is safe!"
echo ""
echo "To restore your original Ghost installation, run:"
echo " bash $RECOVERY_SCRIPT"
echo ""
echo "Need help? Check the migration logs above for error details."
echo "════════════════════════════════════════════════════════════"
fi
exit $exit_code
}
# Set trap for cleanup
trap cleanup EXIT INT TERM
# Create recovery script
create_recovery_script() {
local service_name="$1"
cat > "$RECOVERY_SCRIPT" << EOF
#!/usr/bin/env bash
# Recovery script generated by Ghost migration on $(date)
# This script will restore your original Ghost installation
set -euo pipefail
echo "Restoring original Ghost installation..."
# Stop any Docker containers that might have been started
docker compose down 2>/dev/null || true
# Re-enable and start the original Ghost service
if [[ -n "${service_name}" ]]; then
systemctl enable "${service_name}" 2>/dev/null || true
systemctl start "${service_name}" 2>/dev/null || true
echo "Original Ghost installation has been restored."
echo "You can check the status with: systemctl status ${service_name}"
else
echo "Note: Ghost service was not yet stopped, so no restoration needed."
echo "Your original installation should still be running."
fi
EOF
chmod +x "$RECOVERY_SCRIPT"
echo "✓ Recovery script created at: $RECOVERY_SCRIPT"
}
# Validate MySQL connection
validate_mysql_connection() {
local host=$1
local database=$2
local port=$3
local user=$4
local password=$5
echo "Testing MySQL connection..."
if mysql -h"$host" -u"$user" -p"$password" -P"$port" -e "SELECT 1 FROM information_schema.tables WHERE table_schema='$database' LIMIT 1;" &>/dev/null; then
echo "✓ MySQL connection successful"
return 0
else
echo "✗ MySQL connection failed"
return 1
fi
}
# Check prerequisites
check_prerequisites() {
# Check we're running as root
if [[ "$EUID" -ne 0 ]]; then
echo "Sorry, this script must be run as root!"
exit 1
fi
# Check required commands
local required_commands=("jq" "docker" "bc" "mysql" "mysqldump" "rsync")
local missing_commands=()
for cmd in "${required_commands[@]}"; do
if ! command -v "$cmd" &>/dev/null; then
missing_commands+=("$cmd")
fi
done
if [[ ${#missing_commands[@]} -gt 0 ]]; then
echo "The following required commands are not installed:"
printf ' - %s\n' "${missing_commands[@]}"
echo "Please install them first."
exit 1
fi
}
# Show migration summary
show_migration_summary() {
echo "
═══════════════════════════════════════════════════════════════════
GHOST MIGRATION SUMMARY
═══════════════════════════════════════════════════════════════════
This script will migrate your Ghost CLI installation to Docker.
WHAT WILL HAPPEN:
✓ Validate MySQL credentials
✓ Stop your current Ghost installation
✓ Copy content directory to Docker mount
✓ Export and import your database to a Docker based MySQL instance
✓ Start Ghost in Docker container
✓ Optionally configure Caddy Webserver for HTTPS
WHAT WONT HAPPEN:
✓ No data will be deleted
✓ Recovery script will be created
✓ Original installation remains intact
REQUIREMENTS:
✓ .env file configured for Docker
✓ MySQL credentials with dump permissions
✓ Sufficient disk space for migration
═══════════════════════════════════════════════════════════════════
"
}
# Migrate content directory
migrate_content() {
local source="${current_location}/content/"
local dest="${PWD}/data/ghost/"
echo "Starting content migration..."
echo "Source: $source"
echo "Destination: $dest"
echo ""
# Create destination directory
mkdir -p "$dest"
# Copy with progress
echo "Copying files..."
rsync --info=progress2 -aH "$source" "$dest"
echo ""
echo "Setting permissions for Ghost container (UID: $GHOST_UID, GID: $GHOST_GID)..."
chown -R ${GHOST_UID}:${GHOST_GID} "$dest"
echo "✓ Content migration completed"
}
# Test if we can dump database with given credentials
test_mysql_dump() {
local host=$1
local database=$2
local port=$3
local user=$4
local password=$5
# Try a minimal dump to test permissions
if MYSQL_PWD="$password" mysqldump --no-tablespaces --no-data -P"$port" -h"$host" -u"$user" "$database" >/dev/null 2>&1; then
return 0
else
return 1
fi
}
# Export and import database
migrate_database() {
local mysql_host
local mysql_database
local mysql_port
mysql_host=$(jq -r < "${current_location}/config.production.json" '.database.connection.host')
mysql_database=$(jq -r < "${current_location}/config.production.json" '.database.connection.database')
mysql_port=$(jq -r < "${current_location}/config.production.json" '.database.connection.port')
# Default to 3306 if port is missing or null
if [[ -z "$mysql_port" || "$mysql_port" == "null" ]]; then
mysql_port=3306
fi
echo "Exporting database from $mysql_host..."
# Export database with proper error handling
local dump_output
local dump_status
dump_output=$(MYSQL_PWD="$mysql_password" mysqldump --no-tablespaces -h"$mysql_host" -u"$mysql_user" -P"$mysql_port" "$mysql_database" 2>&1 > "$TEMP_SQL_FILE")
dump_status=$?
# Check for errors in output (mysqldump may return 0 even with some errors)
if [[ $dump_status -ne 0 ]] || [[ "$dump_output" =~ "Error:" ]]; then
echo ""
echo "ERROR: Failed to export database"
if [[ "$dump_output" =~ "PROCESS privilege" ]]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "The MySQL user '$mysql_user' needs the PROCESS privilege."
echo ""
echo "To fix this, connect to MySQL as a privileged user and run:"
echo " GRANT PROCESS ON *.* TO '$mysql_user'@'%';"
echo " FLUSH PRIVILEGES;"
echo ""
echo "Or retry with a user that has sufficient privileges (e.g., root)."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [[ -n "$dump_output" ]]; then
echo "Error details: $dump_output"
fi
exit 1
fi
# Verify the dump file exists and has content
if [[ ! -f "$TEMP_SQL_FILE" ]] || [[ ! -s "$TEMP_SQL_FILE" ]]; then
echo ""
echo "ERROR: Database dump file is empty or missing"
echo "This might indicate insufficient disk space or permissions issues."
exit 1
fi
local dump_size
dump_size=$(human_readable "$(stat -c%s "$TEMP_SQL_FILE")")
echo "✓ Database exported successfully ($dump_size)"
# Wait for MySQL to be ready
echo -n "Waiting for MySQL container to be ready"
local counter=0
until [ "$(docker compose ps db --format json | jq -r '.Health')" = "healthy" ] || [ $counter -eq $MYSQL_TIMEOUT ]; do
echo -n "."
sleep 1
((counter++)) || true
done
if [[ $counter -eq $MYSQL_TIMEOUT ]]; then
echo ""
echo "ERROR: Timed out waiting for MySQL container"
exit 1
fi
echo " ✓"
# Import database
echo "Importing database into Docker MySQL..."
local MYSQL_ROOT_PASSWORD
MYSQL_ROOT_PASSWORD=$(grep DATABASE_ROOT_PASSWORD "$PWD/.env" | cut -d '=' -f 2-)
if ! docker compose exec -e MYSQL_PWD="$MYSQL_ROOT_PASSWORD" -T db sh -c 'exec mysql -uroot $MYSQL_DATABASE' < "$TEMP_SQL_FILE"; then
echo "ERROR: Failed to import database"
exit 1
fi
echo "✓ Database migration completed"
# Clean up SQL file
rm -f "$TEMP_SQL_FILE"
}
# Find Ghost installations in /var/www/
find_ghost_installations() {
local installations=()
# Search one level deep in /var/www/
if [[ -d "/var/www" ]]; then
for dir in /var/www/*/; do
# Skip if not a directory
[[ ! -d "$dir" ]] && continue
# Remove trailing slash
dir="${dir%/}"
# Check if it's a valid Ghost installation
if [[ -f "${dir}/.ghost-cli" ]] && [[ -d "${dir}/content" ]]; then
# Additional validation - check if config file exists
if [[ -f "${dir}/config.production.json" ]]; then
installations+=("$dir")
fi
fi
done
fi
printf '%s\n' "${installations[@]}"
}
# Main script starts here
main() {
check_prerequisites
echo "WARNING: This script is currently in beta, please ensure you have a backup!"
show_migration_summary
read -rp 'Ready to proceed with migration? (y/n): ' confirm
if [[ "${confirm,,}" != "y" ]]; then
echo "Migration cancelled."
exit 0
fi
# Search for Ghost installations
echo ""
echo "Searching for Ghost installations in /var/www/..."
local ghost_installations=()
while IFS= read -r line; do
[[ -n "$line" ]] && ghost_installations+=("$line")
done < <(find_ghost_installations)
# Get installation location
if [[ ${#ghost_installations[@]} -gt 0 ]]; then
echo ""
echo "Found ${#ghost_installations[@]} Ghost installation(s) in /var/www/:"
echo ""
# Display found installations with numbers
local i=1
for installation in "${ghost_installations[@]}"; do
local site_name
site_name=$(basename "$installation")
echo " $i) $site_name (${installation})"
((i++))
done
echo " $i) Enter a different path"
echo ""
read -rp "Select an installation (1-$i): " selection
# Validate selection
if [[ "$selection" =~ ^[0-9]+$ ]] && [[ $selection -ge 1 ]] && [[ $selection -le $i ]]; then
if [[ $selection -eq $i ]]; then
# User wants to enter a different path
read -rp 'Enter your current Ghost installation path: ' current_location
else
# User selected a found installation
current_location="${ghost_installations[$((selection-1))]}"
echo "Selected: $current_location"
fi
else
echo "Invalid selection"
exit 1
fi
else
# No installations found, ask for path directly
echo ""
echo "No Ghost installations found in /var/www/"
read -rp 'Enter your current Ghost installation path: ' current_location
fi
if [[ -z "$current_location" ]]; then
echo "ERROR: Installation path is required"
exit 1
fi
# Validate Ghost installation
if [[ ! -f "${current_location}/.ghost-cli" ]]; then
echo "ERROR: No Ghost-CLI installation found at ${current_location}"
exit 1
fi
if [[ ! -d "${current_location}/content" ]]; then
echo "ERROR: No content directory found at ${current_location}/content"
exit 1
fi
# Check for .env file
if [[ ! -f "${PWD}/.env" ]]; then
echo "ERROR: Please create a .env file for the Docker installation first"
exit 1
fi
# Get Ghost service name
ghost_service_name="ghost_$(jq -r < "${current_location}/.ghost-cli" '.name')"
# Get database configuration
local mysql_host
local mysql_database
local mysql_port
local ghost_mysql_user
local ghost_mysql_password
mysql_host=$(jq -r < "${current_location}/config.production.json" '.database.connection.host')
mysql_database=$(jq -r < "${current_location}/config.production.json" '.database.connection.database')
mysql_port=$(jq -r < "${current_location}/config.production.json" '.database.connection.port')
# Default to 3306 if port is missing or null
if [[ -z "$mysql_port" || "$mysql_port" == "null" ]]; then
mysql_port=3306
fi
ghost_mysql_user=$(jq -r < "${current_location}/config.production.json" '.database.connection.user')
ghost_mysql_password=$(jq -r < "${current_location}/config.production.json" '.database.connection.password')
# Check disk space
echo ""
echo "Checking disk space requirements..."
local content_size
local content_size_human
local required_space
local required_space_human
local available_space
local available_space_human
content_size=$(get_size_bytes "${current_location}/content")
content_size_human=$(human_readable "$content_size")
required_space=$(echo "$content_size * $DISK_SPACE_SAFETY_FACTOR" | bc | cut -d'.' -f1)
required_space_human=$(human_readable "$required_space")
available_space=$(df -B1 "${PWD}" | tail -1 | awk '{print $4}')
available_space_human=$(human_readable "$available_space")
echo " Content size: ${content_size_human}"
echo " Required space: ${required_space_human}"
echo " Available space: ${available_space_human}"
if (( available_space < required_space )); then
echo ""
echo "ERROR: Insufficient disk space!"
echo "Need ${required_space_human} but only ${available_space_human} available."
exit 1
fi
echo "✓ Disk space check passed"
echo ""
# Try Ghost's own credentials first
echo "Testing database export with Ghost's credentials..."
if test_mysql_dump "$mysql_host" "$mysql_database" "$mysql_port" "$ghost_mysql_user" "$ghost_mysql_password"; then
echo "✓ Ghost's credentials have sufficient privileges"
mysql_user="$ghost_mysql_user"
mysql_password="$ghost_mysql_password"
else
echo "Ghost's database user doesn't have sufficient privileges for export."
echo "Please provide credentials for a MySQL user with dump privileges."
echo ""
# Get MySQL credentials and validate
read -rp "MySQL user for database export (default: root): " mysql_user
mysql_user=${mysql_user:-root}
# Get password securely
echo -n "MySQL password for ${mysql_user}: "
read -rs mysql_password
echo ""
# Validate connection
if ! validate_mysql_connection "$mysql_host" "$mysql_database" "$mysql_port" "$mysql_user" "$mysql_password"; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Could not connect to MySQL database."
echo ""
echo "Please verify:"
echo " • MySQL service is running"
echo " • Credentials are correct"
echo " • User has access from this host"
echo " • Database name is correct"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 1
fi
# Test dump permissions
if ! test_mysql_dump "$mysql_host" "$mysql_database" "$mysql_port" "$mysql_user" "$mysql_password"; then
echo ""
echo "ERROR: The provided user doesn't have sufficient privileges for database export."
echo "Please ensure the user has the necessary privileges or try a different user."
exit 1
fi
fi
# Create recovery script (with empty service name since we haven't stopped anything yet)
create_recovery_script ""
# Final confirmation before stopping Ghost
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "⚠️ YOUR SITE WILL BE UNAVAILABLE DURING MIGRATION"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "The next steps will:"
echo " 1. Stop your Ghost service"
echo " 2. Migrate your content and database"
echo " a. Your new content directory will be at ${PWD}/data/ghost/"
echo " b. Your new MySQL database will be at ${PWD}/data/mysql/"
echo " 3. Start Ghost in Docker"
echo ""
echo "If anything goes wrong, run: bash $RECOVERY_SCRIPT"
echo ""
read -rp 'Continue with migration? This will make your site unavailable. (y/n): ' confirm
if [[ "${confirm,,}" != "y" ]]; then
echo "Migration cancelled."
rm -f "$RECOVERY_SCRIPT"
exit 0
fi
# Stop Ghost service
echo ""
echo "Stopping Ghost service..."
# Update recovery script with actual service name before stopping
create_recovery_script "$ghost_service_name"
if ! systemctl stop "$ghost_service_name"; then
echo "ERROR: Failed to stop Ghost service"
echo "Please check: systemctl status $ghost_service_name"
exit 1
fi
systemctl disable "$ghost_service_name" 2>/dev/null || true
echo "✓ Ghost service stopped"
# Start MySQL container
echo "Starting MySQL container for migration..."
docker compose up db -d
# Migrate content
echo ""
migrate_content
# Migrate database
echo ""
migrate_database
# Import configuration
echo ""
echo "Importing configuration from existing installation..."
echo ""
node "${PWD}/scripts/config-to-env.js" "${current_location}/config.production.json"
echo ""
echo -e "\n# Configuration imported from existing Ghost install at ${current_location}" >> "${PWD}/.env"
node "${PWD}/scripts/config-to-env.js" "${current_location}/config.production.json" >> "${PWD}/.env"
echo "✓ Configuration imported"
# Start Ghost
echo ""
echo "Starting Ghost container..."
docker compose up ghost -d
echo "✓ Ghost is running in Docker"
# Caddy setup
echo ""
read -rp 'Start Caddy Webserver for automatic HTTPS? This will stop Nginx. (y/n): ' confirm
if [[ "${confirm,,}" == "y" ]]; then
echo "Stopping Nginx..."
systemctl stop nginx -q || true
systemctl disable nginx -q || true
echo "Starting Caddy..."
docker compose up caddy -d
local domain
domain=$(grep 'DOMAIN' "${PWD}/.env" | cut -d '=' -f 2-)
echo ""
echo "✓ Caddy Webserver is running!"
echo "✓ Your site is available at: https://${domain}"
else
local ghost_port
ghost_port=$(grep 'GHOST_PORT' "${PWD}/.env" | cut -d '=' -f 2-)
echo ""
echo "✓ Ghost is now running"
echo " To finish migration, configure your webserver to forward traffic to 127.0.0.1:${ghost_port}"
fi
# Success! Remove recovery script
rm -f "$RECOVERY_SCRIPT"
echo ""
echo "════════════════════════════════════════════════════════════"
echo "✓ MIGRATION COMPLETED SUCCESSFULLY!"
echo "════════════════════════════════════════════════════════════"
echo ""
echo "Your Ghost site is now running in Docker!"
echo ""
echo "IMPORTANT INFORMATION:"
echo " • Original files: $current_location"
echo " • Original database: $mysql_database on $mysql_host"
echo " • New content location: ${PWD}/data/ghost/"
echo " • Configuration: ${PWD}/.env"
echo ""
echo "QUICK START COMMANDS:"
echo " View logs: docker compose logs -f ghost"
echo " Check status: docker compose ps"
echo " Stop Ghost: docker compose down"
echo " Start Ghost: docker compose up -d"
echo ""
echo "TROUBLESHOOTING:"
echo " • If site is unreachable, check: docker compose logs caddy"
echo " • For 502 errors, Ghost may still be starting (check logs)"
echo " • Database issues: docker compose logs db"
echo ""
echo "UPGRADES:"
echo " 1. git pull"
echo " 2. docker compose pull"
echo " 3. docker compose up -d"
echo " Always backup before major upgrades!"
echo ""
echo "HELP GUIDE:"
echo " For a comprehensive list of commands and troubleshooting tips:"
echo " ./help"
echo ""
echo "CLEANUP:"
echo "Once you're checked over the migration you can remove the old installation files and database by running:"
echo ""
echo " rm -r $current_location/"
echo " mysql -h$mysql_host -u$mysql_user -p -P$mysql_port -e 'DROP DATABASE IF EXISTS ${mysql_database}'"
echo ""
echo "This will remove the old Ghost CLI and Ghost 5.x installation"
echo ""
echo "════════════════════════════════════════════════════════════"
}
# Run main function
main "$@"