Compare commits

...

31 Commits

Author SHA1 Message Date
Marek Miklewicz b180196523 Bump version to 1.2.19
Also fixes a stale usage-text assertion in package_archive_test.sh that
still expected the old <MARIADB_VERSION> <PORT> form (install_db_cli_test.sh
was already updated for the now-optional PORT, but this archive-level
check was missed until repackaging surfaced it against fresh content).
2026-07-05 22:09:51 +02:00
Marek Miklewicz fb800585a4 polish: document AlmaLinux 8/9 requirement for the automatic 3306 default
Final whole-branch review noted usage() advertised the PORT=3306 default
without mentioning it only works on AlmaLinux 8/9 - other distros must
pass an explicit non-3306 port.
2026-07-05 22:06:26 +02:00
Marek Miklewicz 8e43e004f4 feat: wire native MariaDB port takeover into install_db.sh when PORT=3306 2026-07-05 21:57:57 +02:00
Marek Miklewicz 5b68a6aac8 test: fake id command so native port takeover e2e test runs without real root 2026-07-05 21:51:34 +02:00
Marek Miklewicz c59d5b8c77 docs: correct plan's rollback trap design from ERR to EXIT
die() calls exit directly; bash's ERR trap does not fire on an explicit
exit, only when a command's non-zero status propagates through normal
control flow. Verified empirically during Task 4 implementation - switched
to a ROLLBACK_ARMED-guarded EXIT trap, which fires on any shell exit.
2026-07-05 21:49:51 +02:00
Marek Miklewicz 42ec50afe4 feat: add service restart, verification, and full rollback orchestration to native port takeover 2026-07-05 21:47:51 +02:00
Marek Miklewicz 35b9c1af78 feat: add config mutation and rollback for native MariaDB port takeover 2026-07-05 21:32:23 +02:00
Marek Miklewicz 15044f0dea docs: fix subshell-wrapping bug in Task 2's OS-guard test design
Sourcing native_mariadb_port_takeover.sh for unit tests means a failing
check's die() -> exit 1 would terminate the sourcing test script itself
rather than just failing an `if` condition. Wrap the three
expected-to-fail calls in subshells so exit is confined to the subshell.
2026-07-05 21:26:55 +02:00
Marek Miklewicz f15da3b7aa feat: add detection functions for native MariaDB port takeover 2026-07-05 21:25:28 +02:00
Marek Miklewicz 00dbe93531 feat: make install_db.sh's PORT argument optional, defaulting to 3306 2026-07-05 21:09:20 +02:00
Marek Miklewicz 2dd7034558 Add implementation plan for native MariaDB port takeover 2026-07-05 21:05:09 +02:00
Marek Miklewicz a5bd889381 Add design spec for native MariaDB port takeover (alt-mariadb on 3306) 2026-07-05 20:53:17 +02:00
Marek Miklewicz 8ea46e89ab Bump version to 1.2.18 2026-07-05 19:25:12 +02:00
Marek Miklewicz bb3fae1fa6 polish: add trap cleanup_restore_rollback to match sibling restore scripts
Final whole-branch review noted alt_mysql_native_backup_restore.sh was the
only one of three consumers of the shared rollback helpers without this
trap, leaving a stale rollback temp file on signal/die().
2026-07-05 19:21:08 +02:00
Marek Miklewicz d0871acb68 test: cover unavailable native-backup-restore script and zero-imported-databases paths 2026-07-05 17:21:12 +02:00
Marek Miklewicz 98b9436cd1 feat: restore native backup/<dbname>.conf pairs before falling back to native staging 2026-07-05 17:04:05 +02:00
Marek Miklewicz ebddcd73bf test: cover mixed valid-pair + orphan-file directory in native backup restore
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 16:58:47 +02:00
Marek Miklewicz 6b7ec39025 feat: import native backup/<dbname>.conf+.sql pairs directly into alt-mariadb 2026-07-05 16:53:31 +02:00
Marek Miklewicz d5040037d6 fix: reset parse_native_db_conf output on failure; use BASH_SOURCE for SCRIPT_DIR
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 16:45:27 +02:00
Marek Miklewicz b6016bafd8 feat: parse DirectAdmin native backup/<dbname>.conf format 2026-07-05 15:32:43 +02:00
Marek Miklewicz 87c6ba155e fix: restore dropped log() calls in shared rollback helpers
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 15:09:55 +02:00
Marek Miklewicz 9e54bb32da refactor: share restore rollback helpers via mysql_common.sh 2026-07-05 15:04:20 +02:00
Marek Miklewicz 74ac47f75b Add design spec and implementation plan for restore-without-native-staging
Ignore .superpowers/ (subagent-driven-development's scratch progress ledger).
2026-07-05 15:01:52 +02:00
Marek Miklewicz c7c14f0d2a Bump version to 1.2.17 2026-07-04 22:54:22 +02:00
Marek Miklewicz b4fbd46cc1 Harden restore-archive extraction against path traversal
extract_payload_from_archive() relied entirely on tar's own default
path-traversal protection when extracting a DirectAdmin restore
archive to locate the plugin's backup/alt_mysql payload. Explicitly
inspect the archive listing first and refuse to extract at all if any
member has an absolute path or a ".." segment, rather than trusting
that the wildcard extraction pattern happens to filter out an unsafe
member.
2026-07-04 22:53:21 +02:00
Marek Miklewicz f472a7abfc Add behavioral test coverage for DA-native restore payload + hooks
alt_mysql_restore_payload.sh (the DirectAdmin native-restore hook that
imports a plugin-origin backup payload into alt-mariadb) had zero
behavioral test coverage - only hook-installation plumbing was tested.
Add a fake mariadb/mariadb-dump client stub (tracking a simple
existing-databases state file) to exercise the real script end-to-end
without a live server, covering: checksum mismatch rejection,
overwrite=deny enforcement against an existing database, and the
happy-path restore. Add a DA_ALT_MYSQL_SETTINGS_FILE override (same
pattern as worker.sh's PLUGIN_DIR) so the script's settings file can be
pointed at a test fixture instead of the real system one.

Also extend da_integration_install_test.sh to check hook-symlink
install/uninstall coverage for database_delete_pre.sh,
database_user_password_change_pre.sh, database_user_create_post.sh,
and database_destroy_user_post.sh, which were previously untested
(only database_create_pre.sh was checked).
2026-07-04 22:49:28 +02:00
Marek Miklewicz 5ede413f52 Bump version to 1.2.16 2026-07-04 22:00:08 +02:00
Marek Miklewicz a570635d23 Fix busy-loop in withQueueLock() on non-EEXIST mkdir failure
If mkdir(queue.lock) failed for a reason other than "already exists"
(e.g. a permissions or disk problem on the data directory), is_dir()
was false, so the loop `continue`d before ever reaching the tries>=40
retry-limit check or the usleep(), spinning at full CPU forever
instead of failing with a clear error.

Move the staleness check inside an is_dir() guard instead of using
continue to skip past the retry-limit logic, so the bounded-retry
behavior applies regardless of why mkdir failed.
2026-07-04 21:59:13 +02:00
Marek Miklewicz bbc1afb5e1 Fix worker.sh lock permanently stalling after a crash
The mkdir-based worker lock had no staleness detection at all, unlike
BackupQueueService::withQueueLock()'s 120s override for its own queue
lock. If the root cron-driven worker was ever killed ungracefully (OOM,
kill -9, reboot mid-job), the lock directory was left behind forever -
every subsequent cron tick would silently exit without processing any
job, so backups/restores would queue forever with no visible error.

Track the owning PID inside the lock directory and reclaim the lock
when that PID is no longer running, or when the lock has no PID file
and is older than WORKER_LOCK_STALE_SECONDS (default 300s). Log to
stderr on both the "held by a live worker" and "reclaiming stale lock"
paths instead of exiting silently either way. cleanup() now uses rm -rf
since the lock directory holds a pid file, not just an empty marker.
2026-07-04 21:56:52 +02:00
Marek Miklewicz 60ae1e1697 Fix arbitrary file read via restore upload path (security)
stageRestoreUploadFromDirectAdminTemp() accepted the raw
source_file_upload POST value as a literal filesystem path with only
an is_file() check - no ownership or allowed-root validation, unlike
the sibling queueRestoreFromFilePath(). isAllowedSqlFile() only checks
the attacker-controlled display filename, never the real source path.
Since the whole plugin runs as root, any authenticated DirectAdmin user
could stage and restore-import any file readable by root on the
server (e.g. other users' alt-mysql.conf credentials), leaking its
contents via MySQL import error output or the resulting database.

Remove the unsafe raw-path candidate entirely; only accept files whose
basename matches one inside the already-safe, fixed set of known
DirectAdmin/system temp directories.
2026-07-04 21:53:21 +02:00
Marek Miklewicz b077425f93 Exclude docs/superpowers/ planning artifacts from the release package
Specs and plans under docs/superpowers/ are agentic-workflow artifacts,
not plugin runtime files - keep them out of the shipped tarball the
same way tests/ already is.
2026-07-04 21:33:19 +02:00
31 changed files with 4929 additions and 143 deletions
+1
View File
@@ -4,3 +4,4 @@ __MACOSX/
error.log
data/*
assets/adminer/*
.superpowers/
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,996 @@
# Restore Without Native Staging Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Restore external (non-plugin-origin) DirectAdmin account backups directly into `alt-mariadb` by parsing DirectAdmin's native `backup/<dbname>.conf` + `backup/<dbname>.sql` file pairs straight out of the archive, without ever staging data in the CustomBuild-managed native MariaDB.
**Architecture:** A new self-contained script, `alt_mysql_native_backup_restore.sh`, parses the native `.conf` key=value format (charset/collation + per-user password hash/plugin/privileges) and imports each database directly into `alt-mariadb`. The existing restore hook (`alt_mysql_user_restore_post_pre_cleanup.sh`) tries this path per-database before falling back to the existing `da_restore_native_staging_to_alt_mysql.sh`, which remains as a safety net only.
**Tech Stack:** Bash, MariaDB/MySQL client (`mariadb`/`mysql`), existing plugin helper library `mysql_common.sh`.
## Global Constraints
- Match existing test conventions in `tests/`: plain bash scripts with `fail()`/custom asserts, fake `mariadb`/`mysql` binary stubs installed via `MYSQL_PLUGIN_CLIENT_BIN_DIR`, no external test framework.
- Reuse `mysql_common.sh` helpers (`mysql_exec`, `mysql_escape_literal`, `mysql_quote_identifier`, `mysql_safe_identifier`, `mysql_load_alt_credentials`, `mysql_ensure_metadata_schema`) rather than reimplementing SQL execution.
- Every new/changed script keeps `set -Eeuo pipefail` and the existing `log()`/`die()` pattern (timestamped line to `$LOG_FILE`, message to stderr, non-zero exit).
- Never assume the `.conf` format is guaranteed stable across DirectAdmin versions — every parse failure or unrecognized field must fail closed (return non-zero) so the caller falls back to `da_restore_native_staging_to_alt_mysql.sh`, never silently skip or guess.
- Database/user identifiers go through `mysql_safe_identifier`/`mysql_quote_identifier`; string literals go through `mysql_escape_literal`. Never interpolate parsed `.conf` values into SQL unescaped.
- Ground-truth fixture: `example_backup/psy/backup/psy_it301.conf` (and `psy_nowa.conf`) — use this exact content (or a trimmed copy of it) in tests, don't invent a hypothetical format.
---
## Task 1: Extract shared restore-rollback helpers into mysql_common.sh
**Files:**
- Modify: `scripts/setup/mysql_common.sh`
- Modify: `scripts/da-integration/alt_mysql_restore_payload.sh:135-169` (remove local copies, source shared ones)
- Modify: `scripts/da-integration/da_restore_native_staging_to_alt_mysql.sh:158-192` (remove local copies, source shared ones)
- Test: `tests/restore_rollback_safety_test.sh` (existing, must still pass unmodified)
- Test: `tests/mysql_common_runtime_test.sh` (existing, run to confirm no regression)
**Interfaces:**
- Produces (new shared functions in `mysql_common.sh`, usable by any script that has already sourced it and set `LOG_FILE`):
- `backup_alt_database_for_rollback DB_NAME` — dumps `DB_NAME` from `alt-mariadb` to a temp file if it exists; sets globals `RESTORE_ROLLBACK_FILE` and `RESTORE_ROLLBACK_CREATED` (0 or 1); returns 0 on success (including the "database doesn't exist yet" case), non-zero if the dump itself fails.
- `restore_alt_database_from_rollback DB_NAME` — if `RESTORE_ROLLBACK_CREATED=1`, drops/recreates `DB_NAME` and reimports the rollback dump; no-op otherwise.
- `cleanup_restore_rollback` — removes `$RESTORE_ROLLBACK_FILE` if present and resets the two globals. Safe to call multiple times.
- Consumes: `mysql_exec`, `mysqldump_exec`, `mysql_escape_literal`, `mysql_quote_identifier` (all already in `mysql_common.sh`), and the global `$LOG_FILE` (must already be set by the sourcing script before these are called).
This task is a pure refactor (dedupe) with no behavior change — it exists because Task 3 will otherwise add a *third* near-identical copy of this exact logic.
- [ ] **Step 1: Copy the rollback helpers into `mysql_common.sh`**
Open `scripts/setup/mysql_common.sh` and append, right after `mysql_restore_stream()` (currently the last function, ending the file):
```bash
RESTORE_ROLLBACK_FILE=""
RESTORE_ROLLBACK_CREATED=0
cleanup_restore_rollback() {
if [ -n "${RESTORE_ROLLBACK_FILE:-}" ] && [ -f "$RESTORE_ROLLBACK_FILE" ]; then
rm -f "$RESTORE_ROLLBACK_FILE"
fi
RESTORE_ROLLBACK_FILE=""
RESTORE_ROLLBACK_CREATED=0
}
backup_alt_database_for_rollback() {
local db="$1"
cleanup_restore_rollback
RESTORE_ROLLBACK_FILE="$(mktemp)"
if [ -z "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1" 2>>"${LOG_FILE:-/dev/null}" || true)" ]; then
cleanup_restore_rollback
return 0
fi
if mysqldump_exec \
--single-transaction \
--quick \
--skip-lock-tables \
--routines \
--triggers \
--events \
--default-character-set=utf8mb4 \
"$db" > "$RESTORE_ROLLBACK_FILE" 2>>"${LOG_FILE:-/dev/null}"; then
RESTORE_ROLLBACK_CREATED=1
return 0
fi
cleanup_restore_rollback
return 1
}
restore_alt_database_from_rollback() {
local db="$1"
if [ "$RESTORE_ROLLBACK_CREATED" -ne 1 ] || [ -z "${RESTORE_ROLLBACK_FILE:-}" ] || [ ! -r "$RESTORE_ROLLBACK_FILE" ]; then
return 0
fi
mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db");" >> "${LOG_FILE:-/dev/null}" 2>&1 || true
mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "${LOG_FILE:-/dev/null}" 2>&1 || true
mysql_exec "$db" < "$RESTORE_ROLLBACK_FILE" >> "${LOG_FILE:-/dev/null}" 2>&1 || {
return 1
}
}
```
- [ ] **Step 2: Remove the local copies from `alt_mysql_restore_payload.sh`**
In `scripts/da-integration/alt_mysql_restore_payload.sh`, delete lines 135-169 (the `cleanup_restore_rollback`, `backup_alt_database_for_rollback`, `restore_alt_database_from_rollback` function definitions and the `RESTORE_ROLLBACK_FILE=""`/`RESTORE_ROLLBACK_CREATED=0` globals already declared at the top, lines 12-13) — keep the top-of-file variable declarations removed too since `mysql_common.sh` now owns them. Confirm `trap cleanup_restore_rollback INT TERM EXIT` (line 186) stays — it now calls the shared function.
- [ ] **Step 3: Remove the local copies from `da_restore_native_staging_to_alt_mysql.sh`**
In `scripts/da-integration/da_restore_native_staging_to_alt_mysql.sh`, delete lines 158-192 (same three functions) and the top-of-file `RESTORE_ROLLBACK_FILE=""`/`RESTORE_ROLLBACK_CREATED=0` declarations (lines 13-14). Keep `trap cleanup_restore_rollback INT TERM EXIT` (line 209).
- [ ] **Step 4: Run the existing rollback-safety test**
Run: `bash tests/restore_rollback_safety_test.sh`
Expected: `restore_rollback_safety_test: OK` — this test only greps for the function-name strings appearing in each file, and both scripts still *call* `backup_alt_database_for_rollback`/`restore_alt_database_from_rollback`/`trap cleanup_restore_rollback`, so it must still pass even though the definitions moved.
- [ ] **Step 5: Run the full existing restore-related test suite to confirm no regression**
Run:
```bash
bash tests/alt_mysql_restore_payload_test.sh
bash tests/da_restore_archive_traversal_test.sh
bash tests/mysql_common_runtime_test.sh
```
Expected: all three print their own `... : OK` line, no failures.
- [ ] **Step 6: Commit**
```bash
git add scripts/setup/mysql_common.sh scripts/da-integration/alt_mysql_restore_payload.sh scripts/da-integration/da_restore_native_staging_to_alt_mysql.sh
git commit -m "refactor: share restore rollback helpers via mysql_common.sh"
```
---
## Task 2: Parse DirectAdmin's native backup/<dbname>.conf format
**Files:**
- Create: `scripts/da-integration/alt_mysql_native_backup_restore.sh`
- Test: `tests/alt_mysql_native_backup_restore_parse_test.sh`
**Interfaces:**
- Produces (sourced or called by Task 3's own `main()` in the same file, and directly tested here):
- `url_decode STRING` → prints the percent-decoded string.
- `native_conf_field QUERY_STRING FIELD_NAME` → prints the value of `FIELD_NAME` out of an `&`-joined `key=value` query string, or empty if absent.
- `native_conf_privilege_list QUERY_STRING` → prints a comma-joined list of SQL `GRANT` privilege names (e.g. `SELECT,INSERT,UPDATE`) derived from the `*_priv=Y` flags in `QUERY_STRING`, or `ALL` if none matched (mirrors the reverse mapping already used by `db_privilege_profile()` in `da_restore_native_staging_to_alt_mysql.sh`).
- `parse_native_db_conf CONF_FILE` → on success (return 0), sets these globals: `NATIVE_DB_CHARSET`, `NATIVE_DB_COLLATION`, and parallel arrays `NATIVE_DB_USER_NAMES`, `NATIVE_DB_USER_HOST`, `NATIVE_DB_USER_HASH`, `NATIVE_DB_USER_PLUGIN`, `NATIVE_DB_USER_PRIVS` (one entry per MySQL user line in the `.conf`). On any structural problem (missing charset, zero users, unrecognized format) returns non-zero and leaves globals empty — caller must treat this as "fall back to native staging", never partially apply.
- Consumes: nothing from other tasks yet (pure parsing, no DB calls in this task).
- [ ] **Step 1: Write the failing parser test**
Create `tests/alt_mysql_native_backup_restore_parse_test.sh`:
```bash
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT="$PLUGIN_DIR/scripts/da-integration/alt_mysql_native_backup_restore.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
# Source only the parsing functions, without triggering main() (the script
# guards its own main() call behind a BASH_SOURCE check, see Task 3 Step 1).
# shellcheck source=/dev/null
source "$SCRIPT"
# --- Real example content, copied verbatim from example_backup/psy/backup/psy_it301.conf ---
CONF_FILE="$TMP_DIR/psy_it301.conf"
cat > "$CONF_FILE" <<'EOF'
accesshosts=0=localhost
db_collation=DEFAULT_CHARACTER_SET_NAME=utf8mb3&DEFAULT_COLLATION_NAME=utf8mb3_general_ci&SCHEMA_NAME=psy_it301
psy_it301=accesshosts=localhost&alter_priv=Y&alter_routine_priv=Y&create_priv=Y&create_routine_priv=Y&create_tmp_table_priv=Y&create_view_priv=Y&delete_priv=Y&drop_priv=Y&event_priv=Y&execute_priv=Y&index_priv=Y&insert_priv=Y&lock_tables_priv=Y&passwd=%2A8E2A6497279CF1B7F115E213B9904BD32703F4EF&plugin=mysql_native_password&references_priv=Y&select_priv=Y&show_view_priv=Y&trigger_priv=Y&update_priv=Y
EOF
# url_decode
[ "$(url_decode '%2A8E2A6497279CF1B7F115E213B9904BD32703F4EF')" = "*8E2A6497279CF1B7F115E213B9904BD32703F4EF" ] \
|| fail "url_decode did not decode %2A correctly"
# native_conf_field
QS="accesshosts=localhost&select_priv=Y&passwd=%2Aabc&plugin=mysql_native_password"
[ "$(native_conf_field "$QS" passwd)" = "%2Aabc" ] || fail "native_conf_field did not extract passwd"
[ "$(native_conf_field "$QS" plugin)" = "mysql_native_password" ] || fail "native_conf_field did not extract plugin"
[ "$(native_conf_field "$QS" missing_field)" = "" ] || fail "native_conf_field should return empty for a missing field"
# native_conf_privilege_list
PRIVS="$(native_conf_privilege_list "$QS")"
[ "$PRIVS" = "SELECT" ] || fail "expected only SELECT for a single select_priv=Y field, got: $PRIVS"
NO_PRIVS="$(native_conf_privilege_list "accesshosts=localhost&plugin=mysql_native_password")"
[ "$NO_PRIVS" = "ALL" ] || fail "expected ALL when no *_priv=Y flags are present, got: $NO_PRIVS"
# parse_native_db_conf against the real example file
parse_native_db_conf "$CONF_FILE" || fail "parse_native_db_conf should succeed on a valid conf file"
[ "$NATIVE_DB_CHARSET" = "utf8mb3" ] || fail "expected charset utf8mb3, got: $NATIVE_DB_CHARSET"
[ "$NATIVE_DB_COLLATION" = "utf8mb3_general_ci" ] || fail "expected collation utf8mb3_general_ci, got: $NATIVE_DB_COLLATION"
[ "${#NATIVE_DB_USER_NAMES[@]}" -eq 1 ] || fail "expected exactly 1 user, got: ${#NATIVE_DB_USER_NAMES[@]}"
[ "${NATIVE_DB_USER_NAMES[0]}" = "psy_it301" ] || fail "expected username psy_it301, got: ${NATIVE_DB_USER_NAMES[0]}"
[ "${NATIVE_DB_USER_HOST[0]}" = "localhost" ] || fail "expected host localhost, got: ${NATIVE_DB_USER_HOST[0]}"
[ "${NATIVE_DB_USER_HASH[0]}" = "*8E2A6497279CF1B7F115E213B9904BD32703F4EF" ] || fail "password hash not decoded correctly, got: ${NATIVE_DB_USER_HASH[0]}"
[ "${NATIVE_DB_USER_PLUGIN[0]}" = "mysql_native_password" ] || fail "expected mysql_native_password plugin, got: ${NATIVE_DB_USER_PLUGIN[0]}"
echo "${NATIVE_DB_USER_PRIVS[0]}" | grep -q "SELECT" || fail "expected SELECT in privilege list, got: ${NATIVE_DB_USER_PRIVS[0]}"
# --- A conf file with two users on the same database ---
MULTI_CONF="$TMP_DIR/multi.conf"
cat > "$MULTI_CONF" <<'EOF'
accesshosts=0=localhost
db_collation=DEFAULT_CHARACTER_SET_NAME=utf8mb4&DEFAULT_COLLATION_NAME=utf8mb4_general_ci&SCHEMA_NAME=demo_multi
demo_owner=accesshosts=localhost&select_priv=Y&insert_priv=Y&passwd=%2Aaaa&plugin=mysql_native_password
demo_reader=accesshosts=localhost&select_priv=Y&passwd=%2Abbb&plugin=mysql_native_password
EOF
parse_native_db_conf "$MULTI_CONF" || fail "parse_native_db_conf should succeed on a multi-user conf file"
[ "${#NATIVE_DB_USER_NAMES[@]}" -eq 2 ] || fail "expected 2 users in a multi-user conf, got: ${#NATIVE_DB_USER_NAMES[@]}"
# --- A malformed conf file (no db_collation) must fail closed, not guess ---
BAD_CONF="$TMP_DIR/bad.conf"
cat > "$BAD_CONF" <<'EOF'
accesshosts=0=localhost
demo_user=select_priv=Y&passwd=%2Aaaa&plugin=mysql_native_password
EOF
if parse_native_db_conf "$BAD_CONF" 2>/dev/null; then
fail "parse_native_db_conf must fail when db_collation is missing, not guess a default"
fi
echo "alt_mysql_native_backup_restore_parse_test: OK"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bash tests/alt_mysql_native_backup_restore_parse_test.sh`
Expected: FAIL — `scripts/da-integration/alt_mysql_native_backup_restore.sh: No such file or directory` (the script doesn't exist yet).
- [ ] **Step 3: Create `alt_mysql_native_backup_restore.sh` with the parsing functions**
Create `scripts/da-integration/alt_mysql_native_backup_restore.sh`:
```bash
#!/bin/bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
COMMON_FILE="$PLUGIN_DIR/scripts/setup/mysql_common.sh"
SETTINGS_FILE="${DA_ALT_MYSQL_SETTINGS_FILE:-$PLUGIN_DIR/plugin-settings.conf}"
LOG_FILE="${DA_ALT_MYSQL_RESTORE_LOG:-$PLUGIN_DIR/data/logs/da-restore.log}"
mkdir -p "$(dirname "$LOG_FILE")"
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE"
}
die() {
log "ERROR: $*"
printf 'alt-mysql native backup restore: %s\n' "$*" >&2
exit 1
}
url_decode() {
local encoded="${1//+/ }"
printf '%b' "${encoded//%/\\x}"
}
native_conf_field() {
local qs="$1" field="$2" pair
local -a pairs
IFS='&' read -ra pairs <<< "$qs"
for pair in "${pairs[@]}"; do
if [ "${pair%%=*}" = "$field" ]; then
printf '%s' "${pair#*=}"
return 0
fi
done
return 0
}
NATIVE_PRIV_FLAG_MAP=(
"select_priv:SELECT" "insert_priv:INSERT" "update_priv:UPDATE" "delete_priv:DELETE"
"create_priv:CREATE" "drop_priv:DROP" "index_priv:INDEX" "alter_priv:ALTER"
"create_tmp_table_priv:CREATE TEMPORARY TABLES" "lock_tables_priv:LOCK TABLES"
"create_view_priv:CREATE VIEW" "show_view_priv:SHOW VIEW"
"create_routine_priv:CREATE ROUTINE" "alter_routine_priv:ALTER ROUTINE"
"execute_priv:EXECUTE" "event_priv:EVENT" "trigger_priv:TRIGGER"
"references_priv:REFERENCES"
)
native_conf_privilege_list() {
local qs="$1"
local entry flag name value
local -a privs=()
for entry in "${NATIVE_PRIV_FLAG_MAP[@]}"; do
flag="${entry%%:*}"
name="${entry#*:}"
value="$(native_conf_field "$qs" "$flag")"
[ "$value" = "Y" ] && privs+=("$name")
done
if [ "${#privs[@]}" -eq 0 ]; then
printf 'ALL'
else
local IFS=,
printf '%s' "${privs[*]}"
fi
}
parse_native_db_conf() {
local conf_file="$1"
local line key value
NATIVE_DB_CHARSET=""
NATIVE_DB_COLLATION=""
NATIVE_DB_USER_NAMES=()
NATIVE_DB_USER_HOST=()
NATIVE_DB_USER_HASH=()
NATIVE_DB_USER_PLUGIN=()
NATIVE_DB_USER_PRIVS=()
[ -r "$conf_file" ] || { log "cannot read conf file: $conf_file"; return 1; }
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue
[[ "$line" == *=* ]] || continue
key="${line%%=*}"
value="${line#*=}"
case "$key" in
db_collation)
NATIVE_DB_CHARSET="$(native_conf_field "$value" DEFAULT_CHARACTER_SET_NAME)"
NATIVE_DB_COLLATION="$(native_conf_field "$value" DEFAULT_COLLATION_NAME)"
;;
accesshosts) ;;
*)
NATIVE_DB_USER_NAMES+=("$key")
NATIVE_DB_USER_HOST+=("$(native_conf_field "$value" accesshosts)")
NATIVE_DB_USER_HASH+=("$(url_decode "$(native_conf_field "$value" passwd)")")
NATIVE_DB_USER_PLUGIN+=("$(native_conf_field "$value" plugin)")
NATIVE_DB_USER_PRIVS+=("$(native_conf_privilege_list "$value")")
;;
esac
done < "$conf_file"
if [ -z "$NATIVE_DB_CHARSET" ]; then
log "missing db_collation charset in $conf_file"
return 1
fi
if [ "${#NATIVE_DB_USER_NAMES[@]}" -eq 0 ]; then
log "no MySQL users found in $conf_file"
return 1
fi
return 0
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[ -r "$COMMON_FILE" ] || die "missing helper: $COMMON_FILE"
# shellcheck source=../setup/mysql_common.sh
source "$COMMON_FILE"
mysql_load_plugin_settings_file "$SETTINGS_FILE"
# main() is added in Task 3.
fi
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bash tests/alt_mysql_native_backup_restore_parse_test.sh`
Expected: `alt_mysql_native_backup_restore_parse_test: OK`
- [ ] **Step 5: Commit**
```bash
git add scripts/da-integration/alt_mysql_native_backup_restore.sh tests/alt_mysql_native_backup_restore_parse_test.sh
git commit -m "feat: parse DirectAdmin native backup/<dbname>.conf format"
```
---
## Task 3: Discover and import native database pairs into alt-mariadb
**Files:**
- Modify: `scripts/da-integration/alt_mysql_native_backup_restore.sh` (add discovery + import + CLI `main()`)
- Test: `tests/alt_mysql_native_backup_restore_import_test.sh`
**Interfaces:**
- Consumes from Task 2: `parse_native_db_conf`, and its output globals (`NATIVE_DB_CHARSET`, `NATIVE_DB_COLLATION`, `NATIVE_DB_USER_NAMES`, `NATIVE_DB_USER_HOST`, `NATIVE_DB_USER_HASH`, `NATIVE_DB_USER_PLUGIN`, `NATIVE_DB_USER_PRIVS`).
- Consumes from Task 1: `backup_alt_database_for_rollback`, `restore_alt_database_from_rollback`, `cleanup_restore_rollback` (via `mysql_common.sh`).
- Consumes from existing `mysql_common.sh`: `mysql_exec`, `mysql_escape_literal`, `mysql_quote_identifier`, `mysql_safe_identifier`, `mysql_load_alt_credentials`, `mysql_ensure_metadata_schema`.
- Produces (used by Task 4):
- CLI: `alt_mysql_native_backup_restore.sh --user DA_USER --root DIR_OR_ARCHIVE [--overwrite allow|deny]` — locates every `backup/<DA_USER>_*.conf` (with a matching `.sql`) reachable from `DIR_OR_ARCHIVE` (a plain directory, or a `.tar`/`.tar.gz` archive path), imports each into `alt-mariadb`, and prints one line per database to stdout: `IMPORTED <dbname>` on success or `SKIPPED <dbname> <reason>` on a per-database failure (falls back gracefully — does not abort the whole run for one bad database). Exit code is always 0 if it ran to completion (even with some `SKIPPED` lines); non-zero only for usage errors or total inability to reach `alt-mariadb`.
- Function `import_native_database_from_conf DB_NAME CONF_FILE SQL_FILE DA_USER OVERWRITE` (`OVERWRITE` is `allow`|`deny`) — the single-database import used by both the CLI loop and directly by tests. Returns 0 and leaves the database committed on success; returns non-zero (with the rollback dump already re-applied if it had gotten far enough to touch the database) on any failure — callers must treat non-zero as "leave this one to the staging fallback", never retry with different arguments.
- [ ] **Step 1: Write the failing import test**
Create `tests/alt_mysql_native_backup_restore_import_test.sh`:
```bash
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT="$PLUGIN_DIR/scripts/da-integration/alt_mysql_native_backup_restore.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
# --- Fake alt-mariadb client, tracks statements + a simple existing-db state file ---
mkdir -p "$TMP_DIR/mariadb/bin"
cat > "$TMP_DIR/mariadb/bin/mariadb" <<'STUB'
#!/bin/bash
STATE_FILE="${FAKE_DB_STATE_FILE:?FAKE_DB_STATE_FILE must be set}"
CALL_LOG="${FAKE_CALL_LOG:?FAKE_CALL_LOG must be set}"
sql=""
mode=""
for arg in "$@"; do
case "$arg" in
-e) mode="sql" ;;
-Nse) mode="sql" ;;
--database=*|--user=*|--socket=*|--host=*|--port=*|--protocol=*) ;;
*)
if [ "$mode" = "sql" ]; then
sql="$arg"
mode=""
fi
;;
esac
done
if [ -n "$sql" ]; then
printf '%s\n' "$sql" >> "$CALL_LOG"
case "$sql" in
*"SELECT 1 FROM information_schema.SCHEMATA"*)
name="$(printf '%s' "$sql" | sed -n "s/.*SCHEMA_NAME='\([^']*\)'.*/\1/p")"
grep -qx "$name" "$STATE_FILE" 2>/dev/null && echo 1
;;
*"DROP DATABASE"*)
name="$(printf '%s' "$sql" | sed -n 's/.*DATABASE[^`]*`\([A-Za-z0-9_]*\)`.*/\1/p')"
[ -n "$name" ] && { grep -vx "$name" "$STATE_FILE" > "$STATE_FILE.tmp" 2>/dev/null || true; mv -f "$STATE_FILE.tmp" "$STATE_FILE"; }
;;
*"CREATE DATABASE"*)
name="$(printf '%s' "$sql" | sed -n 's/.*DATABASE[^`]*`\([A-Za-z0-9_]*\)`.*/\1/p')"
[ -n "$name" ] && echo "$name" >> "$STATE_FILE"
;;
esac
exit 0
fi
cat >> "$CALL_LOG"
printf 'IMPORT\n' >> "$CALL_LOG"
if [ "${FAKE_IMPORT_FAIL:-0}" = "1" ]; then
exit 1
fi
exit 0
STUB
chmod 755 "$TMP_DIR/mariadb/bin/mariadb"
cat > "$TMP_DIR/mariadb/bin/mariadb-dump" <<'STUB'
#!/bin/bash
echo "-- fake rollback dump"
exit 0
STUB
chmod 755 "$TMP_DIR/mariadb/bin/mariadb-dump"
cat > "$TMP_DIR/alt-mysql.conf" <<EOF
host=
passwd=secret
port=4407
socket=$TMP_DIR/data/mariadb.sock
user=da_admin
EOF
cat > "$TMP_DIR/settings.conf" <<EOF
MYSQL_PLUGIN_CREDENTIALS_FILE=$TMP_DIR/alt-mysql.conf
MYSQL_PLUGIN_CLIENT_BIN_DIR=$TMP_DIR/mariadb/bin
EOF
# run_import NAME_PREFIX -- ARGS...
# Runs the script under test with fresh, isolated state/log/call-log files
# for this scenario, writes its stdout to "$TMP_DIR/<prefix>.stdout" and
# writes every mariadb-stub SQL statement to "$TMP_DIR/<prefix>.calls",
# then prints nothing (the two files are read directly by the caller).
run_import() {
local prefix="$1"
shift
local db_state="$TMP_DIR/${prefix}.dbstate"
local call_log="$TMP_DIR/${prefix}.calls"
local stdout_file="$TMP_DIR/${prefix}.stdout"
local restore_log="$TMP_DIR/${prefix}.restore.log"
: > "$db_state"
: > "$call_log"
DA_ALT_MYSQL_SETTINGS_FILE="$TMP_DIR/settings.conf" \
DA_ALT_MYSQL_RESTORE_LOG="$restore_log" \
FAKE_DB_STATE_FILE="$db_state" \
FAKE_CALL_LOG="$call_log" \
bash "$SCRIPT" "$@" > "$stdout_file"
}
# --- Build a fake backup directory with one valid database pair ---
BACKUP_DIR="$TMP_DIR/backup"
mkdir -p "$BACKUP_DIR"
cat > "$BACKUP_DIR/demo_app.conf" <<'EOF'
accesshosts=0=localhost
db_collation=DEFAULT_CHARACTER_SET_NAME=utf8mb4&DEFAULT_COLLATION_NAME=utf8mb4_unicode_ci&SCHEMA_NAME=demo_app
demo_app=accesshosts=localhost&select_priv=Y&insert_priv=Y&passwd=%2Aabc123&plugin=mysql_native_password
EOF
cat > "$BACKUP_DIR/demo_app.sql" <<'EOF'
CREATE TABLE t (id INT);
EOF
# --- Scenario 1: happy path, single valid database ---
run_import scenario1 --user demo --root "$BACKUP_DIR" --overwrite allow
grep -q "IMPORTED demo_app" "$TMP_DIR/scenario1.stdout" \
|| fail "expected 'IMPORTED demo_app' in output, got: $(cat "$TMP_DIR/scenario1.stdout")"
grep -q "CREATE USER 'demo_app'@'localhost'" "$TMP_DIR/scenario1.calls" \
|| fail "expected CREATE USER statement for demo_app@localhost"
grep -q "GRANT SELECT,INSERT ON \`demo_app\`.\* TO 'demo_app'@'localhost'" "$TMP_DIR/scenario1.calls" \
|| fail "expected GRANT SELECT,INSERT for demo_app@localhost"
grep -q "IMPORT" "$TMP_DIR/scenario1.calls" || fail "expected the .sql dump to be piped in for import"
# --- Scenario 2: a database with no matching .conf must be skipped, not abort the run ---
mkdir -p "$TMP_DIR/backup_missing_conf"
cat > "$TMP_DIR/backup_missing_conf/orphan_db.sql" <<'EOF'
CREATE TABLE t (id INT);
EOF
run_import scenario2 --user orphan --root "$TMP_DIR/backup_missing_conf" --overwrite allow
grep -q "SKIPPED orphan_db" "$TMP_DIR/scenario2.stdout" \
|| fail "expected orphan_db (no .conf) to be reported as SKIPPED, got: $(cat "$TMP_DIR/scenario2.stdout")"
echo "alt_mysql_native_backup_restore_import_test: OK"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bash tests/alt_mysql_native_backup_restore_import_test.sh`
Expected: FAIL — the script has no `--root`/`--user` CLI handling yet, so it exits with a usage error or does nothing.
- [ ] **Step 3: Add discovery, import, and `main()` to `alt_mysql_native_backup_restore.sh`**
Replace the trailing `if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then ... fi` block at the end of `scripts/da-integration/alt_mysql_native_backup_restore.sh` (added in Task 2 Step 3) with:
```bash
mysql_import_sql_file() {
local database="$1" file="$2"
if [[ "$file" == *.gz ]]; then
gunzip -c "$file" | mysql_exec "$database"
return "${PIPESTATUS[1]}"
fi
mysql_exec "$database" < "$file"
}
verify_database_exists() {
local db="$1"
[ -n "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1")" ] \
|| die "database was not restored: $db"
}
rebuild_metadata_for_native_import() {
local db="$1" da_user="$2"
local i owner="" role host
for i in "${!NATIVE_DB_USER_NAMES[@]}"; do
role="${NATIVE_DB_USER_NAMES[$i]}"
host="${NATIVE_DB_USER_HOST[$i]:-localhost}"
[ -n "$owner" ] || owner="$role"
mysql_exec mysql -e "
INSERT INTO da_plugin_grant_profiles (db_name, role_name, privileges)
VALUES ('$(mysql_escape_literal "$db")', '$(mysql_escape_literal "$role")', '$(mysql_escape_literal "${NATIVE_DB_USER_PRIVS[$i]}")')
ON DUPLICATE KEY UPDATE privileges=VALUES(privileges), updated_at=CURRENT_TIMESTAMP;
" >> "$LOG_FILE" 2>&1 || true
mysql_exec mysql -e "
INSERT INTO da_plugin_access_hosts (da_user, role_name, host_pattern, note)
VALUES ('$(mysql_escape_literal "$da_user")', '$(mysql_escape_literal "$role")', '$(mysql_escape_literal "$host")', 'restored from native backup/<db>.conf')
ON DUPLICATE KEY UPDATE da_user=VALUES(da_user), note=VALUES(note), updated_at=CURRENT_TIMESTAMP;
" >> "$LOG_FILE" 2>&1 || true
done
if [ -n "$owner" ]; then
mysql_exec mysql -e "
INSERT INTO da_plugin_database_owners (db_name, da_user, role_name)
VALUES ('$(mysql_escape_literal "$db")', '$(mysql_escape_literal "$da_user")', '$(mysql_escape_literal "$owner")')
ON DUPLICATE KEY UPDATE da_user=VALUES(da_user), role_name=VALUES(role_name), updated_at=CURRENT_TIMESTAMP;
" >> "$LOG_FILE" 2>&1 || true
fi
}
import_native_database_from_conf() {
local db="$1" conf_file="$2" sql_file="$3" da_user="$4" overwrite="$5"
parse_native_db_conf "$conf_file" || { log "SKIP $db: conf did not parse"; return 1; }
local collation="${NATIVE_DB_COLLATION:-}"
if [ -z "$collation" ]; then
log "SKIP $db: no collation parsed"
return 1
fi
local i
for i in "${!NATIVE_DB_USER_PLUGIN[@]}"; do
if [ "${NATIVE_DB_USER_PLUGIN[$i]}" != "mysql_native_password" ]; then
log "SKIP $db: unsupported auth plugin '${NATIVE_DB_USER_PLUGIN[$i]}' for user ${NATIVE_DB_USER_NAMES[$i]}"
return 1
fi
done
if [ "$overwrite" = "deny" ] && [ -n "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1")" ]; then
log "SKIP $db: already exists and overwrite is denied"
return 1
fi
backup_alt_database_for_rollback "$db" || { log "SKIP $db: could not create rollback dump"; return 1; }
if ! mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db");" >> "$LOG_FILE" 2>&1; then
restore_alt_database_from_rollback "$db" || true
log "SKIP $db: could not drop database before import"
return 1
fi
if ! mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db") CHARACTER SET $(mysql_safe_identifier "$NATIVE_DB_CHARSET") COLLATE $(mysql_safe_identifier "$collation");" >> "$LOG_FILE" 2>&1; then
restore_alt_database_from_rollback "$db" || true
log "SKIP $db: could not create database before import"
return 1
fi
local user host hash
for i in "${!NATIVE_DB_USER_NAMES[@]}"; do
user="${NATIVE_DB_USER_NAMES[$i]}"
host="${NATIVE_DB_USER_HOST[$i]:-localhost}"
hash="${NATIVE_DB_USER_HASH[$i]}"
if ! {
printf "DROP USER IF EXISTS '%s'@'%s';\n" "$(mysql_escape_literal "$user")" "$(mysql_escape_literal "$host")"
printf "CREATE USER '%s'@'%s' IDENTIFIED WITH mysql_native_password AS '%s';\n" \
"$(mysql_escape_literal "$user")" "$(mysql_escape_literal "$host")" "$(mysql_escape_literal "$hash")"
printf "GRANT %s ON %s.* TO '%s'@'%s';\n" \
"${NATIVE_DB_USER_PRIVS[$i]}" "$(mysql_quote_identifier "$db")" "$(mysql_escape_literal "$user")" "$(mysql_escape_literal "$host")"
} | mysql_exec mysql >> "$LOG_FILE" 2>&1; then
restore_alt_database_from_rollback "$db" || true
log "SKIP $db: could not recreate user ${user}@${host}"
return 1
fi
done
if ! mysql_import_sql_file "$db" "$sql_file" >> "$LOG_FILE" 2>&1; then
restore_alt_database_from_rollback "$db" || true
log "SKIP $db: could not import dump"
return 1
fi
cleanup_restore_rollback
verify_database_exists "$db"
rebuild_metadata_for_native_import "$db" "$da_user"
mysql_exec mysql -e "FLUSH PRIVILEGES;" >> "$LOG_FILE" 2>&1 || true
return 0
}
find_native_pairs_in_dir() {
local root="$1" prefix="$2"
local conf base sql
while IFS= read -r conf; do
[ -n "$conf" ] || continue
base="$(basename "$conf" .conf)"
sql="$(dirname "$conf")/${base}.sql"
[ -r "$sql" ] || continue
printf '%s\t%s\t%s\n' "$base" "$conf" "$sql"
done < <(find "$root" -maxdepth 4 -type f -name "${prefix}_*.conf" 2>/dev/null | sort)
}
main() {
local da_user="" root="" overwrite="allow"
while [ "$#" -gt 0 ]; do
case "$1" in
--user) da_user="${2:-}"; shift 2 ;;
--root) root="${2:-}"; shift 2 ;;
--overwrite) overwrite="${2:-}"; shift 2 ;;
*) die "unknown argument: $1" ;;
esac
done
[ -n "$da_user" ] || die "--user is required"
[ -n "$root" ] || die "--root is required"
[[ "$da_user" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || die "unsafe DirectAdmin username: $da_user"
[ -d "$root" ] || die "backup root directory does not exist: $root"
mysql_load_alt_credentials
mysql_ensure_metadata_schema
local -a handled=()
local base conf sql
while IFS=$'\t' read -r base conf sql; do
[ -n "$base" ] || continue
handled+=("$base")
if [[ "$base" != "${da_user}_"* ]]; then
printf 'SKIPPED %s wrong-user-prefix\n' "$base"
continue
fi
if import_native_database_from_conf "$base" "$conf" "$sql" "$da_user" "$overwrite"; then
printf 'IMPORTED %s\n' "$base"
else
printf 'SKIPPED %s import-failed\n' "$base"
fi
done < <(find_native_pairs_in_dir "$root" "$da_user")
# Any *.sql belonging to this user's prefix with no matching .conf was not
# covered by the loop above (find_native_pairs_in_dir only yields pairs
# that have both files) - report those explicitly so the caller knows to
# fall back to native-staging-migrate for them too.
local sql_base
while IFS= read -r sql; do
[ -n "$sql" ] || continue
sql_base="$(basename "$sql" .sql)"
[[ "$sql_base" == "${da_user}_"* ]] || continue
local already=0 h
for h in "${handled[@]}"; do
[ "$h" = "$sql_base" ] && { already=1; break; }
done
[ "$already" -eq 1 ] || printf 'SKIPPED %s no-matching-conf\n' "$sql_base"
done < <(find "$root" -maxdepth 4 -type f -name "${da_user}_*.sql" 2>/dev/null | sort)
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[ -r "$COMMON_FILE" ] || die "missing helper: $COMMON_FILE"
# shellcheck source=../setup/mysql_common.sh
source "$COMMON_FILE"
mysql_load_plugin_settings_file "$SETTINGS_FILE"
main "$@"
fi
```
Note: the parser test from Task 2 sources this file directly (`source "$SCRIPT"`), which is why all of the above must stay guarded behind the `BASH_SOURCE[0]` check — sourcing must never call `main`.
- [ ] **Step 4: Run test to verify it passes**
Run: `bash tests/alt_mysql_native_backup_restore_import_test.sh`
Expected: `alt_mysql_native_backup_restore_import_test: OK`
- [ ] **Step 5: Re-run Task 2's parser test to confirm it still passes unchanged**
Run: `bash tests/alt_mysql_native_backup_restore_parse_test.sh`
Expected: `alt_mysql_native_backup_restore_parse_test: OK`
- [ ] **Step 6: Commit**
```bash
git add scripts/da-integration/alt_mysql_native_backup_restore.sh tests/alt_mysql_native_backup_restore_import_test.sh
git commit -m "feat: import native backup/<dbname>.conf+.sql pairs directly into alt-mariadb"
```
---
## Task 4: Wire the new restore tier into the restore hook
**Files:**
- Modify: `scripts/da-integration/alt_mysql_user_restore_post_pre_cleanup.sh:142-174` (the `main()` function)
- Test: `tests/alt_mysql_user_restore_post_pre_cleanup_tiers_test.sh`
**Interfaces:**
- Consumes from Task 3: the CLI contract `alt_mysql_native_backup_restore.sh --user DA_USER --root DIR --overwrite allow|deny`, and its `IMPORTED <db>`/`SKIPPED <db> <reason>` stdout lines.
- Behavior change: `main()` in `alt_mysql_user_restore_post_pre_cleanup.sh` gains a middle tier between the existing plugin-payload check and the existing native-staging-migrate fallback. The native-staging-migrate script still runs afterward, but only to sweep up whatever the new tier reported as `SKIPPED` (or everything, if the new tier found nothing at all) — it must not redo databases the new tier already reported as `IMPORTED`.
- [ ] **Step 1: Write the failing tier-order test**
Create `tests/alt_mysql_user_restore_post_pre_cleanup_tiers_test.sh`:
```bash
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT="$PLUGIN_DIR/scripts/da-integration/alt_mysql_user_restore_post_pre_cleanup.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
mkdir -p "$TMP_DIR/fake_bin"
# Fake native-backup-restore tier: reports one imported, one skipped database.
cat > "$TMP_DIR/fake_bin/alt_mysql_native_backup_restore.sh" <<'EOF'
#!/bin/bash
echo "CALLED native_backup_restore $*" >> "$FAKE_CALL_LOG"
echo "IMPORTED demo_ok"
echo "SKIPPED demo_bad import-failed"
EOF
chmod 755 "$TMP_DIR/fake_bin/alt_mysql_native_backup_restore.sh"
# Fake native-staging-migrate tier: just logs that it ran, and with which user.
cat > "$TMP_DIR/fake_bin/da_restore_native_staging_to_alt_mysql.sh" <<'EOF'
#!/bin/bash
echo "CALLED native_staging_migrate $*" >> "$FAKE_CALL_LOG"
EOF
chmod 755 "$TMP_DIR/fake_bin/da_restore_native_staging_to_alt_mysql.sh"
# No plugin payload present anywhere reachable, so the hook falls past tier 1.
mkdir -p "$TMP_DIR/empty_restore_root"
CALL_LOG="$TMP_DIR/calls.log"
: > "$CALL_LOG"
DA_ALT_MYSQL_RESTORE_LOG="$TMP_DIR/restore.log" \
DA_ALT_MYSQL_NATIVE_BACKUP_RESTORE_BIN="$TMP_DIR/fake_bin/alt_mysql_native_backup_restore.sh" \
DA_ALT_MYSQL_NATIVE_STAGING_BIN="$TMP_DIR/fake_bin/da_restore_native_staging_to_alt_mysql.sh" \
DA_ALT_MYSQL_SETTINGS_FILE="$TMP_DIR/missing-settings.conf" \
FAKE_CALL_LOG="$CALL_LOG" \
restore_path="$TMP_DIR/empty_restore_root" \
username="demo" \
bash "$SCRIPT" demo
grep -q "CALLED native_backup_restore" "$CALL_LOG" || fail "expected the native-backup-restore tier to be invoked"
grep -q "CALLED native_staging_migrate" "$CALL_LOG" || fail "expected the native-staging-migrate fallback to still run for skipped databases"
# The staging-migrate fallback must be told which databases were already
# imported, so it never touches demo_ok again.
grep -q "\-\-skip demo_ok" "$CALL_LOG" || fail "expected native-staging-migrate to be told to skip demo_ok, log: $(cat "$CALL_LOG")"
echo "alt_mysql_user_restore_post_pre_cleanup_tiers_test: OK"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bash tests/alt_mysql_user_restore_post_pre_cleanup_tiers_test.sh`
Expected: FAIL — the hook doesn't call the native-backup-restore tier yet, and doesn't honor `DA_ALT_MYSQL_NATIVE_BACKUP_RESTORE_BIN`/`DA_ALT_MYSQL_NATIVE_STAGING_BIN` overrides.
- [ ] **Step 3: Add the middle tier to `alt_mysql_user_restore_post_pre_cleanup.sh`**
In `scripts/da-integration/alt_mysql_user_restore_post_pre_cleanup.sh`, add near the top (after the existing `RESTORE_PAYLOAD_SCRIPT`/`NATIVE_MIGRATE_SCRIPT` declarations at lines 8-9):
```bash
NATIVE_BACKUP_RESTORE_SCRIPT="${DA_ALT_MYSQL_NATIVE_BACKUP_RESTORE_BIN:-$SCRIPT_DIR/alt_mysql_native_backup_restore.sh}"
NATIVE_MIGRATE_SCRIPT="${DA_ALT_MYSQL_NATIVE_STAGING_BIN:-$NATIVE_MIGRATE_SCRIPT}"
```
Then replace the body of `main()` (lines 142-174) with:
```bash
main() {
local da_user enabled overwrite payload native_mode restore_root
da_user="$(detect_da_user "${1:-}")"
enabled="$(read_plugin_setting DA_ALT_MYSQL_RESTORE_ENABLED true)"
native_mode="$(read_plugin_setting DA_ALT_MYSQL_NATIVE_STAGING_MIGRATE true)"
log "restore hook env: user=$da_user pwd=$(pwd -P) file=${file:-} filename=${filename:-}"
case "$enabled" in
true|yes|on|1) ;;
*) log "restore hook disabled by DA_ALT_MYSQL_RESTORE_ENABLED=$enabled"; return 0 ;;
esac
overwrite="$(read_plugin_setting DA_ALT_MYSQL_RESTORE_OVERWRITE allow)"
if payload="$(detect_payload_dir)"; then
[ -x "$RESTORE_PAYLOAD_SCRIPT" ] || die "restore payload script is not executable: $RESTORE_PAYLOAD_SCRIPT"
log "plugin payload detected: $payload"
"$RESTORE_PAYLOAD_SCRIPT" --user "$da_user" --payload "$payload" --overwrite "$overwrite"
return 0
fi
log "plugin payload not found; trying native backup/<dbname>.conf restore"
restore_root="$(candidate_dir_if_valid "${restore_path:-}")" \
|| restore_root="$(candidate_dir_if_valid "${restore_dir:-}")" \
|| restore_root="$(candidate_dir_if_valid "${tmpdir:-}")" \
|| restore_root="$(candidate_dir_if_valid "${backup_path:-}")" \
|| restore_root="$(pwd -P)"
local -a imported=()
local -a skip_args=()
if [ -x "$NATIVE_BACKUP_RESTORE_SCRIPT" ] && [ -d "$restore_root" ]; then
while IFS= read -r line; do
[ -n "$line" ] || continue
log "native backup restore: $line"
case "$line" in
IMPORTED\ *)
imported+=("${line#IMPORTED }")
;;
esac
done < <("$NATIVE_BACKUP_RESTORE_SCRIPT" --user "$da_user" --root "$restore_root" --overwrite "$overwrite")
else
log "native backup restore script unavailable or restore root not found: $NATIVE_BACKUP_RESTORE_SCRIPT / $restore_root"
fi
for db in "${imported[@]}"; do
skip_args+=("--skip" "$db")
done
log "native backup restore imported: ${imported[*]:-<none>}; considering native staging migration for the rest"
case "$native_mode" in
true|yes|on|1)
[ -x "$NATIVE_MIGRATE_SCRIPT" ] || die "native staging migration script is not executable: $NATIVE_MIGRATE_SCRIPT"
"$NATIVE_MIGRATE_SCRIPT" --user "$da_user" "${skip_args[@]}"
;;
*)
log "native staging migration disabled; account restore continues without further alt-mysql DB migration"
;;
esac
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bash tests/alt_mysql_user_restore_post_pre_cleanup_tiers_test.sh`
Expected: `alt_mysql_user_restore_post_pre_cleanup_tiers_test: OK`
- [ ] **Step 5: Teach `da_restore_native_staging_to_alt_mysql.sh` to accept `--skip DB` and honor it**
In `scripts/da-integration/da_restore_native_staging_to_alt_mysql.sh`, add a new variable near the top (after `CLEANUP_POLICY=""` at line 12):
```bash
declare -a SKIP_DATABASES=()
```
Update the argument-parsing `while` loop (lines 35-43) to add a case:
```bash
--skip) SKIP_DATABASES+=("${2:-}"); shift 2 ;;
```
Update `list_native_databases()` (lines 91-98) to exclude skipped databases:
```bash
list_native_databases() {
local db
native_query information_schema "
SELECT SCHEMA_NAME
FROM SCHEMATA
WHERE SCHEMA_NAME LIKE '$(mysql_escape_literal "$DA_USER")\\_%' ESCAPE '\\'
ORDER BY SCHEMA_NAME
" | grep -Ev '^(information_schema|performance_schema|mysql|sys)$' | while IFS= read -r db; do
[ -n "$db" ] || continue
local skipped=0 candidate
for candidate in "${SKIP_DATABASES[@]}"; do
[ "$candidate" = "$db" ] && { skipped=1; break; }
done
[ "$skipped" -eq 1 ] || printf '%s\n' "$db"
done || true
}
```
- [ ] **Step 6: Run the full restore-hook test suite to confirm no regression**
Run:
```bash
bash tests/alt_mysql_user_restore_post_pre_cleanup_tiers_test.sh
bash tests/da_restore_archive_traversal_test.sh
bash tests/alt_mysql_restore_payload_test.sh
bash tests/restore_rollback_safety_test.sh
```
Expected: all four print their own `... : OK` line.
- [ ] **Step 7: Commit**
```bash
git add scripts/da-integration/alt_mysql_user_restore_post_pre_cleanup.sh scripts/da-integration/da_restore_native_staging_to_alt_mysql.sh tests/alt_mysql_user_restore_post_pre_cleanup_tiers_test.sh
git commit -m "feat: restore native backup/<dbname>.conf pairs before falling back to native staging"
```
---
## Task 5: Update the design doc to match what was actually built
> **Note for the controller:** `mysql-installer/da-integration.md` lives one directory above this
> repo's root (`alt-mysql/mysql-installer/`, not inside the `alt-mysql/src` git repository that
> Tasks 1-4 are implemented in), and is not tracked by that repository or shipped in the plugin
> package. Do **not** dispatch an implementer/reviewer subagent pair or a git commit for this task
> — apply the edit directly after Tasks 1-4's code is merged, since it is a plain prose update with
> no test surface.
**Files:**
- Modify: `../mysql-installer/da-integration.md` (path relative to the `src/` repo root; Phase 5B and Phase 7 sections)
**Interfaces:** None — documentation only.
- [ ] **Step 1: Replace the Phase 5B description**
In `mysql-installer/da-integration.md`, find the `## Phase 5B: External DirectAdmin Backups Without Plugin Payload` section (currently describing "native staging then migrate" as the primary mechanism for external backups). Replace its `### Problem`/`### Recommended Policy` prose to state, as the primary mechanism: the restore hook (`alt_mysql_user_restore_post_pre_cleanup.sh`) now first calls `alt_mysql_native_backup_restore.sh`, which parses `backup/<dbname>.conf` + `backup/<dbname>.sql` pairs directly out of the archive/extraction root and imports each database straight into `alt-mariadb` — no native MariaDB staging involved. `da_restore_native_staging_to_alt_mysql.sh` (the existing native-staging-then-migrate flow) remains, unchanged in its own logic, as the per-database fallback only for databases the new tier reports as `SKIPPED` (missing/malformed `.conf`, unsupported auth plugin, or a genuinely foreign non-DA-native backup).
- [ ] **Step 2: Update the Phase 7 "CustomBuild MariaDB Staging Role" framing**
In the same file's `## Phase 7` section, add a note that CustomBuild's native MariaDB no longer needs to be usable as a restore staging target for the common case — it is now a fallback path only, exercised solely when `alt_mysql_native_backup_restore.sh` reports a database as `SKIPPED`. It must still be kept running so DirectAdmin core's own login/version check has a legacy-license-safe target to talk to.
- [ ] **Step 3: Commit**
```bash
git add mysql-installer/da-integration.md
git commit -m "docs: reflect native-conf-based restore as the primary external-backup path"
```
@@ -0,0 +1,214 @@
# Design: Native MariaDB Port Takeover for alt-mariadb on Port 3306
Date: 2026-07-05
Status: Approved by user, ready for implementation planning
## Goal
Let `install_db.sh` install `alt-mariadb` on the standard MySQL/MariaDB port (3306) by
first moving DirectAdmin's existing CustomBuild-managed native MariaDB off that port, so
that any tool or script that assumes the default port can reach `alt-mariadb` without
custom port configuration.
## Background
`install_db.sh` currently requires `alt-mariadb`'s port as a mandatory CLI argument
(defaulting nowhere, e.g. `install_db.sh 10.11 33033`), and its own `validate_paths()`
explicitly rejects a port that matches DirectAdmin's native engine's port. Native MariaDB
(installed and managed by CustomBuild) occupies 3306 today.
Earlier in this project's design work, a different port-3306 proposal was considered and
rejected: pointing DirectAdmin's own `mysql.conf` *at* `alt-mariadb` itself. That failed
because DirectAdmin's legacy-code-base license enforces a hard MariaDB-version ceiling
(10.6) at login time, independent of CustomBuild settings, and `alt-mariadb` intentionally
runs newer versions.
This design is different and does not hit that problem: DirectAdmin keeps talking to the
*same* native engine, on the *same* version — only the TCP port that engine listens on
changes. The freed port 3306 then goes to `alt-mariadb`.
The user confirmed empirically, on the actual target server, that setting
`mysql_inst=no` in CustomBuild's `options.conf` and then changing the native port in
`/etc/my.cnf` survives a `./build rewrite_confs` run — i.e., once MySQL/MariaDB is marked
as externally-managed, CustomBuild's config-regeneration sweep no longer touches it. This
is the key fact that makes a manual, persistent port change safe here (CustomBuild's
per-service `rewrite_confs` logic is gated on it considering that service "managed").
## Scope
- Applies only to a **fresh `alt-mariadb` install** (`install_db.sh`'s normal use case) —
not to migrating an already-running `alt-mariadb` instance to a new port. Native MariaDB
is always already running at the time `install_db.sh` runs (DirectAdmin itself is
already installed), so the "live service surgery" risk is entirely on the native side,
never the `alt-mariadb` side.
- Runs only on **AlmaLinux 8 or 9**. CloudLinux, Rocky, CentOS, plain RHEL, and AlmaLinux
outside 8/9 are explicitly refused before anything is touched — a stricter, separate
check from `install_db.sh`'s own permissive `detect_os()`, because this script touches a
live, DA-critical service rather than just installing an additional one.
- This is a materially one-way decision: `mysql_inst=no` has no clean, officially
documented path back to CustomBuild-managed MySQL once other things start depending on
the new arrangement. The design rolls back cleanly on failure *during the takeover
itself*, but is not meant to be casually reversed afterward.
## CLI change to `install_db.sh`
`PORT` becomes optional. Usage becomes `install_db.sh <MARIADB_VERSION> [PORT]`:
- `install_db.sh 10.11``PORT` defaults to `3306`, triggering the native takeover.
- `install_db.sh 10.11 3306` — same as above; the trigger is the *resolved* port value,
not whether it was typed explicitly.
- `install_db.sh 10.11 33033` — explicit non-3306 port, no takeover, today's behavior is
unchanged.
`apply_cli_args` changes from requiring exactly 2 arguments to accepting 1 or 2 (still
requires at least `MARIADB_VERSION`). Usage text and examples must reflect the new
optional-port form.
## Architecture
A new, independent script, `scripts/setup/native_mariadb_port_takeover.sh`, holds all
logic for moving native MariaDB off 3306. `install_db.sh` calls it once, early in
`main()`, only when the resolved `PORT` is `3306` — before `load_da_credentials`, so that
by the time DA's credentials are read, `mysql.conf` already reflects the *new* native port
and the existing "alt port must differ from DA's port" check in `validate_paths()` passes
naturally with no changes needed there.
This mirrors how every other multi-step, risk-bearing operation in this plugin is
isolated into its own file (`da_restore_native_staging_to_alt_mysql.sh`,
`alt_mysql_native_backup_restore.sh`, `mysql_upgrade.sh`) rather than growing the already
large `install_db.sh` further, and keeps this logic independently testable with fake
command stubs.
## Configuration
Top-of-file variables in the new script, in the same "hand-editable config block"
convention already used by `install_db.sh` (e.g. `MARIADB_TARBALL_URL`):
- `NATIVE_MY_CNF` (default `/etc/my.cnf`)
- `NATIVE_MY_CNF_D` (default `/etc/my.cnf.d`)
- `NATIVE_MYSQL_CONF` (default `/usr/local/directadmin/conf/mysql.conf`)
- `NEW_NATIVE_PORT` (default `3307`)
## Sequencing
### Step 0: OS guard
Read `/etc/os-release`. Require `ID=almalinux` and `VERSION_ID` starting with `8` or `9`.
Anything else: `die()` immediately, before any other action.
### Step 1: Baseline check
Read DA's current native port/socket/credentials from `NATIVE_MYSQL_CONF` (reuse the
`mysql.conf` parsing convention already used elsewhere in this plugin). Confirm the native
engine is actually reachable right now using those credentials — abort before touching
anything if it isn't (this script must not appear to "fix" an unrelated pre-existing
problem). Also read the *current* `mysql_inst=` value from CustomBuild's `options.conf`
and record it (`mariadb` or `mysql`) — this is what rollback restores.
### Step 2: Idempotency check
If the current native port (from Step 1) is already something other than 3306, log
"already done" and exit 0. Nothing further to do.
### Step 3: Preflight the new port
Confirm `NEW_NATIVE_PORT` is not already bound by anything (reuse `install_db.sh`'s
`port_is_listening` pattern). Refuse before making any change if it is.
### Step 4: Disable CustomBuild's MySQL management
Run `da build set mysql_inst no` — DirectAdmin's supported CLI, not a raw edit of
`options.conf`. Skip if Step 1 already found it set to `no` (idempotency).
### Step 5: Locate, back up, and rewrite the active `port=` directive(s)
Scan `NATIVE_MY_CNF` and every `NATIVE_MY_CNF_D/*.cnf` for a `port=` line inside a
`[mysqld]`/`[mariadb]`/`[server]` section (later-loaded `my.cnf.d` files override
`my.cnf`'s own value for the same directive — both must be checked, not just one). Back up
every file before editing it (timestamped copy, matching `install_db.sh`'s
`backup_existing_file` convention). If no explicit `port=` line exists anywhere (implicit
default), add one to `[mysqld]` in `NATIVE_MY_CNF`. If matches exist, rewrite each to
`NEW_NATIVE_PORT`.
### Step 6: Update `mysql.conf`
Rewrite only the `port=` field in `NATIVE_MYSQL_CONF` to `NEW_NATIVE_PORT`, via a targeted
line replacement — not full-file regeneration — so `user=`/`passwd=`/`host=`/`socket=` are
left untouched exactly as they were.
### Step 7: Restart native MariaDB
Restart the native MariaDB service (reuse `mysql_service_name()`-style detection). Verify
it is listening on `NEW_NATIVE_PORT` and specifically **not** on 3306.
### Step 8: Restart DirectAdmin
Restart the `directadmin` service so DA's core picks up the new port immediately, rather
than relying on an unconfirmed lazy-reload behavior. This causes a brief (seconds-long)
panel interruption — expected, not a failure.
### Step 9: Verify DA's own connectivity
Confirm DA's credentials (from the now-updated `mysql.conf`) can connect to the native
engine on `NEW_NATIVE_PORT`. This is the real pass/fail gate for the whole operation.
Only if Step 9 succeeds does `install_db.sh` continue installing `alt-mariadb`, now
targeting port 3306 (already free).
## Error handling and rollback
Any failure at Step 4 or later triggers rollback of everything done so far, in reverse
order, before the script aborts non-zero:
- Restore every file backed up in Step 5 from its timestamped copy.
- Run `da build set mysql_inst <original_value>` (the value captured in Step 1) to restore
CustomBuild management.
- Attempt to restart native MariaDB back on 3306; verify it's reachable again via the
*original* credentials/port.
- If DirectAdmin was already restarted (Step 8+), restart it again so it re-picks-up the
reverted config, then verify reachability.
The script logs clearly what it reverted and always exits non-zero on any rollback path.
`install_db.sh` must not proceed to installing `alt-mariadb` if this script exits non-zero,
for any reason. One exception: if Step 9 fails only because DirectAdmin itself won't
restart cleanly (unrelated to the port change), the script should say so explicitly rather
than reporting it as a takeover failure.
## Testing
Matching this repo's established convention (plain bash test scripts under `tests/`, fake
command stubs via `PATH`/env-var injection, no external test framework): a new
`tests/native_mariadb_port_takeover_test.sh` fakes `da`, `systemctl`, and the
`mysql`/`mariadb` client binaries as stub scripts that log their invocations, plus fixture
copies of `/etc/my.cnf`, `/etc/my.cnf.d/*.cnf`, and `mysql.conf` under a temp directory (all
overridable via the script's own env vars, same pattern as `alt_mysql_native_backup_restore.sh`'s
tests). Scenarios:
- Happy path: native on 3306 → ends up on 3307, `mysql.conf` updated, exit 0.
- Idempotency: native already on 3307 → no-op, exit 0, no file changes.
- OS guard: non-AlmaLinux or AlmaLinux outside 8/9 → refuses immediately, no files touched.
- Preflight rejection: `NEW_NATIVE_PORT` already in use → refuses before any change.
- Rollback: simulate failure at each of Steps 5 through 9 in turn → confirm every backed-up
file is restored and `da build set mysql_inst <original>` is called with the correct
original value in each case.
## Out of scope
- Migrating an already-running `alt-mariadb` instance to a new port (not needed — this
server has no `alt-mariadb` installed yet).
- Any OS other than AlmaLinux 8/9.
- A documented path back to CustomBuild-managed MySQL/MariaDB after this runs.
- Changing `/etc/services` or CSF/firewall rules (native MariaDB is not intended to be
remotely reachable; out of scope unless that changes).
## Risks / open items for implementation planning
- The exact `[mysqld]`/`[mariadb]`/`[server]` section name in use on this server's
`/etc/my.cnf.d/*.cnf` files should be confirmed empirically during implementation (grep
the actual files) rather than assumed, since MariaDB accepts more than one section name
for the same server-level directives.
- `da build set mysql_inst no` and its reverse are assumed to be synchronous, side-effect-free
writes to `options.conf` with no network/build side effects; this should be confirmed
empirically (e.g. by diffing `options.conf` before/after and confirming no other files
change) during the first implementation task, consistent with how this project has
handled other empirically-verified-not-documented DirectAdmin behaviors.
@@ -0,0 +1,139 @@
# Design: Restore Directly Into alt-mariadb Without Native Staging
Date: 2026-07-05
Status: Approved by user, ready for implementation planning
## Goal
Make DirectAdmin account restores (both plugin-origin and external/foreign backups) land
databases, database users, password hashes, and grants directly in `alt-mariadb`, without
ever writing customer data into the CustomBuild-managed native MariaDB instance as an
intermediate step.
## Background
The plugin already implements a "separated" architecture (see
`mysql-installer/da-integration.md`): `alt-mariadb` is the real database service, CustomBuild's
native MariaDB is kept installed only for DirectAdmin-native compatibility. Backup creation
(`alt_mysql_user_backup_compress_pre.sh`) already augments every native DA backup with a
`backup/alt_mysql/` payload containing the real `alt-mariadb` data, and restoring that payload
(`alt_mysql_restore_payload.sh`) is already a direct, single-hop restore into `alt-mariadb`.
The remaining gap was restoring **external backups** — archives made on a server without this
plugin, which therefore lack `backup/alt_mysql/manifest.json`. The existing fallback,
`da_restore_native_staging_to_alt_mysql.sh`, assumes DirectAdmin has already restored the
account's databases into the native MariaDB instance, then dumps and migrates them into
`alt-mariadb`, then cleans up the native staged copies.
## Rejected alternative: point DirectAdmin at alt-mariadb directly
Early in this design process, a simpler-looking alternative was considered: install
`alt-mariadb` on port 3306, rewrite DirectAdmin's own `/usr/local/directadmin/conf/mysql.conf`
and `my.cnf` to point at it, and set `mysql_inst=no` in CustomBuild's `options.conf` so
CustomBuild stops managing/version-gating the database. This was rejected:
- DirectAdmin's **legacy code-base license** enforces a hard version ceiling (MariaDB 10.6 /
MySQL 8.0) at DA core login time, against whatever `mysql.conf` points to — independent of
`mysql_inst`. This was confirmed by the user's own observed error: *"legacy code-base does
not support this MySQL version, downgrade MySQL or upgrade DirectAdmin license."*
`mysql_inst=no` only silences CustomBuild's own build-time/EOL-notification checks; it does
not bypass this license gate.
- `alt-mariadb` is installed specifically to run versions newer than what CustomBuild/legacy DA
supports (10.11, 11.4, 11.8 — see `install_db.sh`). Pointing DA at it would either force
capping `alt-mariadb` at 10.6 (defeating the point) or break DA login outright.
- It provides no benefit the design below doesn't already achieve without that risk.
**Decision: no `INSTALL_MODE` toggle is added to `install_db.sh`. `alt-mariadb` is never made
reachable through DirectAdmin's own `mysql.conf`/`my.cnf`, at any version.**
## Core design: self-service restore from the archive's native database files
Ground truth for DirectAdmin's native backup format was obtained by inspecting a real account
backup (`example_backup/psy/backup/`), not from public docs (this format is not documented by
DirectAdmin). For each native database, the backup contains a matching pair, flat under
`backup/`:
- `backup/<dbname>.sql` — a plain `mariadb-dump`/`mysqldump` output (table structure + data).
It contains **no `CREATE DATABASE`/`USE` statement** — the target database must already exist
before this is imported.
- `backup/<dbname>.conf` — a flat `key=value` text file:
```
accesshosts=0=localhost
db_collation=DEFAULT_CHARACTER_SET_NAME=utf8mb3&DEFAULT_COLLATION_NAME=utf8mb3_general_ci&SCHEMA_NAME=<dbname>
<username>=accesshosts=<host>&alter_priv=Y&...&passwd=%2A<HASH>&plugin=mysql_native_password&...&update_priv=Y
```
- Line 1: indexed list of database-level access hosts.
- Line 2: `db_collation` — charset/collation needed to `CREATE DATABASE` correctly.
- Line 3+: one line per MySQL user granted on this database, URL-encoded query-string
fields, keyed by username. Contains the real `mysql_native_password` auth hash (not a
plaintext password), the auth plugin, per-privilege `Y`/`N` flags (the same set of
privilege columns `db_privilege_profile()` in `da_restore_native_staging_to_alt_mysql.sh`
already reverse-engineers from `mysql.db`), and the granted host.
This pair is a complete, self-contained record: charset/collation, every user's exact password
hash, full grant set, and access host(s) — sufficient to recreate the database and its users in
`alt-mariadb` without DirectAdmin's cooperation or its native engine being involved at all.
### Restore flow
The post-restore hook (`alt_mysql_user_restore_post_pre_cleanup.sh`) already receives the
original archive path (`$filename`/`$file`) and already checks for a plugin payload first. This
design adds a second tier before falling back to native-staging-migrate:
1. **Plugin payload** (`backup/alt_mysql/manifest.json` present) — unchanged, existing direct
restore via `alt_mysql_restore_payload.sh`.
2. **Native `.conf`+`.sql` pairs present for a database matching the target user's prefix** —
new: extract both files for that database directly from the archive, independent of
whatever DirectAdmin itself did with its own native "database"/"database_data" restore
items. Per database:
- Parse `db_collation` → `CREATE DATABASE` with the correct charset/collation.
- Parse each per-user line → `CREATE USER ... IDENTIFIED WITH mysql_native_password AS
'<hash>'` at the parsed host, then translate the `Y`/`N` privilege flags into a `GRANT`
statement (reuse/adapt the existing privilege-flag mapping from
`db_privilege_profile()`/`rebuild_metadata_for_db()`).
- Import the `.sql` dump into the created database.
- Rebuild the plugin's ownership/grant-profile/access-host metadata tables, same as the
existing native-staging path does today.
- Verify the database exists and is non-empty before considering it restored, with the same
rollback-dump-and-restore-on-failure safety already implemented in
`alt_mysql_restore_payload.sh`/`da_restore_native_staging_to_alt_mysql.sh`.
3. **Neither available for a given database** (malformed `.conf`, missing pair, or a genuinely
foreign non-DA-native backup) — fall back to `da_restore_native_staging_to_alt_mysql.sh` for
that database only, unchanged from today. This keeps the existing staging-then-migrate logic
as a safety net, not the primary path.
Because the hook self-serves from the archive, **no restore-initiation wrapper or forced
item-deselection (`select0=item`) is needed**. Whatever DirectAdmin does internally with its own
native "database" restore item is irrelevant to this flow — if it stages a redundant copy into
the native engine, that's harmless noise, not something the plugin needs to migrate from.
### What this simplifies going forward
- `da_restore_native_staging_to_alt_mysql.sh` becomes a per-database fallback, not the primary
external-backup path. It is not removed.
- CustomBuild's native MariaDB no longer needs to be usable as a restore staging target for the
common case. It only needs to keep existing/running so DirectAdmin core's own login/version
check has a legacy-safe target to talk to (per the rejected-alternative section above) — its
role in `mysql-installer/da-integration.md` Phase 7 shrinks accordingly.
- Backup creation (Phase 4) is unaffected.
## Risks / open items for implementation planning
- **The `.conf` format is not documented by DirectAdmin.** It was reverse-engineered from one
real backup. Treat it defensively: validate structure before trusting it, and fail closed to
the per-database native-staging fallback rather than assuming the format holds across
DirectAdmin/CustomBuild versions.
- Multiple users can be granted on one database (multiple `<username>=...` lines) — the parser
must handle N users per `.conf`, not assume exactly one.
- The exact temporary extraction directory/working path DirectAdmin uses during restore should
still be logged and confirmed empirically on a disposable account as the first implementation
task, consistent with how the backup-side hook was already built.
- Password hashes are `mysql_native_password` in the observed example; the parser should not
assume this is the only plugin value that can appear and should fail safely (fall back) for
unrecognized auth plugins rather than guessing.
## Explicitly out of scope
- No `INSTALL_MODE=separated|simple` toggle in `install_db.sh`.
- No changes to `install_db.sh`'s port/socket/datadir validation.
- No rewriting of DirectAdmin's own `mysql.conf`/`my.cnf`.
+10 -9
View File
@@ -437,10 +437,12 @@ final class BackupQueueService
throw new RuntimeException('Nie można odczytać przesłanego pliku backupu.');
}
// Security: never trust $uploadReference as a literal path - it comes
// straight from user-controlled POST data, and this whole plugin runs
// as root. Only accept a file whose *basename* matches one of the
// known DirectAdmin/system temp directories below, never an
// arbitrary absolute path chosen by the caller.
$sourceCandidates = [];
if (str_starts_with($uploadReference, '/') && is_file($uploadReference)) {
$sourceCandidates[] = $uploadReference;
}
$tmpDirs = [
(string)(getenv('UPLOAD_TMP_DIR') ?: ''),
(string)(getenv('DA_UPLOAD_TMP_DIR') ?: ''),
@@ -1293,13 +1295,12 @@ final class BackupQueueService
$tries = 0;
while (!@mkdir($lockDir, 0700)) {
$tries++;
if (!is_dir($lockDir)) {
continue;
}
$mtime = (int)@filemtime($lockDir);
if ($mtime > 0 && (time() - $mtime) > 120) {
@rmdir($lockDir);
if (is_dir($lockDir)) {
$mtime = (int)@filemtime($lockDir);
if ($mtime > 0 && (time() - $mtime) > 120) {
@rmdir($lockDir);
}
}
if ($tries >= 40) {
+1 -1
View File
@@ -2,7 +2,7 @@ name=alt-mysql
id=alt-mysql
type=user
author=HITME.PL
version=1.2.15
version=1.2.19
active=no
installed=no
user_run_as=root
+315
View File
@@ -0,0 +1,315 @@
#!/bin/bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
COMMON_FILE="$PLUGIN_DIR/scripts/setup/mysql_common.sh"
SETTINGS_FILE="${DA_ALT_MYSQL_SETTINGS_FILE:-$PLUGIN_DIR/plugin-settings.conf}"
LOG_FILE="${DA_ALT_MYSQL_RESTORE_LOG:-$PLUGIN_DIR/data/logs/da-restore.log}"
mkdir -p "$(dirname "$LOG_FILE")"
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE"
}
die() {
log "ERROR: $*"
printf 'alt-mysql native backup restore: %s\n' "$*" >&2
exit 1
}
url_decode() {
local encoded="${1//+/ }"
printf '%b' "${encoded//%/\\x}"
}
native_conf_field() {
local qs="$1" field="$2" pair
local -a pairs
IFS='&' read -ra pairs <<< "$qs"
for pair in "${pairs[@]}"; do
if [ "${pair%%=*}" = "$field" ]; then
printf '%s' "${pair#*=}"
return 0
fi
done
return 0
}
NATIVE_PRIV_FLAG_MAP=(
"select_priv:SELECT" "insert_priv:INSERT" "update_priv:UPDATE" "delete_priv:DELETE"
"create_priv:CREATE" "drop_priv:DROP" "index_priv:INDEX" "alter_priv:ALTER"
"create_tmp_table_priv:CREATE TEMPORARY TABLES" "lock_tables_priv:LOCK TABLES"
"create_view_priv:CREATE VIEW" "show_view_priv:SHOW VIEW"
"create_routine_priv:CREATE ROUTINE" "alter_routine_priv:ALTER ROUTINE"
"execute_priv:EXECUTE" "event_priv:EVENT" "trigger_priv:TRIGGER"
"references_priv:REFERENCES"
)
native_conf_privilege_list() {
local qs="$1"
local entry flag name value
local -a privs=()
for entry in "${NATIVE_PRIV_FLAG_MAP[@]}"; do
flag="${entry%%:*}"
name="${entry#*:}"
value="$(native_conf_field "$qs" "$flag")"
[ "$value" = "Y" ] && privs+=("$name")
done
if [ "${#privs[@]}" -eq 0 ]; then
printf 'ALL'
else
local IFS=,
printf '%s' "${privs[*]}"
fi
}
parse_native_db_conf() {
local conf_file="$1"
local line key value
NATIVE_DB_CHARSET=""
NATIVE_DB_COLLATION=""
NATIVE_DB_USER_NAMES=()
NATIVE_DB_USER_HOST=()
NATIVE_DB_USER_HASH=()
NATIVE_DB_USER_PLUGIN=()
NATIVE_DB_USER_PRIVS=()
[ -r "$conf_file" ] || { log "cannot read conf file: $conf_file"; return 1; }
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue
[[ "$line" == *=* ]] || continue
key="${line%%=*}"
value="${line#*=}"
case "$key" in
db_collation)
NATIVE_DB_CHARSET="$(native_conf_field "$value" DEFAULT_CHARACTER_SET_NAME)"
NATIVE_DB_COLLATION="$(native_conf_field "$value" DEFAULT_COLLATION_NAME)"
;;
accesshosts) ;;
*)
NATIVE_DB_USER_NAMES+=("$key")
NATIVE_DB_USER_HOST+=("$(native_conf_field "$value" accesshosts)")
NATIVE_DB_USER_HASH+=("$(url_decode "$(native_conf_field "$value" passwd)")")
NATIVE_DB_USER_PLUGIN+=("$(native_conf_field "$value" plugin)")
NATIVE_DB_USER_PRIVS+=("$(native_conf_privilege_list "$value")")
;;
esac
done < "$conf_file"
if [ -z "$NATIVE_DB_CHARSET" ]; then
log "missing db_collation charset in $conf_file"
NATIVE_DB_CHARSET=""
NATIVE_DB_COLLATION=""
NATIVE_DB_USER_NAMES=()
NATIVE_DB_USER_HOST=()
NATIVE_DB_USER_HASH=()
NATIVE_DB_USER_PLUGIN=()
NATIVE_DB_USER_PRIVS=()
return 1
fi
if [ "${#NATIVE_DB_USER_NAMES[@]}" -eq 0 ]; then
log "no MySQL users found in $conf_file"
NATIVE_DB_CHARSET=""
NATIVE_DB_COLLATION=""
NATIVE_DB_USER_NAMES=()
NATIVE_DB_USER_HOST=()
NATIVE_DB_USER_HASH=()
NATIVE_DB_USER_PLUGIN=()
NATIVE_DB_USER_PRIVS=()
return 1
fi
return 0
}
mysql_import_sql_file() {
local database="$1" file="$2"
if [[ "$file" == *.gz ]]; then
gunzip -c "$file" | mysql_exec "$database"
return "${PIPESTATUS[1]}"
fi
mysql_exec "$database" < "$file"
}
verify_database_exists() {
local db="$1"
[ -n "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1")" ] \
|| die "database was not restored: $db"
}
rebuild_metadata_for_native_import() {
local db="$1" da_user="$2"
local i owner="" role host
for i in "${!NATIVE_DB_USER_NAMES[@]}"; do
role="${NATIVE_DB_USER_NAMES[$i]}"
host="${NATIVE_DB_USER_HOST[$i]:-localhost}"
[ -n "$owner" ] || owner="$role"
mysql_exec mysql -e "
INSERT INTO da_plugin_grant_profiles (db_name, role_name, privileges)
VALUES ('$(mysql_escape_literal "$db")', '$(mysql_escape_literal "$role")', '$(mysql_escape_literal "${NATIVE_DB_USER_PRIVS[$i]}")')
ON DUPLICATE KEY UPDATE privileges=VALUES(privileges), updated_at=CURRENT_TIMESTAMP;
" >> "$LOG_FILE" 2>&1 || true
mysql_exec mysql -e "
INSERT INTO da_plugin_access_hosts (da_user, role_name, host_pattern, note)
VALUES ('$(mysql_escape_literal "$da_user")', '$(mysql_escape_literal "$role")', '$(mysql_escape_literal "$host")', 'restored from native backup/<db>.conf')
ON DUPLICATE KEY UPDATE da_user=VALUES(da_user), note=VALUES(note), updated_at=CURRENT_TIMESTAMP;
" >> "$LOG_FILE" 2>&1 || true
done
if [ -n "$owner" ]; then
mysql_exec mysql -e "
INSERT INTO da_plugin_database_owners (db_name, da_user, role_name)
VALUES ('$(mysql_escape_literal "$db")', '$(mysql_escape_literal "$da_user")', '$(mysql_escape_literal "$owner")')
ON DUPLICATE KEY UPDATE da_user=VALUES(da_user), role_name=VALUES(role_name), updated_at=CURRENT_TIMESTAMP;
" >> "$LOG_FILE" 2>&1 || true
fi
}
import_native_database_from_conf() {
local db="$1" conf_file="$2" sql_file="$3" da_user="$4" overwrite="$5"
parse_native_db_conf "$conf_file" || { log "SKIP $db: conf did not parse"; return 1; }
local collation="${NATIVE_DB_COLLATION:-}"
if [ -z "$collation" ]; then
log "SKIP $db: no collation parsed"
return 1
fi
local i
for i in "${!NATIVE_DB_USER_PLUGIN[@]}"; do
if [ "${NATIVE_DB_USER_PLUGIN[$i]}" != "mysql_native_password" ]; then
log "SKIP $db: unsupported auth plugin '${NATIVE_DB_USER_PLUGIN[$i]}' for user ${NATIVE_DB_USER_NAMES[$i]}"
return 1
fi
done
if [ "$overwrite" = "deny" ] && [ -n "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1")" ]; then
log "SKIP $db: already exists and overwrite is denied"
return 1
fi
backup_alt_database_for_rollback "$db" || { log "SKIP $db: could not create rollback dump"; return 1; }
if ! mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db");" >> "$LOG_FILE" 2>&1; then
restore_alt_database_from_rollback "$db" || true
log "SKIP $db: could not drop database before import"
return 1
fi
if ! mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db") CHARACTER SET $(mysql_safe_identifier "$NATIVE_DB_CHARSET") COLLATE $(mysql_safe_identifier "$collation");" >> "$LOG_FILE" 2>&1; then
restore_alt_database_from_rollback "$db" || true
log "SKIP $db: could not create database before import"
return 1
fi
local user host hash
for i in "${!NATIVE_DB_USER_NAMES[@]}"; do
user="${NATIVE_DB_USER_NAMES[$i]}"
host="${NATIVE_DB_USER_HOST[$i]:-localhost}"
hash="${NATIVE_DB_USER_HASH[$i]}"
if ! {
printf "DROP USER IF EXISTS '%s'@'%s';\n" "$(mysql_escape_literal "$user")" "$(mysql_escape_literal "$host")"
printf "CREATE USER '%s'@'%s' IDENTIFIED WITH mysql_native_password AS '%s';\n" \
"$(mysql_escape_literal "$user")" "$(mysql_escape_literal "$host")" "$(mysql_escape_literal "$hash")"
printf "GRANT %s ON %s.* TO '%s'@'%s';\n" \
"${NATIVE_DB_USER_PRIVS[$i]}" "$(mysql_quote_identifier "$db")" "$(mysql_escape_literal "$user")" "$(mysql_escape_literal "$host")"
} | mysql_exec mysql >> "$LOG_FILE" 2>&1; then
restore_alt_database_from_rollback "$db" || true
log "SKIP $db: could not recreate user ${user}@${host}"
return 1
fi
done
if ! mysql_import_sql_file "$db" "$sql_file" >> "$LOG_FILE" 2>&1; then
restore_alt_database_from_rollback "$db" || true
log "SKIP $db: could not import dump"
return 1
fi
cleanup_restore_rollback
verify_database_exists "$db"
rebuild_metadata_for_native_import "$db" "$da_user"
mysql_exec mysql -e "FLUSH PRIVILEGES;" >> "$LOG_FILE" 2>&1 || true
return 0
}
find_native_pairs_in_dir() {
local root="$1" prefix="$2"
local conf base sql
while IFS= read -r conf; do
[ -n "$conf" ] || continue
base="$(basename "$conf" .conf)"
sql="$(dirname "$conf")/${base}.sql"
[ -r "$sql" ] || continue
printf '%s\t%s\t%s\n' "$base" "$conf" "$sql"
done < <(find "$root" -maxdepth 4 -type f -name "${prefix}_*.conf" 2>/dev/null | sort)
}
main() {
local da_user="" root="" overwrite="allow"
while [ "$#" -gt 0 ]; do
case "$1" in
--user) da_user="${2:-}"; shift 2 ;;
--root) root="${2:-}"; shift 2 ;;
--overwrite) overwrite="${2:-}"; shift 2 ;;
*) die "unknown argument: $1" ;;
esac
done
[ -n "$da_user" ] || die "--user is required"
[ -n "$root" ] || die "--root is required"
[[ "$da_user" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || die "unsafe DirectAdmin username: $da_user"
[ -d "$root" ] || die "backup root directory does not exist: $root"
mysql_load_alt_credentials
mysql_ensure_metadata_schema
local -a handled=()
local base conf sql
while IFS=$'\t' read -r base conf sql; do
[ -n "$base" ] || continue
handled+=("$base")
if [[ "$base" != "${da_user}_"* ]]; then
printf 'SKIPPED %s wrong-user-prefix\n' "$base"
continue
fi
if import_native_database_from_conf "$base" "$conf" "$sql" "$da_user" "$overwrite"; then
printf 'IMPORTED %s\n' "$base"
else
printf 'SKIPPED %s import-failed\n' "$base"
fi
done < <(find_native_pairs_in_dir "$root" "$da_user")
# Any *.sql belonging to this user's prefix with no matching .conf was not
# covered by the loop above (find_native_pairs_in_dir only yields pairs
# that have both files) - report those explicitly so the caller knows to
# fall back to native-staging-migrate for them too.
local sql_base
while IFS= read -r sql; do
[ -n "$sql" ] || continue
sql_base="$(basename "$sql" .sql)"
[[ "$sql_base" == "${da_user}_"* ]] || continue
local already=0 h
for h in "${handled[@]}"; do
[ "$h" = "$sql_base" ] && { already=1; break; }
done
[ "$already" -eq 1 ] || printf 'SKIPPED %s no-matching-conf\n' "$sql_base"
done < <(find "$root" -maxdepth 4 -type f -name "${da_user}_*.sql" 2>/dev/null | sort)
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[ -r "$COMMON_FILE" ] || die "missing helper: $COMMON_FILE"
# shellcheck source=../setup/mysql_common.sh
source "$COMMON_FILE"
trap cleanup_restore_rollback INT TERM EXIT
mysql_load_plugin_settings_file "$SETTINGS_FILE"
main "$@"
fi
@@ -4,13 +4,11 @@ set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
COMMON_FILE="$PLUGIN_DIR/scripts/setup/mysql_common.sh"
SETTINGS_FILE="$PLUGIN_DIR/plugin-settings.conf"
SETTINGS_FILE="${DA_ALT_MYSQL_SETTINGS_FILE:-$PLUGIN_DIR/plugin-settings.conf}"
LOG_FILE="${DA_ALT_MYSQL_RESTORE_LOG:-$PLUGIN_DIR/data/logs/da-restore.log}"
DA_USER=""
PAYLOAD_DIR=""
OVERWRITE_POLICY=""
RESTORE_ROLLBACK_FILE=""
RESTORE_ROLLBACK_CREATED=0
mkdir -p "$(dirname "$LOG_FILE")"
@@ -132,57 +130,6 @@ import_gzip_sql() {
mysql_exec "$database" < "$file"
}
cleanup_restore_rollback() {
if [ -n "${RESTORE_ROLLBACK_FILE:-}" ] && [ -f "$RESTORE_ROLLBACK_FILE" ]; then
rm -f "$RESTORE_ROLLBACK_FILE"
fi
RESTORE_ROLLBACK_FILE=""
RESTORE_ROLLBACK_CREATED=0
}
backup_alt_database_for_rollback() {
local db="$1"
cleanup_restore_rollback
RESTORE_ROLLBACK_FILE="$(mktemp)"
if [ -z "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1" 2>>"$LOG_FILE" || true)" ]; then
cleanup_restore_rollback
return 0
fi
log "creating rollback dump: $db"
if mysqldump_exec \
--single-transaction \
--quick \
--skip-lock-tables \
--routines \
--triggers \
--events \
--default-character-set=utf8mb4 \
"$db" > "$RESTORE_ROLLBACK_FILE" 2>>"$LOG_FILE"; then
RESTORE_ROLLBACK_CREATED=1
return 0
fi
cleanup_restore_rollback
return 1
}
restore_alt_database_from_rollback() {
local db="$1"
if [ "$RESTORE_ROLLBACK_CREATED" -ne 1 ] || [ -z "${RESTORE_ROLLBACK_FILE:-}" ] || [ ! -r "$RESTORE_ROLLBACK_FILE" ]; then
return 0
fi
log "restoring rollback dump: $db"
mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db");" >> "$LOG_FILE" 2>&1 || true
mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG_FILE" 2>&1 || true
mysql_exec "$db" < "$RESTORE_ROLLBACK_FILE" >> "$LOG_FILE" 2>&1 || {
log "ERROR: rollback restore failed for $db"
return 1
}
}
trap cleanup_restore_rollback INT TERM EXIT
metadata_cleanup_for_user() {
@@ -7,6 +7,8 @@ SETTINGS_FILE="$PLUGIN_DIR/plugin-settings.conf"
LOG_FILE="${DA_ALT_MYSQL_RESTORE_LOG:-$PLUGIN_DIR/data/logs/da-restore.log}"
RESTORE_PAYLOAD_SCRIPT="$SCRIPT_DIR/alt_mysql_restore_payload.sh"
NATIVE_MIGRATE_SCRIPT="$SCRIPT_DIR/da_restore_native_staging_to_alt_mysql.sh"
NATIVE_BACKUP_RESTORE_SCRIPT="${DA_ALT_MYSQL_NATIVE_BACKUP_RESTORE_BIN:-$SCRIPT_DIR/alt_mysql_native_backup_restore.sh}"
NATIVE_MIGRATE_SCRIPT="${DA_ALT_MYSQL_NATIVE_STAGING_BIN:-$NATIVE_MIGRATE_SCRIPT}"
mkdir -p "$(dirname "$LOG_FILE")"
@@ -85,13 +87,24 @@ find_payload_in_dir() {
extract_payload_from_archive() {
local archive="$1"
local tmp_dir payload_member
local tmp_dir payload_member listing
[ -r "$archive" ] || return 1
command -v tar >/dev/null 2>&1 || return 1
payload_member="$(tar -tf "$archive" 2>/dev/null | grep -E '(^|/)backup/alt_mysql/manifest\.json$' | head -n 1 || true)"
listing="$(tar -tf "$archive" 2>/dev/null || true)"
[ -n "$listing" ] || return 1
payload_member="$(printf '%s\n' "$listing" | grep -E '(^|/)backup/alt_mysql/manifest\.json$' | head -n 1 || true)"
[ -n "$payload_member" ] || return 1
# Security: never trust tar's own default path-traversal protection alone -
# refuse to extract archives containing any absolute-path or ".." member,
# even if the offending member wouldn't otherwise match our own wildcard.
if printf '%s\n' "$listing" | grep -Eq '(^|/)\.\.(/|$)|^/'; then
log "refusing to extract archive with unsafe member paths: $archive"
return 1
fi
tmp_dir="$(mktemp -d)"
tar -xf "$archive" -C "$tmp_dir" --wildcards '*/backup/alt_mysql/*' 'backup/alt_mysql/*' 2>/dev/null \
|| tar -xf "$archive" -C "$tmp_dir" 2>/dev/null
@@ -129,7 +142,7 @@ detect_payload_dir() {
}
main() {
local da_user enabled overwrite payload native_mode
local da_user enabled overwrite payload native_mode restore_root
da_user="$(detect_da_user "${1:-}")"
enabled="$(read_plugin_setting DA_ALT_MYSQL_RESTORE_ENABLED true)"
native_mode="$(read_plugin_setting DA_ALT_MYSQL_NATIVE_STAGING_MIGRATE true)"
@@ -150,14 +163,42 @@ main() {
return 0
fi
log "plugin payload not found; considering native staging migration"
log "plugin payload not found; trying native backup/<dbname>.conf restore"
restore_root="$(candidate_dir_if_valid "${restore_path:-}")" \
|| restore_root="$(candidate_dir_if_valid "${restore_dir:-}")" \
|| restore_root="$(candidate_dir_if_valid "${tmpdir:-}")" \
|| restore_root="$(candidate_dir_if_valid "${backup_path:-}")" \
|| restore_root="$(pwd -P)"
local -a imported=()
local -a skip_args=()
if [ -x "$NATIVE_BACKUP_RESTORE_SCRIPT" ] && [ -d "$restore_root" ]; then
while IFS= read -r line; do
[ -n "$line" ] || continue
log "native backup restore: $line"
case "$line" in
IMPORTED\ *)
imported+=("${line#IMPORTED }")
;;
esac
done < <("$NATIVE_BACKUP_RESTORE_SCRIPT" --user "$da_user" --root "$restore_root" --overwrite "$overwrite")
else
log "native backup restore script unavailable or restore root not found: $NATIVE_BACKUP_RESTORE_SCRIPT / $restore_root"
fi
for db in "${imported[@]}"; do
skip_args+=("--skip" "$db")
done
log "native backup restore imported: ${imported[*]:-<none>}; considering native staging migration for the rest"
case "$native_mode" in
true|yes|on|1)
[ -x "$NATIVE_MIGRATE_SCRIPT" ] || die "native staging migration script is not executable: $NATIVE_MIGRATE_SCRIPT"
"$NATIVE_MIGRATE_SCRIPT" --user "$da_user"
"$NATIVE_MIGRATE_SCRIPT" --user "$da_user" "${skip_args[@]}"
;;
*)
log "native staging migration disabled; account restore continues without alt-mysql DB migration"
log "native staging migration disabled; account restore continues without further alt-mysql DB migration"
;;
esac
}
@@ -10,8 +10,7 @@ DA_USER=""
NATIVE_MY_CNF="${NATIVE_MY_CNF:-/usr/local/directadmin/conf/my.cnf}"
OVERWRITE_POLICY=""
CLEANUP_POLICY=""
RESTORE_ROLLBACK_FILE=""
RESTORE_ROLLBACK_CREATED=0
declare -a SKIP_DATABASES=()
mkdir -p "$(dirname "$LOG_FILE")"
@@ -38,6 +37,7 @@ while [ "$#" -gt 0 ]; do
--native-my-cnf) NATIVE_MY_CNF="${2:-}"; shift 2 ;;
--overwrite) OVERWRITE_POLICY="${2:-}"; shift 2 ;;
--cleanup) CLEANUP_POLICY="${2:-}"; shift 2 ;;
--skip) SKIP_DATABASES+=("${2:-}"); shift 2 ;;
*) usage ;;
esac
done
@@ -89,12 +89,20 @@ safe_identifier() {
}
list_native_databases() {
local db
native_query information_schema "
SELECT SCHEMA_NAME
FROM SCHEMATA
WHERE SCHEMA_NAME LIKE '$(mysql_escape_literal "$DA_USER")\\_%' ESCAPE '\\'
ORDER BY SCHEMA_NAME
" | grep -Ev '^(information_schema|performance_schema|mysql|sys)$' || true
" | grep -Ev '^(information_schema|performance_schema|mysql|sys)$' | while IFS= read -r db; do
[ -n "$db" ] || continue
local skipped=0 candidate
for candidate in "${SKIP_DATABASES[@]}"; do
[ "$candidate" = "$db" ] && { skipped=1; break; }
done
[ "$skipped" -eq 1 ] || printf '%s\n' "$db"
done || true
}
list_native_user_hosts() {
@@ -155,57 +163,6 @@ apply_grants_to_alt() {
done < <(native_show_grants "$user" "$host")
}
cleanup_restore_rollback() {
if [ -n "${RESTORE_ROLLBACK_FILE:-}" ] && [ -f "$RESTORE_ROLLBACK_FILE" ]; then
rm -f "$RESTORE_ROLLBACK_FILE"
fi
RESTORE_ROLLBACK_FILE=""
RESTORE_ROLLBACK_CREATED=0
}
backup_alt_database_for_rollback() {
local db="$1"
cleanup_restore_rollback
RESTORE_ROLLBACK_FILE="$(mktemp)"
if [ -z "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1" 2>>"$LOG_FILE" || true)" ]; then
cleanup_restore_rollback
return 0
fi
log "creating rollback dump: $db"
if mysqldump_exec \
--single-transaction \
--quick \
--skip-lock-tables \
--routines \
--triggers \
--events \
--default-character-set=utf8mb4 \
"$db" > "$RESTORE_ROLLBACK_FILE" 2>>"$LOG_FILE"; then
RESTORE_ROLLBACK_CREATED=1
return 0
fi
cleanup_restore_rollback
return 1
}
restore_alt_database_from_rollback() {
local db="$1"
if [ "$RESTORE_ROLLBACK_CREATED" -ne 1 ] || [ -z "${RESTORE_ROLLBACK_FILE:-}" ] || [ ! -r "$RESTORE_ROLLBACK_FILE" ]; then
return 0
fi
log "restoring rollback dump: $db"
mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db");" >> "$LOG_FILE" 2>&1 || true
mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG_FILE" 2>&1 || true
mysql_exec "$db" < "$RESTORE_ROLLBACK_FILE" >> "$LOG_FILE" 2>&1 || {
log "ERROR: rollback restore failed for $db"
return 1
}
}
trap cleanup_restore_rollback INT TERM EXIT
db_privilege_profile() {
+25 -9
View File
@@ -17,6 +17,7 @@ ALT_MYSQL_CONF="/usr/local/directadmin/conf/alt-mysql.conf"
ALT_MY_CNF="/usr/local/directadmin/conf/alt-my.cnf"
ALT_METADATA_ENV="/usr/local/directadmin/conf/alt-mariadb.env"
INSTANCE_CNF="/etc/alt-mariadb.cnf"
NATIVE_TAKEOVER_SCRIPT="${NATIVE_TAKEOVER_SCRIPT:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/setup/native_mariadb_port_takeover.sh}"
SERVICE_NAME="alt-mariadb"
SERVICE_FILE="/etc/systemd/system/alt-mariadb.service"
LOG_DIR="/var/log/alt-mariadb"
@@ -69,14 +70,21 @@ trap cleanup EXIT
usage() {
cat <<EOF
Użycie:
$0 <MARIADB_VERSION> <PORT>
$0 <MARIADB_VERSION> [PORT]
Przykłady:
$0 10.11
$0 10.11 33033
$0 11.4 3020
$0 11.8 3021
Dozwolone gałęzie MariaDB: 10.11, 11.4, 11.8.
Jeżeli PORT zostanie pominięty, przyjmowana jest wartość domyślna 3306. W takim
przypadku skrypt najpierw przenosi natywną instancję MariaDB zarządzaną przez
CustomBuild na inny port (patrz scripts/setup/native_mariadb_port_takeover.sh),
aby zwolnić port 3306 dla alt-mariadb. To automatyczne przejęcie portu działa
wyłącznie na AlmaLinux 8/9 - na innych systemach podaj PORT jawnie (inny niż 3306).
EOF
}
@@ -98,19 +106,15 @@ apply_cli_args() {
fi
if [[ $# -gt 2 ]]; then
usage_error "Podano zbyt wiele argumentów. Oczekiwano: MARIADB_VERSION PORT."
usage_error "Podano zbyt wiele argumentów. Oczekiwano: MARIADB_VERSION [PORT]."
fi
if [[ $# -lt 2 ]]; then
usage_error "Podaj wersję MariaDB i port TCP, np. 11.4 3020."
fi
if [[ $# -ge 1 && -n "${1:-}" ]]; then
MARIADB_VERSION="$1"
fi
MARIADB_VERSION="$1"
if [[ $# -ge 2 && -n "${2:-}" ]]; then
PORT="$2"
else
PORT="3306"
fi
MARIADB_DIR="/usr/local/mariadb-$MARIADB_VERSION"
@@ -122,6 +126,17 @@ require_root() {
fi
}
maybe_takeover_native_port() {
if [[ "$PORT" != "3306" ]]; then
return 0
fi
[[ -x "$NATIVE_TAKEOVER_SCRIPT" ]] || die "Nie znaleziono skryptu przejęcia portu natywnego MariaDB: $NATIVE_TAKEOVER_SCRIPT"
log "PORT=3306 wybrany dla alt-mariadb - uruchamiam przejęcie portu natywnej instancji MariaDB."
"$NATIVE_TAKEOVER_SCRIPT" || die "Przejęcie portu natywnej instancji MariaDB nie powiodło się. Przerwano instalację alt-mariadb."
}
detect_os() {
[[ -r /etc/os-release ]] || die "Nie można odczytać /etc/os-release."
. /etc/os-release
@@ -850,6 +865,7 @@ main() {
validate_port
require_root
detect_os
maybe_takeover_native_port
load_da_credentials
validate_paths
install_dependencies
+54
View File
@@ -487,3 +487,57 @@ mysql_restore_stream() {
fi
mysql_exec "$database" < "$file"
}
RESTORE_ROLLBACK_FILE=""
RESTORE_ROLLBACK_CREATED=0
cleanup_restore_rollback() {
if [ -n "${RESTORE_ROLLBACK_FILE:-}" ] && [ -f "$RESTORE_ROLLBACK_FILE" ]; then
rm -f "$RESTORE_ROLLBACK_FILE"
fi
RESTORE_ROLLBACK_FILE=""
RESTORE_ROLLBACK_CREATED=0
}
backup_alt_database_for_rollback() {
local db="$1"
cleanup_restore_rollback
RESTORE_ROLLBACK_FILE="$(mktemp)"
if [ -z "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1" 2>>"${LOG_FILE:-/dev/null}" || true)" ]; then
cleanup_restore_rollback
return 0
fi
log "creating rollback dump: $db"
if mysqldump_exec \
--single-transaction \
--quick \
--skip-lock-tables \
--routines \
--triggers \
--events \
--default-character-set=utf8mb4 \
"$db" > "$RESTORE_ROLLBACK_FILE" 2>>"${LOG_FILE:-/dev/null}"; then
RESTORE_ROLLBACK_CREATED=1
return 0
fi
cleanup_restore_rollback
return 1
}
restore_alt_database_from_rollback() {
local db="$1"
if [ "$RESTORE_ROLLBACK_CREATED" -ne 1 ] || [ -z "${RESTORE_ROLLBACK_FILE:-}" ] || [ ! -r "$RESTORE_ROLLBACK_FILE" ]; then
return 0
fi
log "restoring rollback dump: $db"
mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db");" >> "${LOG_FILE:-/dev/null}" 2>&1 || true
mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "${LOG_FILE:-/dev/null}" 2>&1 || true
mysql_exec "$db" < "$RESTORE_ROLLBACK_FILE" >> "${LOG_FILE:-/dev/null}" 2>&1 || {
log "ERROR: rollback restore failed for $db"
return 1
}
}
+343
View File
@@ -0,0 +1,343 @@
#!/bin/bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COMMON_FILE="$SCRIPT_DIR/mysql_common.sh"
NATIVE_MY_CNF="${NATIVE_MY_CNF:-/etc/my.cnf}"
NATIVE_MY_CNF_D="${NATIVE_MY_CNF_D:-/etc/my.cnf.d}"
NATIVE_MYSQL_CONF="${NATIVE_MYSQL_CONF:-/usr/local/directadmin/conf/mysql.conf}"
CUSTOMBUILD_OPTIONS_CONF="${CUSTOMBUILD_OPTIONS_CONF:-/usr/local/directadmin/custombuild/options.conf}"
NEW_NATIVE_PORT="${NEW_NATIVE_PORT:-3307}"
DA_CLI_BIN="${DA_CLI_BIN:-da}"
OS_RELEASE_FILE="${OS_RELEASE_FILE:-/etc/os-release}"
LOG_FILE="${NATIVE_TAKEOVER_LOG:-$PLUGIN_DIR/data/logs/native-port-takeover.log}"
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE"
printf '[INFO] %s\n' "$*"
}
die() {
log "ERROR: $*"
printf '[BŁĄD] %s\n' "$*" >&2
exit 1
}
# --- state populated by the detection functions, consumed by later steps ---
NATIVE_CURRENT_PORT=""
NATIVE_CURRENT_SOCKET=""
NATIVE_CURRENT_HOST=""
NATIVE_CURRENT_USER=""
NATIVE_CURRENT_PASS=""
NATIVE_CURRENT_MYSQL_INST=""
require_root() {
[ "$(id -u)" -eq 0 ] || die "Ten skrypt musi zostać uruchomiony jako root."
}
check_os_guard() {
[ -r "$OS_RELEASE_FILE" ] || die "Nie można odczytać $OS_RELEASE_FILE."
local os_id="" os_version=""
os_id="$(awk -F= '$1=="ID"{gsub(/"/,"",$2); print $2}' "$OS_RELEASE_FILE")"
os_version="$(awk -F= '$1=="VERSION_ID"{gsub(/"/,"",$2); print $2}' "$OS_RELEASE_FILE")"
[ "$os_id" = "almalinux" ] || die "Ten skrypt obsługuje wyłącznie AlmaLinux 8/9 (wykryto: ${os_id:-nieznany})."
case "$os_version" in
8*|9*) ;;
*) die "Ten skrypt obsługuje wyłącznie AlmaLinux 8/9 (wykryto wersję: ${os_version:-nieznana})." ;;
esac
log "Wykryto obsługiwany system: AlmaLinux $os_version."
}
read_native_mysql_conf() {
[ -r "$NATIVE_MYSQL_CONF" ] || die "Nie znaleziono pliku $NATIVE_MYSQL_CONF."
NATIVE_CURRENT_USER="$(mysql_read_key_value "$NATIVE_MYSQL_CONF" user || true)"
NATIVE_CURRENT_PASS="$(mysql_read_key_value "$NATIVE_MYSQL_CONF" passwd || true)"
NATIVE_CURRENT_PORT="$(mysql_read_key_value "$NATIVE_MYSQL_CONF" port || true)"
NATIVE_CURRENT_SOCKET="$(mysql_read_key_value "$NATIVE_MYSQL_CONF" socket || true)"
NATIVE_CURRENT_HOST="$(mysql_read_key_value "$NATIVE_MYSQL_CONF" host || true)"
[ -n "$NATIVE_CURRENT_USER" ] || die "Nie udało się odczytać pola user z $NATIVE_MYSQL_CONF."
[ -n "$NATIVE_CURRENT_PORT" ] || die "Nie udało się odczytać pola port z $NATIVE_MYSQL_CONF."
}
read_current_mysql_inst() {
[ -r "$CUSTOMBUILD_OPTIONS_CONF" ] || die "Nie znaleziono pliku $CUSTOMBUILD_OPTIONS_CONF."
NATIVE_CURRENT_MYSQL_INST="$(mysql_read_key_value "$CUSTOMBUILD_OPTIONS_CONF" mysql_inst || true)"
[ -n "$NATIVE_CURRENT_MYSQL_INST" ] || die "Nie udało się odczytać mysql_inst z $CUSTOMBUILD_OPTIONS_CONF."
log "Bieżąca wartość mysql_inst: $NATIVE_CURRENT_MYSQL_INST."
}
already_migrated() {
[ "$NATIVE_CURRENT_PORT" != "3306" ]
}
check_native_reachable() {
local bin
bin="$(mysql_detect_binary mysql)" || die "Nie znaleziono klienta mysql/mariadb do sprawdzenia natywnej instancji."
MYSQL_PWD="$NATIVE_CURRENT_PASS" "$bin" \
--user="$NATIVE_CURRENT_USER" \
--protocol=TCP --host=127.0.0.1 --port="$NATIVE_CURRENT_PORT" \
-e "SELECT 1" >/dev/null 2>>"$LOG_FILE" \
|| die "Natywna instancja MariaDB nie odpowiada na porcie $NATIVE_CURRENT_PORT z bieżącymi poświadczeniami."
log "Natywna instancja MariaDB odpowiada na porcie $NATIVE_CURRENT_PORT."
}
port_is_listening() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -ltn "( sport = :$port )" 2>/dev/null | awk 'NR > 1 { found=1 } END { exit !found }'
return $?
fi
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
return $?
fi
log "Nie znaleziono ss ani lsof - zakładam, że port $port jest wolny."
return 1
}
preflight_new_port_free() {
if port_is_listening "$NEW_NATIVE_PORT"; then
die "Port $NEW_NATIVE_PORT jest już zajęty. Zmień NEW_NATIVE_PORT i spróbuj ponownie."
fi
log "Port docelowy $NEW_NATIVE_PORT jest wolny."
}
DID_SET_MYSQL_INST_NO=0
CONFIG_BACKUPS=()
disable_custombuild_mysql_management() {
if [ "$NATIVE_CURRENT_MYSQL_INST" = "no" ]; then
log "mysql_inst jest już ustawione na 'no' - pomijam."
return 0
fi
"$DA_CLI_BIN" build set mysql_inst no >>"$LOG_FILE" 2>&1 \
|| die "Nie udało się ustawić mysql_inst=no przez '$DA_CLI_BIN build set mysql_inst no'."
DID_SET_MYSQL_INST_NO=1
log "Ustawiono mysql_inst=no przez CustomBuild."
}
restore_custombuild_mysql_management() {
[ "$DID_SET_MYSQL_INST_NO" -eq 1 ] || return 0
"$DA_CLI_BIN" build set mysql_inst "$NATIVE_CURRENT_MYSQL_INST" >>"$LOG_FILE" 2>&1 \
|| log "OSTRZEŻENIE: nie udało się przywrócić mysql_inst=$NATIVE_CURRENT_MYSQL_INST."
log "Przywrócono mysql_inst=$NATIVE_CURRENT_MYSQL_INST."
}
backup_config_file() {
local file="$1"
local backup
backup="${file}.bak.$(date +%Y%m%d%H%M%S)"
cp -a "$file" "$backup"
CONFIG_BACKUPS+=("$file:$backup")
log "Utworzono kopię zapasową $file jako $backup."
}
rewrite_port_in_file() {
local file="$1" new_port="$2"
local tmp changed=0
[ -r "$file" ] || return 1
tmp="$(mktemp)"
awk -v new_port="$new_port" '
BEGIN { in_section = 0; changed = 0 }
/^[[:space:]]*\[[^]]+\][[:space:]]*$/ {
section = tolower($0)
gsub(/^[[:space:]]*\[[[:space:]]*/, "", section)
gsub(/[[:space:]]*\][[:space:]]*$/, "", section)
in_section = (section == "mysqld" || section == "mariadb" || section == "server")
print
next
}
in_section && /^[[:space:]]*port[[:space:]]*=/ {
print "port=" new_port
changed = 1
next
}
{ print }
END { if (changed) exit 0; else exit 1 }
' "$file" > "$tmp"
local awk_status=$?
if [ "$awk_status" -eq 0 ]; then
cp "$tmp" "$file"
changed=1
fi
rm -f "$tmp"
[ "$changed" -eq 1 ]
}
find_and_rewrite_native_port_configs() {
local any_changed=0
local f
for f in "$NATIVE_MY_CNF" "$NATIVE_MY_CNF_D"/*.cnf; do
[ -f "$f" ] || continue
if grep -Eq '^[[:space:]]*port[[:space:]]*=[[:space:]]*3306[[:space:]]*$' "$f"; then
backup_config_file "$f"
if rewrite_port_in_file "$f" "$NEW_NATIVE_PORT"; then
any_changed=1
log "Zaktualizowano port w $f."
fi
fi
done
if [ "$any_changed" -eq 0 ]; then
backup_config_file "$NATIVE_MY_CNF"
if grep -Eq '^[[:space:]]*\[mysqld\][[:space:]]*$' "$NATIVE_MY_CNF"; then
awk -v new_port="$NEW_NATIVE_PORT" '
{ print }
/^[[:space:]]*\[mysqld\][[:space:]]*$/ && !done { print "port=" new_port; done=1 }
' "$NATIVE_MY_CNF" > "${NATIVE_MY_CNF}.tmp" && mv "${NATIVE_MY_CNF}.tmp" "$NATIVE_MY_CNF"
else
printf '\n[mysqld]\nport=%s\n' "$NEW_NATIVE_PORT" >> "$NATIVE_MY_CNF"
fi
log "Brak istniejącej dyrektywy port= w sekcji serwera - dodano port=$NEW_NATIVE_PORT do $NATIVE_MY_CNF."
fi
}
update_native_mysql_conf_port() {
local target_port="$1"
local tmp
tmp="$(mktemp)"
awk -v new_port="$target_port" '
BEGIN { done = 0 }
/^[[:space:]]*port[[:space:]]*=/ { print "port=" new_port; done=1; next }
{ print }
END { if (!done) print "port=" new_port }
' "$NATIVE_MYSQL_CONF" > "$tmp"
cp "$tmp" "$NATIVE_MYSQL_CONF"
rm -f "$tmp"
}
apply_config_mutations() {
backup_config_file "$NATIVE_MYSQL_CONF"
find_and_rewrite_native_port_configs
update_native_mysql_conf_port "$NEW_NATIVE_PORT"
log "Zaktualizowano $NATIVE_MYSQL_CONF na port $NEW_NATIVE_PORT."
}
rollback_config_changes() {
local pair file backup
for pair in "${CONFIG_BACKUPS[@]}"; do
file="${pair%%:*}"
backup="${pair#*:}"
if [ -r "$backup" ]; then
cp -a "$backup" "$file"
log "Przywrócono $file z kopii zapasowej $backup."
fi
done
}
NATIVE_SERVICE_NAME=""
DID_RESTART_NATIVE=0
DID_RESTART_DA=0
# Guards rollback_service_state so it only acts while wired via the EXIT trap
# below. NOTE: this is intentionally an EXIT trap, not an ERR trap - see the
# comment above the trap installation in main() for why.
ROLLBACK_ARMED=0
detect_native_service_name() {
NATIVE_SERVICE_NAME="$(mysql_service_name)"
log "Wykryta usługa natywnej MariaDB: $NATIVE_SERVICE_NAME."
}
restart_native_service() {
systemctl restart "$NATIVE_SERVICE_NAME" || die "Nie udało się zrestartować usługi $NATIVE_SERVICE_NAME."
DID_RESTART_NATIVE=1
}
verify_native_listening_new_port() {
local i
for i in $(seq 1 30); do
if port_is_listening "$NEW_NATIVE_PORT" && ! port_is_listening 3306; then
log "Natywna instancja nasłuchuje na porcie $NEW_NATIVE_PORT, port 3306 wolny."
return 0
fi
sleep 1
done
die "Natywna instancja nie nasłuchuje na porcie $NEW_NATIVE_PORT po restarcie (lub port 3306 wciąż zajęty)."
}
restart_directadmin_service() {
systemctl restart directadmin || die "Nie udało się zrestartować usługi directadmin."
DID_RESTART_DA=1
}
verify_da_connectivity_new_port() {
local bin
bin="$(mysql_detect_binary mysql)" || die "Nie znaleziono klienta mysql/mariadb do weryfikacji łączności DA."
MYSQL_PWD="$NATIVE_CURRENT_PASS" "$bin" \
--user="$NATIVE_CURRENT_USER" \
--protocol=TCP --host=127.0.0.1 --port="$NEW_NATIVE_PORT" \
-e "SELECT 1" >/dev/null 2>>"$LOG_FILE" \
|| die "DirectAdmin nie może połączyć się z natywną instancją na nowym porcie $NEW_NATIVE_PORT."
log "Potwierdzono łączność DirectAdmina z natywną instancją na porcie $NEW_NATIVE_PORT."
}
rollback_service_state() {
[ "$ROLLBACK_ARMED" -eq 1 ] || return 0
rollback_config_changes
restore_custombuild_mysql_management
if [ -n "$NATIVE_SERVICE_NAME" ]; then
systemctl restart "$NATIVE_SERVICE_NAME" \
|| log "OSTRZEŻENIE: nie udało się zrestartować $NATIVE_SERVICE_NAME podczas wycofywania zmian."
fi
if [ "$DID_RESTART_DA" -eq 1 ]; then
systemctl restart directadmin \
|| log "OSTRZEŻENIE: nie udało się zrestartować directadmin podczas wycofywania zmian."
fi
log "Wycofano zmiany przejęcia portu natywnej instancji MariaDB."
}
main() {
require_root
check_os_guard
read_native_mysql_conf
read_current_mysql_inst
if already_migrated; then
log "Natywna instancja już działa na porcie $NATIVE_CURRENT_PORT (nie 3306) - pomijam przejęcie."
exit 0
fi
check_native_reachable
preflight_new_port_free
detect_native_service_name
# NOTE: deliberately an EXIT trap, not an ERR trap as originally planned.
# die() (used by every failure path below) calls `exit` directly, and bash's
# ERR trap does not fire for a function that terminates via an explicit
# `exit` call - it only fires when a command's non-zero status propagates
# through normal control flow. An EXIT trap fires on *any* shell exit
# (explicit exit, errexit-triggered exit, or falling off the end of the
# script), which is what "any failure from this point on triggers
# rollback" actually requires here. ROLLBACK_ARMED guards it so it is a
# no-op before this point and after a successful run.
ROLLBACK_ARMED=1
trap 'rollback_service_state' EXIT
disable_custombuild_mysql_management
apply_config_mutations
restart_native_service
verify_native_listening_new_port
restart_directadmin_service
verify_da_connectivity_new_port
ROLLBACK_ARMED=0
trap - EXIT
log "Przejęcie portu natywnej instancji MariaDB zakończone powodzeniem: $NATIVE_CURRENT_PORT -> $NEW_NATIVE_PORT."
}
[ -r "$COMMON_FILE" ] || die "missing helper: $COMMON_FILE"
# shellcheck source=./mysql_common.sh
source "$COMMON_FILE"
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
+42 -3
View File
@@ -1,7 +1,7 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PLUGIN_DIR="${PLUGIN_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
DATA_DIR="$PLUGIN_DIR/data"
JOB_DIR="$DATA_DIR/jobs/pending"
PROCESSING_DIR="$DATA_DIR/jobs/processing"
@@ -10,15 +10,54 @@ WORKER_LOCK_DIR="$DATA_DIR/worker.lock"
DB_LOCK_DIR="$DATA_DIR/lock"
PID_DIR="$DATA_DIR/pids"
CANCEL_DIR="$DATA_DIR/cancel"
WORKER_LOCK_STALE_SECONDS="${WORKER_LOCK_STALE_SECONDS:-300}"
mkdir -p "$JOB_DIR" "$PROCESSING_DIR" "$DONE_DIR" "$PID_DIR" "$CANCEL_DIR" "$DB_LOCK_DIR"
if ! mkdir "$WORKER_LOCK_DIR" 2>/dev/null; then
acquire_worker_lock() {
if mkdir "$WORKER_LOCK_DIR" 2>/dev/null; then
printf '%s' "$$" > "$WORKER_LOCK_DIR/pid" 2>/dev/null || true
return 0
fi
local existing_pid="" lock_mtime=0 now lock_age=0
if [ -r "$WORKER_LOCK_DIR/pid" ]; then
existing_pid="$(cat "$WORKER_LOCK_DIR/pid" 2>/dev/null || true)"
fi
if [ -n "$existing_pid" ]; then
if kill -0 "$existing_pid" 2>/dev/null; then
echo "alt-mysql worker: lock held by running pid $existing_pid, exiting" >&2
return 1
fi
else
lock_mtime="$(stat -c '%Y' "$WORKER_LOCK_DIR" 2>/dev/null || stat -f '%m' "$WORKER_LOCK_DIR" 2>/dev/null || echo 0)"
now="$(date +%s)"
if [ "$lock_mtime" -gt 0 ]; then
lock_age=$((now - lock_mtime))
fi
if [ "$lock_age" -lt "$WORKER_LOCK_STALE_SECONDS" ]; then
echo "alt-mysql worker: lock present without a live pid but younger than ${WORKER_LOCK_STALE_SECONDS}s (age ${lock_age}s), exiting" >&2
return 1
fi
fi
echo "alt-mysql worker: reclaiming stale lock (pid=${existing_pid:-none}, age=${lock_age}s)" >&2
rm -rf "$WORKER_LOCK_DIR"
if mkdir "$WORKER_LOCK_DIR" 2>/dev/null; then
printf '%s' "$$" > "$WORKER_LOCK_DIR/pid" 2>/dev/null || true
return 0
fi
return 1
}
if ! acquire_worker_lock; then
exit 0
fi
cleanup() {
rmdir "$WORKER_LOCK_DIR" 2>/dev/null || true
rm -rf "$WORKER_LOCK_DIR" 2>/dev/null || true
}
trap cleanup EXIT
+162
View File
@@ -0,0 +1,162 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT="$PLUGIN_DIR/scripts/da-integration/alt_mysql_native_backup_restore.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
# --- Fake alt-mariadb client, tracks statements + a simple existing-db state file ---
mkdir -p "$TMP_DIR/mariadb/bin"
cat > "$TMP_DIR/mariadb/bin/mariadb" <<'STUB'
#!/bin/bash
STATE_FILE="${FAKE_DB_STATE_FILE:?FAKE_DB_STATE_FILE must be set}"
CALL_LOG="${FAKE_CALL_LOG:?FAKE_CALL_LOG must be set}"
sql=""
mode=""
for arg in "$@"; do
case "$arg" in
-e) mode="sql" ;;
-Nse) mode="sql" ;;
--database=*|--user=*|--socket=*|--host=*|--port=*|--protocol=*) ;;
*)
if [ "$mode" = "sql" ]; then
sql="$arg"
mode=""
fi
;;
esac
done
if [ -n "$sql" ]; then
printf '%s\n' "$sql" >> "$CALL_LOG"
case "$sql" in
*"SELECT 1 FROM information_schema.SCHEMATA"*)
name="$(printf '%s' "$sql" | sed -n "s/.*SCHEMA_NAME='\([^']*\)'.*/\1/p")"
grep -qx "$name" "$STATE_FILE" 2>/dev/null && echo 1
;;
*"DROP DATABASE"*)
name="$(printf '%s' "$sql" | sed -n 's/.*DATABASE[^`]*`\([A-Za-z0-9_]*\)`.*/\1/p')"
[ -n "$name" ] && { grep -vx "$name" "$STATE_FILE" > "$STATE_FILE.tmp" 2>/dev/null || true; mv -f "$STATE_FILE.tmp" "$STATE_FILE"; }
;;
*"CREATE DATABASE"*)
name="$(printf '%s' "$sql" | sed -n 's/.*DATABASE[^`]*`\([A-Za-z0-9_]*\)`.*/\1/p')"
[ -n "$name" ] && echo "$name" >> "$STATE_FILE"
;;
esac
exit 0
fi
cat >> "$CALL_LOG"
printf 'IMPORT\n' >> "$CALL_LOG"
if [ "${FAKE_IMPORT_FAIL:-0}" = "1" ]; then
exit 1
fi
exit 0
STUB
chmod 755 "$TMP_DIR/mariadb/bin/mariadb"
cat > "$TMP_DIR/mariadb/bin/mariadb-dump" <<'STUB'
#!/bin/bash
echo "-- fake rollback dump"
exit 0
STUB
chmod 755 "$TMP_DIR/mariadb/bin/mariadb-dump"
cat > "$TMP_DIR/alt-mysql.conf" <<EOF
host=
passwd=secret
port=4407
socket=$TMP_DIR/data/mariadb.sock
user=da_admin
EOF
cat > "$TMP_DIR/settings.conf" <<EOF
MYSQL_PLUGIN_CREDENTIALS_FILE=$TMP_DIR/alt-mysql.conf
MYSQL_PLUGIN_CLIENT_BIN_DIR=$TMP_DIR/mariadb/bin
EOF
# run_import NAME_PREFIX -- ARGS...
# Runs the script under test with fresh, isolated state/log/call-log files
# for this scenario, writes its stdout to "$TMP_DIR/<prefix>.stdout" and
# writes every mariadb-stub SQL statement to "$TMP_DIR/<prefix>.calls",
# then prints nothing (the two files are read directly by the caller).
run_import() {
local prefix="$1"
shift
local db_state="$TMP_DIR/${prefix}.dbstate"
local call_log="$TMP_DIR/${prefix}.calls"
local stdout_file="$TMP_DIR/${prefix}.stdout"
local restore_log="$TMP_DIR/${prefix}.restore.log"
: > "$db_state"
: > "$call_log"
DA_ALT_MYSQL_SETTINGS_FILE="$TMP_DIR/settings.conf" \
DA_ALT_MYSQL_RESTORE_LOG="$restore_log" \
FAKE_DB_STATE_FILE="$db_state" \
FAKE_CALL_LOG="$call_log" \
bash "$SCRIPT" "$@" > "$stdout_file"
}
# --- Build a fake backup directory with one valid database pair ---
BACKUP_DIR="$TMP_DIR/backup"
mkdir -p "$BACKUP_DIR"
cat > "$BACKUP_DIR/demo_app.conf" <<'EOF'
accesshosts=0=localhost
db_collation=DEFAULT_CHARACTER_SET_NAME=utf8mb4&DEFAULT_COLLATION_NAME=utf8mb4_unicode_ci&SCHEMA_NAME=demo_app
demo_app=accesshosts=localhost&select_priv=Y&insert_priv=Y&passwd=%2Aabc123&plugin=mysql_native_password
EOF
cat > "$BACKUP_DIR/demo_app.sql" <<'EOF'
CREATE TABLE t (id INT);
EOF
# --- Scenario 1: happy path, single valid database ---
run_import scenario1 --user demo --root "$BACKUP_DIR" --overwrite allow
grep -q "IMPORTED demo_app" "$TMP_DIR/scenario1.stdout" \
|| fail "expected 'IMPORTED demo_app' in output, got: $(cat "$TMP_DIR/scenario1.stdout")"
grep -q "CREATE USER 'demo_app'@'localhost'" "$TMP_DIR/scenario1.calls" \
|| fail "expected CREATE USER statement for demo_app@localhost"
grep -q "GRANT SELECT,INSERT ON \`demo_app\`.\* TO 'demo_app'@'localhost'" "$TMP_DIR/scenario1.calls" \
|| fail "expected GRANT SELECT,INSERT for demo_app@localhost"
grep -q "IMPORT" "$TMP_DIR/scenario1.calls" || fail "expected the .sql dump to be piped in for import"
# --- Scenario 2: a database with no matching .conf must be skipped, not abort the run ---
mkdir -p "$TMP_DIR/backup_missing_conf"
cat > "$TMP_DIR/backup_missing_conf/orphan_db.sql" <<'EOF'
CREATE TABLE t (id INT);
EOF
run_import scenario2 --user orphan --root "$TMP_DIR/backup_missing_conf" --overwrite allow
grep -q "SKIPPED orphan_db" "$TMP_DIR/scenario2.stdout" \
|| fail "expected orphan_db (no .conf) to be reported as SKIPPED, got: $(cat "$TMP_DIR/scenario2.stdout")"
# --- Scenario 3: a mixed root (one valid pair + one orphan .sql) must report both correctly ---
mkdir -p "$TMP_DIR/backup_mixed"
cat > "$TMP_DIR/backup_mixed/mixed_good.conf" <<'EOF'
accesshosts=0=localhost
db_collation=DEFAULT_CHARACTER_SET_NAME=utf8mb4&DEFAULT_COLLATION_NAME=utf8mb4_unicode_ci&SCHEMA_NAME=mixed_good
mixed_good=accesshosts=localhost&select_priv=Y&passwd=%2Adef456&plugin=mysql_native_password
EOF
cat > "$TMP_DIR/backup_mixed/mixed_good.sql" <<'EOF'
CREATE TABLE t (id INT);
EOF
cat > "$TMP_DIR/backup_mixed/mixed_orphan.sql" <<'EOF'
CREATE TABLE t (id INT);
EOF
run_import scenario3 --user mixed --root "$TMP_DIR/backup_mixed" --overwrite allow
grep -q "IMPORTED mixed_good" "$TMP_DIR/scenario3.stdout" \
|| fail "expected mixed_good to be IMPORTED in a mixed root, got: $(cat "$TMP_DIR/scenario3.stdout")"
grep -q "SKIPPED mixed_orphan no-matching-conf" "$TMP_DIR/scenario3.stdout" \
|| fail "expected mixed_orphan (no .conf) to be SKIPPED in a mixed root alongside a valid pair, got: $(cat "$TMP_DIR/scenario3.stdout")"
echo "alt_mysql_native_backup_restore_import_test: OK"
@@ -0,0 +1,79 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT="$PLUGIN_DIR/scripts/da-integration/alt_mysql_native_backup_restore.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
# Source only the parsing functions, without triggering main() (the script
# guards its own main() call behind a BASH_SOURCE check, see Task 3 Step 1).
# shellcheck source=/dev/null
source "$SCRIPT"
# --- Real example content, copied verbatim from example_backup/psy/backup/psy_it301.conf ---
CONF_FILE="$TMP_DIR/psy_it301.conf"
cat > "$CONF_FILE" <<'EOF'
accesshosts=0=localhost
db_collation=DEFAULT_CHARACTER_SET_NAME=utf8mb3&DEFAULT_COLLATION_NAME=utf8mb3_general_ci&SCHEMA_NAME=psy_it301
psy_it301=accesshosts=localhost&alter_priv=Y&alter_routine_priv=Y&create_priv=Y&create_routine_priv=Y&create_tmp_table_priv=Y&create_view_priv=Y&delete_priv=Y&drop_priv=Y&event_priv=Y&execute_priv=Y&index_priv=Y&insert_priv=Y&lock_tables_priv=Y&passwd=%2A8E2A6497279CF1B7F115E213B9904BD32703F4EF&plugin=mysql_native_password&references_priv=Y&select_priv=Y&show_view_priv=Y&trigger_priv=Y&update_priv=Y
EOF
# url_decode
[ "$(url_decode '%2A8E2A6497279CF1B7F115E213B9904BD32703F4EF')" = "*8E2A6497279CF1B7F115E213B9904BD32703F4EF" ] \
|| fail "url_decode did not decode %2A correctly"
# native_conf_field
QS="accesshosts=localhost&select_priv=Y&passwd=%2Aabc&plugin=mysql_native_password"
[ "$(native_conf_field "$QS" passwd)" = "%2Aabc" ] || fail "native_conf_field did not extract passwd"
[ "$(native_conf_field "$QS" plugin)" = "mysql_native_password" ] || fail "native_conf_field did not extract plugin"
[ "$(native_conf_field "$QS" missing_field)" = "" ] || fail "native_conf_field should return empty for a missing field"
# native_conf_privilege_list
PRIVS="$(native_conf_privilege_list "$QS")"
[ "$PRIVS" = "SELECT" ] || fail "expected only SELECT for a single select_priv=Y field, got: $PRIVS"
NO_PRIVS="$(native_conf_privilege_list "accesshosts=localhost&plugin=mysql_native_password")"
[ "$NO_PRIVS" = "ALL" ] || fail "expected ALL when no *_priv=Y flags are present, got: $NO_PRIVS"
# parse_native_db_conf against the real example file
parse_native_db_conf "$CONF_FILE" || fail "parse_native_db_conf should succeed on a valid conf file"
[ "$NATIVE_DB_CHARSET" = "utf8mb3" ] || fail "expected charset utf8mb3, got: $NATIVE_DB_CHARSET"
[ "$NATIVE_DB_COLLATION" = "utf8mb3_general_ci" ] || fail "expected collation utf8mb3_general_ci, got: $NATIVE_DB_COLLATION"
[ "${#NATIVE_DB_USER_NAMES[@]}" -eq 1 ] || fail "expected exactly 1 user, got: ${#NATIVE_DB_USER_NAMES[@]}"
[ "${NATIVE_DB_USER_NAMES[0]}" = "psy_it301" ] || fail "expected username psy_it301, got: ${NATIVE_DB_USER_NAMES[0]}"
[ "${NATIVE_DB_USER_HOST[0]}" = "localhost" ] || fail "expected host localhost, got: ${NATIVE_DB_USER_HOST[0]}"
[ "${NATIVE_DB_USER_HASH[0]}" = "*8E2A6497279CF1B7F115E213B9904BD32703F4EF" ] || fail "password hash not decoded correctly, got: ${NATIVE_DB_USER_HASH[0]}"
[ "${NATIVE_DB_USER_PLUGIN[0]}" = "mysql_native_password" ] || fail "expected mysql_native_password plugin, got: ${NATIVE_DB_USER_PLUGIN[0]}"
echo "${NATIVE_DB_USER_PRIVS[0]}" | grep -q "SELECT" || fail "expected SELECT in privilege list, got: ${NATIVE_DB_USER_PRIVS[0]}"
# --- A conf file with two users on the same database ---
MULTI_CONF="$TMP_DIR/multi.conf"
cat > "$MULTI_CONF" <<'EOF'
accesshosts=0=localhost
db_collation=DEFAULT_CHARACTER_SET_NAME=utf8mb4&DEFAULT_COLLATION_NAME=utf8mb4_general_ci&SCHEMA_NAME=demo_multi
demo_owner=accesshosts=localhost&select_priv=Y&insert_priv=Y&passwd=%2Aaaa&plugin=mysql_native_password
demo_reader=accesshosts=localhost&select_priv=Y&passwd=%2Abbb&plugin=mysql_native_password
EOF
parse_native_db_conf "$MULTI_CONF" || fail "parse_native_db_conf should succeed on a multi-user conf file"
[ "${#NATIVE_DB_USER_NAMES[@]}" -eq 2 ] || fail "expected 2 users in a multi-user conf, got: ${#NATIVE_DB_USER_NAMES[@]}"
# --- A malformed conf file (no db_collation) must fail closed, not guess ---
BAD_CONF="$TMP_DIR/bad.conf"
cat > "$BAD_CONF" <<'EOF'
accesshosts=0=localhost
demo_user=select_priv=Y&passwd=%2Aaaa&plugin=mysql_native_password
EOF
if parse_native_db_conf "$BAD_CONF" 2>/dev/null; then
fail "parse_native_db_conf must fail when db_collation is missing, not guess a default"
fi
echo "alt_mysql_native_backup_restore_parse_test: OK"
+159
View File
@@ -0,0 +1,159 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT="$PLUGIN_DIR/scripts/da-integration/alt_mysql_restore_payload.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}
# --- Fake alt-mariadb client binaries + credentials, isolated from the real system ---
# The stub tracks a simple "existing databases" state file so CREATE/DROP
# DATABASE during the test actually affects what a later existence check sees.
mkdir -p "$TMP_DIR/mariadb/bin"
cat > "$TMP_DIR/mariadb/bin/mariadb" <<'STUB'
#!/bin/bash
STATE_FILE="${FAKE_DB_STATE_FILE:?FAKE_DB_STATE_FILE must be set}"
sql=""
mode=""
for arg in "$@"; do
case "$arg" in
-e) mode="sql" ;;
-Nse) mode="sql" ;;
--database=*|--user=*|--socket=*|--host=*|--port=*|--protocol=*) ;;
*)
if [ "$mode" = "sql" ]; then
sql="$arg"
mode=""
fi
;;
esac
done
if [ -n "$sql" ]; then
case "$sql" in
*"SELECT 1 FROM information_schema.SCHEMATA"*)
name="$(printf '%s' "$sql" | sed -n "s/.*SCHEMA_NAME='\([^']*\)'.*/\1/p")"
grep -qx "$name" "$STATE_FILE" 2>/dev/null && echo 1
;;
*"DROP DATABASE"*)
name="$(printf '%s' "$sql" | sed -n 's/.*DATABASE[^`]*`\([A-Za-z0-9_]*\)`.*/\1/p')"
if [ -n "$name" ]; then
grep -vx "$name" "$STATE_FILE" > "$STATE_FILE.tmp" 2>/dev/null || true
mv -f "$STATE_FILE.tmp" "$STATE_FILE"
fi
;;
*"CREATE DATABASE"*)
name="$(printf '%s' "$sql" | sed -n 's/.*DATABASE[^`]*`\([A-Za-z0-9_]*\)`.*/\1/p')"
[ -n "$name" ] && echo "$name" >> "$STATE_FILE"
;;
esac
exit 0
fi
# No -e/-Nse: reading an import from stdin
cat >/dev/null
if [ "${FAKE_MYSQL_IMPORT_FAIL:-0}" = "1" ]; then
echo "fake mariadb: simulated import failure" >&2
exit 1
fi
exit 0
STUB
chmod 755 "$TMP_DIR/mariadb/bin/mariadb"
cat > "$TMP_DIR/mariadb/bin/mariadb-dump" <<'STUB'
#!/bin/bash
echo "-- fake rollback dump"
echo "SELECT 1;"
exit 0
STUB
chmod 755 "$TMP_DIR/mariadb/bin/mariadb-dump"
cat > "$TMP_DIR/alt-mysql.conf" <<EOF
host=
passwd=secret
port=4407
socket=$TMP_DIR/data/mariadb.sock
user=da_admin
EOF
cat > "$TMP_DIR/settings.conf" <<EOF
MYSQL_PLUGIN_CREDENTIALS_FILE=$TMP_DIR/alt-mysql.conf
MYSQL_PLUGIN_CLIENT_BIN_DIR=$TMP_DIR/mariadb/bin
EOF
# --- Build a valid manifest + payload files ---
build_payload() {
local payload_dir="$1" db_name="$2"
mkdir -p "$payload_dir/databases" "$payload_dir/grants" "$payload_dir/metadata"
printf 'CREATE TABLE t (id INT);\n' > "$payload_dir/databases/${db_name}.sql"
gzip -9 -f "$payload_dir/databases/${db_name}.sql"
printf '%s\n' '-- users' | gzip -9 > "$payload_dir/grants/users.sql.gz"
printf '%s\n' '-- grants' | gzip -9 > "$payload_dir/grants/grants.sql.gz"
local sha
sha="$(sha256_of "$payload_dir/databases/${db_name}.sql.gz")"
cat > "$payload_dir/manifest.json" <<JSON
{
"format": "da-alt-mysql-v1",
"created_at": "2026-01-01T00:00:00Z",
"da_user": "demo",
"databases": [
{ "name": "${db_name}", "file": "databases/${db_name}.sql.gz", "sha256": "${sha}" }
]
}
JSON
}
run_restore() {
local db_state
db_state="$(mktemp)"
printf '%s\n' "${FAKE_EXISTING_DBS:-}" | tr ',' '\n' | sed '/^$/d' > "$db_state"
DA_ALT_MYSQL_SETTINGS_FILE="$TMP_DIR/settings.conf" \
DA_ALT_MYSQL_RESTORE_LOG="$TMP_DIR/restore.log" \
FAKE_DB_STATE_FILE="$db_state" \
bash "$SCRIPT" "$@"
}
# --- Scenario 1: checksum mismatch must be rejected before any restore happens ---
PAYLOAD1="$TMP_DIR/payload1"
build_payload "$PAYLOAD1" "demo_app"
echo 'CORRUPTED CONTENT' > "$PAYLOAD1/databases/demo_app.sql.gz"
OUTPUT1="$(run_restore --user demo --payload "$PAYLOAD1" --overwrite allow 2>&1)" && fail "checksum mismatch must cause a non-zero exit, output: $OUTPUT1"
echo "$OUTPUT1" | grep -qi "checksum mismatch" \
|| fail "expected a checksum mismatch error, got: $OUTPUT1"
[ ! -f "$TMP_DIR/restore.log" ] || ! grep -qi "restoring user definitions" "$TMP_DIR/restore.log" \
|| fail "restore must not proceed past checksum verification on mismatch"
# --- Scenario 2: overwrite=deny must refuse to touch an existing database ---
PAYLOAD2="$TMP_DIR/payload2"
build_payload "$PAYLOAD2" "demo_existing"
OUTPUT2="$(FAKE_EXISTING_DBS="demo_existing" run_restore --user demo --payload "$PAYLOAD2" --overwrite deny 2>&1)" && fail "overwrite=deny must reject an existing database, output: $OUTPUT2"
echo "$OUTPUT2" | grep -qi "overwrite is denied" \
|| fail "expected an overwrite-denied error, got: $OUTPUT2"
# --- Scenario 3: happy path - valid checksum, no conflict, overwrite=allow ---
PAYLOAD3="$TMP_DIR/payload3"
build_payload "$PAYLOAD3" "demo_new"
run_restore --user demo --payload "$PAYLOAD3" --overwrite allow >/dev/null 2>&1 \
|| fail "a valid payload with no conflicts and overwrite=allow should succeed"
grep -qi "restore payload completed" "$TMP_DIR/restore.log" \
|| fail "expected the restore to log completion"
echo "alt_mysql_restore_payload_test: OK"
+119
View File
@@ -0,0 +1,119 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT="$PLUGIN_DIR/scripts/da-integration/alt_mysql_user_restore_post_pre_cleanup.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
mkdir -p "$TMP_DIR/fake_bin"
# Fake native-backup-restore tier: reports one imported, one skipped database.
cat > "$TMP_DIR/fake_bin/alt_mysql_native_backup_restore.sh" <<'EOF'
#!/bin/bash
echo "CALLED native_backup_restore $*" >> "$FAKE_CALL_LOG"
echo "IMPORTED demo_ok"
echo "SKIPPED demo_bad import-failed"
EOF
chmod 755 "$TMP_DIR/fake_bin/alt_mysql_native_backup_restore.sh"
# Fake native-staging-migrate tier: just logs that it ran, and with which user.
cat > "$TMP_DIR/fake_bin/da_restore_native_staging_to_alt_mysql.sh" <<'EOF'
#!/bin/bash
echo "CALLED native_staging_migrate $*" >> "$FAKE_CALL_LOG"
EOF
chmod 755 "$TMP_DIR/fake_bin/da_restore_native_staging_to_alt_mysql.sh"
# No plugin payload present anywhere reachable, so the hook falls past tier 1.
mkdir -p "$TMP_DIR/empty_restore_root"
CALL_LOG="$TMP_DIR/calls.log"
: > "$CALL_LOG"
DA_ALT_MYSQL_RESTORE_LOG="$TMP_DIR/restore.log" \
DA_ALT_MYSQL_NATIVE_BACKUP_RESTORE_BIN="$TMP_DIR/fake_bin/alt_mysql_native_backup_restore.sh" \
DA_ALT_MYSQL_NATIVE_STAGING_BIN="$TMP_DIR/fake_bin/da_restore_native_staging_to_alt_mysql.sh" \
DA_ALT_MYSQL_SETTINGS_FILE="$TMP_DIR/missing-settings.conf" \
FAKE_CALL_LOG="$CALL_LOG" \
restore_path="$TMP_DIR/empty_restore_root" \
username="demo" \
bash "$SCRIPT" demo
grep -q "CALLED native_backup_restore" "$CALL_LOG" || fail "expected the native-backup-restore tier to be invoked"
grep -q "CALLED native_staging_migrate" "$CALL_LOG" || fail "expected the native-staging-migrate fallback to still run for skipped databases"
# The staging-migrate fallback must be told which databases were already
# imported, so it never touches demo_ok again.
grep -q "\-\-skip demo_ok" "$CALL_LOG" || fail "expected native-staging-migrate to be told to skip demo_ok, log: $(cat "$CALL_LOG")"
### Scenario A: native-backup-restore script unavailable must not abort the restore.
mkdir -p "$TMP_DIR/fake_bin_a"
cat > "$TMP_DIR/fake_bin_a/da_restore_native_staging_to_alt_mysql.sh" <<'EOF'
#!/bin/bash
echo "CALLED native_staging_migrate $*" >> "$FAKE_CALL_LOG"
EOF
chmod 755 "$TMP_DIR/fake_bin_a/da_restore_native_staging_to_alt_mysql.sh"
mkdir -p "$TMP_DIR/empty_restore_root_a"
CALL_LOG="$TMP_DIR/calls_a.log"
: > "$CALL_LOG"
DA_ALT_MYSQL_RESTORE_LOG="$TMP_DIR/restore_a.log" \
DA_ALT_MYSQL_NATIVE_BACKUP_RESTORE_BIN="$TMP_DIR/fake_bin_a/does-not-exist.sh" \
DA_ALT_MYSQL_NATIVE_STAGING_BIN="$TMP_DIR/fake_bin_a/da_restore_native_staging_to_alt_mysql.sh" \
DA_ALT_MYSQL_SETTINGS_FILE="$TMP_DIR/missing-settings.conf" \
FAKE_CALL_LOG="$CALL_LOG" \
restore_path="$TMP_DIR/empty_restore_root_a" \
username="demo" \
bash "$SCRIPT" demo && rc=0 || rc=$?
[ "$rc" -eq 0 ] || fail "expected the hook to exit 0 even when the native-backup-restore script is unavailable, got rc=$rc"
if grep -q "CALLED native_backup_restore" "$CALL_LOG"; then
fail "did not expect the native-backup-restore tier to be invoked when its script is missing"
fi
grep -q "CALLED native_staging_migrate" "$CALL_LOG" || fail "expected the native-staging-migrate fallback to still run when native-backup-restore is unavailable"
### Scenario B: zero databases imported must still invoke the fallback, with no stray/malformed --skip argument.
mkdir -p "$TMP_DIR/fake_bin_b"
cat > "$TMP_DIR/fake_bin_b/alt_mysql_native_backup_restore.sh" <<'EOF'
#!/bin/bash
echo "CALLED native_backup_restore $*" >> "$FAKE_CALL_LOG"
echo "SKIPPED demo_only import-failed"
EOF
chmod 755 "$TMP_DIR/fake_bin_b/alt_mysql_native_backup_restore.sh"
cat > "$TMP_DIR/fake_bin_b/da_restore_native_staging_to_alt_mysql.sh" <<'EOF'
#!/bin/bash
echo "CALLED native_staging_migrate $*" >> "$FAKE_CALL_LOG"
EOF
chmod 755 "$TMP_DIR/fake_bin_b/da_restore_native_staging_to_alt_mysql.sh"
mkdir -p "$TMP_DIR/empty_restore_root_b"
CALL_LOG="$TMP_DIR/calls_b.log"
: > "$CALL_LOG"
DA_ALT_MYSQL_RESTORE_LOG="$TMP_DIR/restore_b.log" \
DA_ALT_MYSQL_NATIVE_BACKUP_RESTORE_BIN="$TMP_DIR/fake_bin_b/alt_mysql_native_backup_restore.sh" \
DA_ALT_MYSQL_NATIVE_STAGING_BIN="$TMP_DIR/fake_bin_b/da_restore_native_staging_to_alt_mysql.sh" \
DA_ALT_MYSQL_SETTINGS_FILE="$TMP_DIR/missing-settings.conf" \
FAKE_CALL_LOG="$CALL_LOG" \
restore_path="$TMP_DIR/empty_restore_root_b" \
username="demo" \
bash "$SCRIPT" demo
grep -q "CALLED native_staging_migrate" "$CALL_LOG" || fail "expected the native-staging-migrate fallback to still run when zero databases were imported"
if grep "CALLED native_staging_migrate" "$CALL_LOG" | grep -q -- "--skip"; then
fail "did not expect a --skip argument to be passed when zero databases were imported, log: $(cat "$CALL_LOG")"
fi
echo "alt_mysql_user_restore_post_pre_cleanup_tiers_test: OK"
+114
View File
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
$pluginTempRoot = sys_get_temp_dir() . '/altmysql-plugin-root-' . bin2hex(random_bytes(4));
mkdir($pluginTempRoot . '/data/jobs', 0700, true);
define('PLUGIN_ROOT', $pluginTempRoot);
$pluginDir = dirname(__DIR__);
require_once $pluginDir . '/exec/lib/Settings.php';
require_once $pluginDir . '/exec/lib/DirectAdminUser.php';
require_once $pluginDir . '/exec/lib/BackupQueueService.php';
function fail(string $message): void
{
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
function load_settings(string $contents): Settings
{
$path = tempnam(sys_get_temp_dir(), 'altmysql-settings-');
if ($path === false) {
fail('cannot create temp settings file');
}
file_put_contents($path, $contents);
$settings = Settings::load($path);
unlink($path);
return $settings;
}
function make_da_user(Settings $settings, string $username): DirectAdminUser
{
$class = new ReflectionClass(DirectAdminUser::class);
/** @var DirectAdminUser $user */
$user = $class->newInstanceWithoutConstructor();
foreach ([
'username' => $username,
'prefix' => $username . '_',
'userConf' => [],
'packageConf' => [],
'settings' => $settings,
] as $property => $value) {
$prop = $class->getProperty($property);
$prop->setAccessible(true);
$prop->setValue($user, $value);
}
return $user;
}
if (!function_exists('pcntl_fork')) {
fail('pcntl extension is required to safely test withQueueLock() without risking a hang');
}
// Make the jobs/ directory read-only so mkdir(queue.lock) fails for a reason
// OTHER than "already exists" (EACCES, not EEXIST) - this is the branch that
// must never busy-loop forever.
chmod($pluginTempRoot . '/data/jobs', 0500);
$pid = pcntl_fork();
if ($pid === -1) {
fail('could not fork to safely bound the withQueueLock() call');
}
if ($pid === 0) {
// Child: attempt the call. If it hangs, the parent will kill us after
// the deadline below and the test fails. If it throws (correct fixed
// behavior), exit 42. Any other outcome exits 1.
$settings = load_settings('');
$daUser = make_da_user($settings, 'demo');
$queue = new BackupQueueService($settings, $daUser);
$class = new ReflectionClass(BackupQueueService::class);
$method = $class->getMethod('withQueueLock');
$method->setAccessible(true);
try {
$method->invoke($queue, static fn () => 'unreachable');
exit(1);
} catch (RuntimeException $e) {
exit(42);
} catch (Throwable $e) {
exit(1);
}
}
$deadline = microtime(true) + 5.0;
$status = 0;
$exited = false;
while (microtime(true) < $deadline) {
$result = pcntl_waitpid($pid, $status, WNOHANG);
if ($result === $pid) {
$exited = true;
break;
}
usleep(50000);
}
if (!$exited) {
posix_kill($pid, SIGKILL);
pcntl_waitpid($pid, $status);
@chmod($pluginTempRoot . '/data/jobs', 0700);
exec('rm -rf ' . escapeshellarg($pluginTempRoot));
fail('withQueueLock() busy-looped instead of giving up after a bounded number of retries (killed after 5s)');
}
@chmod($pluginTempRoot . '/data/jobs', 0700);
exec('rm -rf ' . escapeshellarg($pluginTempRoot));
$exitCode = pcntl_wexitstatus($status);
if ($exitCode !== 42) {
fail("withQueueLock() did not fail with the expected RuntimeException (child exit code: $exitCode)");
}
echo "backup_queue_lock_test: OK\n";
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
$pluginTempRoot = sys_get_temp_dir() . '/altmysql-plugin-root-' . bin2hex(random_bytes(4));
mkdir($pluginTempRoot, 0700, true);
define('PLUGIN_ROOT', $pluginTempRoot);
$pluginDir = dirname(__DIR__);
require_once $pluginDir . '/exec/lib/Settings.php';
require_once $pluginDir . '/exec/lib/DirectAdminUser.php';
require_once $pluginDir . '/exec/lib/BackupQueueService.php';
function fail(string $message): void
{
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
function assert_true(bool $condition, string $message): void
{
if (!$condition) {
fail($message);
}
}
function load_settings(string $contents): Settings
{
$path = tempnam(sys_get_temp_dir(), 'altmysql-settings-');
if ($path === false) {
fail('cannot create temp settings file');
}
file_put_contents($path, $contents);
$settings = Settings::load($path);
unlink($path);
return $settings;
}
function make_da_user(Settings $settings, string $username): DirectAdminUser
{
$class = new ReflectionClass(DirectAdminUser::class);
/** @var DirectAdminUser $user */
$user = $class->newInstanceWithoutConstructor();
foreach ([
'username' => $username,
'prefix' => $username . '_',
'userConf' => [],
'packageConf' => [],
'settings' => $settings,
] as $property => $value) {
$prop = $class->getProperty($property);
$prop->setAccessible(true);
$prop->setValue($user, $value);
}
return $user;
}
$settings = load_settings('');
$daUser = make_da_user($settings, 'demo');
$queue = new BackupQueueService($settings, $daUser);
// A path outside every recognized DirectAdmin/system temp directory must be
// rejected, even if it exists and is readable - it must not be treated as a
// legitimate upload reference just because the caller supplied it verbatim.
$secretDir = sys_get_temp_dir() . '/altmysql-secret-' . bin2hex(random_bytes(4));
mkdir($secretDir, 0700, true);
$secretFile = $secretDir . '/secret_credentials.sql';
file_put_contents($secretFile, 'SECRET CONTENT THAT MUST NEVER BE COPIED');
$threw = false;
try {
$queue->stageRestoreUploadFromDirectAdminTemp($secretFile, 'harmless.sql');
} catch (Throwable $e) {
$threw = true;
}
assert_true($threw, 'a path outside the known DirectAdmin/system temp directories must be rejected, not staged');
// A legitimate reference - a file that DirectAdmin actually placed directly
// inside a recognized temp directory - must still work.
$legitFile = sys_get_temp_dir() . '/altmysql-da-upload-' . bin2hex(random_bytes(4)) . '.sql';
file_put_contents($legitFile, 'SELECT 1;');
$stagedPath = $queue->stageRestoreUploadFromDirectAdminTemp($legitFile, 'legit.sql');
assert_true(is_file($stagedPath), 'a file directly inside a recognized temp directory must still be staged');
assert_true(
trim((string)file_get_contents($stagedPath)) === 'SELECT 1;',
'the staged file must contain the legitimate source content'
);
// cleanup
@unlink($secretFile);
@rmdir($secretDir);
@unlink($legitFile);
@unlink($stagedPath);
exec('rm -rf ' . escapeshellarg($pluginTempRoot));
echo "backup_restore_upload_path_test: OK\n";
+10
View File
@@ -27,9 +27,19 @@ grep -Fq "managed by DirectAdmin MySQL plugin hook dispatcher" "$TMP_DIR/custom/
[ -L "$TMP_DIR/custom/user_backup_compress_pre.sh.d/10-alt-mysql.sh" ] || fail "alt backup hook link missing"
[ -L "$TMP_DIR/custom/user_restore_post_pre_cleanup.sh.d/10-alt-mysql.sh" ] || fail "alt restore hook link missing"
[ -L "$TMP_DIR/custom/database_create_pre.sh.d/20-alt-mysql-block-native-db.sh" ] || fail "native DB blocker link missing"
[ -L "$TMP_DIR/custom/database_delete_pre.sh.d/20-alt-mysql-block-native-db.sh" ] || fail "native DB delete blocker link missing"
[ -L "$TMP_DIR/custom/database_user_password_change_pre.sh.d/20-alt-mysql-block-native-db.sh" ] || fail "native DB password-change blocker link missing"
[ -L "$TMP_DIR/custom/database_user_create_post.sh.d/20-alt-mysql-observe-native-db.sh" ] || fail "native DB user-create observer link missing"
[ -L "$TMP_DIR/custom/database_destroy_user_post.sh.d/20-alt-mysql-observe-native-db.sh" ] || fail "native DB user-destroy observer link missing"
DA_CUSTOM_HOOK_DIR="$TMP_DIR/custom" bash "$INSTALLER" uninstall >/dev/null
[ ! -e "$TMP_DIR/custom/user_backup_compress_pre.sh.d/10-alt-mysql.sh" ] || fail "alt backup hook link not removed"
[ ! -e "$TMP_DIR/custom/user_restore_post_pre_cleanup.sh.d/10-alt-mysql.sh" ] || fail "alt restore hook link not removed"
[ ! -e "$TMP_DIR/custom/database_create_pre.sh.d/20-alt-mysql-block-native-db.sh" ] || fail "native DB blocker link not removed"
[ ! -e "$TMP_DIR/custom/database_delete_pre.sh.d/20-alt-mysql-block-native-db.sh" ] || fail "native DB delete blocker link not removed"
[ ! -e "$TMP_DIR/custom/database_user_password_change_pre.sh.d/20-alt-mysql-block-native-db.sh" ] || fail "native DB password-change blocker link not removed"
[ ! -e "$TMP_DIR/custom/database_user_create_post.sh.d/20-alt-mysql-observe-native-db.sh" ] || fail "native DB user-create observer link not removed"
[ ! -e "$TMP_DIR/custom/database_destroy_user_post.sh.d/20-alt-mysql-observe-native-db.sh" ] || fail "native DB user-destroy observer link not removed"
[ -f "$TMP_DIR/custom/user_backup_compress_pre.sh.d/00-existing.sh" ] || fail "preserved existing hook should remain"
echo "da_integration_install_test: OK"
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
REAL_SCRIPT="$PLUGIN_DIR/scripts/da-integration/alt_mysql_user_restore_post_pre_cleanup.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR" "/tmp/alt-mysql-traversal-proof"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
# Extract the exact, real find_payload_in_dir()/extract_payload_from_archive()
# function bodies from the real script (rather than reimplementing them), so
# this test exercises the actual production logic without triggering the
# script's own main() (which needs a full DA restore environment).
FUNCS_FILE="$TMP_DIR/funcs.sh"
{
echo '#!/bin/bash'
echo 'log() { :; }'
awk '/^find_payload_in_dir\(\)/,/^\}/' "$REAL_SCRIPT"
awk '/^extract_payload_from_archive\(\)/,/^\}/' "$REAL_SCRIPT"
} > "$FUNCS_FILE"
grep -q '^find_payload_in_dir()' "$FUNCS_FILE" || fail "could not extract find_payload_in_dir() from $REAL_SCRIPT"
grep -q '^extract_payload_from_archive()' "$FUNCS_FILE" || fail "could not extract extract_payload_from_archive() from $REAL_SCRIPT"
# shellcheck source=/dev/null
source "$FUNCS_FILE"
# --- A legitimate archive must still extract normally ---
GOOD_STAGE="$TMP_DIR/good_stage"
mkdir -p "$GOOD_STAGE/backup/alt_mysql/databases"
printf 'x' > "$GOOD_STAGE/backup/alt_mysql/manifest.json"
printf 'x' > "$GOOD_STAGE/backup/alt_mysql/databases/demo_db.sql.gz"
GOOD_ARCHIVE="$TMP_DIR/good.tar"
( cd "$GOOD_STAGE" && tar -cf "$GOOD_ARCHIVE" backup/alt_mysql/manifest.json backup/alt_mysql/databases/demo_db.sql.gz )
GOOD_PAYLOAD="$(extract_payload_from_archive "$GOOD_ARCHIVE")" \
|| fail "a legitimate archive with a valid manifest must be extracted"
[ -r "$GOOD_PAYLOAD/manifest.json" ] || fail "extracted payload must contain manifest.json"
# --- An archive containing a path-traversal member must be refused entirely ---
EVIL_STAGE="$TMP_DIR/evil_stage"
mkdir -p "$EVIL_STAGE/backup/alt_mysql" "$EVIL_STAGE/evil_payload"
printf 'x' > "$EVIL_STAGE/backup/alt_mysql/manifest.json"
printf 'malicious content' > "$EVIL_STAGE/evil_payload/evil.sh"
EVIL_ARCHIVE="$TMP_DIR/evil.tar"
(
cd "$EVIL_STAGE" && {
tar -cf "$EVIL_ARCHIVE" -s ',^evil_payload/,../../../../../../tmp/alt-mysql-traversal-proof/,' \
backup/alt_mysql/manifest.json evil_payload/evil.sh 2>/dev/null \
|| tar -cf "$EVIL_ARCHIVE" --transform 's,^evil_payload/,../../../../../../tmp/alt-mysql-traversal-proof/,' \
backup/alt_mysql/manifest.json evil_payload/evil.sh
}
)
tar -tf "$EVIL_ARCHIVE" | grep -Fq '../../../../../../tmp/alt-mysql-traversal-proof/evil.sh' \
|| fail "test setup broken: the crafted archive does not actually contain a traversal member"
EVIL_OUTPUT=""
if EVIL_OUTPUT="$(extract_payload_from_archive "$EVIL_ARCHIVE" 2>&1)"; then
fail "an archive containing a path-traversal member must be refused, got payload: $EVIL_OUTPUT"
fi
[ ! -e "/tmp/alt-mysql-traversal-proof/evil.sh" ] \
|| fail "SECURITY: path-traversal member was actually extracted outside the temp dir"
echo "da_restore_archive_traversal_test: OK"
+12 -2
View File
@@ -41,10 +41,20 @@ set -e
[ "$NO_ARGS_STATUS" -eq 2 ] || fail "install_db.sh without arguments should exit with status 2, got $NO_ARGS_STATUS"
printf '%s\n' "$NO_ARGS_OUTPUT" | grep -Fq 'Użycie:' \
|| fail "install_db.sh without arguments does not print usage"
printf '%s\n' "$NO_ARGS_OUTPUT" | grep -Fq '<MARIADB_VERSION> <PORT>' \
|| fail "install_db.sh usage does not show required version and port"
printf '%s\n' "$NO_ARGS_OUTPUT" | grep -Fq '<MARIADB_VERSION> [PORT]' \
|| fail "install_db.sh usage does not show optional port"
if printf '%s\n' "$NO_ARGS_OUTPUT" | grep -Fq 'Ten skrypt musi zostać uruchomiony jako root'; then
fail "install_db.sh without arguments reaches root check instead of usage"
fi
set +e
ONE_ARG_OUTPUT="$("$SCRIPT" 10.11 2>&1)"
ONE_ARG_STATUS=$?
set -e
[ "$ONE_ARG_STATUS" -eq 1 ] \
|| fail "install_db.sh with only a version argument should reach the root check (status 1), got $ONE_ARG_STATUS: $ONE_ARG_OUTPUT"
printf '%s\n' "$ONE_ARG_OUTPUT" | grep -Fq 'Ten skrypt musi zostać uruchomiony jako root' \
|| fail "install_db.sh with only a version argument should default PORT to 3306 and reach the root check, got: $ONE_ARG_OUTPUT"
echo "install_db_cli_test: OK"
@@ -0,0 +1,72 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
REAL_SCRIPT="$PLUGIN_DIR/scripts/install_db.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
# Extract the exact, real maybe_takeover_native_port() function body from the
# real script (rather than reimplementing it), so this test exercises the
# actual production logic without triggering the script's own main() (which
# needs a full install environment).
FUNCS_FILE="$TMP_DIR/funcs.sh"
{
echo '#!/bin/bash'
echo 'log() { echo "[INFO] $*"; }'
echo 'die() { echo "[BŁĄD] $*" >&2; exit 1; }'
awk '/^maybe_takeover_native_port\(\)/,/^\}/' "$REAL_SCRIPT"
} > "$FUNCS_FILE"
grep -q '^maybe_takeover_native_port()' "$FUNCS_FILE" \
|| fail "could not extract maybe_takeover_native_port() from $REAL_SCRIPT"
# shellcheck source=/dev/null
source "$FUNCS_FILE"
mkdir -p "$TMP_DIR/setup"
CALL_LOG="$TMP_DIR/calls.log"
cat > "$TMP_DIR/setup/native_mariadb_port_takeover.sh" <<EOF
#!/bin/bash
echo "CALLED" >> "$CALL_LOG"
exit 0
EOF
chmod 755 "$TMP_DIR/setup/native_mariadb_port_takeover.sh"
NATIVE_TAKEOVER_SCRIPT="$TMP_DIR/setup/native_mariadb_port_takeover.sh"
# --- Scenario 1: PORT=3306 must call the takeover script ---
PORT="3306"
: > "$CALL_LOG"
maybe_takeover_native_port
grep -Fxq "CALLED" "$CALL_LOG" || fail "expected takeover script to be called when PORT=3306"
# --- Scenario 2: PORT!=3306 must NOT call the takeover script ---
PORT="33033"
: > "$CALL_LOG"
maybe_takeover_native_port
[ ! -s "$CALL_LOG" ] || fail "expected takeover script NOT to be called when PORT=33033"
# --- Scenario 3: takeover script failure must abort (non-zero exit) ---
cat > "$TMP_DIR/setup/native_mariadb_port_takeover.sh" <<EOF
#!/bin/bash
echo "CALLED-FAIL" >> "$CALL_LOG"
exit 1
EOF
chmod 755 "$TMP_DIR/setup/native_mariadb_port_takeover.sh"
PORT="3306"
set +e
FAIL_OUTPUT="$(maybe_takeover_native_port 2>&1)"
FAIL_STATUS=$?
set -e
[ "$FAIL_STATUS" -ne 0 ] || fail "maybe_takeover_native_port should fail when the takeover script fails"
printf '%s\n' "$FAIL_OUTPUT" | grep -Fq "Przejęcie portu" \
|| fail "expected a clear error message on takeover failure, got: $FAIL_OUTPUT"
echo "install_db_native_takeover_wiring_test: OK"
@@ -0,0 +1,115 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT="$PLUGIN_DIR/scripts/setup/native_mariadb_port_takeover.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
# Source only the detection functions, without triggering main() (main() is
# added in Task 4, guarded behind a BASH_SOURCE check).
NATIVE_TAKEOVER_LOG="$TMP_DIR/takeover.log" \
source "$SCRIPT"
# --- check_os_guard: AlmaLinux 9 must pass ---
cat > "$TMP_DIR/os-release-alma9" <<'EOF'
ID="almalinux"
VERSION_ID="9.4"
PRETTY_NAME="AlmaLinux 9.4"
EOF
OS_RELEASE_FILE="$TMP_DIR/os-release-alma9" check_os_guard \
|| fail "AlmaLinux 9 should pass the OS guard"
# --- check_os_guard: AlmaLinux 8 must pass ---
cat > "$TMP_DIR/os-release-alma8" <<'EOF'
ID="almalinux"
VERSION_ID="8.10"
EOF
OS_RELEASE_FILE="$TMP_DIR/os-release-alma8" check_os_guard \
|| fail "AlmaLinux 8 should pass the OS guard"
# --- check_os_guard: CloudLinux must be refused ---
cat > "$TMP_DIR/os-release-cloudlinux" <<'EOF'
ID="cloudlinux"
VERSION_ID="8.9"
ID_LIKE="rhel fedora centos"
EOF
if ( OS_RELEASE_FILE="$TMP_DIR/os-release-cloudlinux" check_os_guard 2>/dev/null ); then
fail "CloudLinux must be refused by the OS guard"
fi
# --- check_os_guard: AlmaLinux 7 (out of supported range) must be refused ---
cat > "$TMP_DIR/os-release-alma7" <<'EOF'
ID="almalinux"
VERSION_ID="7.9"
EOF
if ( OS_RELEASE_FILE="$TMP_DIR/os-release-alma7" check_os_guard 2>/dev/null ); then
fail "AlmaLinux 7 must be refused by the OS guard (only 8/9 supported)"
fi
# --- read_native_mysql_conf + already_migrated ---
cat > "$TMP_DIR/mysql.conf.3306" <<'EOF'
user=da_admin
passwd=secret
port=3306
socket=/var/lib/mysql/mysql.sock
host=
EOF
NATIVE_MYSQL_CONF="$TMP_DIR/mysql.conf.3306" read_native_mysql_conf \
|| fail "read_native_mysql_conf should succeed on a valid mysql.conf"
[ "$NATIVE_CURRENT_USER" = "da_admin" ] || fail "expected user da_admin, got: $NATIVE_CURRENT_USER"
[ "$NATIVE_CURRENT_PORT" = "3306" ] || fail "expected port 3306, got: $NATIVE_CURRENT_PORT"
already_migrated && fail "already_migrated should be false when native port is still 3306"
cat > "$TMP_DIR/mysql.conf.3307" <<'EOF'
user=da_admin
passwd=secret
port=3307
socket=/var/lib/mysql/mysql.sock
host=
EOF
NATIVE_MYSQL_CONF="$TMP_DIR/mysql.conf.3307" read_native_mysql_conf \
|| fail "read_native_mysql_conf should succeed on an already-migrated mysql.conf"
already_migrated || fail "already_migrated should be true when native port is 3307"
# --- read_current_mysql_inst ---
cat > "$TMP_DIR/options.conf" <<'EOF'
mysql_inst=mariadb
mariadb=10.6
EOF
CUSTOMBUILD_OPTIONS_CONF="$TMP_DIR/options.conf" read_current_mysql_inst \
|| fail "read_current_mysql_inst should succeed"
[ "$NATIVE_CURRENT_MYSQL_INST" = "mariadb" ] || fail "expected mysql_inst=mariadb, got: $NATIVE_CURRENT_MYSQL_INST"
# --- preflight_new_port_free using a faked `ss` on PATH ---
mkdir -p "$TMP_DIR/bin"
cat > "$TMP_DIR/bin/ss" <<'STUB'
#!/bin/bash
# Pretends port 3307 is free and port 9999 is occupied.
for arg in "$@"; do
case "$arg" in
*:3307*) exit 0 ;;
*:9999*)
echo "header line"
echo "occupied line"
exit 0
;;
esac
done
exit 0
STUB
chmod 755 "$TMP_DIR/bin/ss"
PATH="$TMP_DIR/bin:$PATH" NEW_NATIVE_PORT="3307" preflight_new_port_free \
|| fail "preflight_new_port_free should pass when the target port is free"
if ( PATH="$TMP_DIR/bin:$PATH" NEW_NATIVE_PORT="9999" preflight_new_port_free 2>/dev/null ); then
fail "preflight_new_port_free should refuse when the target port is already occupied"
fi
echo "native_mariadb_port_takeover_detect_test: OK"
@@ -0,0 +1,213 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT="$PLUGIN_DIR/scripts/setup/native_mariadb_port_takeover.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
setup_fixture() {
local dir="$1"
mkdir -p "$dir/bin" "$dir/my.cnf.d"
cat > "$dir/my.cnf" <<'EOF'
[client]
port=3306
[mysqld]
datadir=/var/lib/mysql
port=3306
socket=/var/lib/mysql/mysql.sock
EOF
cat > "$dir/mysql.conf" <<'EOF'
user=da_admin
passwd=secret
port=3306
socket=/var/lib/mysql/mysql.sock
host=
EOF
cat > "$dir/options.conf" <<'EOF'
mysql_inst=mariadb
mariadb=10.6
EOF
cat > "$dir/os-release" <<'EOF'
ID="almalinux"
VERSION_ID="9.4"
EOF
}
# run_takeover DIR
# Runs the script under test against the fixture already prepared at DIR
# (by a prior call to setup_fixture, possibly customized further by the
# scenario since) - writes stdout+stderr to "$DIR/run.out". Does NOT touch
# the fixture itself, so scenario-specific customizations made after
# setup_fixture (e.g. editing mysql.conf, swapping os-release) are preserved.
run_takeover() {
local dir="$1"
env \
NATIVE_TAKEOVER_LOG="$dir/takeover.log" \
NATIVE_MY_CNF="$dir/my.cnf" \
NATIVE_MY_CNF_D="$dir/my.cnf.d" \
NATIVE_MYSQL_CONF="$dir/mysql.conf" \
CUSTOMBUILD_OPTIONS_CONF="$dir/options.conf" \
OS_RELEASE_FILE="$dir/os-release" \
NEW_NATIVE_PORT="3307" \
DA_CLI_BIN="$dir/bin/da" \
PATH="$dir/bin:$PATH" \
bash "$SCRIPT" > "$dir/run.out" 2>&1
}
# --- Fake command stubs shared by scenarios; each scenario builds its own copies ---
write_common_stubs() {
local dir="$1"
local mode="${2:-happy}"
cat > "$dir/bin/id" <<'EOF'
#!/bin/bash
if [ "$1" = "-u" ]; then
echo 0
exit 0
fi
exit 1
EOF
chmod 755 "$dir/bin/id"
cat > "$dir/bin/da" <<EOF
#!/bin/bash
echo "\$*" >> "$dir/da_calls.log"
exit 0
EOF
chmod 755 "$dir/bin/da"
cat > "$dir/bin/mysql" <<EOF
#!/bin/bash
CURRENT_PORT_FILE="$dir/current_port"
requested_port=""
for arg in "\$@"; do
case "\$arg" in
--port=*) requested_port="\${arg#--port=}" ;;
esac
done
if [ "\$requested_port" = "\$(cat "\$CURRENT_PORT_FILE" 2>/dev/null || echo 3306)" ]; then
exit 0
fi
exit 1
EOF
chmod 755 "$dir/bin/mysql"
echo "3306" > "$dir/current_port"
cat > "$dir/bin/systemctl" <<EOF
#!/bin/bash
# mysql_service_name() (mysql_common.sh) probes "mysqld", then "mariadb", then
# "mysql" via 'systemctl list-unit-files <name>.service' and picks the first
# that succeeds - only mariadb.service "exists" here, matching a real
# AlmaLinux+MariaDB box where mysqld.service does not exist.
if [ "\$1" = "list-unit-files" ]; then
case "\$2" in
mariadb.service) exit 0 ;;
*) exit 1 ;;
esac
fi
echo "systemctl \$*" >> "$dir/systemctl_calls.log"
if [ "\$1" = "restart" ] && [ "\$2" = "mariadb" ] && [ "$mode" = "restart-fail" ]; then
exit 1
fi
if [ "\$1" = "restart" ] && [ "\$2" = "mariadb" ]; then
echo "3307" > "$dir/current_port"
fi
if [ "\$1" = "restart" ] && [ "\$2" = "directadmin" ] && [ "$mode" = "da-restart-fail" ]; then
exit 1
fi
exit 0
EOF
chmod 755 "$dir/bin/systemctl"
cat > "$dir/bin/ss" <<EOF
#!/bin/bash
current="\$(cat "$dir/current_port" 2>/dev/null || echo 3306)"
for arg in "\$@"; do
case "\$arg" in
*":\$current"*) echo header; echo occupied; exit 0 ;;
esac
done
exit 0
EOF
chmod 755 "$dir/bin/ss"
}
# --- Scenario 1: happy path ---
DIR="$TMP_DIR/happy"
setup_fixture "$DIR"
write_common_stubs "$DIR" happy
run_takeover "$DIR"
STATUS=$?
[ "$STATUS" -eq 0 ] || fail "happy path should exit 0, got $STATUS: $(cat "$DIR/run.out")"
grep -Fxq "port=3307" "$DIR/mysql.conf" || fail "happy path should leave mysql.conf on port 3307"
grep -Fq "build set mysql_inst no" "$DIR/da_calls.log" || fail "happy path should call da build set mysql_inst no"
grep -Fq "restart mariadb" "$DIR/systemctl_calls.log" || fail "happy path should restart mariadb"
grep -Fq "restart directadmin" "$DIR/systemctl_calls.log" || fail "happy path should restart directadmin"
# --- Scenario 2: already migrated (idempotent no-op) ---
DIR="$TMP_DIR/already"
setup_fixture "$DIR"
write_common_stubs "$DIR" happy
sed -i.orig 's/^port=3306$/port=3307/' "$DIR/mysql.conf"
run_takeover "$DIR"
STATUS=$?
[ "$STATUS" -eq 0 ] || fail "already-migrated case should exit 0, got $STATUS"
grep -Fq "build set" "$DIR/da_calls.log" 2>/dev/null && fail "already-migrated case should not touch CustomBuild settings"
# --- Scenario 3: OS guard rejection ---
DIR="$TMP_DIR/badose"
setup_fixture "$DIR"
write_common_stubs "$DIR" happy
cat > "$DIR/os-release" <<'EOF'
ID="cloudlinux"
VERSION_ID="8.9"
EOF
set +e
run_takeover "$DIR"
STATUS=$?
set -e
[ "$STATUS" -ne 0 ] || fail "CloudLinux should be refused, not exit 0"
grep -Fq "build set" "$DIR/da_calls.log" 2>/dev/null && fail "OS-guard rejection must not touch CustomBuild settings"
# --- Scenario 4: rollback when native restart fails ---
DIR="$TMP_DIR/restartfail"
setup_fixture "$DIR"
write_common_stubs "$DIR" restart-fail
set +e
run_takeover "$DIR"
STATUS=$?
set -e
[ "$STATUS" -ne 0 ] || fail "a failed native restart should not exit 0"
grep -Fxq "port=3306" "$DIR/mysql.conf" \
|| fail "mysql.conf should be rolled back to port 3306 after a failed native restart"
grep -Fq "build set mysql_inst mariadb" "$DIR/da_calls.log" \
|| fail "mysql_inst should be restored to mariadb after a failed native restart"
# --- Scenario 5: rollback when DirectAdmin restart fails ---
DIR="$TMP_DIR/darestartfail"
setup_fixture "$DIR"
write_common_stubs "$DIR" da-restart-fail
set +e
run_takeover "$DIR"
STATUS=$?
set -e
[ "$STATUS" -ne 0 ] || fail "a failed directadmin restart should not exit 0"
grep -Fxq "port=3306" "$DIR/mysql.conf" \
|| fail "mysql.conf should be rolled back to port 3306 after a failed directadmin restart"
grep -Fq "build set mysql_inst mariadb" "$DIR/da_calls.log" \
|| fail "mysql_inst should be restored to mariadb after a failed directadmin restart"
echo "native_mariadb_port_takeover_e2e_test: OK"
@@ -0,0 +1,100 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT="$PLUGIN_DIR/scripts/setup/native_mariadb_port_takeover.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
mkdir -p "$TMP_DIR/bin" "$TMP_DIR/my.cnf.d"
CALL_LOG="$TMP_DIR/calls.log"
cat > "$TMP_DIR/bin/da" <<EOF
#!/bin/bash
echo "\$*" >> "$CALL_LOG"
exit 0
EOF
chmod 755 "$TMP_DIR/bin/da"
cat > "$TMP_DIR/my.cnf" <<'EOF'
[client]
port=3306
[mysqld]
datadir=/var/lib/mysql
port=3306
socket=/var/lib/mysql/mysql.sock
EOF
cat > "$TMP_DIR/mysql.conf" <<'EOF'
user=da_admin
passwd=secret
port=3306
socket=/var/lib/mysql/mysql.sock
host=
EOF
NATIVE_TAKEOVER_LOG="$TMP_DIR/takeover.log" \
source "$SCRIPT"
NATIVE_MY_CNF="$TMP_DIR/my.cnf"
NATIVE_MY_CNF_D="$TMP_DIR/my.cnf.d"
NATIVE_MYSQL_CONF="$TMP_DIR/mysql.conf"
NEW_NATIVE_PORT="3307"
DA_CLI_BIN="$TMP_DIR/bin/da"
NATIVE_CURRENT_MYSQL_INST="mariadb"
# --- disable_custombuild_mysql_management ---
: > "$CALL_LOG"
disable_custombuild_mysql_management
grep -Fxq "build set mysql_inst no" "$CALL_LOG" \
|| fail "expected 'da build set mysql_inst no' to be called, got: $(cat "$CALL_LOG")"
[ "$DID_SET_MYSQL_INST_NO" -eq 1 ] || fail "expected DID_SET_MYSQL_INST_NO to be set"
# --- idempotency: already "no" must skip calling da ---
: > "$CALL_LOG"
DID_SET_MYSQL_INST_NO=0
NATIVE_CURRENT_MYSQL_INST="no"
disable_custombuild_mysql_management
[ ! -s "$CALL_LOG" ] || fail "expected no 'da' call when mysql_inst is already 'no'"
[ "$DID_SET_MYSQL_INST_NO" -eq 0 ] || fail "DID_SET_MYSQL_INST_NO should stay 0 when already 'no'"
NATIVE_CURRENT_MYSQL_INST="mariadb"
# --- apply_config_mutations ---
CONFIG_BACKUPS=()
apply_config_mutations
grep -Fxq "port=3307" "$TMP_DIR/mysql.conf" \
|| fail "expected mysql.conf port to be rewritten to 3307, got: $(cat "$TMP_DIR/mysql.conf")"
grep -Fxq "passwd=secret" "$TMP_DIR/mysql.conf" \
|| fail "expected mysql.conf passwd to be preserved untouched"
grep -A2 '^\[mysqld\]' "$TMP_DIR/my.cnf" | grep -Fxq "port=3307" \
|| fail "expected [mysqld] port in my.cnf to be rewritten to 3307"
grep -A1 '^\[client\]' "$TMP_DIR/my.cnf" | grep -Fxq "port=3306" \
|| fail "expected [client] port in my.cnf to be left at 3306 (only server sections rewritten)"
[ "${#CONFIG_BACKUPS[@]}" -ge 2 ] \
|| fail "expected at least 2 backup entries (my.cnf + mysql.conf), got ${#CONFIG_BACKUPS[@]}"
# --- rollback_config_changes ---
rollback_config_changes
grep -A2 '^\[mysqld\]' "$TMP_DIR/my.cnf" | grep -Fxq "port=3306" \
|| fail "expected [mysqld] port in my.cnf to be restored to 3306 after rollback"
grep -Fxq "port=3306" "$TMP_DIR/mysql.conf" \
|| fail "expected mysql.conf port to be restored to 3306 after rollback"
# --- restore_custombuild_mysql_management ---
: > "$CALL_LOG"
DID_SET_MYSQL_INST_NO=1
restore_custombuild_mysql_management
grep -Fxq "build set mysql_inst mariadb" "$CALL_LOG" \
|| fail "expected 'da build set mysql_inst mariadb' to be called on rollback, got: $(cat "$CALL_LOG")"
echo "native_mariadb_port_takeover_mutation_test: OK"
+7 -4
View File
@@ -2,7 +2,7 @@
set -euo pipefail
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ARCHIVE="${1:-$(cd "$PLUGIN_DIR/.." && pwd)/archives/1.2.15/alt-mysql.tar.gz}"
ARCHIVE="${1:-$(cd "$PLUGIN_DIR/.." && pwd)/archives/1.2.19/alt-mysql.tar.gz}"
fail() {
echo "FAIL: $*" >&2
@@ -23,6 +23,9 @@ fi
if printf '%s\n' "$LIST" | grep -Eq '^(\./)?tests/'; then
fail "archive contains local test suite"
fi
if printf '%s\n' "$LIST" | grep -Eq '^(\./)?docs/superpowers/'; then
fail "archive contains internal docs/superpowers planning artifacts (specs/plans), not plugin runtime files"
fi
if printf '%s\n' "$LIST" | grep -Eq '(^|/)\._[^/]+$|(^|/)\.DS_Store$|__MACOSX'; then
fail "archive contains macOS AppleDouble or Finder metadata"
fi
@@ -37,7 +40,7 @@ fi
CONF="$(tar -xOzf "$ARCHIVE" ./plugin.conf 2>/dev/null || tar -xOzf "$ARCHIVE" plugin.conf)"
printf '%s\n' "$CONF" | grep -Fxq "name=alt-mysql" || fail "archive plugin name is not alt-mysql"
printf '%s\n' "$CONF" | grep -Fxq "id=alt-mysql" || fail "archive plugin id is not alt-mysql"
printf '%s\n' "$CONF" | grep -Fxq "version=1.2.15" || fail "archive plugin version is not 1.2.15"
printf '%s\n' "$CONF" | grep -Fxq "version=1.2.19" || fail "archive plugin version is not 1.2.19"
SETTINGS="$(tar -xOzf "$ARCHIVE" ./plugin-settings.conf 2>/dev/null || tar -xOzf "$ARCHIVE" plugin-settings.conf)"
printf '%s\n' "$SETTINGS" | grep -Fxq "PHPMYADMIN_INSTALL_DIR=/var/www/html/alt-mysql-phpmyadmin" \
@@ -66,8 +69,8 @@ printf '%s\n' "$DB_INSTALLER" | grep -Fq 'SKIP_PLUGIN_INSTALL_REFRESH' \
|| fail "archive install_db.sh does not expose a test/maintenance escape hatch for plugin refresh"
printf '%s\n' "$DB_INSTALLER" | grep -Fq '[[ $# -eq 0 ]]' \
|| fail "archive install_db.sh does not show usage when called without arguments"
printf '%s\n' "$DB_INSTALLER" | grep -Fq '<MARIADB_VERSION> <PORT>' \
|| fail "archive install_db.sh usage does not require version and port"
printf '%s\n' "$DB_INSTALLER" | grep -Fq '<MARIADB_VERSION> [PORT]' \
|| fail "archive install_db.sh usage does not show optional port"
INSTALLER="$(tar -xOzf "$ARCHIVE" ./scripts/setup/phpmyadmin_install.sh 2>/dev/null || tar -xOzf "$ARCHIVE" scripts/setup/phpmyadmin_install.sh)"
printf '%s\n' "$INSTALLER" | grep -Fq "\$cfg['Servers'][\$i]['SignonURL'] = da_mysql_phpmyadmin_signon_url(\$runtimeConfig);" \
+1 -1
View File
@@ -14,7 +14,7 @@ case "$(basename "$PLUGIN_DIR")" in
esac
grep -Fxq "name=alt-mysql" "$PLUGIN_DIR/plugin.conf" || fail "plugin.conf name is not alt-mysql"
grep -Fxq "id=alt-mysql" "$PLUGIN_DIR/plugin.conf" || fail "plugin.conf id is not alt-mysql"
grep -Fxq "version=1.2.15" "$PLUGIN_DIR/plugin.conf" || fail "plugin.conf version is not 1.2.15"
grep -Fxq "version=1.2.19" "$PLUGIN_DIR/plugin.conf" || fail "plugin.conf version is not 1.2.19"
grep -Fq "return '/CMD_PLUGINS/alt-mysql';" "$PLUGIN_DIR/exec/lib/AppContext.php" \
|| fail "AppContext base URL is not alt-mysql"
grep -Fq "\$pluginId = 'alt-mysql';" "$PLUGIN_DIR/exec/lib/DirectAdminUser.php" \
+71
View File
@@ -0,0 +1,71 @@
#!/bin/bash
set -euo pipefail
PLUGIN_DIR_REAL="$(cd "$(dirname "$0")/.." && pwd)"
WORKER="$PLUGIN_DIR_REAL/scripts/worker.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
backdate() {
local path="$1" seconds_ago="$2"
local ts
ts="$(date -v-"${seconds_ago}"S +%Y%m%d%H%M.%S 2>/dev/null || date -d "-${seconds_ago} seconds" +%Y%m%d%H%M.%S)"
touch -t "$ts" "$path"
}
run_worker() {
PLUGIN_DIR="$TMP_DIR" bash "$WORKER"
}
# --- Scenario 1: no existing lock - worker acquires it, runs, and cleans up normally ---
OUTPUT1="$(run_worker 2>&1)"
[ ! -e "$TMP_DIR/data/worker.lock" ] \
|| fail "worker lock should be removed after a normal run with an empty queue, output: $OUTPUT1"
# --- Scenario 2: lock genuinely held by a live process - worker must back off, not reclaim ---
mkdir -p "$TMP_DIR/data"
mkdir "$TMP_DIR/data/worker.lock"
printf '%s' "$$" > "$TMP_DIR/data/worker.lock/pid"
OUTPUT2="$(run_worker 2>&1)"
echo "$OUTPUT2" | grep -qi "held by running pid" \
|| fail "worker must log that the lock is held by a live pid, got: $OUTPUT2"
[ -d "$TMP_DIR/data/worker.lock" ] \
|| fail "worker must not remove a lock held by a genuinely live process"
rm -rf "$TMP_DIR/data/worker.lock"
# --- Scenario 3: lock left behind by a crashed worker (dead pid) - must be reclaimed ---
mkdir -p "$TMP_DIR/data"
mkdir "$TMP_DIR/data/worker.lock"
printf '999999999' > "$TMP_DIR/data/worker.lock/pid"
OUTPUT3="$(run_worker 2>&1)"
echo "$OUTPUT3" | grep -qi "reclaiming stale lock" \
|| fail "worker must log that it is reclaiming a stale lock (dead pid), got: $OUTPUT3"
[ ! -e "$TMP_DIR/data/worker.lock" ] \
|| fail "worker lock should be removed again after successfully reclaiming and running, output: $OUTPUT3"
# --- Scenario 4: lock with no pid file but old enough to be considered abandoned ---
mkdir -p "$TMP_DIR/data"
mkdir "$TMP_DIR/data/worker.lock"
backdate "$TMP_DIR/data/worker.lock" 301
OUTPUT4="$(run_worker 2>&1)"
echo "$OUTPUT4" | grep -qi "reclaiming stale lock" \
|| fail "worker must reclaim an old lock with no pid file, got: $OUTPUT4"
[ ! -e "$TMP_DIR/data/worker.lock" ] \
|| fail "worker lock should be removed again after reclaiming an old pid-less lock, output: $OUTPUT4"
# --- Scenario 5: lock with no pid file, but too young to be considered abandoned yet ---
mkdir -p "$TMP_DIR/data"
mkdir "$TMP_DIR/data/worker.lock"
OUTPUT5="$(run_worker 2>&1)"
echo "$OUTPUT5" | grep -qi "younger than" \
|| fail "worker must back off on a young pid-less lock instead of reclaiming it, got: $OUTPUT5"
[ -d "$TMP_DIR/data/worker.lock" ] \
|| fail "worker must not remove a young pid-less lock (might just be mid-acquisition)"
rm -rf "$TMP_DIR/data/worker.lock"
echo "worker_lock_test: OK"