Compare commits
36 Commits
1560759c51
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b180196523 | |||
| fb800585a4 | |||
| 8e43e004f4 | |||
| 5b68a6aac8 | |||
| c59d5b8c77 | |||
| 42ec50afe4 | |||
| 35b9c1af78 | |||
| 15044f0dea | |||
| f15da3b7aa | |||
| 00dbe93531 | |||
| 2dd7034558 | |||
| a5bd889381 | |||
| 8ea46e89ab | |||
| bb3fae1fa6 | |||
| d0871acb68 | |||
| 98b9436cd1 | |||
| ebddcd73bf | |||
| 6b7ec39025 | |||
| d5040037d6 | |||
| b6016bafd8 | |||
| 87c6ba155e | |||
| 9e54bb32da | |||
| 74ac47f75b | |||
| c7c14f0d2a | |||
| b4fbd46cc1 | |||
| f472a7abfc | |||
| 5ede413f52 | |||
| a570635d23 | |||
| bbc1afb5e1 | |||
| 60ae1e1697 | |||
| b077425f93 | |||
| 6ef4fd79ce | |||
| 766380c9eb | |||
| 9a5781e5ed | |||
| 95b6d774fc | |||
| 722ce5beaa |
@@ -4,3 +4,4 @@ __MACOSX/
|
|||||||
error.log
|
error.log
|
||||||
data/*
|
data/*
|
||||||
assets/adminer/*
|
assets/adminer/*
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
# phpMyAdmin All-Databases Top-Nav Access 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 "grant access to every owned database" behavior for the top-nav PHPMYADMIN button while keeping the per-row "Zaloguj do bazy" button scoped to a single database.
|
||||||
|
|
||||||
|
**Architecture:** `PhpMyAdminSso::issueLoginPayload()` treats "no database requested" as its own explicit case (grant all, don't restrict) instead of defaulting it into "scope to the first database." A new `restrict_db` field flows from the ticket payload into the phpMyAdmin SSO session, and `config.inc.php`'s `only_db` builder reads that field instead of the landing-page `db` field.
|
||||||
|
|
||||||
|
**Tech Stack:** PHP 8 (plugin classes), bash heredocs generating standalone PHP files (`da_login.php`, `config.inc.php`) consumed by phpMyAdmin.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- No new "Adminer"-branded identifiers; new names use `phpmyadmin`/`PhpMyAdmin`/`DA_MYSQL_PHPMYADMIN` conventions (per spec `docs/superpowers/specs/2026-07-04-phpmyadmin-all-databases-design.md`).
|
||||||
|
- The per-row "Zaloguj do bazy" button's behavior (grant + restrict to one database) must not change.
|
||||||
|
- Every task must leave `bash tests/*.sh` and `php tests/*.php` green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Scope-aware grant targets and ticket payload in PhpMyAdminSso
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `exec/lib/PhpMyAdminSso.php:20-96` (`issueLoginPayload`, `determineGrantTargets`, `writeLoginTicket`)
|
||||||
|
- Test: `tests/phpmyadmin_sso_test.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing new.
|
||||||
|
- Produces: `PhpMyAdminSso::determineGrantTargets(?string $requestedDatabase, array $ownedDatabases): array` (signature change — was `string $targetDatabase`), and a new `restrict_db` string key in the ticket JSON payload written by `writeLoginTicket()`. Task 2 reads this exact key name from the ticket.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Open `tests/phpmyadmin_sso_test.php`. Replace the existing `determineGrantTargets` block (currently lines 64-80) with:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// --- phpMyAdmin SSO must only grant access to the database being logged into, not every database the account owns ---
|
||||||
|
// --- unless no specific database was requested at all (the top-nav PHPMYADMIN button), which must grant everything ---
|
||||||
|
|
||||||
|
$ssoClassForGrants = new ReflectionClass(PhpMyAdminSso::class);
|
||||||
|
assert_true($ssoClassForGrants->hasMethod('determineGrantTargets'), 'PhpMyAdminSso must expose a determineGrantTargets() decision method');
|
||||||
|
$determineGrantTargets = $ssoClassForGrants->getMethod('determineGrantTargets');
|
||||||
|
$determineGrantTargets->setAccessible(true);
|
||||||
|
|
||||||
|
assert_same(
|
||||||
|
['demo_target'],
|
||||||
|
$determineGrantTargets->invoke(null, 'demo_target', ['demo_target', 'demo_other']),
|
||||||
|
'requesting one database must only grant that database, not every database the user owns'
|
||||||
|
);
|
||||||
|
assert_same(
|
||||||
|
[],
|
||||||
|
$determineGrantTargets->invoke(null, 'not_owned', ['demo_target', 'demo_other']),
|
||||||
|
'a requested database that is not in the owned list must never be granted'
|
||||||
|
);
|
||||||
|
assert_same(
|
||||||
|
['demo_target', 'demo_other'],
|
||||||
|
$determineGrantTargets->invoke(null, null, ['demo_target', 'demo_other']),
|
||||||
|
'no database requested at all (top-nav PHPMYADMIN button) must grant every owned database'
|
||||||
|
);
|
||||||
|
assert_same(
|
||||||
|
['demo_target', 'demo_other'],
|
||||||
|
$determineGrantTargets->invoke(null, '', ['demo_target', 'demo_other']),
|
||||||
|
'an empty-string requested database must also grant every owned database'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then replace the `writeLoginTicket` invocation block (currently lines 96-118) with:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$writeTicket = $ssoClass->getMethod('writeLoginTicket');
|
||||||
|
$writeTicket->setAccessible(true);
|
||||||
|
$auth = [
|
||||||
|
'user' => 'da_tmp_phpmyadmin_demo_test',
|
||||||
|
'password' => 'secret-password',
|
||||||
|
'host' => '127.0.0.1',
|
||||||
|
'port' => 33033,
|
||||||
|
'db' => 'demo_db',
|
||||||
|
];
|
||||||
|
$token = $writeTicket->invoke($sso, $auth, 'demo_db', 'demo_db');
|
||||||
|
|
||||||
|
assert_true(preg_match('/\A[a-f0-9]{64}\z/', $token) === 1, 'ticket token must be a 64 char lowercase hex string');
|
||||||
|
|
||||||
|
$ticketPath = $ssoDir . '/tickets/' . $token . '.json';
|
||||||
|
assert_true(is_file($ticketPath), 'ticket file must be written to <sso_dir>/tickets/<token>.json');
|
||||||
|
|
||||||
|
$mode = fileperms($ticketPath) & 0777;
|
||||||
|
assert_same(0640, $mode, 'ticket files contain a plaintext MySQL password and must not be world-readable (expected 0640)');
|
||||||
|
|
||||||
|
$decoded = json_decode((string)file_get_contents($ticketPath), true);
|
||||||
|
assert_true(is_array($decoded), 'ticket file must contain valid JSON');
|
||||||
|
assert_same('demo_db', $decoded['db'] ?? null, 'ticket must record the target/landing database');
|
||||||
|
assert_same('demo_db', $decoded['restrict_db'] ?? null, 'a single-database login must record that database as the grant restriction');
|
||||||
|
assert_same('da_tmp_phpmyadmin_demo_test', $decoded['auth']['user'] ?? null, 'ticket must record the temporary role name');
|
||||||
|
|
||||||
|
$allDbToken = $writeTicket->invoke($sso, $auth, 'demo_db', '');
|
||||||
|
$allDbTicketPath = $ssoDir . '/tickets/' . $allDbToken . '.json';
|
||||||
|
$allDbDecoded = json_decode((string)file_get_contents($allDbTicketPath), true);
|
||||||
|
assert_same('', $allDbDecoded['restrict_db'] ?? null, 'an all-databases login must record an empty restrict_db (no restriction)');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src && php tests/phpmyadmin_sso_test.php`
|
||||||
|
Expected: FAIL — either an arity/type error calling `writeLoginTicket` with 4 arguments (current signature only accepts 3), or the `null`/`''` `determineGrantTargets` assertions failing (current implementation treats a `string $targetDatabase` positionally, `null`/`''` won't behave as "grant all" yet).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
In `exec/lib/PhpMyAdminSso.php`, replace the block from `$targetDatabase = '';` through `$targetDatabase = $databaseNames[0]; }` (current lines 42-54) with:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$normalizedRequestedDatabase = null;
|
||||||
|
if ($requestedDatabase !== null) {
|
||||||
|
$requestedDatabase = trim($requestedDatabase);
|
||||||
|
if ($requestedDatabase !== '') {
|
||||||
|
if (!in_array($requestedDatabase, $databaseNames, true)) {
|
||||||
|
throw new RuntimeException('Wskazana baza danych nie należy do zalogowanego użytkownika.');
|
||||||
|
}
|
||||||
|
$normalizedRequestedDatabase = $requestedDatabase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetDatabase = $normalizedRequestedDatabase ?? $databaseNames[0];
|
||||||
|
$restrictToDatabase = $normalizedRequestedDatabase ?? '';
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the grant loop (current lines 61-72):
|
||||||
|
|
||||||
|
```php
|
||||||
|
$this->db->createTemporaryRole($role, $password, $expiresAt);
|
||||||
|
try {
|
||||||
|
foreach (self::determineGrantTargets($normalizedRequestedDatabase, $databaseNames) as $dbName) {
|
||||||
|
$this->db->grantTemporaryAdminerAccess($dbName, $role);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
try {
|
||||||
|
$this->db->dropTemporaryRole($role);
|
||||||
|
} catch (Throwable $ignored) {
|
||||||
|
}
|
||||||
|
throw new RuntimeException('Nie udało się przygotować sesji phpMyAdmin: ' . $e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the `writeLoginTicket` call (current line 81):
|
||||||
|
|
||||||
|
```php
|
||||||
|
$ticket = $this->writeLoginTicket($auth, $targetDatabase, $restrictToDatabase);
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `determineGrantTargets` (current lines 89-96):
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* @param string[] $ownedDatabases
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private static function determineGrantTargets(?string $requestedDatabase, array $ownedDatabases): array
|
||||||
|
{
|
||||||
|
if ($requestedDatabase === null || $requestedDatabase === '') {
|
||||||
|
return $ownedDatabases;
|
||||||
|
}
|
||||||
|
return in_array($requestedDatabase, $ownedDatabases, true) ? [$requestedDatabase] : [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `writeLoginTicket`'s signature and payload (current lines 139-156):
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* @param array<string,string|int> $auth
|
||||||
|
*/
|
||||||
|
private function writeLoginTicket(array $auth, string $database, string $restrictToDatabase): string
|
||||||
|
{
|
||||||
|
$ticketDir = rtrim($this->settings->adminerSsoDir(), '/') . '/tickets';
|
||||||
|
if (!is_dir($ticketDir) && !@mkdir($ticketDir, 0755, true) && !is_dir($ticketDir)) {
|
||||||
|
throw new RuntimeException('Nie można utworzyć katalogu ticketów phpMyAdmin: ' . $ticketDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cleanupExpiredTickets($ticketDir);
|
||||||
|
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$payload = [
|
||||||
|
'version' => 1,
|
||||||
|
'created_at' => time(),
|
||||||
|
'expires_at' => time() + $this->settings->adminerTicketTtl(),
|
||||||
|
'auth' => $auth,
|
||||||
|
'route' => '/database/structure',
|
||||||
|
'db' => $database,
|
||||||
|
'restrict_db' => $restrictToDatabase,
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src && php tests/phpmyadmin_sso_test.php`
|
||||||
|
Expected: `phpmyadmin_sso_test: OK`
|
||||||
|
|
||||||
|
Also run the full local suite to confirm no regressions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src
|
||||||
|
find . -name "*.php" | xargs -I{} php -l {} 2>&1 | grep -v "No syntax errors"
|
||||||
|
cd tests
|
||||||
|
for f in *_test.sh; do bash "$f" || echo "FAIL: $f"; done
|
||||||
|
for f in *_test.php; do php "$f" || echo "FAIL: $f"; done
|
||||||
|
```
|
||||||
|
Expected: every line ends `OK`, no `FAIL:` lines, no `php -l` output.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src
|
||||||
|
git add exec/lib/PhpMyAdminSso.php tests/phpmyadmin_sso_test.php
|
||||||
|
git commit -m "Grant all owned databases when phpMyAdmin SSO has no specific target
|
||||||
|
|
||||||
|
The top-nav PHPMYADMIN button intentionally omits adminer_db to request
|
||||||
|
access to every database. determineGrantTargets() and the ticket's new
|
||||||
|
restrict_db field now distinguish that case from a specific per-row
|
||||||
|
database request instead of collapsing both into single-database scope."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Thread restrict_db through da_login.php session and config.inc.php only_db
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/setup/phpmyadmin_install.sh` (the `da_login.php` heredoc around line 517, and the `config.inc.php` heredoc's `da_mysql_phpmyadmin_only_db()` function around lines 340-361)
|
||||||
|
- Test: `tests/phpmyadmin_install_test.sh`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: ticket JSON's `restrict_db` key produced by Task 1's `writeLoginTicket()`.
|
||||||
|
- Produces: PHP session key `$_SESSION['DA_MYSQL_PHPMYADMIN_RESTRICT_DB']`, read by `da_mysql_phpmyadmin_only_db()`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Open `tests/phpmyadmin_install_test.sh`. Find the existing `only_db` scenario (the block starting with the comment `# config.inc.php must restrict phpMyAdmin's visible database...` and the `ONLY_DB=` assignment). Immediately after that block's `fail` line, add a second scenario:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# When no specific database was requested (the top-nav PHPMYADMIN button),
|
||||||
|
# only_db must be empty so phpMyAdmin shows every granted database.
|
||||||
|
ALL_DB_ONLY_DB="$(php -d session.save_path="$SESSION_DIR" -r '
|
||||||
|
session_name("DA_MYSQL_PHPMYADMIN");
|
||||||
|
session_id("alldbtest");
|
||||||
|
session_start();
|
||||||
|
$_SESSION["DA_MYSQL_PHPMYADMIN_AUTH"] = ["user" => "x", "password" => "y", "db" => "demo_landing_db"];
|
||||||
|
$_SESSION["DA_MYSQL_PHPMYADMIN_RESTRICT_DB"] = "";
|
||||||
|
session_write_close();
|
||||||
|
|
||||||
|
$_COOKIE["DA_MYSQL_PHPMYADMIN"] = "alldbtest";
|
||||||
|
$cfg = [];
|
||||||
|
include $argv[1];
|
||||||
|
echo $cfg["Servers"][1]["only_db"] ?? "(unset)";
|
||||||
|
' "$CONFIG_INC")"
|
||||||
|
[ "$ALL_DB_ONLY_DB" = "" ] \
|
||||||
|
|| fail "config.inc.php only_db must be empty for an all-databases login, got: '$ALL_DB_ONLY_DB'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the *existing* `only_db` scenario's session payload to set `DA_MYSQL_PHPMYADMIN_RESTRICT_DB` explicitly (matching what Task 1's ticket now produces) instead of relying on the auth `db` field:
|
||||||
|
|
||||||
|
Find:
|
||||||
|
```bash
|
||||||
|
$_SESSION["DA_MYSQL_PHPMYADMIN_AUTH"] = ["user" => "x", "password" => "y", "db" => "demo_target_db"];
|
||||||
|
session_write_close();
|
||||||
|
|
||||||
|
$_COOKIE["DA_MYSQL_PHPMYADMIN"] = "onlydbtest";
|
||||||
|
```
|
||||||
|
Replace with:
|
||||||
|
```bash
|
||||||
|
$_SESSION["DA_MYSQL_PHPMYADMIN_AUTH"] = ["user" => "x", "password" => "y", "db" => "demo_target_db"];
|
||||||
|
$_SESSION["DA_MYSQL_PHPMYADMIN_RESTRICT_DB"] = "demo_target_db";
|
||||||
|
session_write_close();
|
||||||
|
|
||||||
|
$_COOKIE["DA_MYSQL_PHPMYADMIN"] = "onlydbtest";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src && bash tests/phpmyadmin_install_test.sh`
|
||||||
|
Expected: FAIL on `config.inc.php only_db should be scoped to the SSO ticket's database` (since `only_db` still reads the auth `db` field, which is `demo_target_db` regardless of the new `RESTRICT_DB` session key — this proves the test exercises the not-yet-updated code).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
In `scripts/setup/phpmyadmin_install.sh`, replace `da_mysql_phpmyadmin_only_db()`'s body (current lines 340-361):
|
||||||
|
|
||||||
|
```php
|
||||||
|
function da_mysql_phpmyadmin_only_db(): string
|
||||||
|
{
|
||||||
|
$sessionName = 'DA_MYSQL_PHPMYADMIN';
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionId = (string)($_COOKIE[$sessionName] ?? '');
|
||||||
|
if ($sessionId === '' || preg_match('/\A[A-Za-z0-9,-]+\z/', $sessionId) !== 1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
session_name($sessionName);
|
||||||
|
session_id($sessionId);
|
||||||
|
session_start();
|
||||||
|
$db = (string)($_SESSION['DA_MYSQL_PHPMYADMIN_RESTRICT_DB'] ?? '');
|
||||||
|
session_write_close();
|
||||||
|
|
||||||
|
return $db;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then find the `da_login.php` heredoc's session-writing line (current line 517):
|
||||||
|
|
||||||
|
```php
|
||||||
|
$_SESSION['DA_MYSQL_PHPMYADMIN_AUTH'] = $ticket['auth'];
|
||||||
|
session_write_close();
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$_SESSION['DA_MYSQL_PHPMYADMIN_AUTH'] = $ticket['auth'];
|
||||||
|
$_SESSION['DA_MYSQL_PHPMYADMIN_RESTRICT_DB'] = (string)($ticket['restrict_db'] ?? '');
|
||||||
|
session_write_close();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src && bash tests/phpmyadmin_install_test.sh`
|
||||||
|
Expected: `phpmyadmin_install_test: OK`
|
||||||
|
|
||||||
|
Run the full local suite again:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src
|
||||||
|
bash -n scripts/setup/phpmyadmin_install.sh
|
||||||
|
cd tests
|
||||||
|
for f in *_test.sh; do bash "$f" || echo "FAIL: $f"; done
|
||||||
|
for f in *_test.php; do php "$f" || echo "FAIL: $f"; done
|
||||||
|
```
|
||||||
|
Expected: every line ends `OK`, no `FAIL:` lines.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src
|
||||||
|
git add scripts/setup/phpmyadmin_install.sh tests/phpmyadmin_install_test.sh
|
||||||
|
git commit -m "Read only_db restriction from a dedicated session key
|
||||||
|
|
||||||
|
da_login.php now stores the ticket's restrict_db into
|
||||||
|
DA_MYSQL_PHPMYADMIN_RESTRICT_DB, and config.inc.php's only_db builder
|
||||||
|
reads that key instead of the landing-page db field, so an
|
||||||
|
all-databases login (empty restrict_db) leaves phpMyAdmin unrestricted."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Version bump, full verification, commit, push, package
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `plugin.conf`
|
||||||
|
- Modify: `tests/plugin_identity_test.sh`
|
||||||
|
- Modify: `tests/package_archive_test.sh`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing new.
|
||||||
|
- Produces: `archives/<version>/alt-mysql.tar.gz`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump the version**
|
||||||
|
|
||||||
|
Read `plugin.conf`, note the current `version=X.Y.Z` line, and bump the patch number by 1 (e.g. `1.2.14` → `1.2.15`). Apply the same new version string to:
|
||||||
|
- `plugin.conf`'s `version=` line
|
||||||
|
- `tests/plugin_identity_test.sh`'s `grep -Fxq "version=X.Y.Z"` line
|
||||||
|
- `tests/package_archive_test.sh`'s `archives/X.Y.Z/alt-mysql.tar.gz` default path and its `version=X.Y.Z` grep line
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the full test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src
|
||||||
|
find . -name "*.php" | xargs -I{} php -l {} 2>&1 | grep -v "No syntax errors"
|
||||||
|
find . -name "*.sh" | xargs -I{} bash -n {} 2>&1
|
||||||
|
cd tests
|
||||||
|
for f in *_test.sh; do bash "$f" || echo "FAIL: $f"; done
|
||||||
|
for f in *_test.php; do php "$f" || echo "FAIL: $f"; done
|
||||||
|
```
|
||||||
|
Expected: every test prints `OK`; `package_archive_test.sh` is expected to fail here (archive not built yet) — that single failure is acceptable at this step.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit the version bump**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src
|
||||||
|
git add plugin.conf tests/plugin_identity_test.sh tests/package_archive_test.sh
|
||||||
|
git commit -m "Bump version to <new-version>"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Push to Gitea**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src && git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build the release package**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql
|
||||||
|
mkdir -p archives/<new-version>
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
cp -a src/. "$STAGE"/
|
||||||
|
rm -rf "$STAGE/tests" "$STAGE/.git"
|
||||||
|
rm -f "$STAGE/.gitignore" "$STAGE/error.log"
|
||||||
|
find "$STAGE" -name ".DS_Store" -delete
|
||||||
|
find "$STAGE" -name "._*" -delete
|
||||||
|
tar -C "$STAGE" -czf archives/<new-version>/alt-mysql.tar.gz .
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify the package**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src && bash tests/package_archive_test.sh
|
||||||
|
```
|
||||||
|
Expected: `package_archive_test: OK`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage:** Task 1 covers the spec's `determineGrantTargets`/ticket-payload section. Task 2 covers the `da_login.php` session and `config.inc.php` `only_db` sections. Task 3 covers packaging (spec's "out of scope" items — Adminer renaming, UI changes — are correctly not tasked).
|
||||||
|
- **Placeholder scan:** no TBD/TODO; all code blocks are complete and copy-pasteable.
|
||||||
|
- **Type consistency:** `determineGrantTargets(?string $requestedDatabase, array $ownedDatabases): array` and `writeLoginTicket(array $auth, string $database, string $restrictToDatabase): string` signatures match between Task 1's implementation step and its test step. The `restrict_db` key name matches between Task 1's ticket payload and Task 2's session-read code.
|
||||||
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,144 @@
|
|||||||
|
# phpMyAdmin: all-databases access from the top-nav button
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The plugin's page header (`AppContext::renderTopActions()`) renders a
|
||||||
|
top-level "PHPMYADMIN" button, separate from the per-row "Zaloguj do
|
||||||
|
bazy" button in the database table. Both POST to the same
|
||||||
|
`open_phpmyadmin.raw` action and go through `PhpMyAdminSso`, but they
|
||||||
|
differ in one respect: the row button always sends `adminer_db=<name>`;
|
||||||
|
the top-nav button never sends `adminer_db` at all.
|
||||||
|
|
||||||
|
A recent security fix (scoping the temporary SSO MySQL role to only the
|
||||||
|
requested database instead of every database the account owns) treated
|
||||||
|
both cases identically: when no database was requested, it defaulted to
|
||||||
|
the first owned database and granted only that one. This is correct for
|
||||||
|
the row button, but broke the top-nav button's intent, which is to open
|
||||||
|
phpMyAdmin with access to every database the DirectAdmin user owns.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Restore "access to all owned databases" for the top-nav PHPMYADMIN
|
||||||
|
button, without reintroducing over-broad access for the per-row button.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### `PhpMyAdminSso::issueLoginPayload()`
|
||||||
|
|
||||||
|
Treat "no database requested" (`$requestedDatabase === null` or empty
|
||||||
|
after trim) as its own explicit case, not as "default to the first
|
||||||
|
database and scope to it."
|
||||||
|
|
||||||
|
Extract a pure, testable decision function:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* @param string[] $ownedDatabases
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private static function determineGrantTargets(?string $requestedDatabase, array $ownedDatabases): array
|
||||||
|
{
|
||||||
|
if ($requestedDatabase === null || $requestedDatabase === '') {
|
||||||
|
return $ownedDatabases;
|
||||||
|
}
|
||||||
|
return in_array($requestedDatabase, $ownedDatabases, true) ? [$requestedDatabase] : [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This replaces the current `determineGrantTargets(string $targetDatabase, array $ownedDatabases)`,
|
||||||
|
which took the already-resolved target database and could no longer
|
||||||
|
distinguish "explicitly requested this one" from "nothing was requested."
|
||||||
|
|
||||||
|
`issueLoginPayload()` keeps resolving a concrete `$targetDatabase` for
|
||||||
|
the phpMyAdmin *landing page* route (`route=/database/structure&db=...`)
|
||||||
|
exactly as it does today — defaulting to the first owned database when
|
||||||
|
none was requested. That landing behavior is unaffected; only the grant
|
||||||
|
scope and the `only_db` restriction change.
|
||||||
|
|
||||||
|
A new local value, `$restrictToDatabase`, is derived alongside the
|
||||||
|
grant targets: empty string when granting all databases, or the single
|
||||||
|
target database name when scoped to one. This value is written into the
|
||||||
|
ticket payload as a new field, `restrict_db`, separate from the
|
||||||
|
existing `db` field (which continues to mean "which database to land
|
||||||
|
on," not "which database(s) to allow").
|
||||||
|
|
||||||
|
### Ticket payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"created_at": 0,
|
||||||
|
"expires_at": 0,
|
||||||
|
"auth": { "user": "...", "password": "...", "host": "...", "port": 0, "db": "..." },
|
||||||
|
"route": "/database/structure",
|
||||||
|
"db": "landing_database",
|
||||||
|
"restrict_db": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`restrict_db` is `""` for the top-nav (all-databases) case, or the
|
||||||
|
specific database name for the per-row case.
|
||||||
|
|
||||||
|
### `da_login.php` (generated by `phpmyadmin_install.sh`)
|
||||||
|
|
||||||
|
When consuming a ticket, store `restrict_db` into the SSO session under
|
||||||
|
a new key, alongside the existing auth payload:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$_SESSION['DA_MYSQL_PHPMYADMIN_AUTH'] = $ticket['auth'];
|
||||||
|
$_SESSION['DA_MYSQL_PHPMYADMIN_RESTRICT_DB'] = (string)($ticket['restrict_db'] ?? '');
|
||||||
|
```
|
||||||
|
|
||||||
|
### `config.inc.php`'s `only_db` builder
|
||||||
|
|
||||||
|
The existing `da_mysql_phpmyadmin_only_db()` function currently reads
|
||||||
|
`$_SESSION['DA_MYSQL_PHPMYADMIN_AUTH']['db']`. It will instead read the
|
||||||
|
new `DA_MYSQL_PHPMYADMIN_RESTRICT_DB` session key, defaulting to `''`
|
||||||
|
(no restriction) when absent. An empty value means phpMyAdmin's
|
||||||
|
`only_db` stays unset, showing every database the temporary role has
|
||||||
|
been granted.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
- Row button: POST includes `adminer_db=admin_wiesio` → handler passes
|
||||||
|
`'admin_wiesio'` to `issueLoginPayload()` → grants only `admin_wiesio`,
|
||||||
|
`restrict_db=admin_wiesio` → phpMyAdmin shows only that database.
|
||||||
|
- Top-nav button: POST has no `adminer_db` field → handler's existing
|
||||||
|
`if ($requestedDb === '') { $requestedDb = null; }` passes `null` →
|
||||||
|
`issueLoginPayload(null)` → grants every owned database,
|
||||||
|
`restrict_db=''` → phpMyAdmin shows all databases, landing on the
|
||||||
|
first one's structure page (unchanged from today).
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
Unchanged. If the DirectAdmin user owns zero databases, both entry
|
||||||
|
points still throw "Użytkownik nie ma żadnej bazy MySQL do otwarcia w
|
||||||
|
phpMyAdmin." before reaching the grant-scope logic.
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
No new "Adminer"-branded identifiers. New field/session key names
|
||||||
|
(`restrict_db`, `DA_MYSQL_PHPMYADMIN_RESTRICT_DB`) follow the existing
|
||||||
|
`da_mysql_*` / `DA_MYSQL_*` convention already used throughout the
|
||||||
|
generated phpMyAdmin integration files. Pre-existing legacy names like
|
||||||
|
`MySQLService::grantTemporaryAdminerAccess()` are out of scope for this
|
||||||
|
change.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `tests/phpmyadmin_sso_test.php`: update the existing
|
||||||
|
`determineGrantTargets` assertions for the new `?string` signature,
|
||||||
|
and add a case asserting that `null`/`''` returns every owned
|
||||||
|
database.
|
||||||
|
- `tests/phpmyadmin_install_test.sh`: extend the existing `only_db`
|
||||||
|
PHP-fixture check with a second scenario — a session with
|
||||||
|
`DA_MYSQL_PHPMYADMIN_RESTRICT_DB` unset/empty — asserting
|
||||||
|
`$cfg['Servers'][1]['only_db']` is `''` (not the landing `db` value).
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Renaming legacy "Adminer"-named methods/settings elsewhere in the
|
||||||
|
codebase.
|
||||||
|
- Any change to the row button's behavior (already correct).
|
||||||
|
- Any new UI element — the top-nav button already exists and already
|
||||||
|
omits `adminer_db`; no markup changes are needed.
|
||||||
@@ -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`.
|
||||||
@@ -437,10 +437,12 @@ final class BackupQueueService
|
|||||||
throw new RuntimeException('Nie można odczytać przesłanego pliku backupu.');
|
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 = [];
|
$sourceCandidates = [];
|
||||||
if (str_starts_with($uploadReference, '/') && is_file($uploadReference)) {
|
|
||||||
$sourceCandidates[] = $uploadReference;
|
|
||||||
}
|
|
||||||
$tmpDirs = [
|
$tmpDirs = [
|
||||||
(string)(getenv('UPLOAD_TMP_DIR') ?: ''),
|
(string)(getenv('UPLOAD_TMP_DIR') ?: ''),
|
||||||
(string)(getenv('DA_UPLOAD_TMP_DIR') ?: ''),
|
(string)(getenv('DA_UPLOAD_TMP_DIR') ?: ''),
|
||||||
@@ -1293,14 +1295,13 @@ final class BackupQueueService
|
|||||||
$tries = 0;
|
$tries = 0;
|
||||||
while (!@mkdir($lockDir, 0700)) {
|
while (!@mkdir($lockDir, 0700)) {
|
||||||
$tries++;
|
$tries++;
|
||||||
if (!is_dir($lockDir)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (is_dir($lockDir)) {
|
||||||
$mtime = (int)@filemtime($lockDir);
|
$mtime = (int)@filemtime($lockDir);
|
||||||
if ($mtime > 0 && (time() - $mtime) > 120) {
|
if ($mtime > 0 && (time() - $mtime) > 120) {
|
||||||
@rmdir($lockDir);
|
@rmdir($lockDir);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($tries >= 40) {
|
if ($tries >= 40) {
|
||||||
throw new RuntimeException('Nie można uzyskać blokady kolejki zadań. Spróbuj ponownie.');
|
throw new RuntimeException('Nie można uzyskać blokady kolejki zadań. Spróbuj ponownie.');
|
||||||
|
|||||||
+14
-10
@@ -39,19 +39,19 @@ final class PhpMyAdminSso
|
|||||||
}
|
}
|
||||||
$databaseNames = array_values(array_unique($databaseNames));
|
$databaseNames = array_values(array_unique($databaseNames));
|
||||||
|
|
||||||
$targetDatabase = '';
|
$normalizedRequestedDatabase = null;
|
||||||
if ($requestedDatabase !== null) {
|
if ($requestedDatabase !== null) {
|
||||||
$requestedDatabase = trim($requestedDatabase);
|
$requestedDatabase = trim($requestedDatabase);
|
||||||
if ($requestedDatabase !== '') {
|
if ($requestedDatabase !== '') {
|
||||||
if (!in_array($requestedDatabase, $databaseNames, true)) {
|
if (!in_array($requestedDatabase, $databaseNames, true)) {
|
||||||
throw new RuntimeException('Wskazana baza danych nie należy do zalogowanego użytkownika.');
|
throw new RuntimeException('Wskazana baza danych nie należy do zalogowanego użytkownika.');
|
||||||
}
|
}
|
||||||
$targetDatabase = $requestedDatabase;
|
$normalizedRequestedDatabase = $requestedDatabase;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($targetDatabase === '') {
|
|
||||||
$targetDatabase = $databaseNames[0];
|
$targetDatabase = $normalizedRequestedDatabase ?? $databaseNames[0];
|
||||||
}
|
$restrictToDatabase = $normalizedRequestedDatabase ?? '';
|
||||||
|
|
||||||
$roleTtl = max($this->settings->adminerRoleTtl(), $this->settings->adminerSessionTtl() + 120);
|
$roleTtl = max($this->settings->adminerRoleTtl(), $this->settings->adminerSessionTtl() + 120);
|
||||||
$role = $this->generateTemporaryRoleName();
|
$role = $this->generateTemporaryRoleName();
|
||||||
@@ -60,7 +60,7 @@ final class PhpMyAdminSso
|
|||||||
|
|
||||||
$this->db->createTemporaryRole($role, $password, $expiresAt);
|
$this->db->createTemporaryRole($role, $password, $expiresAt);
|
||||||
try {
|
try {
|
||||||
foreach (self::determineGrantTargets($targetDatabase, $databaseNames) as $dbName) {
|
foreach (self::determineGrantTargets($normalizedRequestedDatabase, $databaseNames) as $dbName) {
|
||||||
$this->db->grantTemporaryAdminerAccess($dbName, $role);
|
$this->db->grantTemporaryAdminerAccess($dbName, $role);
|
||||||
}
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -78,7 +78,7 @@ final class PhpMyAdminSso
|
|||||||
'port' => $this->db->connectionEndpoint()['port'],
|
'port' => $this->db->connectionEndpoint()['port'],
|
||||||
'db' => $targetDatabase,
|
'db' => $targetDatabase,
|
||||||
];
|
];
|
||||||
$ticket = $this->writeLoginTicket($auth, $targetDatabase);
|
$ticket = $this->writeLoginTicket($auth, $targetDatabase, $restrictToDatabase);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'target' => $this->buildPhpMyAdminLoginUrl($targetDatabase, $ticket),
|
'target' => $this->buildPhpMyAdminLoginUrl($targetDatabase, $ticket),
|
||||||
@@ -90,9 +90,12 @@ final class PhpMyAdminSso
|
|||||||
* @param string[] $ownedDatabases
|
* @param string[] $ownedDatabases
|
||||||
* @return string[]
|
* @return string[]
|
||||||
*/
|
*/
|
||||||
private static function determineGrantTargets(string $targetDatabase, array $ownedDatabases): array
|
private static function determineGrantTargets(?string $requestedDatabase, array $ownedDatabases): array
|
||||||
{
|
{
|
||||||
return in_array($targetDatabase, $ownedDatabases, true) ? [$targetDatabase] : [];
|
if ($requestedDatabase === null || $requestedDatabase === '') {
|
||||||
|
return $ownedDatabases;
|
||||||
|
}
|
||||||
|
return in_array($requestedDatabase, $ownedDatabases, true) ? [$requestedDatabase] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildPhpMyAdminLoginUrl(string $database, string $ticket): string
|
private function buildPhpMyAdminLoginUrl(string $database, string $ticket): string
|
||||||
@@ -136,7 +139,7 @@ final class PhpMyAdminSso
|
|||||||
/**
|
/**
|
||||||
* @param array<string,string|int> $auth
|
* @param array<string,string|int> $auth
|
||||||
*/
|
*/
|
||||||
private function writeLoginTicket(array $auth, string $database): string
|
private function writeLoginTicket(array $auth, string $database, string $restrictToDatabase): string
|
||||||
{
|
{
|
||||||
$ticketDir = rtrim($this->settings->adminerSsoDir(), '/') . '/tickets';
|
$ticketDir = rtrim($this->settings->adminerSsoDir(), '/') . '/tickets';
|
||||||
if (!is_dir($ticketDir) && !@mkdir($ticketDir, 0755, true) && !is_dir($ticketDir)) {
|
if (!is_dir($ticketDir) && !@mkdir($ticketDir, 0755, true) && !is_dir($ticketDir)) {
|
||||||
@@ -153,6 +156,7 @@ final class PhpMyAdminSso
|
|||||||
'auth' => $auth,
|
'auth' => $auth,
|
||||||
'route' => '/database/structure',
|
'route' => '/database/structure',
|
||||||
'db' => $database,
|
'db' => $database,
|
||||||
|
'restrict_db' => $restrictToDatabase,
|
||||||
];
|
];
|
||||||
$encoded = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
$encoded = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||||
if (!is_string($encoded) || $encoded === '') {
|
if (!is_string($encoded) || $encoded === '') {
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ name=alt-mysql
|
|||||||
id=alt-mysql
|
id=alt-mysql
|
||||||
type=user
|
type=user
|
||||||
author=HITME.PL
|
author=HITME.PL
|
||||||
version=1.2.14
|
version=1.2.19
|
||||||
active=no
|
active=no
|
||||||
installed=no
|
installed=no
|
||||||
user_run_as=root
|
user_run_as=root
|
||||||
|
|||||||
+315
@@ -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)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PLUGIN_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
PLUGIN_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
COMMON_FILE="$PLUGIN_DIR/scripts/setup/mysql_common.sh"
|
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}"
|
LOG_FILE="${DA_ALT_MYSQL_RESTORE_LOG:-$PLUGIN_DIR/data/logs/da-restore.log}"
|
||||||
DA_USER=""
|
DA_USER=""
|
||||||
PAYLOAD_DIR=""
|
PAYLOAD_DIR=""
|
||||||
OVERWRITE_POLICY=""
|
OVERWRITE_POLICY=""
|
||||||
RESTORE_ROLLBACK_FILE=""
|
|
||||||
RESTORE_ROLLBACK_CREATED=0
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "$LOG_FILE")"
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
|
|
||||||
@@ -132,57 +130,6 @@ import_gzip_sql() {
|
|||||||
mysql_exec "$database" < "$file"
|
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
|
trap cleanup_restore_rollback INT TERM EXIT
|
||||||
|
|
||||||
metadata_cleanup_for_user() {
|
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}"
|
LOG_FILE="${DA_ALT_MYSQL_RESTORE_LOG:-$PLUGIN_DIR/data/logs/da-restore.log}"
|
||||||
RESTORE_PAYLOAD_SCRIPT="$SCRIPT_DIR/alt_mysql_restore_payload.sh"
|
RESTORE_PAYLOAD_SCRIPT="$SCRIPT_DIR/alt_mysql_restore_payload.sh"
|
||||||
NATIVE_MIGRATE_SCRIPT="$SCRIPT_DIR/da_restore_native_staging_to_alt_mysql.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")"
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
|
|
||||||
@@ -85,13 +87,24 @@ find_payload_in_dir() {
|
|||||||
|
|
||||||
extract_payload_from_archive() {
|
extract_payload_from_archive() {
|
||||||
local archive="$1"
|
local archive="$1"
|
||||||
local tmp_dir payload_member
|
local tmp_dir payload_member listing
|
||||||
[ -r "$archive" ] || return 1
|
[ -r "$archive" ] || return 1
|
||||||
command -v tar >/dev/null 2>&1 || 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
|
[ -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)"
|
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" --wildcards '*/backup/alt_mysql/*' 'backup/alt_mysql/*' 2>/dev/null \
|
||||||
|| tar -xf "$archive" -C "$tmp_dir" 2>/dev/null
|
|| tar -xf "$archive" -C "$tmp_dir" 2>/dev/null
|
||||||
@@ -129,7 +142,7 @@ detect_payload_dir() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
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:-}")"
|
da_user="$(detect_da_user "${1:-}")"
|
||||||
enabled="$(read_plugin_setting DA_ALT_MYSQL_RESTORE_ENABLED true)"
|
enabled="$(read_plugin_setting DA_ALT_MYSQL_RESTORE_ENABLED true)"
|
||||||
native_mode="$(read_plugin_setting DA_ALT_MYSQL_NATIVE_STAGING_MIGRATE true)"
|
native_mode="$(read_plugin_setting DA_ALT_MYSQL_NATIVE_STAGING_MIGRATE true)"
|
||||||
@@ -150,14 +163,42 @@ main() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
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
|
case "$native_mode" in
|
||||||
true|yes|on|1)
|
true|yes|on|1)
|
||||||
[ -x "$NATIVE_MIGRATE_SCRIPT" ] || die "native staging migration script is not executable: $NATIVE_MIGRATE_SCRIPT"
|
[ -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
|
esac
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ DA_USER=""
|
|||||||
NATIVE_MY_CNF="${NATIVE_MY_CNF:-/usr/local/directadmin/conf/my.cnf}"
|
NATIVE_MY_CNF="${NATIVE_MY_CNF:-/usr/local/directadmin/conf/my.cnf}"
|
||||||
OVERWRITE_POLICY=""
|
OVERWRITE_POLICY=""
|
||||||
CLEANUP_POLICY=""
|
CLEANUP_POLICY=""
|
||||||
RESTORE_ROLLBACK_FILE=""
|
declare -a SKIP_DATABASES=()
|
||||||
RESTORE_ROLLBACK_CREATED=0
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "$LOG_FILE")"
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
|
|
||||||
@@ -38,6 +37,7 @@ while [ "$#" -gt 0 ]; do
|
|||||||
--native-my-cnf) NATIVE_MY_CNF="${2:-}"; shift 2 ;;
|
--native-my-cnf) NATIVE_MY_CNF="${2:-}"; shift 2 ;;
|
||||||
--overwrite) OVERWRITE_POLICY="${2:-}"; shift 2 ;;
|
--overwrite) OVERWRITE_POLICY="${2:-}"; shift 2 ;;
|
||||||
--cleanup) CLEANUP_POLICY="${2:-}"; shift 2 ;;
|
--cleanup) CLEANUP_POLICY="${2:-}"; shift 2 ;;
|
||||||
|
--skip) SKIP_DATABASES+=("${2:-}"); shift 2 ;;
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -89,12 +89,20 @@ safe_identifier() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
list_native_databases() {
|
list_native_databases() {
|
||||||
|
local db
|
||||||
native_query information_schema "
|
native_query information_schema "
|
||||||
SELECT SCHEMA_NAME
|
SELECT SCHEMA_NAME
|
||||||
FROM SCHEMATA
|
FROM SCHEMATA
|
||||||
WHERE SCHEMA_NAME LIKE '$(mysql_escape_literal "$DA_USER")\\_%' ESCAPE '\\'
|
WHERE SCHEMA_NAME LIKE '$(mysql_escape_literal "$DA_USER")\\_%' ESCAPE '\\'
|
||||||
ORDER BY SCHEMA_NAME
|
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() {
|
list_native_user_hosts() {
|
||||||
@@ -155,57 +163,6 @@ apply_grants_to_alt() {
|
|||||||
done < <(native_show_grants "$user" "$host")
|
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
|
trap cleanup_restore_rollback INT TERM EXIT
|
||||||
|
|
||||||
db_privilege_profile() {
|
db_privilege_profile() {
|
||||||
|
|||||||
+24
-8
@@ -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_MY_CNF="/usr/local/directadmin/conf/alt-my.cnf"
|
||||||
ALT_METADATA_ENV="/usr/local/directadmin/conf/alt-mariadb.env"
|
ALT_METADATA_ENV="/usr/local/directadmin/conf/alt-mariadb.env"
|
||||||
INSTANCE_CNF="/etc/alt-mariadb.cnf"
|
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_NAME="alt-mariadb"
|
||||||
SERVICE_FILE="/etc/systemd/system/alt-mariadb.service"
|
SERVICE_FILE="/etc/systemd/system/alt-mariadb.service"
|
||||||
LOG_DIR="/var/log/alt-mariadb"
|
LOG_DIR="/var/log/alt-mariadb"
|
||||||
@@ -69,14 +70,21 @@ trap cleanup EXIT
|
|||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Użycie:
|
Użycie:
|
||||||
$0 <MARIADB_VERSION> <PORT>
|
$0 <MARIADB_VERSION> [PORT]
|
||||||
|
|
||||||
Przykłady:
|
Przykłady:
|
||||||
|
$0 10.11
|
||||||
$0 10.11 33033
|
$0 10.11 33033
|
||||||
$0 11.4 3020
|
$0 11.4 3020
|
||||||
$0 11.8 3021
|
$0 11.8 3021
|
||||||
|
|
||||||
Dozwolone gałęzie MariaDB: 10.11, 11.4, 11.8.
|
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
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,19 +106,15 @@ apply_cli_args() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $# -gt 2 ]]; then
|
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
|
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"
|
MARIADB_VERSION="$1"
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $# -ge 2 && -n "${2:-}" ]]; then
|
if [[ $# -ge 2 && -n "${2:-}" ]]; then
|
||||||
PORT="$2"
|
PORT="$2"
|
||||||
|
else
|
||||||
|
PORT="3306"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
MARIADB_DIR="/usr/local/mariadb-$MARIADB_VERSION"
|
MARIADB_DIR="/usr/local/mariadb-$MARIADB_VERSION"
|
||||||
@@ -122,6 +126,17 @@ require_root() {
|
|||||||
fi
|
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() {
|
detect_os() {
|
||||||
[[ -r /etc/os-release ]] || die "Nie można odczytać /etc/os-release."
|
[[ -r /etc/os-release ]] || die "Nie można odczytać /etc/os-release."
|
||||||
. /etc/os-release
|
. /etc/os-release
|
||||||
@@ -850,6 +865,7 @@ main() {
|
|||||||
validate_port
|
validate_port
|
||||||
require_root
|
require_root
|
||||||
detect_os
|
detect_os
|
||||||
|
maybe_takeover_native_port
|
||||||
load_da_credentials
|
load_da_credentials
|
||||||
validate_paths
|
validate_paths
|
||||||
install_dependencies
|
install_dependencies
|
||||||
|
|||||||
@@ -487,3 +487,57 @@ mysql_restore_stream() {
|
|||||||
fi
|
fi
|
||||||
mysql_exec "$database" < "$file"
|
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
@@ -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
|
||||||
@@ -353,8 +353,7 @@ function da_mysql_phpmyadmin_only_db(): string
|
|||||||
session_name($sessionName);
|
session_name($sessionName);
|
||||||
session_id($sessionId);
|
session_id($sessionId);
|
||||||
session_start();
|
session_start();
|
||||||
$auth = $_SESSION['DA_MYSQL_PHPMYADMIN_AUTH'] ?? [];
|
$db = (string)($_SESSION['DA_MYSQL_PHPMYADMIN_RESTRICT_DB'] ?? '');
|
||||||
$db = is_array($auth) ? (string)($auth['db'] ?? '') : '';
|
|
||||||
session_write_close();
|
session_write_close();
|
||||||
|
|
||||||
return $db;
|
return $db;
|
||||||
@@ -515,6 +514,7 @@ session_set_cookie_params([
|
|||||||
]);
|
]);
|
||||||
session_start();
|
session_start();
|
||||||
$_SESSION['DA_MYSQL_PHPMYADMIN_AUTH'] = $ticket['auth'];
|
$_SESSION['DA_MYSQL_PHPMYADMIN_AUTH'] = $ticket['auth'];
|
||||||
|
$_SESSION['DA_MYSQL_PHPMYADMIN_RESTRICT_DB'] = (string)($ticket['restrict_db'] ?? '');
|
||||||
session_write_close();
|
session_write_close();
|
||||||
|
|
||||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||||
|
|||||||
+42
-3
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
PLUGIN_DIR="${PLUGIN_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||||
DATA_DIR="$PLUGIN_DIR/data"
|
DATA_DIR="$PLUGIN_DIR/data"
|
||||||
JOB_DIR="$DATA_DIR/jobs/pending"
|
JOB_DIR="$DATA_DIR/jobs/pending"
|
||||||
PROCESSING_DIR="$DATA_DIR/jobs/processing"
|
PROCESSING_DIR="$DATA_DIR/jobs/processing"
|
||||||
@@ -10,15 +10,54 @@ WORKER_LOCK_DIR="$DATA_DIR/worker.lock"
|
|||||||
DB_LOCK_DIR="$DATA_DIR/lock"
|
DB_LOCK_DIR="$DATA_DIR/lock"
|
||||||
PID_DIR="$DATA_DIR/pids"
|
PID_DIR="$DATA_DIR/pids"
|
||||||
CANCEL_DIR="$DATA_DIR/cancel"
|
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"
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rmdir "$WORKER_LOCK_DIR" 2>/dev/null || true
|
rm -rf "$WORKER_LOCK_DIR" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
|||||||
+162
@@ -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"
|
||||||
@@ -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
@@ -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"
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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_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/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_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
|
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_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"
|
[ -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"
|
echo "da_integration_install_test: OK"
|
||||||
|
|||||||
Executable
+70
@@ -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"
|
||||||
@@ -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"
|
[ "$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:' \
|
printf '%s\n' "$NO_ARGS_OUTPUT" | grep -Fq 'Użycie:' \
|
||||||
|| fail "install_db.sh without arguments does not print usage"
|
|| fail "install_db.sh without arguments does not print usage"
|
||||||
printf '%s\n' "$NO_ARGS_OUTPUT" | grep -Fq '<MARIADB_VERSION> <PORT>' \
|
printf '%s\n' "$NO_ARGS_OUTPUT" | grep -Fq '<MARIADB_VERSION> [PORT]' \
|
||||||
|| fail "install_db.sh usage does not show required version and 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
|
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"
|
fail "install_db.sh without arguments reaches root check instead of usage"
|
||||||
fi
|
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"
|
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"
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
ARCHIVE="${1:-$(cd "$PLUGIN_DIR/.." && pwd)/archives/1.2.14/alt-mysql.tar.gz}"
|
ARCHIVE="${1:-$(cd "$PLUGIN_DIR/.." && pwd)/archives/1.2.19/alt-mysql.tar.gz}"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
echo "FAIL: $*" >&2
|
echo "FAIL: $*" >&2
|
||||||
@@ -23,6 +23,9 @@ fi
|
|||||||
if printf '%s\n' "$LIST" | grep -Eq '^(\./)?tests/'; then
|
if printf '%s\n' "$LIST" | grep -Eq '^(\./)?tests/'; then
|
||||||
fail "archive contains local test suite"
|
fail "archive contains local test suite"
|
||||||
fi
|
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
|
if printf '%s\n' "$LIST" | grep -Eq '(^|/)\._[^/]+$|(^|/)\.DS_Store$|__MACOSX'; then
|
||||||
fail "archive contains macOS AppleDouble or Finder metadata"
|
fail "archive contains macOS AppleDouble or Finder metadata"
|
||||||
fi
|
fi
|
||||||
@@ -37,7 +40,7 @@ fi
|
|||||||
CONF="$(tar -xOzf "$ARCHIVE" ./plugin.conf 2>/dev/null || tar -xOzf "$ARCHIVE" plugin.conf)"
|
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 "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 "id=alt-mysql" || fail "archive plugin id is not alt-mysql"
|
||||||
printf '%s\n' "$CONF" | grep -Fxq "version=1.2.14" || fail "archive plugin version is not 1.2.14"
|
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)"
|
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" \
|
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"
|
|| 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 ]]' \
|
printf '%s\n' "$DB_INSTALLER" | grep -Fq '[[ $# -eq 0 ]]' \
|
||||||
|| fail "archive install_db.sh does not show usage when called without arguments"
|
|| fail "archive install_db.sh does not show usage when called without arguments"
|
||||||
printf '%s\n' "$DB_INSTALLER" | grep -Fq '<MARIADB_VERSION> <PORT>' \
|
printf '%s\n' "$DB_INSTALLER" | grep -Fq '<MARIADB_VERSION> [PORT]' \
|
||||||
|| fail "archive install_db.sh usage does not require version and 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)"
|
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);" \
|
printf '%s\n' "$INSTALLER" | grep -Fq "\$cfg['Servers'][\$i]['SignonURL'] = da_mysql_phpmyadmin_signon_url(\$runtimeConfig);" \
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ ONLY_DB="$(php -d session.save_path="$SESSION_DIR" -r '
|
|||||||
session_id("onlydbtest");
|
session_id("onlydbtest");
|
||||||
session_start();
|
session_start();
|
||||||
$_SESSION["DA_MYSQL_PHPMYADMIN_AUTH"] = ["user" => "x", "password" => "y", "db" => "demo_target_db"];
|
$_SESSION["DA_MYSQL_PHPMYADMIN_AUTH"] = ["user" => "x", "password" => "y", "db" => "demo_target_db"];
|
||||||
|
$_SESSION["DA_MYSQL_PHPMYADMIN_RESTRICT_DB"] = "demo_target_db";
|
||||||
session_write_close();
|
session_write_close();
|
||||||
|
|
||||||
$_COOKIE["DA_MYSQL_PHPMYADMIN"] = "onlydbtest";
|
$_COOKIE["DA_MYSQL_PHPMYADMIN"] = "onlydbtest";
|
||||||
@@ -140,6 +141,24 @@ ONLY_DB="$(php -d session.save_path="$SESSION_DIR" -r '
|
|||||||
[ "$ONLY_DB" = "demo_target_db" ] \
|
[ "$ONLY_DB" = "demo_target_db" ] \
|
||||||
|| fail "config.inc.php only_db should be scoped to the SSO ticket's database, got: '$ONLY_DB'"
|
|| fail "config.inc.php only_db should be scoped to the SSO ticket's database, got: '$ONLY_DB'"
|
||||||
|
|
||||||
|
# When no specific database was requested (the top-nav PHPMYADMIN button),
|
||||||
|
# only_db must be empty so phpMyAdmin shows every granted database.
|
||||||
|
ALL_DB_ONLY_DB="$(php -d session.save_path="$SESSION_DIR" -r '
|
||||||
|
session_name("DA_MYSQL_PHPMYADMIN");
|
||||||
|
session_id("alldbtest");
|
||||||
|
session_start();
|
||||||
|
$_SESSION["DA_MYSQL_PHPMYADMIN_AUTH"] = ["user" => "x", "password" => "y", "db" => "demo_landing_db"];
|
||||||
|
$_SESSION["DA_MYSQL_PHPMYADMIN_RESTRICT_DB"] = "";
|
||||||
|
session_write_close();
|
||||||
|
|
||||||
|
$_COOKIE["DA_MYSQL_PHPMYADMIN"] = "alldbtest";
|
||||||
|
$cfg = [];
|
||||||
|
include $argv[1];
|
||||||
|
echo $cfg["Servers"][1]["only_db"] ?? "(unset)";
|
||||||
|
' "$CONFIG_INC")"
|
||||||
|
[ "$ALL_DB_ONLY_DB" = "" ] \
|
||||||
|
|| fail "config.inc.php only_db must be empty for an all-databases login, got: '$ALL_DB_ONLY_DB'"
|
||||||
|
|
||||||
grep -Fq 'return [$username, $password];' "$SIGNON_SCRIPT" \
|
grep -Fq 'return [$username, $password];' "$SIGNON_SCRIPT" \
|
||||||
|| fail "signon script should return username/password only"
|
|| fail "signon script should return username/password only"
|
||||||
php -d display_errors=1 -r 'set_error_handler(static function($errno, $errstr) { fwrite(STDERR, $errstr . "\n"); exit(9); }); $cfg=[]; include $argv[1]; include $argv[2];' "$CONFIG_INC" "$SIGNON_SCRIPT" \
|
php -d display_errors=1 -r 'set_error_handler(static function($errno, $errstr) { fwrite(STDERR, $errstr . "\n"); exit(9); }); $cfg=[]; include $argv[1]; include $argv[2];' "$CONFIG_INC" "$SIGNON_SCRIPT" \
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ assert_true($mysqlServiceClass->hasConstant('TMP_ROLE_HOST'), 'MySQLService must
|
|||||||
assert_same('127.0.0.1', $mysqlServiceClass->getConstant('TMP_ROLE_HOST'), 'TMP_ROLE_HOST must scope the temporary SSO role to the phpMyAdmin app host');
|
assert_same('127.0.0.1', $mysqlServiceClass->getConstant('TMP_ROLE_HOST'), 'TMP_ROLE_HOST must scope the temporary SSO role to the phpMyAdmin app host');
|
||||||
|
|
||||||
// --- phpMyAdmin SSO must only grant access to the database being logged into, not every database the account owns ---
|
// --- phpMyAdmin SSO must only grant access to the database being logged into, not every database the account owns ---
|
||||||
|
// --- unless no specific database was requested at all (the top-nav PHPMYADMIN button), which must grant everything ---
|
||||||
|
|
||||||
$ssoClassForGrants = new ReflectionClass(PhpMyAdminSso::class);
|
$ssoClassForGrants = new ReflectionClass(PhpMyAdminSso::class);
|
||||||
assert_true($ssoClassForGrants->hasMethod('determineGrantTargets'), 'PhpMyAdminSso must expose a determineGrantTargets() decision method');
|
assert_true($ssoClassForGrants->hasMethod('determineGrantTargets'), 'PhpMyAdminSso must expose a determineGrantTargets() decision method');
|
||||||
@@ -71,12 +72,22 @@ $determineGrantTargets->setAccessible(true);
|
|||||||
assert_same(
|
assert_same(
|
||||||
['demo_target'],
|
['demo_target'],
|
||||||
$determineGrantTargets->invoke(null, 'demo_target', ['demo_target', 'demo_other']),
|
$determineGrantTargets->invoke(null, 'demo_target', ['demo_target', 'demo_other']),
|
||||||
'issuing a ticket for one database must only grant that database, not every database the user owns'
|
'requesting one database must only grant that database, not every database the user owns'
|
||||||
);
|
);
|
||||||
assert_same(
|
assert_same(
|
||||||
[],
|
[],
|
||||||
$determineGrantTargets->invoke(null, 'not_owned', ['demo_target', 'demo_other']),
|
$determineGrantTargets->invoke(null, 'not_owned', ['demo_target', 'demo_other']),
|
||||||
'a target database that is not in the owned list must never be granted'
|
'a requested database that is not in the owned list must never be granted'
|
||||||
|
);
|
||||||
|
assert_same(
|
||||||
|
['demo_target', 'demo_other'],
|
||||||
|
$determineGrantTargets->invoke(null, null, ['demo_target', 'demo_other']),
|
||||||
|
'no database requested at all (top-nav PHPMYADMIN button) must grant every owned database'
|
||||||
|
);
|
||||||
|
assert_same(
|
||||||
|
['demo_target', 'demo_other'],
|
||||||
|
$determineGrantTargets->invoke(null, '', ['demo_target', 'demo_other']),
|
||||||
|
'an empty-string requested database must also grant every owned database'
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- phpMyAdmin login ticket files must not be world-readable ---
|
// --- phpMyAdmin login ticket files must not be world-readable ---
|
||||||
@@ -102,7 +113,7 @@ $auth = [
|
|||||||
'port' => 33033,
|
'port' => 33033,
|
||||||
'db' => 'demo_db',
|
'db' => 'demo_db',
|
||||||
];
|
];
|
||||||
$token = $writeTicket->invoke($sso, $auth, 'demo_db');
|
$token = $writeTicket->invoke($sso, $auth, 'demo_db', 'demo_db');
|
||||||
|
|
||||||
assert_true(preg_match('/\A[a-f0-9]{64}\z/', $token) === 1, 'ticket token must be a 64 char lowercase hex string');
|
assert_true(preg_match('/\A[a-f0-9]{64}\z/', $token) === 1, 'ticket token must be a 64 char lowercase hex string');
|
||||||
|
|
||||||
@@ -114,9 +125,15 @@ assert_same(0640, $mode, 'ticket files contain a plaintext MySQL password and mu
|
|||||||
|
|
||||||
$decoded = json_decode((string)file_get_contents($ticketPath), true);
|
$decoded = json_decode((string)file_get_contents($ticketPath), true);
|
||||||
assert_true(is_array($decoded), 'ticket file must contain valid JSON');
|
assert_true(is_array($decoded), 'ticket file must contain valid JSON');
|
||||||
assert_same('demo_db', $decoded['db'] ?? null, 'ticket must record the target database');
|
assert_same('demo_db', $decoded['db'] ?? null, 'ticket must record the target/landing database');
|
||||||
|
assert_same('demo_db', $decoded['restrict_db'] ?? null, 'a single-database login must record that database as the grant restriction');
|
||||||
assert_same('da_tmp_phpmyadmin_demo_test', $decoded['auth']['user'] ?? null, 'ticket must record the temporary role name');
|
assert_same('da_tmp_phpmyadmin_demo_test', $decoded['auth']['user'] ?? null, 'ticket must record the temporary role name');
|
||||||
|
|
||||||
|
$allDbToken = $writeTicket->invoke($sso, $auth, 'demo_db', '');
|
||||||
|
$allDbTicketPath = $ssoDir . '/tickets/' . $allDbToken . '.json';
|
||||||
|
$allDbDecoded = json_decode((string)file_get_contents($allDbTicketPath), true);
|
||||||
|
assert_same('', $allDbDecoded['restrict_db'] ?? null, 'an all-databases login must record an empty restrict_db (no restriction)');
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
array_map('unlink', glob($ssoDir . '/tickets/*') ?: []);
|
array_map('unlink', glob($ssoDir . '/tickets/*') ?: []);
|
||||||
@rmdir($ssoDir . '/tickets');
|
@rmdir($ssoDir . '/tickets');
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ case "$(basename "$PLUGIN_DIR")" in
|
|||||||
esac
|
esac
|
||||||
grep -Fxq "name=alt-mysql" "$PLUGIN_DIR/plugin.conf" || fail "plugin.conf name is not alt-mysql"
|
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 "id=alt-mysql" "$PLUGIN_DIR/plugin.conf" || fail "plugin.conf id is not alt-mysql"
|
||||||
grep -Fxq "version=1.2.14" "$PLUGIN_DIR/plugin.conf" || fail "plugin.conf version is not 1.2.14"
|
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" \
|
grep -Fq "return '/CMD_PLUGINS/alt-mysql';" "$PLUGIN_DIR/exec/lib/AppContext.php" \
|
||||||
|| fail "AppContext base URL is not alt-mysql"
|
|| fail "AppContext base URL is not alt-mysql"
|
||||||
grep -Fq "\$pluginId = 'alt-mysql';" "$PLUGIN_DIR/exec/lib/DirectAdminUser.php" \
|
grep -Fq "\$pluginId = 'alt-mysql';" "$PLUGIN_DIR/exec/lib/DirectAdminUser.php" \
|
||||||
|
|||||||
Executable
+71
@@ -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"
|
||||||
Reference in New Issue
Block a user