Integrate Coolify API client and module for managing Coolify services, enhancing automation and deployment processes.
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Aws\S3\S3Client;
|
||||
use mysqli;
|
||||
use Predis\Client as PredisClient;
|
||||
use Throwable;
|
||||
|
||||
class replica_failover_manager
|
||||
{
|
||||
public const KIND_DATABASE = 'database';
|
||||
public const KIND_REDIS = 'redis';
|
||||
public const KIND_MINIO = 'minio';
|
||||
public const DEFAULT_MAX_STATUS_AGE_SECONDS = 90;
|
||||
|
||||
public static function configDefaults(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => false,
|
||||
'database_enabled' => false,
|
||||
'redis_enabled' => false,
|
||||
'minio_enabled' => false,
|
||||
'max_status_age_seconds' => self::DEFAULT_MAX_STATUS_AGE_SECONDS,
|
||||
];
|
||||
}
|
||||
|
||||
public static function normalizeConfig(array $config): array
|
||||
{
|
||||
$normalized = self::configDefaults();
|
||||
foreach (['enabled', 'database_enabled', 'redis_enabled', 'minio_enabled'] as $key) {
|
||||
if (array_key_exists($key, $config)) {
|
||||
$normalized[$key] = self::boolValue($config[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('max_status_age_seconds', $config)) {
|
||||
$normalized['max_status_age_seconds'] = max(1, (int)$config['max_status_age_seconds']);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
public static function kindEnabled(array $config, string $kind): bool
|
||||
{
|
||||
$config = self::normalizeConfig($config);
|
||||
return $config['enabled'] && !empty($config[$kind . '_enabled']);
|
||||
}
|
||||
|
||||
public static function snapshotHostIsStrictlyFresh(array $host, int $maxAgeSeconds, ?int $now = null): bool
|
||||
{
|
||||
if (($host['role'] ?? '') !== 'replica') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($host['deleted_at'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = self::hostStatus($host);
|
||||
if (($status['status'] ?? '') !== 'ok') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (round((float)($status['replication_percent'] ?? 0), 2) < 100.0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$blockers = $status['blockers'] ?? [];
|
||||
if (is_array($blockers) && $blockers !== []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$checkedAt = self::hostCheckedAt($host, $status);
|
||||
if ($checkedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (($now ?? time()) - $checkedAt) <= max(1, $maxAgeSeconds);
|
||||
}
|
||||
|
||||
public static function snapshotFailoverCandidate(array $hosts, string $kind, int $maxAgeSeconds, ?int $now = null): ?array
|
||||
{
|
||||
$eligible = array_values(array_filter(
|
||||
$hosts,
|
||||
static fn(array $host): bool => ($host['kind'] ?? '') === $kind
|
||||
&& self::snapshotHostIsStrictlyFresh($host, $maxAgeSeconds, $now)
|
||||
));
|
||||
|
||||
if ($eligible === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($eligible, static function (array $a, array $b) use ($now): int {
|
||||
$aChecked = self::hostCheckedAt($a, self::hostStatus($a)) ?? 0;
|
||||
$bChecked = self::hostCheckedAt($b, self::hostStatus($b)) ?? 0;
|
||||
if ($aChecked === $bChecked) {
|
||||
return (int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0);
|
||||
}
|
||||
return $bChecked <=> $aChecked;
|
||||
});
|
||||
|
||||
return $eligible[0];
|
||||
}
|
||||
|
||||
public static function activeConfigFromHost(string $kind, array $host): ?array
|
||||
{
|
||||
if ($kind === self::KIND_DATABASE) {
|
||||
$database = trim((string)($host['database_name'] ?? $host['database'] ?? ''));
|
||||
$user = trim((string)($host['username'] ?? $host['user'] ?? ''));
|
||||
if ($database === '' || $user === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => isset($host['id']) ? (int)$host['id'] : null,
|
||||
'host' => (string)($host['host'] ?? ''),
|
||||
'port' => (int)($host['port'] ?? 3306) ?: 3306,
|
||||
'database' => $database,
|
||||
'user' => $user,
|
||||
'password_secret' => (string)($host['password_secret'] ?? ''),
|
||||
'ssl_mode' => (string)($host['ssl_mode'] ?? 'DISABLED'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($kind === self::KIND_REDIS) {
|
||||
return [
|
||||
'id' => isset($host['id']) ? (int)$host['id'] : null,
|
||||
'host' => (string)($host['host'] ?? ''),
|
||||
'port' => (int)($host['port'] ?? 6379) ?: 6379,
|
||||
'database' => (int)($host['database_index'] ?? $host['database'] ?? 0),
|
||||
'user' => (string)($host['username'] ?? $host['user'] ?? ''),
|
||||
'password_secret' => (string)($host['password_secret'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
if ($kind === self::KIND_MINIO) {
|
||||
$options = self::jsonDecode($host['options_json'] ?? null);
|
||||
return [
|
||||
'id' => isset($host['id']) ? (int)$host['id'] : null,
|
||||
'endpoint' => self::minioEndpoint($host, $options),
|
||||
'access_key' => (string)($host['username'] ?? $host['access_key'] ?? ''),
|
||||
'secret_key_secret' => (string)($host['password_secret'] ?? ''),
|
||||
'buckets' => is_array($options['buckets'] ?? null) ? array_values($options['buckets']) : [],
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function applyStartupFailoverFromSnapshot(?string $path = null, array $probes = []): array
|
||||
{
|
||||
$snapshot = replication_bootstrap_config::loadSnapshot($path);
|
||||
$failover = is_array($snapshot['failover'] ?? null) ? $snapshot['failover'] : [];
|
||||
$config = self::normalizeConfig(is_array($failover['config'] ?? null) ? $failover['config'] : $failover);
|
||||
$active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : [];
|
||||
$hostGroups = is_array($failover['hosts'] ?? null) ? $failover['hosts'] : [];
|
||||
$maxAgeSeconds = (int)$config['max_status_age_seconds'];
|
||||
$summary = [];
|
||||
$changed = false;
|
||||
|
||||
$primaryDown = $probes['primary_down'] ?? [self::class, 'activePrimaryIsDown'];
|
||||
$candidateReachable = $probes['candidate_reachable'] ?? [self::class, 'candidateReachable'];
|
||||
$promoteCandidate = $probes['promote_candidate'] ?? [self::class, 'promoteCandidate'];
|
||||
|
||||
foreach ([self::KIND_DATABASE, self::KIND_REDIS, self::KIND_MINIO] as $kind) {
|
||||
if (!self::kindEnabled($config, $kind)) {
|
||||
$summary[$kind] = ['status' => 'skipped', 'reason' => 'disabled'];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_array($active[$kind] ?? null)) {
|
||||
$summary[$kind] = ['status' => 'skipped', 'reason' => 'missing_active_primary'];
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!call_user_func($primaryDown, $kind, $active[$kind], $snapshot)) {
|
||||
$summary[$kind] = ['status' => 'skipped', 'reason' => 'primary_healthy'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$hosts = is_array($hostGroups[$kind] ?? null) ? $hostGroups[$kind] : [];
|
||||
$candidate = self::snapshotFailoverCandidate($hosts, $kind, $maxAgeSeconds);
|
||||
if ($candidate === null) {
|
||||
$summary[$kind] = ['status' => 'skipped', 'reason' => 'no_fresh_caught_up_replica'];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!call_user_func($candidateReachable, $kind, $candidate)) {
|
||||
$summary[$kind] = [
|
||||
'status' => 'skipped',
|
||||
'reason' => 'candidate_unreachable',
|
||||
'candidate_id' => (int)($candidate['id'] ?? 0),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
call_user_func($promoteCandidate, $kind, $candidate);
|
||||
$candidateActive = self::activeConfigFromHost($kind, $candidate);
|
||||
if ($candidateActive === null) {
|
||||
$summary[$kind] = [
|
||||
'status' => 'skipped',
|
||||
'reason' => 'candidate_missing_active_config',
|
||||
'candidate_id' => (int)($candidate['id'] ?? 0),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot['active'][$kind] = $candidateActive;
|
||||
$pending = is_array($snapshot['pending_failovers'] ?? null) ? $snapshot['pending_failovers'] : [];
|
||||
$pending[] = [
|
||||
'kind' => $kind,
|
||||
'host_id' => (int)($candidate['id'] ?? 0),
|
||||
'label' => (string)($candidate['label'] ?? ''),
|
||||
'source' => 'startup_snapshot',
|
||||
'promoted_at' => date('c'),
|
||||
];
|
||||
$snapshot['pending_failovers'] = $pending;
|
||||
$summary[$kind] = [
|
||||
'status' => 'promoted',
|
||||
'candidate_id' => (int)($candidate['id'] ?? 0),
|
||||
];
|
||||
$changed = true;
|
||||
} catch (Throwable $throwable) {
|
||||
$summary[$kind] = [
|
||||
'status' => 'failed',
|
||||
'reason' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$snapshot['generated_at'] = date('c');
|
||||
replication_bootstrap_config::writeSnapshot($snapshot, $path);
|
||||
if ($path === null) {
|
||||
replication_bootstrap_config::applyToGlobals($snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'changed' => $changed,
|
||||
'results' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
public static function activePrimaryIsDown(string $kind, array $activeConfig, array $snapshot = []): bool
|
||||
{
|
||||
try {
|
||||
match ($kind) {
|
||||
self::KIND_DATABASE => self::probeActiveDatabase($activeConfig),
|
||||
self::KIND_REDIS => self::probeActiveRedis($activeConfig),
|
||||
self::KIND_MINIO => self::probeActiveMinio($activeConfig),
|
||||
default => null,
|
||||
};
|
||||
return false;
|
||||
} catch (Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static function candidateReachable(string $kind, array $host): bool
|
||||
{
|
||||
try {
|
||||
match ($kind) {
|
||||
self::KIND_DATABASE => self::probeHostDatabase($host),
|
||||
self::KIND_REDIS => self::probeHostRedis($host),
|
||||
self::KIND_MINIO => self::probeHostMinio($host),
|
||||
default => null,
|
||||
};
|
||||
return true;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function promoteCandidate(string $kind, array $host): void
|
||||
{
|
||||
match ($kind) {
|
||||
self::KIND_DATABASE => self::promoteDatabaseCandidate($host),
|
||||
self::KIND_REDIS => self::promoteRedisCandidate($host),
|
||||
self::KIND_MINIO => self::probeHostMinio($host),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function probeActiveDatabase(array $config): void
|
||||
{
|
||||
$host = (string)($config['host'] ?? '');
|
||||
$user = (string)($config['user'] ?? '');
|
||||
$database = (string)($config['database'] ?? '');
|
||||
$password = self::activePassword($config, 'password_secret', 'password');
|
||||
self::connectMysqli($host, $user, $password, $database, (int)($config['port'] ?? 3306))->close();
|
||||
}
|
||||
|
||||
private static function probeHostDatabase(array $host): void
|
||||
{
|
||||
$credentials = self::hostCredentials($host);
|
||||
$connection = self::connectMysqli(
|
||||
(string)($host['host'] ?? ''),
|
||||
$credentials['username'],
|
||||
$credentials['password'],
|
||||
(string)($host['database_name'] ?? ''),
|
||||
(int)($host['port'] ?? 3306)
|
||||
);
|
||||
$connection->close();
|
||||
}
|
||||
|
||||
private static function promoteDatabaseCandidate(array $host): void
|
||||
{
|
||||
$credentials = self::hostCredentials($host);
|
||||
$user = $credentials['admin_username'] !== '' ? $credentials['admin_username'] : $credentials['username'];
|
||||
$password = $credentials['admin_password'] !== '' ? $credentials['admin_password'] : $credentials['password'];
|
||||
$connection = self::connectMysqli(
|
||||
(string)($host['host'] ?? ''),
|
||||
$user,
|
||||
$password,
|
||||
(string)($host['database_name'] ?? ''),
|
||||
(int)($host['port'] ?? 3306)
|
||||
);
|
||||
|
||||
try {
|
||||
foreach (['STOP REPLICA', 'STOP SLAVE'] as $statement) {
|
||||
try {
|
||||
$connection->query($statement);
|
||||
break;
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
foreach (['SET GLOBAL super_read_only = OFF', 'SET GLOBAL read_only = OFF'] as $statement) {
|
||||
try {
|
||||
$connection->query($statement);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$connection->close();
|
||||
}
|
||||
}
|
||||
|
||||
private static function probeActiveRedis(array $config): void
|
||||
{
|
||||
self::redisClientFromConfig([
|
||||
'host' => (string)($config['host'] ?? ''),
|
||||
'port' => (int)($config['port'] ?? 6379),
|
||||
'database' => (int)($config['database'] ?? 0),
|
||||
'user' => (string)($config['user'] ?? ''),
|
||||
'password' => self::activePassword($config, 'password_secret', 'password'),
|
||||
])->ping();
|
||||
}
|
||||
|
||||
private static function probeHostRedis(array $host): void
|
||||
{
|
||||
self::redisClientFromHost($host)->ping();
|
||||
}
|
||||
|
||||
private static function promoteRedisCandidate(array $host): void
|
||||
{
|
||||
$client = self::redisClientFromHost($host);
|
||||
$client->executeRaw(['REPLICAOF', 'NO', 'ONE']);
|
||||
try {
|
||||
$client->executeRaw(['CONFIG', 'REWRITE']);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private static function probeActiveMinio(array $config): void
|
||||
{
|
||||
self::minioClientFromConfig([
|
||||
'endpoint' => (string)($config['endpoint'] ?? ''),
|
||||
'access_key' => (string)($config['access_key'] ?? $config['user'] ?? ''),
|
||||
'secret_key' => self::activePassword($config, 'secret_key_secret', 'secret_key'),
|
||||
])->listBuckets();
|
||||
}
|
||||
|
||||
private static function probeHostMinio(array $host): void
|
||||
{
|
||||
self::minioClientFromHost($host)->listBuckets();
|
||||
}
|
||||
|
||||
private static function connectMysqli(string $host, string $user, string $password, string $database, int $port): mysqli
|
||||
{
|
||||
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
||||
$connection = mysqli_init();
|
||||
$connection->options(MYSQLI_OPT_CONNECT_TIMEOUT, 2);
|
||||
$connection->real_connect($host, $user, $password, $database, $port ?: 3306);
|
||||
$connection->set_charset('utf8mb4');
|
||||
return $connection;
|
||||
}
|
||||
|
||||
private static function redisClientFromHost(array $host): PredisClient
|
||||
{
|
||||
$credentials = self::hostCredentials($host);
|
||||
return self::redisClientFromConfig([
|
||||
'host' => (string)($host['host'] ?? ''),
|
||||
'port' => (int)($host['port'] ?? 6379),
|
||||
'database' => (int)($host['database_index'] ?? 0),
|
||||
'user' => $credentials['username'],
|
||||
'password' => $credentials['password'],
|
||||
]);
|
||||
}
|
||||
|
||||
private static function redisClientFromConfig(array $config): PredisClient
|
||||
{
|
||||
$params = [
|
||||
'scheme' => 'tcp',
|
||||
'host' => (string)$config['host'],
|
||||
'port' => (int)$config['port'],
|
||||
'database' => (int)$config['database'],
|
||||
'password' => (string)$config['password'],
|
||||
'timeout' => 2.0,
|
||||
'read_write_timeout' => 2.0,
|
||||
];
|
||||
if (($config['user'] ?? '') !== '' && $config['user'] !== 'default') {
|
||||
$params['username'] = (string)$config['user'];
|
||||
}
|
||||
return new PredisClient($params);
|
||||
}
|
||||
|
||||
private static function minioClientFromHost(array $host): S3Client
|
||||
{
|
||||
$credentials = self::hostCredentials($host);
|
||||
$options = self::jsonDecode($host['options_json'] ?? null);
|
||||
return self::minioClientFromConfig([
|
||||
'endpoint' => self::minioEndpoint($host, $options),
|
||||
'access_key' => $credentials['username'],
|
||||
'secret_key' => $credentials['password'],
|
||||
]);
|
||||
}
|
||||
|
||||
private static function minioClientFromConfig(array $config): S3Client
|
||||
{
|
||||
return new S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => 'us-east-1',
|
||||
'endpoint' => (string)$config['endpoint'],
|
||||
'use_path_style_endpoint' => true,
|
||||
'credentials' => [
|
||||
'key' => (string)$config['access_key'],
|
||||
'secret' => (string)$config['secret_key'],
|
||||
],
|
||||
'http' => [
|
||||
'connect_timeout' => 2,
|
||||
'timeout' => 2,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private static function minioEndpoint(array $host, array $options): string
|
||||
{
|
||||
$endpoint = trim((string)($options['endpoint'] ?? ''));
|
||||
if ($endpoint !== '') {
|
||||
return $endpoint;
|
||||
}
|
||||
|
||||
$scheme = strtolower(trim((string)($options['scheme'] ?? 'http')));
|
||||
if ($scheme !== 'https') {
|
||||
$scheme = 'http';
|
||||
}
|
||||
|
||||
return $scheme . '://' . (string)($host['host'] ?? '') . ':' . ((int)($host['port'] ?? 9000) ?: 9000);
|
||||
}
|
||||
|
||||
private static function hostCredentials(array $host): array
|
||||
{
|
||||
return [
|
||||
'username' => (string)($host['username'] ?? ''),
|
||||
'password' => replication_secret_box::decrypt($host['password_secret'] ?? ''),
|
||||
'admin_username' => (string)($host['admin_username'] ?? ''),
|
||||
'admin_password' => replication_secret_box::decrypt($host['admin_password_secret'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private static function activePassword(array $config, string $secretKey, string $plainKey): string
|
||||
{
|
||||
if (!empty($config[$secretKey])) {
|
||||
return replication_secret_box::decrypt((string)$config[$secretKey]);
|
||||
}
|
||||
|
||||
return (string)($config[$plainKey] ?? '');
|
||||
}
|
||||
|
||||
private static function hostStatus(array $host): array
|
||||
{
|
||||
if (isset($host['last_status']) && is_array($host['last_status'])) {
|
||||
return $host['last_status'];
|
||||
}
|
||||
|
||||
return self::jsonDecode($host['last_status_json'] ?? null);
|
||||
}
|
||||
|
||||
private static function hostCheckedAt(array $host, array $status): ?int
|
||||
{
|
||||
$raw = $host['last_checked_at'] ?? $status['checked_at'] ?? null;
|
||||
if (!is_string($raw) || trim($raw) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timestamp = strtotime($raw);
|
||||
return $timestamp === false ? null : $timestamp;
|
||||
}
|
||||
|
||||
private static function boolValue(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private static function jsonDecode(mixed $value): array
|
||||
{
|
||||
if (!is_string($value) || trim($value) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user