mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
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
This commit is contained in:
Executable
+175
@@ -0,0 +1,175 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user