Implement replication management endpoints and enhance application write freeze handling

This commit is contained in:
Jeppe Bundgaard
2026-05-18 09:59:15 +02:00
parent 0399cb4bb4
commit f53b99ad94
13 changed files with 4439 additions and 0 deletions
@@ -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');
+6
View File
@@ -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()
);
+17
View File
@@ -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'])) {
+531
View File
@@ -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');
});