Implement replication management endpoints and enhance application write freeze handling
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class application_write_freeze
|
||||
{
|
||||
public static function freeze(string $reason, string $owner, int $ttlSeconds = 300): void
|
||||
{
|
||||
$payload = [
|
||||
'reason' => $reason,
|
||||
'owner' => $owner,
|
||||
'created_at' => date('c'),
|
||||
'expires_at' => date('c', time() + max(30, $ttlSeconds)),
|
||||
];
|
||||
|
||||
self::writeState($payload);
|
||||
}
|
||||
|
||||
public static function unfreeze(?string $owner = null): void
|
||||
{
|
||||
$state = self::state();
|
||||
if ($owner !== null && isset($state['owner']) && $state['owner'] !== $owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = self::statePath();
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public static function state(): array
|
||||
{
|
||||
$path = self::statePath();
|
||||
if (!is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$state = json_decode((string)file_get_contents($path), true);
|
||||
if (!is_array($state)) {
|
||||
@unlink($path);
|
||||
return [];
|
||||
}
|
||||
|
||||
$expiresAt = strtotime((string)($state['expires_at'] ?? ''));
|
||||
if ($expiresAt !== false && $expiresAt < time()) {
|
||||
@unlink($path);
|
||||
return [];
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
public static function isFrozen(): bool
|
||||
{
|
||||
return self::state() !== [];
|
||||
}
|
||||
|
||||
public static function shouldBlock(string $method, string $uri, bool $isCronOrCli): bool
|
||||
{
|
||||
if (!self::isFrozen()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($isCronOrCli) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$method = strtoupper($method);
|
||||
if (in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
||||
return !str_starts_with($path, '/superuser/replication');
|
||||
}
|
||||
|
||||
private static function writeState(array $state): void
|
||||
{
|
||||
$path = self::statePath();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException('Could not create write-freeze directory.');
|
||||
}
|
||||
|
||||
$tempPath = tempnam($dir, 'write-freeze-');
|
||||
if ($tempPath === false) {
|
||||
throw new RuntimeException('Could not create write-freeze temp file.');
|
||||
}
|
||||
|
||||
try {
|
||||
file_put_contents($tempPath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL, LOCK_EX);
|
||||
if (!rename($tempPath, $path)) {
|
||||
throw new RuntimeException('Could not atomically replace write-freeze state.');
|
||||
}
|
||||
} finally {
|
||||
if (is_file($tempPath)) {
|
||||
@unlink($tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function statePath(): string
|
||||
{
|
||||
$root = defined('WD') ? WD : dirname(__DIR__);
|
||||
return $root . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'application-write-freeze.json';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class replication_bootstrap_config
|
||||
{
|
||||
public static function snapshotPath(): string
|
||||
{
|
||||
return self::storageDir() . DIRECTORY_SEPARATOR . 'replication-bootstrap.json';
|
||||
}
|
||||
|
||||
public static function loadSnapshot(?string $path = null): array
|
||||
{
|
||||
$path = $path ?: self::snapshotPath();
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)file_get_contents($path), true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
public static function writeSnapshot(array $snapshot, ?string $path = null): void
|
||||
{
|
||||
$path = $path ?: self::snapshotPath();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException('Could not create replication bootstrap snapshot directory.');
|
||||
}
|
||||
|
||||
$snapshot = array_replace([
|
||||
'version' => 1,
|
||||
'generated_at' => date('c'),
|
||||
], $snapshot);
|
||||
|
||||
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Could not encode replication bootstrap snapshot.');
|
||||
}
|
||||
|
||||
$tempPath = tempnam($dir, 'replication-bootstrap-');
|
||||
if ($tempPath === false) {
|
||||
throw new RuntimeException('Could not create replication bootstrap snapshot temp file.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (file_put_contents($tempPath, $json . PHP_EOL, LOCK_EX) === false) {
|
||||
throw new RuntimeException('Could not write replication bootstrap snapshot.');
|
||||
}
|
||||
|
||||
if (!rename($tempPath, $path)) {
|
||||
throw new RuntimeException('Could not atomically replace replication bootstrap snapshot.');
|
||||
}
|
||||
} finally {
|
||||
if (is_file($tempPath)) {
|
||||
@unlink($tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function applyToGlobals(array $snapshot): void
|
||||
{
|
||||
try {
|
||||
$active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : [];
|
||||
$database = self::activeDatabaseConfigFromSnapshot($active['database'] ?? null);
|
||||
$redis = self::activeRedisConfigFromSnapshot($active['redis'] ?? null);
|
||||
|
||||
if ($database !== null) {
|
||||
$GLOBALS['CONFIG_DB'] = array_merge($GLOBALS['CONFIG_DB'] ?? [], $database);
|
||||
}
|
||||
|
||||
if ($redis !== null) {
|
||||
$GLOBALS['REDIS_CONFIG'] = array_merge($GLOBALS['REDIS_CONFIG'] ?? [], $redis);
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[replication-bootstrap] Falling back to environment configuration: ' . $throwable->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static function activeDatabaseConfigFromSnapshot(mixed $config): ?array
|
||||
{
|
||||
if (!is_array($config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = trim((string)($config['host'] ?? ''));
|
||||
$database = trim((string)($config['database'] ?? ''));
|
||||
$user = trim((string)($config['user'] ?? ''));
|
||||
if ($host === '' || $database === '' || $user === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'user' => $user,
|
||||
'password' => replication_secret_box::decrypt($config['password_secret'] ?? ''),
|
||||
'database' => $database,
|
||||
'port' => (int)($config['port'] ?? 3306) ?: 3306,
|
||||
'ssl_mode' => (string)($config['ssl_mode'] ?? 'DISABLED'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function activeRedisConfigFromSnapshot(mixed $config): ?array
|
||||
{
|
||||
if (!is_array($config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = trim((string)($config['host'] ?? ''));
|
||||
if ($host === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'user' => (string)($config['user'] ?? ''),
|
||||
'password' => replication_secret_box::decrypt($config['password_secret'] ?? ''),
|
||||
'database' => (int)($config['database'] ?? 0),
|
||||
'port' => (int)($config['port'] ?? 6379) ?: 6379,
|
||||
];
|
||||
}
|
||||
|
||||
private static function storageDir(): string
|
||||
{
|
||||
$root = defined('WD') ? WD : dirname(__DIR__);
|
||||
return $root . DIRECTORY_SEPARATOR . 'storage';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class replication_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS replication_hosts (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
label VARCHAR(128) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
port INT UNSIGNED NOT NULL,
|
||||
database_name VARCHAR(128) NULL,
|
||||
database_index INT NULL,
|
||||
username VARCHAR(128) NULL,
|
||||
password_secret TEXT NULL,
|
||||
admin_username VARCHAR(128) NULL,
|
||||
admin_password_secret TEXT NULL,
|
||||
replication_username VARCHAR(128) NULL,
|
||||
replication_password_secret TEXT NULL,
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'replica',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
||||
replication_source_id BIGINT UNSIGNED NULL,
|
||||
ssl_mode VARCHAR(32) NULL,
|
||||
options_json LONGTEXT NULL,
|
||||
last_status_json LONGTEXT NULL,
|
||||
last_checked_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_replication_hosts_kind_role (kind, role),
|
||||
INDEX idx_replication_hosts_kind_status (kind, status),
|
||||
INDEX idx_replication_hosts_source (replication_source_id),
|
||||
INDEX idx_replication_hosts_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS replication_operations (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
host_id BIGINT UNSIGNED NOT NULL,
|
||||
operation VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
progress_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
message VARCHAR(512) NULL,
|
||||
error_message TEXT NULL,
|
||||
context_json LONGTEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_replication_operations_host (host_id),
|
||||
INDEX idx_replication_operations_kind_status (kind, status),
|
||||
INDEX idx_replication_operations_operation (operation)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS replication_status_snapshots (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
host_id BIGINT UNSIGNED NOT NULL,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
replication_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
lag_seconds INT NULL,
|
||||
blockers_json LONGTEXT NULL,
|
||||
raw_status_json LONGTEXT NULL,
|
||||
checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_replication_status_host_checked (host_id, checked_at),
|
||||
INDEX idx_replication_status_kind_checked (kind, checked_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS replication_audit_logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
host_id BIGINT UNSIGNED NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
severity VARCHAR(16) NOT NULL DEFAULT 'info',
|
||||
context_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_replication_audit_kind_host (kind, host_id),
|
||||
INDEX idx_replication_audit_action (action),
|
||||
INDEX idx_replication_audit_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
self::ensureColumn('replication_operations', 'progress_percent', "DECIMAL(5,2) NOT NULL DEFAULT 0.00");
|
||||
self::ensureColumn('replication_operations', 'message', 'VARCHAR(512) NULL');
|
||||
self::ensureColumn('replication_operations', 'context_json', 'LONGTEXT NULL');
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class replication_secret_box
|
||||
{
|
||||
private const PREFIX = 'twsec:v1:';
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
|
||||
public static function encrypt(string $plaintext): string
|
||||
{
|
||||
$nonce = random_bytes(12);
|
||||
$tag = '';
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER,
|
||||
self::key(),
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag,
|
||||
'',
|
||||
16
|
||||
);
|
||||
|
||||
if ($ciphertext === false || $tag === '') {
|
||||
throw new RuntimeException('Secret encryption failed.');
|
||||
}
|
||||
|
||||
return self::PREFIX . base64_encode(json_encode([
|
||||
'nonce' => base64_encode($nonce),
|
||||
'tag' => base64_encode($tag),
|
||||
'ciphertext' => base64_encode($ciphertext),
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
public static function decrypt(?string $secret): string
|
||||
{
|
||||
$secret = (string)$secret;
|
||||
if ($secret === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!str_starts_with($secret, self::PREFIX)) {
|
||||
return $secret;
|
||||
}
|
||||
|
||||
$payload = json_decode(base64_decode(substr($secret, strlen(self::PREFIX)), true) ?: '', true);
|
||||
if (!is_array($payload)) {
|
||||
throw new RuntimeException('Encrypted secret payload is invalid.');
|
||||
}
|
||||
|
||||
$nonce = base64_decode((string)($payload['nonce'] ?? ''), true);
|
||||
$tag = base64_decode((string)($payload['tag'] ?? ''), true);
|
||||
$ciphertext = base64_decode((string)($payload['ciphertext'] ?? ''), true);
|
||||
|
||||
if ($nonce === false || $tag === false || $ciphertext === false) {
|
||||
throw new RuntimeException('Encrypted secret payload is incomplete.');
|
||||
}
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER,
|
||||
self::key(),
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('Secret decryption failed.');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
public static function mask(?string $value): string
|
||||
{
|
||||
$value = (string)$value;
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$length = strlen($value);
|
||||
if ($length <= 4) {
|
||||
return str_repeat('*', $length);
|
||||
}
|
||||
|
||||
return substr($value, 0, 2) . str_repeat('*', max(4, $length - 4)) . substr($value, -2);
|
||||
}
|
||||
|
||||
private static function key(): string
|
||||
{
|
||||
$keyMaterial = (string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: '');
|
||||
if (trim($keyMaterial) === '') {
|
||||
throw new RuntimeException('ENCRYPTION_KEY is required for replication secret encryption.');
|
||||
}
|
||||
|
||||
return hash('sha256', $keyMaterial, true);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ class superuser_system_status_service
|
||||
$dependencies['database']['status'] ?? 'down',
|
||||
$dependencies['redis']['status'] ?? 'down',
|
||||
$dependencies['minio']['status'] ?? 'down',
|
||||
$dependencies['database']['replication']['status'] ?? 'not_configured',
|
||||
$dependencies['redis']['replication']['status'] ?? 'not_configured',
|
||||
];
|
||||
foreach ($modules as $module) {
|
||||
if (($module['enabled'] ?? false) === true) {
|
||||
@@ -295,6 +297,14 @@ class superuser_system_status_service
|
||||
$database = $this->probeDatabase();
|
||||
$redis = $this->probeRedis();
|
||||
$minio = $this->probeMinio();
|
||||
try {
|
||||
$replicationManager = new replication_manager();
|
||||
$database['replication'] = $replicationManager->dependencyReplication('database');
|
||||
$redis['replication'] = $replicationManager->dependencyReplication('redis');
|
||||
} catch (Throwable $throwable) {
|
||||
$database['replication'] = $this->replicationStatusFallback('database', $throwable);
|
||||
$redis['replication'] = $this->replicationStatusFallback('redis', $throwable);
|
||||
}
|
||||
|
||||
if (($redis['status'] ?? '') === 'down') {
|
||||
$this->pushWarning(
|
||||
@@ -320,6 +330,19 @@ class superuser_system_status_service
|
||||
];
|
||||
}
|
||||
|
||||
private function replicationStatusFallback(string $kind, Throwable $throwable): array
|
||||
{
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'min_percent' => 0.0,
|
||||
'average_percent' => 0.0,
|
||||
'replicas' => [],
|
||||
'blockers' => [
|
||||
'Replication status for ' . $kind . ' could not be loaded: ' . $throwable->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function probeCpu(array &$warnings): array
|
||||
{
|
||||
$checkedAt = date('c');
|
||||
|
||||
@@ -163,3 +163,9 @@ if (strtolower(trim((string)($_ENV['USE_ENV'] ?? getenv('USE_ENV') ?? ''))) ===
|
||||
// Throw an error if the environment variables are not set
|
||||
throw new Exception('Environment variables are not set');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/classes/replication_secret_box.php';
|
||||
require_once __DIR__ . '/classes/replication_bootstrap_config.php';
|
||||
\classes\replication_bootstrap_config::applyToGlobals(
|
||||
\classes\replication_bootstrap_config::loadSnapshot()
|
||||
);
|
||||
|
||||
@@ -167,6 +167,7 @@ spl_autoload_register(function (string $class): void {
|
||||
}
|
||||
});
|
||||
|
||||
use classes\application_write_freeze;
|
||||
use classes\db;
|
||||
use classes\redis;
|
||||
use classes\request;
|
||||
@@ -195,7 +196,23 @@ try {
|
||||
$response->error($e->getMessage(), 500);
|
||||
}
|
||||
|
||||
if (application_write_freeze::shouldBlock(
|
||||
$_SERVER['REQUEST_METHOD'] ?? 'GET',
|
||||
$_SERVER['REQUEST_URI'] ?? '/',
|
||||
php_sapi_name() === 'cli' || isset($_GET['internalCronCall'])
|
||||
)) {
|
||||
$freezeState = application_write_freeze::state();
|
||||
if (php_sapi_name() === 'cli') {
|
||||
fwrite(STDERR, 'Application writes are frozen: ' . (string)($freezeState['reason'] ?? 'replication promotion') . PHP_EOL);
|
||||
exit(75);
|
||||
}
|
||||
|
||||
$response->error([
|
||||
'message' => 'Application writes are temporarily frozen.',
|
||||
'reason' => $freezeState['reason'] ?? null,
|
||||
'expires_at' => $freezeState['expires_at'] ?? null,
|
||||
], 503);
|
||||
}
|
||||
|
||||
// If the program was called from the command line, run the cli script
|
||||
if (php_sapi_name() === 'cli' || isset($_GET['internalCronCall'])) {
|
||||
|
||||
@@ -10582,6 +10582,202 @@ paths:
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
/superuser/replication:
|
||||
get:
|
||||
tags:
|
||||
- Superuser
|
||||
summary: Database and Redis replication topology
|
||||
operationId: getSuperuserReplication
|
||||
parameters:
|
||||
- in: query
|
||||
name: refresh
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Refresh host connectivity and replication status before returning the topology.
|
||||
responses:
|
||||
'200':
|
||||
description: Replication topology returned successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationResponse'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/superuser/replication/databases:
|
||||
post:
|
||||
tags:
|
||||
- Superuser
|
||||
summary: Add database replication host credentials
|
||||
operationId: addSuperuserDatabaseReplicationHost
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationHostCreateRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Database replication host added
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationHostResponse'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/superuser/replication/redis:
|
||||
post:
|
||||
tags:
|
||||
- Superuser
|
||||
summary: Add Redis replication host credentials
|
||||
operationId: addSuperuserRedisReplicationHost
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationHostCreateRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Redis replication host added
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationHostResponse'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/superuser/replication/compose-template:
|
||||
post:
|
||||
tags:
|
||||
- Superuser
|
||||
summary: Generate a replication-ready Docker Compose template
|
||||
operationId: generateSuperuserReplicationComposeTemplate
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationComposeTemplateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Docker Compose template generated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationComposeTemplateResponse'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/superuser/replication/test-credentials:
|
||||
post:
|
||||
tags:
|
||||
- Superuser
|
||||
summary: Test replication host credentials before saving
|
||||
operationId: testSuperuserReplicationCredentials
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationUnsavedCredentialTestRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Credential test returned successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationOperationResponse'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/superuser/replication/{kind}/{id}/test:
|
||||
post:
|
||||
tags:
|
||||
- Superuser
|
||||
summary: Test replication host connectivity and privileges
|
||||
operationId: testSuperuserReplicationHost
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/SuperuserReplicationKindParam'
|
||||
- $ref: '#/components/parameters/SuperuserReplicationHostIdParam'
|
||||
responses:
|
||||
'200':
|
||||
description: Host test result returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationOperationResponse'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
/superuser/replication/{kind}/{id}/provision:
|
||||
post:
|
||||
tags:
|
||||
- Superuser
|
||||
summary: Provision a host as a replica of the current primary
|
||||
operationId: provisionSuperuserReplicationHost
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/SuperuserReplicationKindParam'
|
||||
- $ref: '#/components/parameters/SuperuserReplicationHostIdParam'
|
||||
responses:
|
||||
'200':
|
||||
description: Replica provisioning started or completed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationOperationResponse'
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/superuser/replication/{kind}/{id}/promote:
|
||||
post:
|
||||
tags:
|
||||
- Superuser
|
||||
summary: Promote a caught-up replica to primary
|
||||
operationId: promoteSuperuserReplicationHost
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/SuperuserReplicationKindParam'
|
||||
- $ref: '#/components/parameters/SuperuserReplicationHostIdParam'
|
||||
responses:
|
||||
'200':
|
||||
description: Replica promoted to primary
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationOperationResponse'
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/superuser/replication/{kind}/{id}:
|
||||
delete:
|
||||
tags:
|
||||
- Superuser
|
||||
summary: Remove an inactive or unhealthy replication host
|
||||
operationId: removeSuperuserReplicationHost
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/SuperuserReplicationKindParam'
|
||||
- $ref: '#/components/parameters/SuperuserReplicationHostIdParam'
|
||||
responses:
|
||||
'200':
|
||||
description: Replication host removed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuperuserReplicationOperationResponse'
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
# Configuration Endpoints
|
||||
/economic/config:
|
||||
get:
|
||||
@@ -12085,6 +12281,20 @@ components:
|
||||
the target customer can be inferred from context.
|
||||
schema:
|
||||
type: integer
|
||||
SuperuserReplicationKindParam:
|
||||
name: kind
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [databases, redis]
|
||||
SuperuserReplicationHostIdParam:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
|
||||
responses:
|
||||
BadRequest:
|
||||
@@ -12160,6 +12370,325 @@ components:
|
||||
- meta
|
||||
- includes
|
||||
|
||||
SuperuserReplicationResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
$ref: '#/components/schemas/SuperuserReplicationSummary'
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
SuperuserReplicationHostResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
$ref: '#/components/schemas/SuperuserReplicationHost'
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
SuperuserReplicationOperationResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
SuperuserReplicationComposeTemplateResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
$ref: '#/components/schemas/SuperuserReplicationComposeTemplate'
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
SuperuserReplicationComposeTemplate:
|
||||
type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [database, redis]
|
||||
engine:
|
||||
type: string
|
||||
enum: [mariadb, redis]
|
||||
role:
|
||||
type: string
|
||||
enum: [primary, replica]
|
||||
service_name:
|
||||
type: string
|
||||
host_port:
|
||||
type: integer
|
||||
server_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
compose:
|
||||
type: string
|
||||
description: Complete docker-compose.yml content with secret environment placeholders.
|
||||
env:
|
||||
type: string
|
||||
description: Example .env content for the placeholders used by compose.
|
||||
seed_command:
|
||||
type: string
|
||||
description: One-time MariaDB seed command to initialize a replica from the primary before provisioning.
|
||||
credentials:
|
||||
$ref: '#/components/schemas/SuperuserReplicationGeneratedCredentials'
|
||||
steps:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
SuperuserReplicationGeneratedCredentials:
|
||||
type: object
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
host:
|
||||
type: string
|
||||
port:
|
||||
type: integer
|
||||
database:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: integer
|
||||
username:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
admin_username:
|
||||
type: string
|
||||
admin_password:
|
||||
type: string
|
||||
format: password
|
||||
replication_username:
|
||||
type: string
|
||||
replication_password:
|
||||
type: string
|
||||
format: password
|
||||
ssl_mode:
|
||||
type: string
|
||||
allow_preseeded_replica:
|
||||
type: boolean
|
||||
|
||||
SuperuserReplicationSummary:
|
||||
type: object
|
||||
properties:
|
||||
generated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
database:
|
||||
$ref: '#/components/schemas/SuperuserReplicationKindSummary'
|
||||
redis:
|
||||
$ref: '#/components/schemas/SuperuserReplicationKindSummary'
|
||||
write_freeze:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
SuperuserReplicationKindSummary:
|
||||
type: object
|
||||
properties:
|
||||
primary:
|
||||
$ref: '#/components/schemas/SuperuserReplicationHost'
|
||||
nullable: true
|
||||
hosts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SuperuserReplicationHost'
|
||||
replication:
|
||||
$ref: '#/components/schemas/SuperuserReplicationStatus'
|
||||
|
||||
SuperuserReplicationStatus:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [ok, degraded, down, not_configured]
|
||||
min_percent:
|
||||
type: number
|
||||
format: float
|
||||
average_percent:
|
||||
type: number
|
||||
format: float
|
||||
replicas:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SuperuserReplicationHost'
|
||||
blockers:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
SuperuserReplicationHost:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
kind:
|
||||
type: string
|
||||
enum: [database, redis]
|
||||
label:
|
||||
type: string
|
||||
host:
|
||||
type: string
|
||||
port:
|
||||
type: integer
|
||||
database:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: integer
|
||||
role:
|
||||
type: string
|
||||
enum: [primary, replica, inactive]
|
||||
status:
|
||||
type: string
|
||||
replication_source_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
replication_percent:
|
||||
type: number
|
||||
format: float
|
||||
last_status:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
credential_summary:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
SuperuserReplicationHostCreateRequest:
|
||||
type: object
|
||||
required:
|
||||
- host
|
||||
- port
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
host:
|
||||
type: string
|
||||
port:
|
||||
type: integer
|
||||
database:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: integer
|
||||
username:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
admin_username:
|
||||
type: string
|
||||
admin_password:
|
||||
type: string
|
||||
format: password
|
||||
replication_username:
|
||||
type: string
|
||||
replication_password:
|
||||
type: string
|
||||
format: password
|
||||
ssl_mode:
|
||||
type: string
|
||||
options:
|
||||
type: object
|
||||
properties:
|
||||
allow_preseeded_replica:
|
||||
type: boolean
|
||||
description: Allow configuring replication when the replica has already been safely seeded outside the orchestrator. Required for MariaDB, which does not support MySQL Clone.
|
||||
additionalProperties: true
|
||||
|
||||
SuperuserReplicationUnsavedCredentialTestRequest:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuperuserReplicationHostCreateRequest'
|
||||
- type: object
|
||||
required:
|
||||
- kind
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [database, databases, mysql, redis]
|
||||
role:
|
||||
type: string
|
||||
enum: [primary, replica]
|
||||
|
||||
SuperuserReplicationComposeTemplateRequest:
|
||||
type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [database, databases, mysql, redis]
|
||||
default: database
|
||||
role:
|
||||
type: string
|
||||
enum: [primary, replica]
|
||||
default: replica
|
||||
service_name:
|
||||
type: string
|
||||
volume_name:
|
||||
type: string
|
||||
image:
|
||||
type: string
|
||||
database:
|
||||
type: string
|
||||
description: MariaDB database name to create on first startup.
|
||||
username:
|
||||
type: string
|
||||
description: MariaDB application username to create on first startup.
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
description: Optional application password to reuse instead of generating one.
|
||||
admin_password:
|
||||
type: string
|
||||
format: password
|
||||
description: Optional MariaDB root password to reuse instead of generating one.
|
||||
replication_username:
|
||||
type: string
|
||||
description: Replication username to place in generated credentials.
|
||||
replication_password:
|
||||
type: string
|
||||
format: password
|
||||
description: Optional replication password to reuse instead of generating one.
|
||||
host_port:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 65535
|
||||
server_id:
|
||||
type: integer
|
||||
minimum: 1
|
||||
description: MariaDB server-id. Must be unique across the primary and replicas.
|
||||
primary_host:
|
||||
type: string
|
||||
description: Redis primary host used when generating a Redis replica template.
|
||||
primary_port:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 65535
|
||||
description: Redis primary port used when generating a Redis replica template.
|
||||
|
||||
SuperuserSystemStatusPayload:
|
||||
type: object
|
||||
properties:
|
||||
@@ -12270,6 +12799,8 @@ components:
|
||||
error:
|
||||
type: string
|
||||
nullable: true
|
||||
replication:
|
||||
$ref: '#/components/schemas/SuperuserReplicationStatus'
|
||||
|
||||
SuperuserMinioDependencyStatus:
|
||||
type: object
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\replication_manager;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
class superuserReplicationRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/superuser/replication', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('superuser_replication_view');
|
||||
$refresh = $this->toBool($this->getParameter('refresh'), false);
|
||||
$response->success((new replication_manager())->summary($refresh));
|
||||
}, [
|
||||
'superuser_replication_view' => 'View database and Redis replication topology and status',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/databases', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('superuser_replication_manage');
|
||||
$host = (new replication_manager())->addHost('database', $this->getParametersAsArray(), $this->actorUserId());
|
||||
$response->success($host, 201);
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Add and manage database replication host credentials',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/redis', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('superuser_replication_manage');
|
||||
$host = (new replication_manager())->addHost('redis', $this->getParametersAsArray(), $this->actorUserId());
|
||||
$response->success($host, 201);
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Add and manage Redis replication host credentials',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/compose-template', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('superuser_replication_manage');
|
||||
$response->success(replication_manager::composeTemplate($this->getParametersAsArray()));
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Generate Docker Compose templates for replication-ready database and Redis hosts',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/test-credentials', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('superuser_replication_manage');
|
||||
$parameters = $this->getParametersAsArray();
|
||||
$response->success((new replication_manager())->testCredentials(
|
||||
(string)($parameters['kind'] ?? ''),
|
||||
$parameters
|
||||
));
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Test database and Redis replication host credentials before saving them',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/{kind}/{id}/test', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('superuser_replication_manage');
|
||||
$response->success((new replication_manager())->testHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Validate database and Redis replication host connectivity and privileges',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/{kind}/{id}/provision', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('superuser_replication_manage');
|
||||
try {
|
||||
$result = (new replication_manager())->provisionHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->actorUserId()
|
||||
);
|
||||
if (($result['ok'] ?? false) !== true) {
|
||||
$response->error($result, 409);
|
||||
}
|
||||
$response->success($result);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Provision a database or Redis host as a replica of the current primary',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/{kind}/{id}/promote', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('superuser_replication_promote');
|
||||
try {
|
||||
$response->success((new replication_manager())->promoteHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'superuser_replication_promote' => 'Promote a healthy caught-up database or Redis replica to primary',
|
||||
]);
|
||||
|
||||
$this->delete('/superuser/replication/{kind}/{id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('superuser_replication_remove');
|
||||
try {
|
||||
$response->success((new replication_manager())->removeHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'superuser_replication_remove' => 'Remove inactive prior hosts and unhealthy database or Redis replicas',
|
||||
]);
|
||||
}
|
||||
|
||||
private function routeId(): int
|
||||
{
|
||||
$id = (int)$this->fromRoute('id');
|
||||
$this->requireParameterIntPositive($id, 'id');
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function actorUserId(): ?int
|
||||
{
|
||||
try {
|
||||
$user = (new authentication())->get_user();
|
||||
return $user !== false && isset($user->id) ? (int)$user->id : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function toBool(mixed $value, bool $default): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
$normalized = strtolower(trim((string)$value));
|
||||
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
|
||||
return true;
|
||||
}
|
||||
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
|
||||
return false;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/replication_manager.php');
|
||||
|
||||
use classes\replication_manager;
|
||||
|
||||
it('computes MySQL GTID interval counts and coverage percentages', function (): void {
|
||||
$source = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-5:7-10,bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb:1-2';
|
||||
$executed = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-3:7-10,bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb:1';
|
||||
|
||||
expect(replication_manager::mysqlGtidIntervalCount($source))->toBe(11);
|
||||
expect(replication_manager::mysqlGtidCoveragePercent($source, $executed))->toBe(72.73);
|
||||
});
|
||||
|
||||
it('reports empty source GTID sets as caught up', function (): void {
|
||||
expect(replication_manager::mysqlGtidCoveragePercent('', ''))->toBe(100.0);
|
||||
});
|
||||
|
||||
it('computes Redis offset percentages safely', function (): void {
|
||||
expect(replication_manager::redisOffsetPercent(1000, 750))->toBe(75.0);
|
||||
expect(replication_manager::redisOffsetPercent(0, 0))->toBe(100.0);
|
||||
expect(replication_manager::redisOffsetPercent(1000, 1250))->toBe(100.0);
|
||||
});
|
||||
|
||||
it('computes MariaDB GTID coverage by domain sequence', function (): void {
|
||||
$source = '0-1-10,1-1-20';
|
||||
$replica = '0-2-8,1-3-20';
|
||||
|
||||
expect(replication_manager::mariadbGtidCoveragePercent($source, $replica))->toBe(93.33);
|
||||
expect(replication_manager::mariadbGtidCoveragePercent('', ''))->toBe(100.0);
|
||||
});
|
||||
|
||||
it('normalizes public replication kind aliases', function (): void {
|
||||
expect(replication_manager::normalizeKind('databases'))->toBe('database');
|
||||
expect(replication_manager::normalizeKind('mysql'))->toBe('database');
|
||||
expect(replication_manager::normalizeKind('redis'))->toBe('redis');
|
||||
});
|
||||
|
||||
it('generates replication-ready MariaDB compose templates without embedding secrets', function (): void {
|
||||
$template = replication_manager::composeTemplate([
|
||||
'kind' => 'database',
|
||||
'role' => 'replica',
|
||||
'service_name' => 'MariaDB Replica 2',
|
||||
'database' => 'nnks_db',
|
||||
'username' => 'nnks_db_user',
|
||||
'host_port' => 5433,
|
||||
'server_id' => 2,
|
||||
]);
|
||||
|
||||
expect($template['kind'])->toBe('database');
|
||||
expect($template['role'])->toBe('replica');
|
||||
expect($template['service_name'])->toBe('mariadb-replica-2');
|
||||
expect($template['compose'])->toContain('image: "mariadb:11"');
|
||||
expect($template['compose'])->toContain('"--server-id=2"');
|
||||
expect($template['compose'])->toContain('"--log-bin=/var/lib/mysql/mariadb-bin"');
|
||||
expect($template['compose'])->toContain('"--binlog-format=ROW"');
|
||||
expect($template['compose'])->toContain('"--gtid-strict-mode=ON"');
|
||||
expect($template['compose'])->toContain('"--read-only=ON"');
|
||||
expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.logs"');
|
||||
expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.edge_gateway_log_entries"');
|
||||
expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.replication_status_snapshots"');
|
||||
expect($template['compose'])->toContain('"5433:3306"');
|
||||
expect($template['compose'])->toContain('mariadb-replica-2-seed');
|
||||
expect($template['compose'])->toContain('MARIADB_PRIMARY_ADMIN_PASSWORD');
|
||||
expect($template['compose'])->toContain('mariadb-dump --host="$MARIADB_PRIMARY_HOST"');
|
||||
expect($template['compose'])->toContain('--ignore-table="$MARIADB_SEED_DATABASE.logs"');
|
||||
expect($template['compose'])->toContain('--ignore-table="$MARIADB_SEED_DATABASE.edge_gateway_log_entries"');
|
||||
expect($template['compose'])->toContain('--ignore-table="$MARIADB_SEED_DATABASE.replication_status_snapshots"');
|
||||
expect($template['compose'])->toContain('--no-data "$MARIADB_SEED_DATABASE" "$table"');
|
||||
expect($template['compose'])->toContain('${MARIADB_ROOT_PASSWORD:?set MARIADB_ROOT_PASSWORD}');
|
||||
expect($template['compose'])->not->toContain('<set-a-long-random-root-password>');
|
||||
expect($template['env'])->toMatch('/MARIADB_ROOT_PASSWORD=[A-Za-z0-9_-]{32}/');
|
||||
expect($template['env'])->toMatch('/MARIADB_PASSWORD=[A-Za-z0-9_-]{32}/');
|
||||
expect($template['env'])->toContain('MARIADB_PRIMARY_ADMIN_PASSWORD=');
|
||||
expect($template['credentials']['password'])->toMatch('/^[A-Za-z0-9_-]{32}$/');
|
||||
expect($template['credentials']['admin_password'])->toMatch('/^[A-Za-z0-9_-]{32}$/');
|
||||
expect($template['credentials']['port'])->toBe(5433);
|
||||
expect($template['credentials']['allow_preseeded_replica'])->toBeTrue();
|
||||
expect($template['seed_command'])->toContain('mariadb-dump');
|
||||
expect($template['seed_command'])->toContain('--gtid');
|
||||
expect($template['seed_command'])->toContain('--master-data=2');
|
||||
expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.logs\'');
|
||||
expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.edge_gateway_log_entries\'');
|
||||
expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.replication_status_snapshots\'');
|
||||
});
|
||||
|
||||
it('detects missing database tables before provisioning a preseeded replica', function (): void {
|
||||
expect(replication_manager::missingDatabaseTables(
|
||||
['customers', 'edge_gateways', 'orders'],
|
||||
['customers', 'orders']
|
||||
))->toBe(['edge_gateways']);
|
||||
});
|
||||
|
||||
it('seeds MariaDB replicas in place instead of requiring container recreation', function (): void {
|
||||
$content = file_get_contents(app_path('classes/replication_manager.php'));
|
||||
|
||||
expect($content)->toContain('advanceMariaDbReplicaSeed($operationId, $primary, $host, $target)');
|
||||
expect($content)->toContain('MARIADB_SEED_BATCH_ROWS');
|
||||
expect($content)->toContain('MARIADB_SEED_STEP_SECONDS');
|
||||
expect($content)->toContain('activeOperationId($kind, $id, \'provision\')');
|
||||
expect($content)->toContain('updateOperationProgress($operationId, $progress, $message, $context)');
|
||||
expect($content)->toContain("application_write_freeze::freeze('MariaDB replica seed is copying data.'");
|
||||
expect($content)->toContain('DROP DATABASE IF EXISTS');
|
||||
expect($content)->toContain('CREATE DATABASE ');
|
||||
expect($content)->toContain('SHOW CREATE TABLE');
|
||||
expect($content)->toContain('SET GLOBAL gtid_slave_pos');
|
||||
expect($content)->toContain('databasePrimaryKeyColumnNames($source, $primaryDatabase, $tableName)');
|
||||
expect($content)->toContain('$target->begin_transaction()');
|
||||
expect($content)->toContain("'connect_without_database' => \$usePreseededReplica");
|
||||
});
|
||||
|
||||
it('keeps operational log tables schema-only during MariaDB seeding and replication', function (): void {
|
||||
$content = file_get_contents(app_path('classes/replication_manager.php'));
|
||||
|
||||
expect($content)->toContain('MARIADB_SCHEMA_ONLY_TABLES');
|
||||
expect($content)->toContain("'logs'");
|
||||
expect($content)->toContain("'edge_gateway_log_entries'");
|
||||
expect($content)->toContain("'replication_status_snapshots'");
|
||||
expect($content)->toContain("'replication_operations'");
|
||||
expect($content)->toContain("'replication_audit_logs'");
|
||||
expect($content)->toContain("'skip_data' => \$skipData");
|
||||
expect($content)->toContain('createMariaDbReplicaTable(');
|
||||
expect($content)->toContain('SET GLOBAL replicate_ignore_table');
|
||||
expect($content)->toContain('--replicate-ignore-table=');
|
||||
expect($content)->toContain('mariaDbSchemaOnlyDumpIgnoreArgs');
|
||||
expect($content)->toContain('mariaDbSchemaOnlySeedCommandIgnoreArgs');
|
||||
expect($content)->toContain('databaseSchemaOnlyTablesWithRows');
|
||||
expect($content)->toContain('schemaOnlyTablesContainRowsBlocker');
|
||||
expect($content)->toContain('RESET SLAVE ALL');
|
||||
expect($content)->toContain('mariaDbSeedContextRequiresFilterReset($context)');
|
||||
});
|
||||
|
||||
it('allows failed replicas to be removed without allowing primary or healthy replica removal', function (): void {
|
||||
expect(replication_manager::replicationHostCanBeRemoved(['role' => 'primary', 'status' => 'ok']))->toBeFalse();
|
||||
expect(replication_manager::replicationHostCanBeRemoved(['role' => 'inactive', 'status' => 'inactive']))->toBeTrue();
|
||||
expect(replication_manager::replicationHostCanBeRemoved(['role' => 'replica', 'status' => 'degraded']))->toBeTrue();
|
||||
expect(replication_manager::replicationHostCanBeRemoved(['role' => 'replica', 'status' => 'ok']))->toBeFalse();
|
||||
});
|
||||
|
||||
it('generates Redis replica compose templates with primary connection placeholders', function (): void {
|
||||
$template = replication_manager::composeTemplate([
|
||||
'kind' => 'redis',
|
||||
'role' => 'replica',
|
||||
'service_name' => 'Redis Replica',
|
||||
'host_port' => 6380,
|
||||
'primary_host' => 'redis-primary.internal',
|
||||
'primary_port' => 6379,
|
||||
]);
|
||||
|
||||
expect($template['kind'])->toBe('redis');
|
||||
expect($template['compose'])->toContain('image: "redis:7"');
|
||||
expect($template['compose'])->toContain('"--replicaof"');
|
||||
expect($template['compose'])->toContain('"redis-primary.internal"');
|
||||
expect($template['compose'])->toContain('"6379"');
|
||||
expect($template['compose'])->toContain('${REDIS_PRIMARY_PASSWORD:?set REDIS_PRIMARY_PASSWORD}');
|
||||
expect($template['env'])->toMatch('/REDIS_PASSWORD=[A-Za-z0-9_-]{32}/');
|
||||
expect($template['env'])->toMatch('/REDIS_PRIMARY_PASSWORD=[A-Za-z0-9_-]{32}/');
|
||||
expect($template['credentials']['password'])->toMatch('/^[A-Za-z0-9_-]{32}$/');
|
||||
});
|
||||
|
||||
it('does not require replica SQL threads before database provisioning configures them', function (): void {
|
||||
$content = file_get_contents(app_path('classes/replication_manager.php'));
|
||||
|
||||
expect($content)->toContain("'test_connectivity_only' => true");
|
||||
expect($content)->toContain("'connect_without_database' => \$usePreseededReplica");
|
||||
expect($content)->toContain("'healthy' => \$status['blockers'] === []");
|
||||
expect($content)->toContain("'message' => \$targetEngine === 'mariadb'");
|
||||
expect($content)->toContain('Database replica status is not configured.');
|
||||
expect($content)->toContain('Database replication IO thread is not running.');
|
||||
expect($content)->toContain('Database replication SQL thread is not running.');
|
||||
expect($content)->toContain("if (\$status !== [])");
|
||||
});
|
||||
|
||||
it('keeps replication operation progress schema idempotent for existing installs', function (): void {
|
||||
$content = file_get_contents(app_path('classes/replication_schema_bootstrap.php'));
|
||||
|
||||
expect($content)->toContain("ensureColumn('replication_operations', 'progress_percent'");
|
||||
expect($content)->toContain("ensureColumn('replication_operations', 'message'");
|
||||
expect($content)->toContain("ensureColumn('replication_operations', 'context_json'");
|
||||
});
|
||||
|
||||
it('creates the generated replication user on the primary during provisioning', function (): void {
|
||||
$content = file_get_contents(app_path('classes/replication_manager.php'));
|
||||
|
||||
expect($content)->toContain('ensureDatabaseReplicationUser($primary, $replicationUser, $replicationPassword)');
|
||||
expect($content)->toContain('GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO');
|
||||
});
|
||||
|
||||
it('supports MariaDB prerequisites without requiring Oracle MySQL variables', function (): void {
|
||||
$blockers = replication_manager::databasePrerequisiteBlockers([
|
||||
'server_version' => '11.8.6-MariaDB-ubu2404',
|
||||
'log_bin' => 'ON',
|
||||
'server_id' => '12',
|
||||
'gtid_binlog_pos' => '0-12-42',
|
||||
]);
|
||||
|
||||
expect($blockers)->toBe([]);
|
||||
|
||||
$quietServerBlockers = replication_manager::databasePrerequisiteBlockers([
|
||||
'server_version' => '11.8.6-MariaDB-ubu2404',
|
||||
'log_bin' => 'ON',
|
||||
'server_id' => '12',
|
||||
'gtid_current_pos' => '',
|
||||
]);
|
||||
|
||||
expect($quietServerBlockers)->toBe([]);
|
||||
});
|
||||
|
||||
it('reports MariaDB-specific blockers when GTID or binary logging prerequisites are missing', function (): void {
|
||||
$blockers = replication_manager::databasePrerequisiteBlockers([
|
||||
'server_version' => '11.8.6-MariaDB-ubu2404',
|
||||
'log_bin' => 'OFF',
|
||||
'server_id' => '12',
|
||||
]);
|
||||
|
||||
expect($blockers)->toContain('MariaDB binary logging must be enabled.');
|
||||
expect($blockers)->toContain('MariaDB GTID position must be available.');
|
||||
expect($blockers)->not->toContain('Oracle MySQL 8.x is required for managed replication. Current server reports 11.8.6-MariaDB-ubu2404.');
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/replication_secret_box.php');
|
||||
app_require('classes/replication_bootstrap_config.php');
|
||||
|
||||
use classes\replication_bootstrap_config;
|
||||
use classes\replication_secret_box;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->previousEncryptionKey = $GLOBALS['ENCRYPTION_KEY'] ?? null;
|
||||
$GLOBALS['ENCRYPTION_KEY'] = 'unit-test-replication-encryption-key';
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
if ($this->previousEncryptionKey === null) {
|
||||
unset($GLOBALS['ENCRYPTION_KEY']);
|
||||
return;
|
||||
}
|
||||
$GLOBALS['ENCRYPTION_KEY'] = $this->previousEncryptionKey;
|
||||
});
|
||||
|
||||
it('encrypts replication secrets without storing plaintext', function (): void {
|
||||
$secret = replication_secret_box::encrypt('replica-password');
|
||||
|
||||
expect($secret)->toStartWith('twsec:v1:');
|
||||
expect($secret)->not->toContain('replica-password');
|
||||
expect(replication_secret_box::decrypt($secret))->toBe('replica-password');
|
||||
});
|
||||
|
||||
it('builds active database and redis config from encrypted bootstrap snapshots', function (): void {
|
||||
$snapshot = [
|
||||
'active' => [
|
||||
'database' => [
|
||||
'host' => 'mysql-replica.internal',
|
||||
'port' => 3307,
|
||||
'database' => 'truckwash',
|
||||
'user' => 'app',
|
||||
'password_secret' => replication_secret_box::encrypt('db-secret'),
|
||||
'ssl_mode' => 'REQUIRED',
|
||||
],
|
||||
'redis' => [
|
||||
'host' => 'redis-replica.internal',
|
||||
'port' => 6380,
|
||||
'database' => 2,
|
||||
'user' => 'default',
|
||||
'password_secret' => replication_secret_box::encrypt('redis-secret'),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
expect(replication_bootstrap_config::activeDatabaseConfigFromSnapshot($snapshot['active']['database']))->toMatchArray([
|
||||
'host' => 'mysql-replica.internal',
|
||||
'port' => 3307,
|
||||
'database' => 'truckwash',
|
||||
'user' => 'app',
|
||||
'password' => 'db-secret',
|
||||
'ssl_mode' => 'REQUIRED',
|
||||
]);
|
||||
expect(replication_bootstrap_config::activeRedisConfigFromSnapshot($snapshot['active']['redis']))->toMatchArray([
|
||||
'host' => 'redis-replica.internal',
|
||||
'port' => 6380,
|
||||
'database' => 2,
|
||||
'user' => 'default',
|
||||
'password' => 'redis-secret',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
it('registers superuser replication endpoints and permissions', function (): void {
|
||||
$content = file_get_contents(app_path('routes/superuserReplicationRoute.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain('/superuser/replication');
|
||||
expect($content)->toContain('/superuser/replication/databases');
|
||||
expect($content)->toContain('/superuser/replication/redis');
|
||||
expect($content)->toContain('/superuser/replication/compose-template');
|
||||
expect($content)->toContain('/superuser/replication/test-credentials');
|
||||
expect($content)->toContain('/superuser/replication/{kind}/{id}/test');
|
||||
expect($content)->toContain('/superuser/replication/{kind}/{id}/provision');
|
||||
expect($content)->toContain('/superuser/replication/{kind}/{id}/promote');
|
||||
expect($content)->toContain("requirePermission('superuser_replication_view')");
|
||||
expect($content)->toContain("requirePermission('superuser_replication_manage')");
|
||||
expect($content)->toContain("requirePermission('superuser_replication_promote')");
|
||||
expect($content)->toContain("requirePermission('superuser_replication_remove')");
|
||||
});
|
||||
|
||||
it('documents replication management in openapi', function (): void {
|
||||
$content = file_get_contents(app_path('openapi.yaml'));
|
||||
|
||||
expect($content)->toContain('/superuser/replication:');
|
||||
expect($content)->toContain('operationId: getSuperuserReplication');
|
||||
expect($content)->toContain('operationId: generateSuperuserReplicationComposeTemplate');
|
||||
expect($content)->toContain('operationId: testSuperuserReplicationCredentials');
|
||||
expect($content)->toContain('SuperuserReplicationStatus');
|
||||
expect($content)->toContain('SuperuserReplicationHostCreateRequest');
|
||||
expect($content)->toContain('SuperuserReplicationComposeTemplateRequest');
|
||||
});
|
||||
Reference in New Issue
Block a user