Files
novacpx/tools/migrate-to-sqlite.sh
T
myron fbc445dad2 Migrate panel DB from MySQL to SQLite
Panel no longer depends on the user-managed MariaDB service.
SQLite at /var/lib/novacpx/panel.db runs independently so the
control panel stays up even when MariaDB is stopped.

- DB.php: switch to sqlite: DSN, add SQL translator (ON DUPLICATE KEY,
  DATE_ADD/DATE_SUB INTERVAL, NOW(), UNIX_TIMESTAMP(), IFNULL)
- Core.php: replace DB_HOST/NAME/USER/PASS with DB_PATH constant
- schema.sql: full SQLite syntax, add TOTP columns to users table
- _branding.php: use sqlite: PDO, datetime('now') for session check
- install.sh: apt install sqlite3, create SQLite DB instead of MySQL DB
- tools/migrate-to-sqlite.sh: one-shot migration script for existing installs
2026-06-09 14:52:02 +00:00

176 lines
6.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# Migrate NovaCPX panel DB from MySQL to SQLite
# Run as root on the NovaCPX VM.
# Usage: bash tools/migrate-to-sqlite.sh [--schema-only]
set -euo pipefail
SCHEMA_ONLY=false
[[ "${1:-}" == "--schema-only" ]] && SCHEMA_ONLY=true
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
DB_PATH="/var/lib/novacpx/panel.db"
SCHEMA="$REPO_ROOT/db/schema.sql"
CONFIG="/etc/novacpx/config.ini"
BACKUP_DIR="/var/lib/novacpx/migration-backup-$(date +%Y%m%d-%H%M%S)"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
log() { echo -e "${GREEN}[✓]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
fail() { echo -e "${RED}[✗]${NC} $*"; exit 1; }
[[ $EUID -ne 0 ]] && fail "Run as root."
command -v sqlite3 >/dev/null 2>&1 || { apt-get install -y sqlite3 >> /dev/null; log "sqlite3 installed"; }
# ── Read MySQL creds from existing config ─────────────────────────────────────
read_ini() { grep -E "^\s*$1\s*=" "$CONFIG" | head -1 | cut -d= -f2- | tr -d ' '; }
DB_HOST=$(read_ini host)
DB_NAME=$(read_ini name)
DB_USER=$(read_ini user)
DB_PASS=$(read_ini pass)
DB_WP_USER=$(read_ini wp_user)
DB_WP_PASS=$(read_ini wp_pass)
SECRET_KEY=$(read_ini secret)
if [[ -z "$DB_NAME" ]]; then
warn "No MySQL config found in $CONFIG — this install may already be on SQLite."
warn "If you only need to (re)create the SQLite DB from schema, use --schema-only."
[[ "$SCHEMA_ONLY" == "false" ]] && exit 0
fi
# ── Backup ────────────────────────────────────────────────────────────────────
log "Backing up to $BACKUP_DIR..."
mkdir -p "$BACKUP_DIR"
[[ -f "$DB_PATH" ]] && cp "$DB_PATH" "$BACKUP_DIR/panel.db.bak"
cp "$CONFIG" "$BACKUP_DIR/config.ini.bak"
[[ -n "$DB_NAME" ]] && mysqldump -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" > "$BACKUP_DIR/mysql-dump.sql" 2>/dev/null && log "MySQL dump saved" || warn "MySQL dump failed (service may be down)"
# ── Create SQLite DB from schema ──────────────────────────────────────────────
log "Creating SQLite database at $DB_PATH..."
mkdir -p /var/lib/novacpx
rm -f "$DB_PATH"
sqlite3 "$DB_PATH" < "$SCHEMA"
log "Schema applied"
if [[ "$SCHEMA_ONLY" == "false" && -n "$DB_NAME" ]]; then
# ── Migrate data from MySQL → SQLite ─────────────────────────────────────────
log "Migrating data from MySQL ($DB_NAME)..."
TABLES=(
users settings email_domains email_accounts dns_zones dns_records
accounts packages features account_features
ssl_certificates backups backup_schedules
ftp_accounts cron_jobs databases db_users
wordpress_installs
resellers reseller_branding reseller_packages
ip_addresses ssh_keys firewall_rules
docker_containers docker_compose_stacks docker_quotas
webmail_sso_tokens audit_log
)
for TABLE in "${TABLES[@]}"; do
# Check table exists in MySQL
ROWS=$(mysql -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" -se "SELECT COUNT(*) FROM \`$TABLE\`;" 2>/dev/null) || { warn "Table $TABLE not found in MySQL — skipping"; continue; }
[[ "$ROWS" == "0" ]] && { log " $TABLE — empty, skip"; continue; }
# Get column names from MySQL for this table
MYSQL_COLS=$(mysql -u "$DB_USER" -p"$DB_PASS" --skip-column-names --batch "$DB_NAME" \
-e "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='${DB_NAME}' AND TABLE_NAME='${TABLE}' ORDER BY ORDINAL_POSITION;" 2>/dev/null)
[[ -z "$MYSQL_COLS" ]] && { warn " $TABLE: could not get MySQL columns, skipping"; continue; }
# Dump only the columns that exist in both MySQL and SQLite
mysql -u "$DB_USER" -p"$DB_PASS" --skip-column-names --batch "$DB_NAME" \
-e "SELECT $(echo "$MYSQL_COLS" | tr '\n' ',' | sed 's/,$//') FROM \`$TABLE\`" 2>/dev/null | \
python3 - "$TABLE" "$DB_PATH" "$MYSQL_COLS" <<'PYEOF'
import sys, sqlite3, csv, io
table = sys.argv[1]
db_path = sys.argv[2]
mysql_cols = [c for c in sys.argv[3].splitlines() if c]
data = sys.stdin.read()
if not data.strip():
sys.exit(0)
con = sqlite3.connect(db_path)
cur = con.cursor()
cur.execute(f"SELECT name FROM pragma_table_info('{table}')")
sqlite_cols = {r[0] for r in cur.fetchall()}
if not sqlite_cols:
print(f" {table}: not in SQLite schema, skipping", file=sys.stderr)
con.close()
sys.exit(0)
# Only insert columns present in both
common = [c for c in mysql_cols if c in sqlite_cols]
col_idx = [i for i, c in enumerate(mysql_cols) if c in sqlite_cols]
placeholders = ','.join(['?'] * len(common))
col_list = ','.join(common)
reader = csv.reader(io.StringIO(data), delimiter='\t')
rows = list(reader)
inserted = 0
for row in rows:
if len(row) != len(mysql_cols):
continue
vals = [None if row[i] == 'NULL' else row[i] for i in col_idx]
try:
cur.execute(f"INSERT OR IGNORE INTO {table} ({col_list}) VALUES ({placeholders})", vals)
inserted += 1
except Exception as e:
pass
con.commit()
con.close()
print(f" {table}: {inserted}/{len(rows)} rows migrated ({len(common)} cols)")
PYEOF
done
log "Data migration complete"
fi
# ── Update config.ini ─────────────────────────────────────────────────────────
log "Updating $CONFIG to use SQLite..."
# Build new [database] section
NEW_DB_SECTION="[database]
path = ${DB_PATH}
wp_user = ${DB_WP_USER}
wp_pass = ${DB_WP_PASS}"
# Replace everything between [database] and the next [section]
python3 - "$CONFIG" "$NEW_DB_SECTION" <<'PYEOF'
import sys, re
config_path = sys.argv[1]
new_section = sys.argv[2]
with open(config_path) as f:
content = f.read()
# Replace the [database] section with new content
content = re.sub(
r'\[database\].*?(?=\[|\Z)',
new_section + '\n\n',
content,
flags=re.DOTALL
)
with open(config_path, 'w') as f:
f.write(content)
print("config.ini updated")
PYEOF
chown root:www-data "$CONFIG"
chmod 640 "$CONFIG"
# ── Fix permissions ───────────────────────────────────────────────────────────
chown www-data:www-data "$DB_PATH"
chmod 660 "$DB_PATH"
log "Migration complete!"
log "Panel DB: $DB_PATH"
log "Backup: $BACKUP_DIR"
warn "Restart PHP-FPM to clear any cached DB connections:"
echo " systemctl restart php8.3-fpm php8.2-fpm php8.1-fpm php7.4-fpm 2>/dev/null || true"