Integrate Coolify API client and module for managing Coolify services, enhancing automation and deployment processes.

This commit is contained in:
Jeppe Bundgaard
2026-05-19 13:17:07 +02:00
parent ab31cd6dbb
commit 00a8723347
69 changed files with 16632 additions and 215 deletions
+24
View File
@@ -57,6 +57,30 @@ jobs:
cache: npm
cache-dependency-path: services/edge-agent/package-lock.json
- name: Install native build tools
run: |
set -euo pipefail
if command -v make >/dev/null 2>&1 && command -v g++ >/dev/null 2>&1; then
exit 0
fi
if ! command -v apt-get >/dev/null 2>&1; then
echo "make and g++ are required to install node-pty, but apt-get is not available on this runner." >&2
exit 1
fi
apt_cmd=(apt-get)
if [ "$(id -u)" -ne 0 ]; then
if ! command -v sudo >/dev/null 2>&1; then
echo "make and g++ are missing, and sudo is not available to install them." >&2
exit 1
fi
apt_cmd=(sudo apt-get)
fi
"${apt_cmd[@]}" update
"${apt_cmd[@]}" install -y --no-install-recommends build-essential python3
- name: Install dependencies
working-directory: services/edge-agent
run: npm ci
+15 -1
View File
@@ -96,6 +96,13 @@ services:
- "traefik.http.routers.edge-broker-api-io.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-io.priority=200"
- "traefik.http.routers.edge-broker-api-io.service=edge-broker"
- "traefik.http.routers.edge-broker-api-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-v2.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api-v2.tls=true"
- "traefik.http.routers.edge-broker-api-v2.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-v2.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-v2.priority=200"
- "traefik.http.routers.edge-broker-api-v2.service=edge-broker"
- "traefik.http.routers.edge-broker-api-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-api-staging.tls=true"
@@ -153,7 +160,14 @@ services:
- "traefik.http.routers.api-io.tls.certresolver=le_io"
- "traefik.http.routers.api-io.service=caddy"
- "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`)"
- "traefik.http.routers.api-v2.rule=Host(`api-v2.truckwash.io`)"
- "traefik.http.routers.api-v2.entrypoints=websecure"
- "traefik.http.routers.api-v2.tls=true"
- "traefik.http.routers.api-v2.tls.domains[0].main=api-v2.truckwash.io"
- "traefik.http.routers.api-v2.tls.certresolver=le_io"
- "traefik.http.routers.api-v2.service=caddy"
- "traefik.http.routers.api-v2.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`)"
- "traefik.http.routers.api-http.entrypoints=web"
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.api-http.service=caddy"
+1 -1
View File
@@ -27,5 +27,5 @@ services:
## docker compose up -d traefik caddy php1 php2 php3 php4 php5 db redis
##
## Notes:
## - Traefik uses Lets Encrypt production. Ensure DNS A/AAAA records for api.truckwash.dk, api.truckwash.io and traefik.truckwash.dk point to this host and ports 80/443 are reachable.
## - Traefik uses Lets Encrypt production. Ensure DNS A/AAAA records for api.truckwash.dk, api.truckwash.io, api-v2.truckwash.io and traefik.truckwash.dk point to the expected ingress and ports 80/443 are reachable.
## - The dashboard is protected by basic auth and an IP allowlist (defined in dynamic.yml). Replace the bcrypt hash before enabling in production.
+23 -1
View File
@@ -49,6 +49,13 @@ services:
- "traefik.http.routers.edge-broker-io.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-io.priority=200"
- "traefik.http.routers.edge-broker-io.service=edge-broker"
- "traefik.http.routers.edge-broker-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-v2.entrypoints=websecure"
- "traefik.http.routers.edge-broker-v2.tls=true"
- "traefik.http.routers.edge-broker-v2.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-v2.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-v2.priority=200"
- "traefik.http.routers.edge-broker-v2.service=edge-broker"
- "traefik.http.routers.edge-broker-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-staging.tls=true"
@@ -133,6 +140,13 @@ services:
- "traefik.http.routers.edge-broker-api-io.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-io.priority=200"
- "traefik.http.routers.edge-broker-api-io.service=edge-broker"
- "traefik.http.routers.edge-broker-api-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-v2.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api-v2.tls=true"
- "traefik.http.routers.edge-broker-api-v2.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-v2.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-v2.priority=200"
- "traefik.http.routers.edge-broker-api-v2.service=edge-broker"
- "traefik.http.routers.edge-broker-api-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-api-staging.tls=true"
@@ -192,8 +206,16 @@ services:
- "traefik.http.routers.api-io.tls.certresolver=le_io"
- "traefik.http.routers.api-io.service=caddy"
- "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file"
# Public API (.io load-balanced gateway)
- "traefik.http.routers.api-v2.rule=Host(`api-v2.truckwash.io`)"
- "traefik.http.routers.api-v2.entrypoints=websecure"
- "traefik.http.routers.api-v2.tls=true"
- "traefik.http.routers.api-v2.tls.domains[0].main=api-v2.truckwash.io"
- "traefik.http.routers.api-v2.tls.certresolver=le_io"
- "traefik.http.routers.api-v2.service=caddy"
- "traefik.http.routers.api-v2.middlewares=secure-headers@file,api-ratelimit@file"
# HTTP to HTTPS redirect for both API domains
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`)"
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`)"
- "traefik.http.routers.api-http.entrypoints=web"
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.api-http.service=caddy"
+2 -2
View File
@@ -38,14 +38,14 @@ http {
location / {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
add_header Access-Control-Allow-Credentials true;
# If OPTIONS method is needed for preflight
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
return 204; # No Content
}
+2 -2
View File
@@ -52,14 +52,14 @@ http {
location / {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
add_header Access-Control-Allow-Credentials true;
# If OPTIONS method is needed for preflight
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
return 204; # No Content
}
+332
View File
@@ -72,6 +72,8 @@ tags:
description: Form submissions and management
- name: Worker
description: System worker status and maintenance
- name: Error Reports
description: Authenticated application error reporting
- name: Plate Scans
description: License plate scanning operations
- name: Config
@@ -90,6 +92,160 @@ tags:
description: Voice Calls via Bird
paths:
/error-reports:
post:
tags:
- Error Reports
summary: Submit an authenticated user error report
operationId: submitErrorReport
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReportSubmissionRequest'
responses:
'201':
description: Error report submitted
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReportResponse'
'400':
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Authentication required
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/superuser/error-reports:
get:
tags:
- Error Reports
summary: List error reports for superusers
operationId: listSuperuserErrorReports
security:
- BearerAuth: []
parameters:
- name: status
in: query
required: false
schema:
type: string
enum: [open, resolved, all]
default: open
- name: q
in: query
required: false
schema:
type: string
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 50
- name: offset
in: query
required: false
schema:
type: integer
minimum: 0
default: 0
responses:
'200':
description: Error reports retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReportListResponse'
'403':
description: Missing superuser error report permission
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/superuser/error-reports/{id}:
get:
tags:
- Error Reports
summary: Get an error report detail
operationId: getSuperuserErrorReport
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
minimum: 1
responses:
'200':
description: Error report retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReportResponse'
'404':
description: Error report not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/superuser/error-reports/{id}/status:
patch:
tags:
- Error Reports
summary: Mark an error report open or resolved
operationId: updateSuperuserErrorReportStatus
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
minimum: 1
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReportStatusUpdateRequest'
responses:
'200':
description: Error report status updated
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReportResponse'
'400':
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: Missing superuser error report resolve permission
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# Bird Voice Calls
/bird/voice/calls:
post:
@@ -11939,6 +12095,182 @@ components:
type: integer
description: HTTP status code
ErrorReportSubmissionRequest:
type: object
required:
- before_error
- expected
- actual
- data_collection_accepted
- screenshot
properties:
before_error:
type: string
maxLength: 4000
description: What the user was doing before the error occurred
expected:
type: string
maxLength: 4000
description: What the user expected would happen
actual:
type: string
maxLength: 4000
description: What actually happened
data_collection_accepted:
type: boolean
description: Required acceptance of collecting screenshot and diagnostic error data
screenshot:
type: string
description: PNG, JPEG, or WebP data URI of the current app viewport
route_path:
type: string
nullable: true
page_url:
type: string
nullable: true
release_trace_id:
type: string
nullable: true
request_errors:
type: array
items:
type: object
additionalProperties: true
vue_errors:
type: array
items:
type: object
additionalProperties: true
context:
type: object
additionalProperties: true
ErrorReportStatusUpdateRequest:
type: object
required:
- status
properties:
status:
type: string
enum: [open, resolved]
resolution_note:
type: string
nullable: true
maxLength: 2000
ErrorReportResponse:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/ErrorReport'
meta:
type: object
additionalProperties: true
includes:
type: object
additionalProperties: true
ErrorReportListResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/ErrorReport'
counts:
type: object
properties:
open:
type: integer
resolved:
type: integer
all:
type: integer
limit:
type: integer
offset:
type: integer
meta:
type: object
additionalProperties: true
includes:
type: object
additionalProperties: true
ErrorReport:
type: object
properties:
id:
type: integer
status:
type: string
enum: [open, resolved]
reporter:
type: object
additionalProperties: true
route_path:
type: string
nullable: true
page_url:
type: string
nullable: true
release_trace_id:
type: string
nullable: true
frontend_version:
type: string
nullable: true
api_version:
type: string
nullable: true
screenshot:
type: object
additionalProperties: true
answers:
type: object
properties:
before_error:
type: string
expected:
type: string
actual:
type: string
request_error_count:
type: integer
vue_error_count:
type: integer
request_errors:
type: array
items:
type: object
additionalProperties: true
vue_errors:
type: array
items:
type: object
additionalProperties: true
runtime_context:
type: object
additionalProperties: true
resolved_at:
type: string
nullable: true
resolved_by_user_id:
type: integer
nullable: true
created_at:
type: string
updated_at:
type: string
nullable: true
SuperuserSystemStatusResponse:
type: object
properties:
@@ -45,6 +45,7 @@ test("base docker compose routes edge broker traffic through traefik", () => {
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-v2\.rule=Host\(`api-v2\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip\.stripPrefix\.prefixes=\/edge-broker/);
assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip-local\.stripPrefix\.prefixes=\/api\/edge-broker/);
@@ -70,6 +71,7 @@ test("standalone production compose routes edge broker traffic through traefik",
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-v2\.rule=Host\(`api-v2\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
});
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace classes;
require_once WD . '/interfaces/universal_module_i.php';
require_once WD . '/modules/coolify/coolify_c.php';
use Exception;
use interfaces\universal_module_i;
use modules\coolify\coolify_c;
class coolify implements universal_module_i
{
public coolify_c $config;
public function __construct()
{
$this->config = new coolify_c();
}
/**
* @throws Exception
*/
public function requireModuleEnabled(): void
{
if (!$this->config->enabled->isTrue()) {
throw new Exception('The Coolify module is not enabled');
}
}
public function isEnabled(): bool
{
try {
return $this->config->enabled->isTrue();
} catch (Exception) {
return false;
}
}
}
@@ -0,0 +1,215 @@
<?php
namespace classes;
use RuntimeException;
class coolify_api_client
{
private string $baseUrl;
private string $token;
private int $timeoutSeconds;
public function __construct(string $baseUrl, string $token, int $timeoutSeconds = 4)
{
$this->baseUrl = self::normalizeBaseUrl($baseUrl);
$this->token = trim($token);
$this->timeoutSeconds = max(1, $timeoutSeconds);
if ($this->baseUrl === '' || $this->token === '') {
throw new RuntimeException('Coolify base URL and API token are required.');
}
}
public static function normalizeBaseUrl(string $baseUrl): string
{
$baseUrl = rtrim(trim($baseUrl), '/');
if ($baseUrl === '') {
return '';
}
if (preg_match('#/api/v[0-9]+$#i', $baseUrl) === 1) {
return $baseUrl;
}
return $baseUrl . '/api/v1';
}
public function healthcheck(): array
{
return $this->request('GET', '/health', null, false);
}
public function version(): array
{
return $this->request('GET', '/version');
}
public function listServers(): array
{
return $this->request('GET', '/servers');
}
public function listProjects(): array
{
return $this->request('GET', '/projects');
}
public function listProjectEnvironments(string $projectUuid): array
{
return $this->request('GET', '/projects/' . rawurlencode($projectUuid) . '/environments');
}
public function listServices(): array
{
return $this->request('GET', '/services');
}
public function getService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid));
}
public function createService(array $payload): array
{
return $this->request('POST', '/services', $payload);
}
public function updateService(string $uuid, array $payload): array
{
return $this->request('PATCH', '/services/' . rawurlencode($uuid), $payload);
}
public function updateServiceEnvsBulk(string $uuid, array $env): array
{
$data = [];
foreach ($env as $key => $value) {
$data[] = [
'key' => (string)$key,
'value' => (string)$value,
'is_preview' => false,
'is_literal' => true,
'is_multiline' => str_contains((string)$value, "\n"),
'is_shown_once' => false,
];
}
if ($data === []) {
return [];
}
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', ['data' => $data]);
}
public function startService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/start');
}
public function restartService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/restart');
}
public function deleteService(string $uuid): array
{
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
}
public function listDeployments(): array
{
return $this->request('GET', '/deployments');
}
protected function request(string $method, string $path, ?array $payload = null, bool $versionedApi = true): array
{
$url = ($versionedApi ? $this->baseUrl : $this->apiRootUrl()) . '/' . ltrim($path, '/');
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Could not initialize Coolify API request.');
}
$headers = [
'Accept: application/json',
'Authorization: Bearer ' . $this->token,
];
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method));
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, min(2, $this->timeoutSeconds));
curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeoutSeconds);
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
if ($payload !== null) {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($body === false) {
throw new RuntimeException('Could not encode Coolify API payload.');
}
$headers[] = 'Content-Type: application/json';
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
}
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
$raw = curl_exec($curl);
$error = curl_error($curl);
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($raw === false) {
throw new RuntimeException('Coolify API request failed: ' . $error);
}
$decoded = null;
if (trim((string)$raw) !== '') {
$decoded = json_decode((string)$raw, true);
if (!is_array($decoded)) {
$decoded = ['raw' => (string)$raw];
}
}
if ($status < 200 || $status >= 300) {
$message = is_array($decoded)
? (string)($decoded['message'] ?? $decoded['error'] ?? ('HTTP ' . $status))
: ('HTTP ' . $status);
if (is_array($decoded)) {
$details = self::validationErrorSummary($decoded);
if ($details !== '') {
$message .= ': ' . $details;
}
}
throw new RuntimeException('Coolify API request failed: ' . $message);
}
return is_array($decoded) ? $decoded : [];
}
private static function validationErrorSummary(array $decoded): string
{
$errors = $decoded['errors'] ?? $decoded['data']['errors'] ?? null;
if (!is_array($errors)) {
return '';
}
$parts = [];
foreach ($errors as $field => $messages) {
$fieldName = trim((string)$field);
$fieldPrefix = $fieldName !== '' ? $fieldName . ': ' : '';
if (is_array($messages)) {
$messages = implode(', ', array_filter(array_map(static fn(mixed $message): string => trim((string)$message), $messages)));
} else {
$messages = trim((string)$messages);
}
if ($messages !== '') {
$parts[] = $fieldPrefix . $messages;
}
}
return implode('; ', array_slice($parts, 0, 5));
}
private function apiRootUrl(): string
{
return preg_replace('#/v[0-9]+$#i', '', $this->baseUrl) ?: $this->baseUrl;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,236 @@
<?php
namespace classes;
/**
* Additive schema bootstrap for the Coolify infrastructure integration.
*
* The legacy stack has no centralized migration runner, so this class must be
* safe to call from request handlers, cron, and tests.
*/
class coolify_schema_bootstrap
{
private static bool $initialized = false;
private static ?bool $tablesExist = null;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$queries = [
"CREATE TABLE IF NOT EXISTS coolify_instances (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
label VARCHAR(128) NOT NULL,
base_url VARCHAR(512) NOT NULL,
api_token_secret TEXT NOT NULL,
default_project_uuid VARCHAR(128) NULL,
default_environment_uuid VARCHAR(128) NULL,
default_environment_name VARCHAR(128) NULL,
default_server_uuid VARCHAR(128) NULL,
default_destination_uuid VARCHAR(128) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
last_checked_at DATETIME NULL,
last_error TEXT 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_coolify_instances_status (status),
INDEX idx_coolify_instances_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS coolify_targets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
instance_id BIGINT UNSIGNED NOT NULL,
replication_host_id BIGINT UNSIGNED NULL,
kind VARCHAR(16) NOT NULL,
label VARCHAR(128) NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'replica',
server_uuid VARCHAR(128) NULL,
project_uuid VARCHAR(128) NULL,
environment_uuid VARCHAR(128) NULL,
environment_name VARCHAR(128) NULL,
destination_uuid VARCHAR(128) NULL,
resource_uuid VARCHAR(128) NULL,
resource_type VARCHAR(32) NOT NULL DEFAULT 'service',
resource_name VARCHAR(128) NULL,
deployment_status VARCHAR(32) NOT NULL DEFAULT 'pending',
availability_state VARCHAR(32) NOT NULL DEFAULT 'degraded',
desired_compose_hash CHAR(64) NULL,
last_reconcile_status VARCHAR(32) NULL,
last_reconcile_json LONGTEXT NULL,
last_reconciled_at DATETIME NULL,
options_json LONGTEXT 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_coolify_targets_instance (instance_id),
INDEX idx_coolify_targets_replication_host (replication_host_id),
INDEX idx_coolify_targets_kind_status (kind, deployment_status),
INDEX idx_coolify_targets_availability (availability_state),
INDEX idx_coolify_targets_resource_uuid (resource_uuid),
INDEX idx_coolify_targets_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS coolify_operations (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
target_id BIGINT UNSIGNED NULL,
instance_id BIGINT UNSIGNED NULL,
operation VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'running',
guarded TINYINT(1) NOT NULL DEFAULT 1,
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_coolify_operations_target (target_id),
INDEX idx_coolify_operations_instance (instance_id),
INDEX idx_coolify_operations_status (status),
INDEX idx_coolify_operations_operation (operation)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS coolify_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
target_id BIGINT UNSIGNED NULL,
instance_id BIGINT UNSIGNED NULL,
replication_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_coolify_audit_target (target_id),
INDEX idx_coolify_audit_instance (instance_id),
INDEX idx_coolify_audit_replication_host (replication_host_id),
INDEX idx_coolify_audit_action (action),
INDEX idx_coolify_audit_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS coolify_instance_gateways (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
instance_id BIGINT UNSIGNED NULL,
hostname VARCHAR(255) NOT NULL,
target_ip VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
priority INT NOT NULL DEFAULT 100,
health_state VARCHAR(32) NOT NULL DEFAULT 'unknown',
lb_state VARCHAR(32) NOT NULL DEFAULT 'unknown',
last_probe_json LONGTEXT NULL,
last_probed_at DATETIME NULL,
last_reconciled_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,
UNIQUE KEY uniq_coolify_instance_gateways_target_ip (target_ip),
INDEX idx_coolify_instance_gateways_instance (instance_id),
INDEX idx_coolify_instance_gateways_enabled (enabled),
INDEX idx_coolify_instance_gateways_lb_state (lb_state),
INDEX idx_coolify_instance_gateways_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
$db->query($sql);
}
self::ensureColumn('coolify_instances', 'default_destination_uuid', 'VARCHAR(128) NULL');
self::ensureColumn('coolify_targets', 'availability_state', "VARCHAR(32) NOT NULL DEFAULT 'degraded'");
self::ensureColumn('coolify_targets', 'desired_compose_hash', 'CHAR(64) NULL');
self::ensureColumn('coolify_targets', 'last_reconcile_json', 'LONGTEXT NULL');
self::ensureColumn('coolify_operations', 'guarded', 'TINYINT(1) NOT NULL DEFAULT 1');
self::ensureColumn('coolify_instance_gateways', 'last_reconciled_at', 'DATETIME NULL');
self::ensureModuleConfigDefault('Coolify', 'enabled', 'false', 'bool');
self::ensureModuleConfigDefault('Coolify', 'lb_automation_enabled', 'false', 'bool');
self::ensureModuleConfigDefault('Coolify', 'lb_automation_mode', 'report_only', 'string');
self::ensureModuleConfigDefault('Coolify', 'hetzner_load_balancer_id', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', 'string');
self::ensureDefaultGateway('node1.truckwash.io', '94.130.142.41', 10);
self::ensureDefaultGateway('node2.truckwash.io', '65.21.214.30', 20);
self::ensureDefaultGateway('node3.truckwash.io', '23.88.23.183', 30);
self::$initialized = true;
self::$tablesExist = true;
}
public static function tablesExist(): bool
{
if (self::$tablesExist !== null) {
return self::$tablesExist;
}
global $db;
foreach (['coolify_instances', 'coolify_targets', 'coolify_operations', 'coolify_audit_logs', 'coolify_instance_gateways'] as $table) {
$tableSql = $db->escape_string($table);
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
if ($result === false || $result->num_rows === 0) {
self::$tablesExist = false;
return false;
}
}
self::$tablesExist = true;
return self::$tablesExist;
}
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");
}
private static function ensureModuleConfigDefault(string $module, string $variable, string $value, string $type): void
{
global $db;
$moduleSql = $db->escape_string($module);
$variableSql = $db->escape_string($variable);
$result = $db->query("SELECT value FROM module_config WHERE module = '$moduleSql' AND variable = '$variableSql' LIMIT 1");
if ($result !== false && $result->num_rows > 0) {
return;
}
$valueSql = $db->escape_string($value);
$typeSql = $db->escape_string($type);
$db->query("INSERT INTO module_config (module, variable, value, type) VALUES ('$moduleSql', '$variableSql', '$valueSql', '$typeSql')");
}
private static function ensureDefaultGateway(string $hostname, string $targetIp, int $priority): void
{
global $db;
$targetIpSql = $db->escape_string($targetIp);
$result = $db->query("SELECT id FROM coolify_instance_gateways WHERE target_ip = '$targetIpSql' LIMIT 1");
if ($result !== false && $result->num_rows > 0) {
return;
}
$hostnameSql = $db->escape_string($hostname);
$db->query(
"INSERT INTO coolify_instance_gateways (hostname, target_ip, enabled, priority)
VALUES ('$hostnameSql', '$targetIpSql', 1, " . (int)$priority . ")"
);
}
}
@@ -0,0 +1,76 @@
<?php
namespace classes;
class error_report_schema_bootstrap
{
private static bool $initialized = false;
private static ?bool $tablesExist = null;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$db->query("CREATE TABLE IF NOT EXISTS error_reports (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
status VARCHAR(16) NOT NULL DEFAULT 'open',
reporter_type VARCHAR(16) NOT NULL,
reporter_user_id INT NULL,
reporter_subuser_id INT NULL,
reporter_customer_number INT NULL,
reporter_customer_number_context INT NULL,
reporter_name VARCHAR(255) NULL,
reporter_email VARCHAR(255) NULL,
route_path VARCHAR(512) NULL,
page_url VARCHAR(1024) NULL,
release_trace_id VARCHAR(64) NULL,
frontend_version VARCHAR(128) NULL,
api_version VARCHAR(128) NULL,
screenshot_object_key VARCHAR(512) NOT NULL,
screenshot_mime_type VARCHAR(64) NOT NULL,
screenshot_size_bytes INT UNSIGNED NOT NULL DEFAULT 0,
before_error TEXT NOT NULL,
expected TEXT NOT NULL,
actual TEXT NOT NULL,
request_error_count INT UNSIGNED NOT NULL DEFAULT 0,
vue_error_count INT UNSIGNED NOT NULL DEFAULT 0,
request_errors_json LONGTEXT NULL,
vue_errors_json LONGTEXT NULL,
runtime_context_json LONGTEXT NULL,
data_collection_accepted TINYINT(1) NOT NULL DEFAULT 0,
data_collection_accepted_at DATETIME NOT NULL,
data_collection_policy_version VARCHAR(64) NOT NULL DEFAULT 'error-report-v1',
resolved_at DATETIME NULL,
resolved_by_user_id INT NULL,
resolution_note TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_error_reports_status_created (status, created_at),
INDEX idx_error_reports_reporter_user (reporter_user_id, created_at),
INDEX idx_error_reports_reporter_subuser (reporter_subuser_id, created_at),
INDEX idx_error_reports_customer (reporter_customer_number, reporter_customer_number_context),
INDEX idx_error_reports_trace (release_trace_id),
INDEX idx_error_reports_route (route_path)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
self::$initialized = true;
self::$tablesExist = true;
}
public static function tablesExist(): bool
{
if (self::$tablesExist !== null) {
return self::$tablesExist;
}
global $db;
$result = $db->query("SHOW TABLES LIKE 'error_reports'");
self::$tablesExist = $result !== false && $result->num_rows > 0;
return self::$tablesExist;
}
}
@@ -0,0 +1,567 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
class error_report_service
{
private const ANSWER_MAX_LENGTH = 4000;
private const NOTE_MAX_LENGTH = 2000;
private const SCREENSHOT_MAX_BYTES = 6_000_000;
private const JSON_MAX_LENGTH = 200_000;
private const STATUS_OPEN = 'open';
private const STATUS_RESOLVED = 'resolved';
private bool $schemaEnsured = false;
private error_report_store $store;
public function __construct(?error_report_store $store = null)
{
$this->store = $store ?? new error_report_store();
}
public static function redactPayload(mixed $value, int $depth = 0): mixed
{
if ($depth > 8) {
return '[depth-limit]';
}
if (is_array($value)) {
$redacted = [];
$index = 0;
foreach ($value as $key => $item) {
$index++;
if ($index > 80) {
$redacted['[truncated]'] = 'More than 80 keys omitted.';
break;
}
$keyString = (string)$key;
if (preg_match('/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i', $keyString) === 1) {
$redacted[$key] = '[redacted]';
continue;
}
$redacted[$key] = self::redactPayload($item, $depth + 1);
}
return $redacted;
}
if (is_object($value)) {
return self::redactPayload((array)$value, $depth + 1);
}
if (is_string($value) && strlen($value) > 4000) {
return substr($value, 0, 4000) . "\n... [truncated]";
}
return $value;
}
public static function decodeScreenshotDataUri(string $dataUri): array
{
if (!preg_match('/^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+\/=\r\n]+)$/', trim($dataUri), $matches)) {
throw new RuntimeException('Screenshot must be a PNG, JPEG, or WebP data URI.');
}
$contents = base64_decode(preg_replace('/\s+/', '', $matches[2]) ?? '', true);
if ($contents === false || $contents === '') {
throw new RuntimeException('Screenshot could not be decoded.');
}
if (strlen($contents) > self::SCREENSHOT_MAX_BYTES) {
throw new RuntimeException('Screenshot is too large.');
}
return [
'mime_type' => $matches[1],
'contents' => $contents,
'size_bytes' => strlen($contents),
];
}
public function createFromCurrentPrincipal(array $payload): array
{
$this->ensureSchema();
$principal = $this->resolvePrincipal();
$answers = $this->validatedAnswers($payload);
if (!$this->acceptedDataCollection($payload['data_collection_accepted'] ?? null)) {
throw new RuntimeException('Data collection acceptance is required.');
}
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
$runtimeContext = $this->runtimeContext($payload, $context);
$this->execute(
"INSERT INTO error_reports (
status,
reporter_type,
reporter_user_id,
reporter_subuser_id,
reporter_customer_number,
reporter_customer_number_context,
reporter_name,
reporter_email,
route_path,
page_url,
release_trace_id,
frontend_version,
api_version,
screenshot_object_key,
screenshot_mime_type,
screenshot_size_bytes,
before_error,
expected,
actual,
request_error_count,
vue_error_count,
request_errors_json,
vue_errors_json,
runtime_context_json,
data_collection_accepted,
data_collection_accepted_at,
data_collection_policy_version
) VALUES (
'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NOW(), ?
)",
'siiiisssssssssisssiissss',
[
$principal['type'],
$principal['user_id'],
$principal['subuser_id'],
$principal['customer_number'],
$principal['customer_number_context'],
$principal['name'],
$principal['email'],
$runtimeContext['route_path'],
$runtimeContext['page_url'],
$runtimeContext['release_trace_id'],
$runtimeContext['frontend_version'],
$runtimeContext['api_version'],
$storedScreenshot['key'],
$storedScreenshot['mime_type'],
(int)$storedScreenshot['size_bytes'],
$answers['before_error'],
$answers['expected'],
$answers['actual'],
count($requestErrors),
count($vueErrors),
$this->jsonEncodeLimited(self::redactPayload($requestErrors)),
$this->jsonEncodeLimited(self::redactPayload($vueErrors)),
$this->jsonEncodeLimited(self::redactPayload($runtimeContext)),
$runtimeContext['data_collection_policy_version'],
]
);
return $this->get($this->insertId());
}
public function list(array $filters = []): array
{
$this->ensureSchema();
$where = ['1 = 1'];
$types = '';
$params = [];
$status = $this->statusFilter($filters['status'] ?? self::STATUS_OPEN);
if ($status !== 'all') {
$where[] = 'status = ?';
$types .= 's';
$params[] = $status;
}
$search = trim((string)($filters['q'] ?? $filters['search'] ?? ''));
if ($search !== '') {
$where[] = '(route_path LIKE ? OR page_url LIKE ? OR before_error LIKE ? OR actual LIKE ? OR reporter_name LIKE ? OR reporter_email LIKE ?)';
$types .= 'ssssss';
$like = '%' . $search . '%';
array_push($params, $like, $like, $like, $like, $like, $like);
}
$limit = min(200, max(1, (int)($filters['limit'] ?? 50)));
$offset = max(0, (int)($filters['offset'] ?? 0));
$types .= 'ii';
$params[] = $limit;
$params[] = $offset;
$items = $this->selectRows(
"SELECT id, status, reporter_type, reporter_user_id, reporter_subuser_id,
reporter_customer_number, reporter_customer_number_context, reporter_name, reporter_email,
route_path, page_url, release_trace_id, frontend_version, api_version,
screenshot_mime_type, screenshot_size_bytes, before_error, expected, actual,
request_error_count, vue_error_count, resolved_at, resolved_by_user_id, created_at, updated_at
FROM error_reports
WHERE " . implode(' AND ', $where) . "
ORDER BY created_at DESC
LIMIT ? OFFSET ?",
$types,
$params
);
return [
'items' => array_map(fn(array $row): array => $this->publicReport($row, false), $items),
'counts' => $this->counts(),
'limit' => $limit,
'offset' => $offset,
];
}
public function get(int $id): array
{
$this->ensureSchema();
$row = $this->selectOne('SELECT * FROM error_reports WHERE id = ? LIMIT 1', 'i', [$id]);
if ($row === null) {
throw new RuntimeException('Error report not found.');
}
return $this->publicReport($row, true);
}
public function updateStatus(int $id, string $status, ?string $resolutionNote, ?int $actorUserId): array
{
$this->ensureSchema();
$status = self::normalizeStatus($status);
$note = $resolutionNote !== null ? $this->trimmedString($resolutionNote, self::NOTE_MAX_LENGTH, false) : null;
if ($status === self::STATUS_RESOLVED) {
$this->execute(
'UPDATE error_reports SET status = ?, resolved_at = NOW(), resolved_by_user_id = ?, resolution_note = ? WHERE id = ?',
'sisi',
[$status, $actorUserId, $note, $id]
);
} else {
$this->execute(
'UPDATE error_reports SET status = ?, resolved_at = NULL, resolved_by_user_id = NULL, resolution_note = ? WHERE id = ?',
'ssi',
[$status, $note, $id]
);
}
return $this->get($id);
}
public static function normalizeStatus(string $status): string
{
$status = strtolower(trim($status));
if (!in_array($status, [self::STATUS_OPEN, self::STATUS_RESOLVED], true)) {
throw new RuntimeException('Invalid error report status.');
}
return $status;
}
private function validatedAnswers(array $payload): array
{
return [
'before_error' => $this->requiredAnswer($payload, ['before_error', 'what_were_you_doing_before_error_occurred']),
'expected' => $this->requiredAnswer($payload, ['expected', 'what_did_you_expect_would_happen']),
'actual' => $this->requiredAnswer($payload, ['actual', 'what_actually_happened']),
];
}
private function requiredAnswer(array $payload, array $keys): string
{
foreach ($keys as $key) {
if (array_key_exists($key, $payload)) {
return $this->trimmedString((string)$payload[$key], self::ANSWER_MAX_LENGTH, true);
}
}
throw new RuntimeException('Missing required answer.');
}
private function trimmedString(string $value, int $maxLength, bool $required): string
{
$value = trim($value);
if ($required && $value === '') {
throw new RuntimeException('Required text fields must not be empty.');
}
if (strlen($value) > $maxLength) {
return substr($value, 0, $maxLength);
}
return $value;
}
private function acceptedDataCollection(mixed $value): bool
{
return $value === true || $value === 1 || $value === '1' || $value === 'true';
}
private function runtimeContext(array $payload, array $context): array
{
return [
'route_path' => $this->nullableString($payload['route_path'] ?? $context['route_path'] ?? $context['route'] ?? null, 512),
'page_url' => $this->nullableString($payload['page_url'] ?? $context['page_url'] ?? $context['url'] ?? null, 1024),
'release_trace_id' => $this->nullableString($payload['release_trace_id'] ?? $context['release_trace_id'] ?? $context['trace_id'] ?? $this->releaseRequestContext('trace_id'), 64),
'frontend_version' => $this->nullableString($payload['frontend_version'] ?? $context['frontend_version'] ?? $this->releaseRequestContext('frontend_version'), 128),
'api_version' => $this->nullableString($payload['api_version'] ?? $context['api_version'] ?? $this->releaseRequestContext('backend_version'), 128),
'viewport' => is_array($context['viewport'] ?? null) ? $context['viewport'] : null,
'user_agent' => $this->nullableString($context['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? null), 1024),
'captured_at' => $this->nullableString($context['captured_at'] ?? null, 64),
'data_collection_policy_version' => $this->nullableString($payload['data_collection_policy_version'] ?? $context['data_collection_policy_version'] ?? 'error-report-v1', 64) ?? 'error-report-v1',
];
}
private function releaseRequestContext(string $key): ?string
{
$context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] : [];
return isset($context[$key]) ? (string)$context[$key] : null;
}
private function nullableString(mixed $value, int $maxLength): ?string
{
if ($value === null) {
return null;
}
$value = trim((string)$value);
if ($value === '') {
return null;
}
return substr($value, 0, $maxLength);
}
private function boundedArray(mixed $value, int $limit): array
{
return is_array($value) ? array_slice(array_values($value), 0, $limit) : [];
}
private function statusFilter(mixed $status): string
{
$status = strtolower(trim((string)$status));
if ($status === '' || $status === self::STATUS_OPEN) {
return self::STATUS_OPEN;
}
if ($status === self::STATUS_RESOLVED || $status === 'all') {
return $status;
}
return self::STATUS_OPEN;
}
private function counts(): array
{
$rows = $this->selectRows('SELECT status, COUNT(*) AS count FROM error_reports GROUP BY status');
$counts = [
self::STATUS_OPEN => 0,
self::STATUS_RESOLVED => 0,
'all' => 0,
];
foreach ($rows as $row) {
$status = (string)($row['status'] ?? '');
$count = (int)($row['count'] ?? 0);
if (isset($counts[$status])) {
$counts[$status] = $count;
}
$counts['all'] += $count;
}
return $counts;
}
private function resolvePrincipal(): array
{
$auth = new authentication();
try {
$subuser = $auth->get_subuser();
if ($subuser !== false) {
return [
'type' => 'subuser',
'user_id' => null,
'subuser_id' => (int)$subuser->id,
'customer_number' => null,
'customer_number_context' => $this->headerInt('X-Customer-Number'),
'name' => $this->safeObjectValue($subuser, 'name') ?: $this->safeObjectValue($subuser, 'username'),
'email' => $this->safeObjectValue($subuser, 'email'),
];
}
} catch (Throwable) {
}
try {
$user = $auth->get_user();
if ($user !== false) {
return [
'type' => 'user',
'user_id' => (int)$user->id,
'subuser_id' => null,
'customer_number' => isset($user->customer_number) ? (int)$user->customer_number->value() : null,
'customer_number_context' => null,
'name' => $this->safeObjectValue($user, 'display_name'),
'email' => $this->safeObjectValue($user, 'email'),
];
}
} catch (Throwable) {
}
throw new RuntimeException('Authentication failed. Invalid or missing token.');
}
private function headerInt(string $name): ?int
{
$headers = function_exists('getallheaders') ? getallheaders() : [];
foreach ($headers as $key => $value) {
if (strcasecmp((string)$key, $name) === 0) {
$int = (int)$value;
return $int > 0 ? $int : null;
}
}
$serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
$int = (int)($_SERVER[$serverKey] ?? 0);
return $int > 0 ? $int : null;
}
private function safeObjectValue(object $object, string $property): ?string
{
try {
if (!isset($object->{$property}) || !method_exists($object->{$property}, 'value')) {
return null;
}
$value = $object->{$property}->value();
return $value === null ? null : substr((string)$value, 0, 255);
} catch (Throwable) {
return null;
}
}
private function publicReport(array $row, bool $includeDetail): array
{
$report = [
'id' => (int)$row['id'],
'status' => (string)$row['status'],
'reporter' => [
'type' => $row['reporter_type'] ?? null,
'user_id' => isset($row['reporter_user_id']) ? (int)$row['reporter_user_id'] : null,
'subuser_id' => isset($row['reporter_subuser_id']) ? (int)$row['reporter_subuser_id'] : null,
'customer_number' => isset($row['reporter_customer_number']) ? (int)$row['reporter_customer_number'] : null,
'customer_number_context' => isset($row['reporter_customer_number_context']) ? (int)$row['reporter_customer_number_context'] : null,
'name' => $row['reporter_name'] ?? null,
'email' => $row['reporter_email'] ?? null,
],
'route_path' => $row['route_path'] ?? null,
'page_url' => $row['page_url'] ?? null,
'release_trace_id' => $row['release_trace_id'] ?? null,
'frontend_version' => $row['frontend_version'] ?? null,
'api_version' => $row['api_version'] ?? null,
'screenshot' => [
'mime_type' => $row['screenshot_mime_type'] ?? null,
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
],
'answers' => [
'before_error' => $row['before_error'] ?? '',
'expected' => $row['expected'] ?? '',
'actual' => $row['actual'] ?? '',
],
'request_error_count' => isset($row['request_error_count']) ? (int)$row['request_error_count'] : 0,
'vue_error_count' => isset($row['vue_error_count']) ? (int)$row['vue_error_count'] : 0,
'resolved_at' => $row['resolved_at'] ?? null,
'resolved_by_user_id' => isset($row['resolved_by_user_id']) ? (int)$row['resolved_by_user_id'] : null,
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
if ($includeDetail) {
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
$report['data_collection'] = [
'accepted' => (bool)($row['data_collection_accepted'] ?? false),
'accepted_at' => $row['data_collection_accepted_at'] ?? null,
'policy_version' => $row['data_collection_policy_version'] ?? null,
];
$report['resolution_note'] = $row['resolution_note'] ?? null;
}
return $report;
}
private function ensureSchema(): void
{
if ($this->schemaEnsured) {
return;
}
error_report_schema_bootstrap::ensureTables();
$this->schemaEnsured = true;
}
private function selectOne(string $sql, string $types = '', array $params = []): ?array
{
$rows = $this->selectRows($sql, $types, $params);
return $rows[0] ?? null;
}
private function selectRows(string $sql, string $types = '', array $params = []): array
{
global $db;
if ($types === '') {
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
$stmt = $db->prepare($sql);
if ($stmt === false) {
throw new RuntimeException('Could not prepare error report query.');
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
private function execute(string $sql, string $types = '', array $params = []): void
{
global $db;
if ($types === '') {
$db->query($sql);
return;
}
$stmt = $db->prepare($sql);
if ($stmt === false) {
throw new RuntimeException('Could not prepare error report statement.');
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
}
private function insertId(): int
{
global $db;
return (int)$db->insert_id();
}
private function jsonEncodeLimited(mixed $value): string
{
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Could not encode error report JSON payload.');
}
if (strlen($json) <= self::JSON_MAX_LENGTH) {
return $json;
}
$truncated = [
'[truncated]' => 'Payload exceeded ' . self::JSON_MAX_LENGTH . ' bytes.',
'preview' => substr($json, 0, self::JSON_MAX_LENGTH),
];
$encoded = json_encode($truncated, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $encoded === false ? '{}' : $encoded;
}
private function jsonDecode(mixed $value): array
{
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
}
@@ -0,0 +1,47 @@
<?php
namespace classes;
use traits\minio_t;
class error_report_store
{
use minio_t;
public function __construct()
{
self::setBucket('uploads');
}
public function storeScreenshot(string $mimeType, string $contents): array
{
$extension = match ($mimeType) {
'image/webp' => 'webp',
'image/jpeg' => 'jpg',
default => 'png',
};
$datePath = date('Y/m');
$key = sprintf('error-reports/%s/%s.%s', $datePath, bin2hex(random_bytes(16)), $extension);
if (!self::createObject($key, $contents)) {
throw new \RuntimeException('Could not store error report screenshot.');
}
return [
'key' => $key,
'mime_type' => $mimeType,
'size_bytes' => strlen($contents),
];
}
public function screenshotUrl(string $key): ?string
{
$key = trim($key);
if ($key === '') {
return null;
}
return self::getPresignedUrl($key, 1200, false);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace classes;
require_once WD . '/modules/failover/failover_c.php';
use failover\failover_c;
class failover
{
public failover_c $config;
public function __construct()
{
$this->config = new failover_c();
}
}
@@ -0,0 +1,123 @@
<?php
namespace classes;
use RuntimeException;
class hetzner_cloud_api_exception extends RuntimeException
{
public function __construct(
string $message,
private readonly int $statusCode = 0,
private readonly string $apiCode = ''
) {
parent::__construct($message, $statusCode);
}
public function statusCode(): int
{
return $this->statusCode;
}
public function apiCode(): string
{
return $this->apiCode;
}
}
class hetzner_cloud_client
{
private const BASE_URL = 'https://api.hetzner.cloud/v1';
public function __construct(private readonly string $token, private readonly int $timeoutSeconds = 8)
{
if (trim($token) === '') {
throw new RuntimeException('Hetzner Cloud API token is required.');
}
}
public function getLoadBalancer(int|string $id): array
{
return $this->request('GET', '/load_balancers/' . rawurlencode((string)$id))['load_balancer'] ?? [];
}
public function addIpTarget(int|string $loadBalancerId, string $ip): array
{
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_target', [
'type' => 'ip',
'ip' => ['ip' => $ip],
]);
}
public function removeIpTarget(int|string $loadBalancerId, string $ip): array
{
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/remove_target', [
'type' => 'ip',
'ip' => ['ip' => $ip],
]);
}
public function addService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort): array
{
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_service', [
'protocol' => strtolower($protocol),
'listen_port' => $listenPort,
'destination_port' => $destinationPort,
'proxyprotocol' => false,
]);
}
private function request(string $method, string $path, ?array $payload = null): array
{
$curl = curl_init(self::BASE_URL . $path);
if ($curl === false) {
throw new RuntimeException('Could not initialize Hetzner Cloud API request.');
}
$headers = [
'Accept: application/json',
'Authorization: Bearer ' . trim($this->token),
];
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method));
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, min(3, $this->timeoutSeconds));
curl_setopt($curl, CURLOPT_TIMEOUT, max(1, $this->timeoutSeconds));
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
if ($payload !== null) {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($body === false) {
throw new RuntimeException('Could not encode Hetzner Cloud API payload.');
}
$headers[] = 'Content-Type: application/json';
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
}
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
$raw = curl_exec($curl);
$error = curl_error($curl);
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($raw === false) {
throw new RuntimeException('Hetzner Cloud API request failed: ' . $error);
}
$decoded = trim((string)$raw) === '' ? [] : json_decode((string)$raw, true);
if (!is_array($decoded)) {
$decoded = ['raw' => (string)$raw];
}
if ($status < 200 || $status >= 300) {
$errorPayload = is_array($decoded['error'] ?? null) ? $decoded['error'] : [];
$apiCode = (string)($errorPayload['code'] ?? $decoded['code'] ?? '');
$message = (string)($errorPayload['message'] ?? $decoded['message'] ?? ('HTTP ' . $status));
throw new hetzner_cloud_api_exception('Hetzner Cloud API request failed: ' . $message, $status, $apiCode);
}
return $decoded;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,411 @@
<?php
namespace classes;
/**
* Additive schema bootstrap for application release management.
*
* The legacy API does not have a centralized migration runner, so these
* migrations must be safe to call from request handlers, tests, and cron.
*/
class release_manager_schema_bootstrap
{
private static bool $initialized = false;
private static ?bool $tablesExist = null;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$queries = [
"CREATE TABLE IF NOT EXISTS release_channels (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
description TEXT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
default_channel TINYINT(1) NOT NULL DEFAULT 0,
rollout_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
frontend_base_url VARCHAR(512) NULL,
api_base_url VARCHAR(512) NULL,
replay_enabled TINYINT(1) NOT NULL DEFAULT 0,
capture_level VARCHAR(32) NOT NULL DEFAULT 'metadata',
retention_days INT NOT NULL DEFAULT 14,
metadata_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
UNIQUE KEY uq_release_channels_slug (slug),
INDEX idx_release_channels_enabled (enabled, deleted_at),
INDEX idx_release_channels_default (default_channel, deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_versions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
app VARCHAR(16) NOT NULL,
repository VARCHAR(255) NULL,
branch VARCHAR(128) NULL,
commit_sha VARCHAR(64) NULL,
tag VARCHAR(128) NULL,
version_label VARCHAR(128) NULL,
build_url VARCHAR(512) NULL,
artifact_url VARCHAR(512) NULL,
deployed_url VARCHAR(512) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'discovered',
metadata_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deployed_at DATETIME NULL,
INDEX idx_release_versions_app_status (app, status),
INDEX idx_release_versions_commit (commit_sha),
INDEX idx_release_versions_repo_branch (repository, branch)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_channel_versions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
frontend_version_id BIGINT UNSIGNED NULL,
api_version_id BIGINT UNSIGNED NULL,
deployment_id BIGINT UNSIGNED NULL,
service_set_id BIGINT UNSIGNED NULL,
bundle_id BIGINT UNSIGNED NULL,
actor_user_id INT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
activated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_release_channel_versions_channel_active (channel_id, active),
INDEX idx_release_channel_versions_frontend (frontend_version_id),
INDEX idx_release_channel_versions_api (api_version_id),
INDEX idx_release_channel_versions_deployment (deployment_id),
INDEX idx_release_channel_versions_service_set (service_set_id),
INDEX idx_release_channel_versions_bundle (bundle_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_assignments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
subject_type VARCHAR(16) NOT NULL,
subject_id VARCHAR(64) NOT NULL,
channel_id BIGINT UNSIGNED NOT NULL,
reason VARCHAR(255) NULL,
expires_at DATETIME NULL,
actor_user_id INT 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_release_assignments_subject (subject_type, subject_id, deleted_at, expires_at),
INDEX idx_release_assignments_channel (channel_id, deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_deployment_targets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
app VARCHAR(16) NOT NULL,
coolify_instance_id BIGINT UNSIGNED NULL,
coolify_service_uuid VARCHAR(128) NULL,
repository VARCHAR(255) NOT NULL,
branch VARCHAR(128) NOT NULL DEFAULT 'main',
auto_deploy TINYINT(1) NOT NULL DEFAULT 1,
health_url VARCHAR(512) NULL,
deploy_context_json LONGTEXT 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_release_targets_channel_app (channel_id, app, deleted_at),
INDEX idx_release_targets_repo_branch (repository, branch, auto_deploy, deleted_at),
INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_service_sets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
name VARCHAR(128) NOT NULL,
slug VARCHAR(64) NOT NULL,
mode VARCHAR(32) NOT NULL DEFAULT 'attach_existing',
source_service_set_id BIGINT UNSIGNED NULL,
frontend_target_id BIGINT UNSIGNED NULL,
api_target_id BIGINT UNSIGNED NULL,
database_coolify_target_id BIGINT UNSIGNED NULL,
redis_coolify_target_id BIGINT UNSIGNED NULL,
minio_coolify_target_id BIGINT UNSIGNED NULL,
status VARCHAR(32) NOT NULL DEFAULT 'needs_configuration',
health_json LONGTEXT NULL,
metadata_json LONGTEXT NULL,
actor_user_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
UNIQUE KEY uq_release_service_sets_slug (slug),
INDEX idx_release_service_sets_channel (channel_id, deleted_at),
INDEX idx_release_service_sets_source (source_service_set_id),
INDEX idx_release_service_sets_status (status, deleted_at),
INDEX idx_release_service_sets_frontend_target (frontend_target_id),
INDEX idx_release_service_sets_api_target (api_target_id),
INDEX idx_release_service_sets_database_target (database_coolify_target_id),
INDEX idx_release_service_sets_redis_target (redis_coolify_target_id),
INDEX idx_release_service_sets_minio_target (minio_coolify_target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_deployments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
target_id BIGINT UNSIGNED NULL,
version_id BIGINT UNSIGNED NULL,
service_set_id BIGINT UNSIGNED NULL,
bundle_id BIGINT UNSIGNED NULL,
deployment_kind VARCHAR(32) NOT NULL DEFAULT 'single_app',
app VARCHAR(16) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'coolify',
repository VARCHAR(255) NULL,
branch VARCHAR(128) NULL,
commit_sha VARCHAR(64) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
provider_operation_id VARCHAR(128) NULL,
deployment_url VARCHAR(512) NULL,
actor_user_id INT NULL,
requested_payload_json LONGTEXT NULL,
result_json LONGTEXT NULL,
error_message TEXT NULL,
started_at DATETIME NULL,
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_release_deployments_channel_status (channel_id, status),
INDEX idx_release_deployments_target (target_id),
INDEX idx_release_deployments_version (version_id),
INDEX idx_release_deployments_service_set (service_set_id),
INDEX idx_release_deployments_bundle (bundle_id),
INDEX idx_release_deployments_kind (deployment_kind),
INDEX idx_release_deployments_commit (commit_sha)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_bundles (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
service_set_id BIGINT UNSIGNED NOT NULL,
version_label VARCHAR(128) NULL,
frontend_version_id BIGINT UNSIGNED NULL,
api_version_id BIGINT UNSIGNED NULL,
frontend_deployment_id BIGINT UNSIGNED NULL,
api_deployment_id BIGINT UNSIGNED NULL,
frontend_repository VARCHAR(255) NULL,
frontend_branch VARCHAR(128) NULL,
frontend_commit_sha VARCHAR(64) NULL,
api_repository VARCHAR(255) NULL,
api_branch VARCHAR(128) NULL,
api_commit_sha VARCHAR(64) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'draft',
deployment_result_json LONGTEXT NULL,
metadata_json LONGTEXT NULL,
actor_user_id INT NULL,
deployed_at DATETIME NULL,
promoted_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_release_bundles_channel_status (channel_id, status, deleted_at),
INDEX idx_release_bundles_service_set (service_set_id, status, deleted_at),
INDEX idx_release_bundles_frontend_version (frontend_version_id),
INDEX idx_release_bundles_api_version (api_version_id),
INDEX idx_release_bundles_frontend_deployment (frontend_deployment_id),
INDEX idx_release_bundles_api_deployment (api_deployment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_replay_targets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
target_type VARCHAR(16) NOT NULL,
target_id VARCHAR(64) NULL,
channel_id BIGINT UNSIGNED NULL,
capture_level VARCHAR(32) NOT NULL DEFAULT 'full_redacted',
enabled TINYINT(1) NOT NULL DEFAULT 1,
expires_at DATETIME NULL,
actor_user_id INT 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_release_replay_target (target_type, target_id, enabled, deleted_at, expires_at),
INDEX idx_release_replay_channel (channel_id, enabled, deleted_at, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_timeline_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
trace_id VARCHAR(64) NOT NULL,
session_hash VARCHAR(64) NULL,
principal_type VARCHAR(16) NULL,
principal_id VARCHAR(64) NULL,
customer_number INT NULL,
channel_id BIGINT UNSIGNED NULL,
channel_slug VARCHAR(64) NULL,
frontend_version_id BIGINT UNSIGNED NULL,
api_version_id BIGINT UNSIGNED NULL,
user_agent VARCHAR(512) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_release_timeline_trace (trace_id),
INDEX idx_release_timeline_principal (principal_type, principal_id),
INDEX idx_release_timeline_channel (channel_id, channel_slug),
INDEX idx_release_timeline_customer (customer_number),
INDEX idx_release_timeline_last_seen (last_seen_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_timeline_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
timeline_session_id BIGINT UNSIGNED NULL,
trace_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
module_key VARCHAR(64) NULL,
route_path VARCHAR(255) NULL,
component VARCHAR(255) NULL,
request_id VARCHAR(128) NULL,
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
payload_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_release_timeline_events_session (timeline_session_id, occurred_at),
INDEX idx_release_timeline_events_trace (trace_id, occurred_at),
INDEX idx_release_timeline_events_type (event_type, severity),
INDEX idx_release_timeline_events_module (module_key, occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_module_health_snapshots (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
module_key VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
reason VARCHAR(512) NULL,
payload_json LONGTEXT NULL,
checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_release_module_health_module (module_key, checked_at),
INDEX idx_release_module_health_channel (channel_id, module_key, checked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
deployment_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_release_audit_channel (channel_id, created_at),
INDEX idx_release_audit_deployment (deployment_id),
INDEX idx_release_audit_action (action)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
$db->query($sql);
}
self::ensureColumn('release_channel_versions', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER deployment_id');
self::ensureColumn('release_channel_versions', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
self::ensureColumn('release_deployments', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER version_id');
self::ensureColumn('release_deployments', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
self::ensureColumn('release_deployments', 'deployment_kind', "VARCHAR(32) NOT NULL DEFAULT 'single_app' AFTER bundle_id");
self::ensureModuleConfigDefault('ReleaseManager', 'enabled', 'true', 'bool');
self::ensureModuleConfigDefault('ReleaseManager', 'github_webhook_secret', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'github_token', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'github_api_url', 'https://api.github.com', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'default_retention_days', '14', 'int');
self::ensureDefaultChannels();
self::$initialized = true;
self::$tablesExist = true;
}
public static function tablesExist(): bool
{
if (self::$tablesExist !== null) {
return self::$tablesExist;
}
global $db;
foreach ([
'release_channels',
'release_versions',
'release_channel_versions',
'release_assignments',
'release_service_sets',
'release_deployments',
'release_bundles',
'release_timeline_sessions',
'release_timeline_events',
] as $table) {
$tableSql = $db->escape_string($table);
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
if ($result === false || $result->num_rows === 0) {
self::$tablesExist = false;
return false;
}
}
self::$tablesExist = true;
return true;
}
private static function ensureDefaultChannels(): void
{
global $db;
$channels = [
['stable', 'Stable', 'Default production channel.', 1, 1, 'metadata'],
['canary', 'Canary', 'Earliest production validation channel.', 1, 0, 'metadata'],
['beta', 'Beta', 'Broader pre-stable rollout channel.', 1, 0, 'metadata'],
['internal', 'Internal', 'Internal staff and superuser validation channel.', 1, 0, 'metadata'],
];
foreach ($channels as [$slug, $name, $description, $enabled, $default, $captureLevel]) {
$db->query(sprintf(
"INSERT IGNORE INTO release_channels (slug, name, description, enabled, default_channel, capture_level)
VALUES ('%s', '%s', '%s', %d, %d, '%s')",
$db->escape_string($slug),
$db->escape_string($name),
$db->escape_string($description),
(int)$enabled,
(int)$default,
$db->escape_string($captureLevel)
));
}
}
private static function ensureModuleConfigDefault(string $module, string $variable, string $value, string $type): void
{
global $db;
$moduleSql = $db->escape_string($module);
$variableSql = $db->escape_string($variable);
$result = $db->query("SELECT value FROM module_config WHERE module = '$moduleSql' AND variable = '$variableSql' LIMIT 1");
if ($result !== false && $result->num_rows > 0) {
return;
}
$valueSql = $db->escape_string($value);
$typeSql = $db->escape_string($type);
$db->query("INSERT INTO module_config (module, variable, value, type) VALUES ('$moduleSql', '$variableSql', '$valueSql', '$typeSql')");
}
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,19 @@
<?php
namespace classes;
class releasemanager
{
public function isEnabled(): bool
{
try {
release_manager_schema_bootstrap::ensureTables();
global $db;
$result = $db->query("SELECT value FROM module_config WHERE module = 'ReleaseManager' AND variable = 'enabled' LIMIT 1");
$row = $result ? $result->fetch_assoc() : null;
return in_array(strtolower(trim((string)($row['value'] ?? 'true'))), ['1', 'true', 'yes', 'on'], true);
} catch (\Throwable) {
return true;
}
}
}
@@ -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 : [];
}
}
File diff suppressed because it is too large Load Diff
+42 -55
View File
@@ -14,6 +14,7 @@ class response implements response_i
private array $meta = [];
private array $includes = [];
private users_o $users_o;
private ?array $jsonRequestBody = null;
#[NoReturn] public function success(mixed $data, int $status = null): void
{
@@ -47,6 +48,11 @@ class response implements response_i
'data' => $this->get_data()
]);
}
try {
release_manager::recordBackendFailure($success, $data, $status ?? ($success ? 200 : 400));
} catch (\Throwable) {
// Release failure telemetry is best-effort and must not block responses.
}
echo json_encode([
'success' => $success,
'data' => $data,
@@ -175,35 +181,7 @@ class response implements response_i
public function getRequestParameter(string $key): mixed
{
$data = [];
// Get the request data if the method is POST, PUT or PATCH
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
$data = json_decode(file_get_contents('php://input'), true);
}
// Get the request data if the method is GET, DELETE or OPTIONS
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
$data = $_GET;
}
if (!is_array($data)) {
$data = [];
}
// If the data key is not set, try to get it from the opposite method
if (!array_key_exists($key, $data)) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
$data = $_GET;
} else {
$data = json_decode(file_get_contents('php://input'), true);
}
}
if (!is_array($data)) {
$data = [];
}
// Return the data
return $data[$key] ?? null;
return $this->requestParametersForMethod()[$key] ?? null;
}
/**
@@ -212,21 +190,7 @@ class response implements response_i
*/
public function getAllRequestParameters(): array
{
$data = [];
// Get the request data if the method is POST, PUT or PATCH
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
$data = json_decode(file_get_contents('php://input'), true);
}
// Get the request data if the method is GET, DELETE or OPTIONS
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
$data = $_GET;
}
if (!is_array($data)) {
return [];
}
return $data;
return $this->requestParametersForMethod();
}
/**
@@ -237,21 +201,44 @@ class response implements response_i
*/
public function isRequestParameterSet(string $key): bool
{
$data = [];
// Get the request data if the method is POST, PUT or PATCH
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
$data = json_decode(file_get_contents('php://input'), true);
}
// Get the request data if the method is GET, DELETE or OPTIONS
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
$data = $_GET;
return array_key_exists($key, $this->requestParametersForMethod());
}
if (!is_array($data)) {
return false;
private function requestParametersForMethod(): array
{
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
return array_replace($_GET, $this->jsonRequestBody());
}
return array_key_exists($key, $data);
if ($method === 'DELETE') {
return array_replace($_GET, $this->jsonRequestBody());
}
if ($method === 'GET' || $method === 'OPTIONS') {
return $_GET;
}
return $this->jsonRequestBody();
}
private function jsonRequestBody(): array
{
if ($this->jsonRequestBody !== null) {
return $this->jsonRequestBody;
}
$decoded = json_decode($this->rawRequestBody(), true);
$this->jsonRequestBody = is_array($decoded) ? $decoded : [];
return $this->jsonRequestBody;
}
protected function rawRequestBody(): string
{
$body = file_get_contents('php://input');
return is_string($body) ? $body : '';
}
public function parseFilters(?string $filters): array|null
@@ -842,11 +842,89 @@ class superuser_system_status_service
['key' => 'licenseplaterecognizer', 'module' => 'licenseplaterecognizer', 'enabled_variable' => 'enabled', 'required' => ['api_key'], 'probe' => fn(array $config): array => $this->probeLicensePlateRecognizerModule($config)],
['key' => 'virkdata', 'module' => 'virkdata', 'enabled_variable' => 'enabled', 'required' => ['secret_key']],
['key' => 'shelly', 'module' => 'shelly', 'enabled_variable' => 'enabled', 'required' => ['server_url', 'secret_key'], 'probe' => fn(array $config): array => $this->probeShellyModule($config)],
['key' => 'coolify', 'module' => 'Coolify', 'enabled_variable' => 'enabled', 'required' => [], 'probe' => fn(array $config): array => $this->probeCoolifyModule($config)],
['key' => 'releasemanager', 'module' => 'ReleaseManager', 'enabled_variable' => 'enabled', 'required' => [], 'always_enabled' => true, 'probe' => fn(array $config): array => $this->probeReleaseManagerModule($config)],
['key' => 'selfserve', 'module' => 'selfserve', 'enabled_variable' => 'enabled', 'required' => ['machine_wash_minutes_included', 'minute_product'], 'probe' => fn(array $config): array => $this->probeSelfserveModule($config)],
['key' => 'bird', 'module' => 'bird', 'enabled_variable' => 'enabled', 'required' => ['server_url', 'api_key', 'channelId', 'workplaceId'], 'probe' => fn(array $config): array => $this->probeBirdModule($config)],
];
}
protected function probeReleaseManagerModule(array $config): array
{
return (new release_manager())->healthProbe();
}
protected function probeCoolifyModule(array $config): array
{
$startedAt = microtime(true);
try {
$summary = (new coolify_manager())->summary();
$instances = is_array($summary['instances'] ?? null) ? $summary['instances'] : [];
$targets = is_array($summary['targets'] ?? null) ? $summary['targets'] : [];
if ($instances === []) {
return [
'status' => 'not_configured',
'status_reason' => 'Coolify is enabled, but no Coolify API instance is configured.',
'status_reason_key' => 'coolify_instances_missing',
'status_reason_params' => [],
'checked_at' => date('c'),
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
}
$downInstances = array_values(array_filter($instances, static fn(array $instance): bool => ($instance['status'] ?? 'unknown') === 'down'));
$blockedTargets = array_values(array_filter($targets, static function (array $target): bool {
$state = (string)($target['availability_state'] ?? 'degraded');
return $state === 'destructive_action_required' || str_contains($state, 'blocked');
}));
$failedTargets = array_values(array_filter($targets, static function (array $target): bool {
return in_array((string)($target['deployment_status'] ?? ''), ['reconcile_failed', 'restart_failed', 'provision_blocked'], true);
}));
$status = 'ok';
$reason = 'Coolify deployment state is available.';
$reasonKey = 'coolify_available';
if ($downInstances !== []) {
$status = 'down';
$reason = 'One or more Coolify API instances are unreachable.';
$reasonKey = 'coolify_instances_down';
} elseif ($blockedTargets !== [] || $failedTargets !== []) {
$status = 'degraded';
$reason = 'One or more Coolify targets need operator attention before availability can be protected.';
$reasonKey = 'coolify_targets_need_attention';
} elseif ($targets === []) {
$status = 'degraded';
$reason = 'Coolify is connected, but no replicated infrastructure targets are managed yet.';
$reasonKey = 'coolify_targets_missing';
}
return [
'status' => $status,
'status_reason' => $reason,
'status_reason_key' => $reasonKey,
'status_reason_params' => [
'instances' => count($instances),
'targets' => count($targets),
'blocked_targets' => count($blockedTargets),
'failed_targets' => count($failedTargets),
],
'checked_at' => date('c'),
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
} catch (Throwable $throwable) {
return [
'status' => 'down',
'status_reason' => 'Coolify module probe failed: ' . $throwable->getMessage(),
'status_reason_key' => 'coolify_probe_failed',
'status_reason_params' => ['error' => $throwable->getMessage()],
'checked_at' => date('c'),
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
}
}
protected function probeEconomicModule(array $config): array
{
$appSecretToken = trim((string)($GLOBALS['ECONOMIC_API']['app_secret_token'] ?? ''));
+8 -3
View File
@@ -166,6 +166,11 @@ if (strtolower(trim((string)($_ENV['USE_ENV'] ?? getenv('USE_ENV') ?? ''))) ===
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()
);
require_once __DIR__ . '/classes/replica_failover_manager.php';
$replicationBootstrapSnapshot = \classes\replication_bootstrap_config::loadSnapshot();
\classes\replication_bootstrap_config::applyToGlobals($replicationBootstrapSnapshot);
try {
\classes\replica_failover_manager::applyStartupFailoverFromSnapshot();
} catch (Throwable $throwable) {
error_log('[replication-bootstrap] Startup failover skipped: ' . $throwable->getMessage());
}
+85
View File
@@ -5,6 +5,8 @@ use classes\backup_store;
use classes\economic;
use classes\economic_transfer_queue;
use classes\invoice_period_flag_service;
use classes\coolify_manager;
use classes\replication_manager;
use classes\redis;
use classes\system_search_cache;
use classes\system_search_document_index;
@@ -68,6 +70,24 @@ $cron_tasks = [
'next_run' => 0,
'function' => 'syncLogsToDatabase',
],
'ReplicaFailoverMonitorCron' => [
'interval' => 60, // 1 minute
'last_run' => 0,
'next_run' => 0,
'function' => 'ReplicaFailoverMonitorCron',
],
'CoolifyAvailabilityMonitorCron' => [
'interval' => 60, // 1 minute
'last_run' => 0,
'next_run' => 0,
'function' => 'CoolifyAvailabilityMonitorCron',
],
'CoolifyLoadBalancerReconcileCron' => [
'interval' => 60, // 1 minute
'last_run' => 0,
'next_run' => 0,
'function' => 'CoolifyLoadBalancerReconcileCron',
],
'SyncUserEconomicCustomerDiscounts' => [
'interval' => 180, // 3 minutes
'last_run' => 0,
@@ -160,6 +180,71 @@ $cron_tasks = [
],
];
function ReplicaFailoverMonitorCron(): void
{
global $db;
if (!($db instanceof \classes\db)) {
warn('ReplicaFailoverMonitorCron skipped: database connection is unavailable.');
return;
}
try {
$result = (new replication_manager())->runAutomaticFailoverMonitor();
$promoted = array_filter(
$result['results'] ?? [],
static fn(array $entry): bool => ($entry['status'] ?? '') === 'promoted'
);
echo "[" . date('Y-m-d H:i:s') . "][CRON] ReplicaFailoverMonitorCron: "
. count($promoted) . " promotions.\n";
} catch (Throwable $throwable) {
warn('ReplicaFailoverMonitorCron failed: ' . $throwable->getMessage());
}
}
function CoolifyAvailabilityMonitorCron(): void
{
global $db;
if (!($db instanceof \classes\db)) {
warn('CoolifyAvailabilityMonitorCron skipped: database connection is unavailable.');
return;
}
try {
$result = (new coolify_manager())->runAvailabilityMaintenance();
echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyAvailabilityMonitorCron: "
. count($result['targets'] ?? []) . " targets checked.\n";
} catch (Throwable $throwable) {
warn('CoolifyAvailabilityMonitorCron failed: ' . $throwable->getMessage());
}
}
function CoolifyLoadBalancerReconcileCron(): void
{
global $db;
if (!($db instanceof \classes\db)) {
warn('CoolifyLoadBalancerReconcileCron skipped: database connection is unavailable.');
return;
}
try {
$manager = new coolify_manager();
if (!$manager->loadBalancerAutomationEnabled()) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: skipped.\n";
return;
}
$result = $manager->reconcileLoadBalancer(false);
echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: "
. count($result['applied'] ?? []) . " applied, "
. count($result['skipped'] ?? []) . " skipped.\n";
} catch (Throwable $throwable) {
warn('CoolifyLoadBalancerReconcileCron failed: ' . $throwable->getMessage());
}
}
function WarmInvoicePeriodManualFlagsCron(): void
{
(new invoice_period_flag_service())->warmManualFlagsCache();
+23 -2
View File
@@ -8,6 +8,7 @@ ini_set('zlib.output_compression', false);
*/
const WD = __DIR__;
require_once __DIR__ . '/vendor/autoload.php';
require_once 'config.php';
/** CORS */
@@ -16,7 +17,7 @@ $allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
header("Access-Control-Allow-Credentials: true");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, *");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *");
header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS");
}
@@ -26,7 +27,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
header("Access-Control-Allow-Credentials: true");
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: *');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *');
header('Content-Type: application/json');
http_response_code(200);
exit;
@@ -169,6 +170,8 @@ spl_autoload_register(function (string $class): void {
use classes\application_write_freeze;
use classes\db;
use classes\replication_manager;
use classes\release_manager;
use classes\redis;
use classes\request;
use classes\response;
@@ -196,6 +199,24 @@ try {
$response->error($e->getMessage(), 500);
}
try {
release_manager::initializeRequestContext();
} catch (Throwable $e) {
error_log('[release-manager] Could not initialize request context: ' . $e->getMessage());
}
try {
$replicationBootstrapSnapshotForRequest = replication_bootstrap_config::loadSnapshot();
$pendingStartupFailovers = is_array($replicationBootstrapSnapshotForRequest['pending_failovers'] ?? null)
? $replicationBootstrapSnapshotForRequest['pending_failovers']
: [];
if ($pendingStartupFailovers !== []) {
(new replication_manager())->syncStartupFailoversFromSnapshot();
}
} catch (Throwable $e) {
error_log('[replication-bootstrap] Could not sync startup failover metadata: ' . $e->getMessage());
}
if (application_write_freeze::shouldBlock(
$_SERVER['REQUEST_METHOD'] ?? 'GET',
$_SERVER['REQUEST_URI'] ?? '/',
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_enabled_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'enabled',
'bool',
false,
null,
'Enable Coolify-managed replicated infrastructure targets.',
'true',
false,
'false'
);
}
}
@@ -0,0 +1,38 @@
<?php
namespace modules\coolify\config;
use classes\replication_secret_box;
use traits\module_config_variable;
class coolify_hetzner_cloud_api_token_c
{
use module_config_variable {
setVariableValue as private traitSetVariableValue;
}
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'hetzner_cloud_api_token',
'string',
false,
null,
'Hetzner Cloud API token with Load Balancer read/write permissions.',
'pat_...',
true,
''
);
}
public function setVariableValue(mixed $value): void
{
$value = trim((string)($value ?? ''));
if ($value !== '' && !str_starts_with($value, 'twsec:v1:')) {
$value = replication_secret_box::encrypt($value);
}
$this->traitSetVariableValue($value);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_hetzner_load_balancer_id_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'hetzner_load_balancer_id',
'string',
false,
null,
'Hetzner Cloud Load Balancer ID used as the public Coolify gateway.',
'1234567',
false,
''
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_lb_automation_enabled_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'lb_automation_enabled',
'bool',
false,
null,
'Enable automated Hetzner Load Balancer reconciliation for the Coolify public gateway.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_lb_automation_mode_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'lb_automation_mode',
'string',
false,
['report_only', 'enforce'],
'Controls whether Hetzner Load Balancer reconciliation reports drift only or applies changes.',
'report_only',
false,
'report_only'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_public_gateway_host_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'public_gateway_host',
'string',
false,
null,
'Public DNS hostname served by the replicated Coolify gateway Load Balancer.',
'api-v2.truckwash.io',
false,
'api-v2.truckwash.io'
);
}
}
@@ -0,0 +1,63 @@
<?php
namespace modules\coolify;
require_once WD . '/modules/coolify/config/coolify_enabled_c.php';
require_once WD . '/modules/coolify/config/coolify_hetzner_cloud_api_token_c.php';
require_once WD . '/modules/coolify/config/coolify_hetzner_load_balancer_id_c.php';
require_once WD . '/modules/coolify/config/coolify_lb_automation_enabled_c.php';
require_once WD . '/modules/coolify/config/coolify_lb_automation_mode_c.php';
require_once WD . '/modules/coolify/config/coolify_public_gateway_host_c.php';
use modules\coolify\config\coolify_hetzner_cloud_api_token_c;
use modules\coolify\config\coolify_hetzner_load_balancer_id_c;
use modules\coolify\config\coolify_enabled_c;
use modules\coolify\config\coolify_lb_automation_enabled_c;
use modules\coolify\config\coolify_lb_automation_mode_c;
use modules\coolify\config\coolify_public_gateway_host_c;
use traits\module_config_t;
class coolify_c
{
use module_config_t {
getConfigRequest as private traitGetConfigRequest;
}
public coolify_enabled_c $enabled;
public coolify_lb_automation_enabled_c $lb_automation_enabled;
public coolify_lb_automation_mode_c $lb_automation_mode;
public coolify_hetzner_load_balancer_id_c $hetzner_load_balancer_id;
public coolify_hetzner_cloud_api_token_c $hetzner_cloud_api_token;
public coolify_public_gateway_host_c $public_gateway_host;
public function __construct()
{
$this->setupConfig('Coolify');
$this->allowUpdate([
coolify_enabled_c::class,
coolify_lb_automation_enabled_c::class,
coolify_lb_automation_mode_c::class,
coolify_hetzner_load_balancer_id_c::class,
coolify_hetzner_cloud_api_token_c::class,
coolify_public_gateway_host_c::class,
]);
$this->enabled = new coolify_enabled_c();
$this->lb_automation_enabled = new coolify_lb_automation_enabled_c();
$this->lb_automation_mode = new coolify_lb_automation_mode_c();
$this->hetzner_load_balancer_id = new coolify_hetzner_load_balancer_id_c();
$this->hetzner_cloud_api_token = new coolify_hetzner_cloud_api_token_c();
$this->public_gateway_host = new coolify_public_gateway_host_c();
}
public function getConfigRequest(): array
{
return array_map(static function (array $row): array {
if (($row['variable'] ?? '') === 'hetzner_cloud_api_token') {
$secretSet = trim((string)($row['value'] ?? '')) !== '';
$row['value'] = $secretSet ? '[redacted]' : '';
$row['secret_set'] = $secretSet;
}
return $row;
}, $this->traitGetConfigRequest());
}
}
@@ -364,6 +364,7 @@ class edge_gateway_manager
}
$gatewayPayload = $this->getGateway($gatewayId);
$gatewayPayload['broker_url'] = $this->buildBrokerPublicUrl();
edge_gateway_view_cache::syncGateway($gatewayPayload);
return $gatewayPayload;
@@ -21,9 +21,9 @@ class edgegateway_public_broker_url_c
false,
null,
'The browser-routable edge broker URL, including the proxy path prefix.',
'https://api.truckwash.io:4433/edge-broker',
'https://api-v2.truckwash.io/edge-broker',
false,
trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: ''))
trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')) ?: 'https://api-v2.truckwash.io/edge-broker'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_database_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'database_enabled',
'bool',
true,
null,
'Whether automatic database replica failover is enabled.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'enabled',
'bool',
true,
null,
'Whether automatic failover is enabled.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_max_status_age_seconds_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'max_status_age_seconds',
'int',
true,
null,
'Maximum age in seconds for a stored replica status to be eligible for automatic failover.',
'90',
false,
'90'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_minio_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'minio_enabled',
'bool',
true,
null,
'Whether automatic MinIO replica failover is enabled.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_redis_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'redis_enabled',
'bool',
true,
null,
'Whether automatic Redis replica failover is enabled.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,45 @@
<?php
namespace failover;
require_once WD . '/modules/failover/config/failover_enabled_c.php';
require_once WD . '/modules/failover/config/failover_database_enabled_c.php';
require_once WD . '/modules/failover/config/failover_redis_enabled_c.php';
require_once WD . '/modules/failover/config/failover_minio_enabled_c.php';
require_once WD . '/modules/failover/config/failover_max_status_age_seconds_c.php';
use failover\config\failover_database_enabled_c;
use failover\config\failover_enabled_c;
use failover\config\failover_max_status_age_seconds_c;
use failover\config\failover_minio_enabled_c;
use failover\config\failover_redis_enabled_c;
use traits\module_config_t;
class failover_c
{
use module_config_t;
public failover_enabled_c $enabled;
public failover_database_enabled_c $database_enabled;
public failover_redis_enabled_c $redis_enabled;
public failover_minio_enabled_c $minio_enabled;
public failover_max_status_age_seconds_c $max_status_age_seconds;
public function __construct()
{
$this->setupConfig('Failover');
$this->allowUpdate([
failover_enabled_c::class,
failover_database_enabled_c::class,
failover_redis_enabled_c::class,
failover_minio_enabled_c::class,
failover_max_status_age_seconds_c::class,
]);
$this->enabled = new failover_enabled_c();
$this->database_enabled = new failover_database_enabled_c();
$this->redis_enabled = new failover_redis_enabled_c();
$this->minio_enabled = new failover_minio_enabled_c();
$this->max_status_age_seconds = new failover_max_status_age_seconds_c();
}
}
@@ -27,7 +27,7 @@ require_once 'twc_spreadsheet_class.php';
// Set CORS headers
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST");
header("Access-Control-Allow-Headers: Content-Type, X-Customer-Number");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version");
// Set the timezone
date_default_timezone_set('Europe/Copenhagen');
@@ -364,11 +364,11 @@ class order_bookings_o extends db
$this->createOrderItemsBy($user_id);
}
if (!$this->containsWashCertificateItem()) {
$order = $this->getOrder();
if (!$this->containsWashCertificateItem() && !$order->containsWashCertificateItem()) {
return;
}
$order = $this->getOrder();
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal);
if ($normalizedSafetySeal !== null) {
$order->setSafetySealValue($normalizedSafetySeal);
File diff suppressed because it is too large Load Diff
@@ -1018,6 +1018,7 @@ final class TruckwashEdgeAgent
private int $lastHeartbeatAt = 0;
private int $lastMachineSignalPollAt = 0;
private int $lastMachineSignalMonitorRefreshAt = 0;
private ?array $lastControlPlaneResponse = null;
private string $agentInstanceId;
public function __construct(string $configPath)
@@ -1283,6 +1284,7 @@ final class TruckwashEdgeAgent
}
$heartbeatSucceededAt = date('c');
$this->applyBrokerUrlFromControlPlaneResponse($this->lastControlPlaneResponse);
$this->lastHeartbeatAt = time();
$this->recordSuccessfulSync($heartbeatSucceededAt, [
'last_heartbeat_success_at' => $heartbeatSucceededAt,
@@ -2430,13 +2432,15 @@ final class TruckwashEdgeAgent
private function sendControlPlaneEvent(string $endpoint, array $payload, string $type): bool
{
$this->lastControlPlaneResponse = null;
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
if ($brokerDispatch === true && !$this->shouldPersistControlPlaneEventOverHttp($endpoint)) {
return true;
}
try {
$this->http->post($endpoint, $payload, 20);
$response = $this->http->post($endpoint, $payload, 20);
$this->lastControlPlaneResponse = is_array($response) ? $response : null;
$this->recordSuccessfulSync();
return true;
} catch (Throwable $throwable) {
@@ -2457,6 +2461,29 @@ final class TruckwashEdgeAgent
}
}
private function applyBrokerUrlFromControlPlaneResponse(?array $response): void
{
if (!is_array($response)) {
return;
}
$payload = is_array($response['data'] ?? null) ? (array)$response['data'] : $response;
$brokerUrl = trim((string)($payload['broker_url'] ?? $payload['gateway']['broker_url'] ?? ''));
if ($brokerUrl === '') {
return;
}
$current = trim((string)$this->config->get('brokerUrl'));
if (rtrim($current, '/') === rtrim($brokerUrl, '/')) {
return;
}
$this->config->set('brokerUrl', rtrim($brokerUrl, '/'));
$this->config->save();
$this->configureBrokerClient();
$this->logger->info('Updated broker URL from control plane heartbeat response.');
}
private function shouldPersistControlPlaneEventOverHttp(string $endpoint): bool
{
return preg_match('#/edge-agent/gateways/\d+/heartbeat$#', $endpoint) === 1;
+2
View File
@@ -5,6 +5,7 @@ namespace routes;
use classes\authentication;
use classes\economic;
use classes\email;
use classes\release_manager;
use classes\recaptcha;
use classes\totp;
use classes\virkdata;
@@ -827,6 +828,7 @@ class authRoute
'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(),
'default_distribution_department_id' => (new economic())->getDefaultDistributionDepartmentId(),
],
'release' => (new release_manager())->runtimeForPayload($payload),
]
);
@@ -0,0 +1,95 @@
<?php
namespace routes;
use classes\authentication;
use classes\error_report_service;
use Throwable;
use traits\route_t;
class errorReportRoute
{
use route_t;
public function run(): void
{
$this->post('/error-reports', function () {
global $response;
try {
$response->success((new error_report_service())->createFromCurrentPrincipal($this->requestPayload()), 201);
} catch (Throwable $throwable) {
$status = str_contains(strtolower($throwable->getMessage()), 'authentication failed') ? 401 : 400;
$response->error(['message' => $throwable->getMessage()], $status);
}
});
$this->get('/superuser/error-reports', function () {
global $response;
$this->requirePermission('superuser_error_reports_view');
$response->success((new error_report_service())->list($this->getParametersAsArray()));
}, [
'superuser_error_reports_view' => 'View authenticated user error reports',
]);
$this->get('/superuser/error-reports/{id}', function () {
global $response;
$this->requirePermission('superuser_error_reports_view');
try {
$response->success((new error_report_service())->get($this->routeId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 404);
}
}, [
'superuser_error_reports_view' => 'View authenticated user error report details',
]);
$this->patch('/superuser/error-reports/{id}/status', function () {
global $response;
$this->requirePermission('superuser_error_reports_resolve');
try {
$payload = $this->requestPayload();
$response->success((new error_report_service())->updateStatus(
$this->routeId(),
(string)($payload['status'] ?? ''),
isset($payload['resolution_note']) ? (string)$payload['resolution_note'] : null,
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_error_reports_resolve' => 'Resolve and reopen authenticated user error reports',
]);
}
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 requestPayload(): array
{
$payload = json_decode(file_get_contents('php://input'), true);
if (!is_array($payload)) {
$payload = [];
}
if ($_GET !== []) {
$payload = array_replace($payload, $_GET);
}
return $payload;
}
}
@@ -218,6 +218,82 @@ class moduleConfigRoute
]
);
$this->get('/failover/config', function () {
global $response;
$this->requirePermission('modules_failover_config');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('failover_config', 'global', 1, $user->id, 'FAILOVER_CONFIG', 'Successfully fetched failover config');
$response->success(
(new \classes\failover())->config->getConfigRequest()
);
} else {
(new logs_o())->add('failover_config', 'global', 1, 0, 'FAILOVER_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'modules_failover_config' => 'Get failover config'
]
);
$this->post('/failover/config', function () {
global $response;
$this->requirePermission('modules_failover_config');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('failover_config', 'global', 1, $user->id, 'FAILOVER_CONFIG', 'Successfully updated failover config');
$response->success(
(new \classes\failover())->config->postConfigRequest()
);
} else {
(new logs_o())->add('failover_config', 'global', 1, 0, 'FAILOVER_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'modules_failover_config' => 'Update failover config'
]
);
$this->get('/coolify/config', function () {
global $response;
$this->requirePermission('superuser_coolify_manage');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('coolify_config', 'global', 1, $user->id, 'COOLIFY_CONFIG', 'Successfully fetched Coolify config');
$response->success(
(new \classes\coolify())->config->getConfigRequest()
);
} else {
(new logs_o())->add('coolify_config', 'global', 1, 0, 'COOLIFY_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'superuser_coolify_manage' => 'Get Coolify config'
]
);
$this->post('/coolify/config', function () {
global $response;
$this->requirePermission('superuser_coolify_manage');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('coolify_config', 'global', 1, $user->id, 'COOLIFY_CONFIG', 'Successfully updated Coolify config');
$response->success(
(new \classes\coolify())->config->postConfigRequest()
);
} else {
(new logs_o())->add('coolify_config', 'global', 1, 0, 'COOLIFY_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'superuser_coolify_manage' => 'Update Coolify config'
]
);
/** Bird config > GET */
$this->get('/bird/config', function () {
global $response;
@@ -49,8 +49,10 @@ class moduleScannerRoute
// Success
$response->success(['success' => true, 'license_plate_number' => $lpr_result['license_plate_number']]);
} else {
throw new Exception('License plate extraction failed.');
//$response->error($lpr_result['message'] ?? 'License plate recognition failed.', $lpr_result);
$response->response(false, [
'message' => $lpr_result['message'] ?? 'No license plate detected.',
'reason' => 'no_license_plate_detected',
], 200);
}
exit;
// For future use with OpenAI.
@@ -0,0 +1,377 @@
<?php
namespace routes;
use classes\authentication;
use classes\release_manager;
use Throwable;
use traits\route_t;
class releaseManagerRoute
{
use route_t;
public function run(): void
{
$this->get('/release/bootstrap', function () {
global $response;
$response->success((new release_manager())->bootstrap());
});
$this->get('/release/runtime', function () {
global $response;
$response->success((new release_manager())->runtimeForCurrentPrincipal());
});
$this->post('/release/timeline/events', function () {
global $response;
$payload = $this->requestPayload();
$events = is_array($payload['events'] ?? null) ? $payload['events'] : ($payload['event'] ?? $payload);
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
$response->success((new release_manager())->ingestTimelineEvents(
is_array($events) ? $events : [],
$context
), 202);
});
$this->post('/release/github/webhook', function () {
global $response;
try {
$headers = function_exists('getallheaders') ? getallheaders() : [];
$rawBody = file_get_contents('php://input') ?: '';
$response->success((new release_manager())->handleGithubWebhook($headers, $rawBody), 202);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 401);
}
});
$this->get('/superuser/releases', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
$response->success((new release_manager())->summary());
}, [
'superuser_release_manager_view' => 'View release manager channels, deployments, and health',
]);
$this->get('/superuser/releases/config', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
$response->success((new release_manager())->releaseConfig());
}, [
'superuser_release_manager_view' => 'View Release Manager source configuration',
]);
$this->post('/superuser/releases/config', function () {
global $response;
$this->requirePermission('superuser_release_manager_manage');
try {
$response->success((new release_manager())->updateReleaseConfig($this->requestPayload(), $this->actorUserId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_manage' => 'Update Release Manager source configuration',
]);
$this->get('/superuser/releases/github/repositories', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
try {
$response->success((new release_manager())->listGithubRepositories($this->getParametersAsArray()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_view' => 'List private GitHub repositories available to Release Manager',
]);
$this->get('/superuser/releases/github/branches', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->listGithubBranches($this->getParametersAsArray()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_deploy' => 'List GitHub branches for a Release Manager repository',
]);
$this->get('/superuser/releases/github/commits', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->listGithubCommits($this->getParametersAsArray()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_deploy' => 'List or resolve GitHub commits for a Release Manager repository',
]);
$this->post('/superuser/releases/github/test', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
$response->success((new release_manager())->testGithubRepositoryAccess($this->requestPayload()));
}, [
'superuser_release_manager_deploy' => 'Test Release Manager access to a GitHub repository, branch, and commit',
]);
$this->get('/superuser/releases/channels', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
$response->success((new release_manager())->listChannels());
}, [
'superuser_release_manager_view' => 'View release channels',
]);
$this->post('/superuser/releases/channels', function () {
global $response;
$this->requirePermission('superuser_release_manager_manage');
try {
$response->success((new release_manager())->createChannel($this->requestPayload(), $this->actorUserId()), 201);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_manage' => 'Create release channels',
]);
$this->patch('/superuser/releases/channels/{id}', function () {
global $response;
$this->requirePermission('superuser_release_manager_manage');
try {
$response->success((new release_manager())->updateChannel($this->routeId(), $this->requestPayload(), $this->actorUserId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_manage' => 'Update release channels',
]);
$this->post('/superuser/releases/channels/{id}/rollback', function () {
global $response;
$this->requirePermission('superuser_release_manager_rollback');
try {
$response->success((new release_manager())->rollbackChannel($this->routeId(), $this->actorUserId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_release_manager_rollback' => 'Rollback an active release channel',
]);
$this->get('/superuser/releases/assignments', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
$response->success((new release_manager())->listAssignments());
}, [
'superuser_release_manager_view' => 'View release channel assignments',
]);
$this->post('/superuser/releases/assignments', function () {
global $response;
$this->requirePermission('superuser_release_manager_manage');
try {
$response->success((new release_manager())->createAssignment($this->requestPayload(), $this->actorUserId()), 201);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_manage' => 'Assign users, subusers, or customers to release channels',
]);
$this->delete('/superuser/releases/assignments/{id}', function () {
global $response;
$this->requirePermission('superuser_release_manager_manage');
try {
$response->success((new release_manager())->deleteAssignment($this->routeId(), $this->actorUserId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 404);
}
}, [
'superuser_release_manager_manage' => 'Remove release channel assignments',
]);
$this->get('/superuser/releases/targets', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
$response->success((new release_manager())->listDeploymentTargets());
}, [
'superuser_release_manager_deploy' => 'View release deployment targets',
]);
$this->post('/superuser/releases/targets', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->upsertDeploymentTarget($this->requestPayload(), $this->actorUserId()), 201);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_deploy' => 'Create or update GitHub to Coolify release deployment targets',
]);
$this->delete('/superuser/releases/targets/{id}', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->deleteDeploymentTarget($this->routeId(), $this->actorUserId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 404);
}
}, [
'superuser_release_manager_deploy' => 'Delete release deployment targets',
]);
$this->get('/superuser/releases/service-sets', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
$response->success((new release_manager())->listServiceSets());
}, [
'superuser_release_manager_view' => 'View reusable Release Manager service sets',
]);
$this->post('/superuser/releases/service-sets', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->createServiceSet($this->requestPayload(), $this->actorUserId()), 201);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_deploy' => 'Create reusable Release Manager service sets',
]);
$this->get('/superuser/releases/bundles', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
$limit = (int)($this->getParameter('limit') ?? 50);
$response->success((new release_manager())->listBundles($limit));
}, [
'superuser_release_manager_view' => 'View Release Manager bundles',
]);
$this->post('/superuser/releases/bundles', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->createBundle($this->requestPayload(), $this->actorUserId()), 201);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_deploy' => 'Create Release Manager full-stack bundles',
]);
$this->post('/superuser/releases/bundles/{id}/deploy', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->deployBundle($this->routeId(), $this->actorUserId()), 202);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_release_manager_deploy' => 'Deploy Release Manager full-stack bundles',
]);
$this->post('/superuser/releases/bundles/{id}/promote', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->promoteBundle($this->routeId(), $this->actorUserId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_release_manager_deploy' => 'Promote Release Manager bundles without data failover',
]);
$this->get('/superuser/releases/deployments', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
$limit = (int)($this->getParameter('limit') ?? 50);
$response->success((new release_manager())->listDeployments($limit));
}, [
'superuser_release_manager_view' => 'View release deployments',
]);
$this->post('/superuser/releases/deployments', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->startDeployment($this->requestPayload(), $this->actorUserId()), 202);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_release_manager_deploy' => 'Trigger release deployments from GitHub/Coolify targets',
]);
$this->post('/superuser/releases/deployments/{id}/promote', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->promoteDeployment($this->routeId(), $this->actorUserId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_release_manager_deploy' => 'Promote a deployment to its release channel',
]);
$this->post('/superuser/releases/replay-targets', function () {
global $response;
$this->requirePermission('superuser_release_manager_replay');
try {
$response->success((new release_manager())->setReplayTarget($this->requestPayload(), $this->actorUserId()), 201);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_replay' => 'Enable release timeline replay capture for a user, customer, subuser, or channel',
]);
$this->get('/superuser/releases/timeline', function () {
global $response;
$this->requirePermission('superuser_release_manager_replay');
$response->success((new release_manager())->searchTimeline($this->getParametersAsArray()));
}, [
'superuser_release_manager_replay' => 'Replay release failure timelines',
]);
}
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 requestPayload(): array
{
$payload = json_decode(file_get_contents('php://input'), true);
if (!is_array($payload)) {
$payload = [];
}
if ($_GET !== []) {
$payload = array_replace($payload, $_GET);
}
return $payload;
}
}
@@ -0,0 +1,258 @@
<?php
namespace routes;
use classes\authentication;
use classes\coolify_manager;
use Throwable;
use traits\route_t;
class superuserCoolifyRoute
{
use route_t;
public function run(): void
{
$this->get('/superuser/coolify', function () {
global $response;
$this->requirePermission('superuser_coolify_view');
$response->success((new coolify_manager())->summary());
}, [
'superuser_coolify_view' => 'View Coolify-managed replicated infrastructure targets',
]);
$this->get('/superuser/coolify/load-balancer', function () {
global $response;
$this->requirePermission('superuser_coolify_view');
$response->success((new coolify_manager())->loadBalancerSummary());
}, [
'superuser_coolify_view' => 'View the Coolify public gateway Load Balancer state',
]);
$this->post('/superuser/coolify/load-balancer/reconcile', function () {
global $response;
$this->requirePermission('superuser_coolify_manage');
try {
$parameters = $this->getParametersAsArray();
$dryRun = array_key_exists('dry_run', $parameters)
? filter_var($parameters['dry_run'], FILTER_VALIDATE_BOOLEAN)
: !filter_var($parameters['enforce'] ?? false, FILTER_VALIDATE_BOOLEAN);
$result = (new coolify_manager())->reconcileLoadBalancer($dryRun, $this->actorUserId());
if (($result['ok'] ?? false) !== true) {
$response->error($result, 409);
}
$response->success($result);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_coolify_manage' => 'Reconcile Hetzner Load Balancer targets and services for the Coolify gateway',
]);
$this->get('/superuser/coolify/gateways', function () {
global $response;
$this->requirePermission('superuser_coolify_view');
$response->success((new coolify_manager())->listLoadBalancerGateways());
}, [
'superuser_coolify_view' => 'List Coolify public gateway Load Balancer targets',
]);
$this->post('/superuser/coolify/gateways', function () {
global $response;
$this->requirePermission('superuser_coolify_manage');
try {
$response->success((new coolify_manager())->saveLoadBalancerGateway(
$this->getParametersAsArray(),
$this->actorUserId()
), 201);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_coolify_manage' => 'Create or update Coolify public gateway Load Balancer targets',
]);
$this->post('/superuser/coolify/gateways/{id}/test', function () {
global $response;
$this->requirePermission('superuser_coolify_manage');
try {
$response->success((new coolify_manager())->testLoadBalancerGateway(
$this->routeId(),
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_coolify_manage' => 'Probe an individual Coolify public gateway target',
]);
$this->post('/superuser/coolify/instances', function () {
global $response;
$this->requirePermission('superuser_coolify_manage');
try {
$response->success((new coolify_manager())->createInstance(
$this->getParametersAsArray(),
$this->actorUserId()
), 201);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_coolify_manage' => 'Create and update Coolify API connections',
]);
$this->post('/superuser/coolify/instances/{id}/test', function () {
global $response;
$this->requirePermission('superuser_coolify_manage');
$response->success((new coolify_manager())->testInstance($this->routeId(), $this->actorUserId()));
}, [
'superuser_coolify_manage' => 'Test Coolify API connectivity',
]);
$this->get('/superuser/coolify/instances/{id}/placement', function () {
global $response;
$this->requirePermission('superuser_coolify_view');
try {
$response->success((new coolify_manager())->discoverInstancePlacement($this->routeId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 502);
}
}, [
'superuser_coolify_view' => 'Discover Coolify projects, environments, and servers for target placement',
]);
$this->get('/superuser/coolify/targets', function () {
global $response;
$this->requirePermission('superuser_coolify_view');
$kind = (string)($this->getParameter('kind') ?? '');
$response->success((new coolify_manager())->listTargets($kind !== '' ? $kind : null));
}, [
'superuser_coolify_view' => 'List Coolify-managed replication targets',
]);
$this->post('/superuser/coolify/targets', function () {
global $response;
$this->requirePermission('superuser_coolify_manage');
try {
$response->success((new coolify_manager())->createTarget(
$this->getParametersAsArray(),
$this->actorUserId()
), 201);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_coolify_manage' => 'Create Coolify-managed MariaDB, Redis, and MinIO replication targets',
]);
$this->post('/superuser/coolify/targets/{id}/reconcile', function () {
global $response;
$this->requirePermission('superuser_coolify_reconcile');
try {
$result = (new coolify_manager())->reconcileTarget($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_coolify_reconcile' => 'Reconcile expected Coolify deployment state without primary downtime',
]);
$this->post('/superuser/coolify/targets/{id}/deploy', function () {
global $response;
$this->requirePermission('superuser_coolify_reconcile');
try {
$result = (new coolify_manager())->deployTarget($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_coolify_reconcile' => 'Deploy and provision a passive Coolify-managed replication target',
]);
$this->post('/superuser/coolify/targets/{id}/restart', function () {
global $response;
$this->requirePermission('superuser_coolify_reconcile');
try {
$result = (new coolify_manager())->restartTarget($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_coolify_reconcile' => 'Restart a passive Coolify target without restarting the active primary',
]);
$this->post('/superuser/coolify/targets/{id}/failover', function () {
global $response;
$this->requirePermission('superuser_coolify_failover');
try {
$response->success((new coolify_manager())->failoverTarget($this->routeId(), $this->actorUserId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_coolify_failover' => 'Promote a healthy Coolify-managed replica through the replication module',
]);
$this->delete('/superuser/coolify/targets/{id}', function () {
global $response;
$this->requirePermission('superuser_coolify_manage');
try {
$response->success((new coolify_manager())->deleteTarget(
$this->routeId(),
$this->getParametersAsArray(),
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_coolify_manage' => 'Delete Coolify target mappings with explicit destructive confirmation',
]);
}
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;
}
}
}
@@ -96,7 +96,8 @@ class superuserReplicationRoute
$result = (new replication_manager())->provisionHost(
(string)$this->fromRoute('kind'),
$this->routeId(),
$this->actorUserId()
$this->actorUserId(),
true
);
if (($result['ok'] ?? false) !== true) {
$response->error($result, 409);
@@ -126,6 +127,24 @@ class superuserReplicationRoute
'superuser_replication_promote' => 'Promote a healthy caught-up database, Redis, or MinIO replica to primary',
]);
$this->patch('/superuser/replication/{kind}/{id}', function () {
global $response;
$this->requirePermission('superuser_replication_manage');
try {
$response->success((new replication_manager())->renameHost(
(string)$this->fromRoute('kind'),
$this->routeId(),
$this->getParametersAsArray(),
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_replication_manage' => 'Rename database, Redis, and MinIO replication hosts',
]);
$this->delete('/superuser/replication/{kind}/{id}', function () {
global $response;
@@ -0,0 +1,222 @@
{
"version": 1,
"generated_at": "2026-05-19T00:57:20+02:00",
"active": {
"database": {
"id": 1,
"host": "23.88.23.183",
"port": 5432,
"database": "nnks_db",
"user": "root",
"password_secret": "twsec:v1:eyJub25jZSI6ImRBSDRkejROdGNKTm5NNHQiLCJ0YWciOiJFRFNCMmlNRWI3eXZXUDdvT1FIZnB3PT0iLCJjaXBoZXJ0ZXh0IjoiNEpSUGJnSjExVmV1cWIrV3ByZDV0b0l0NlE9PSJ9",
"ssl_mode": "DISABLED"
},
"redis": {
"id": 2,
"host": "23.88.23.183",
"port": 5433,
"database": 0,
"user": "default",
"password_secret": "twsec:v1:eyJub25jZSI6Ii9PVTB0bExoend6RWlPRGoiLCJ0YWciOiIxVGRyWEFlVkV4NnpieHIxQVBrcGR3PT0iLCJjaXBoZXJ0ZXh0IjoiZG02ekVoRjI4blZjUWFuN0R5UWRxYWExY0E9PSJ9"
},
"minio": {
"id": 4,
"endpoint": "http://162.55.225.220:9000",
"access_key": "d7u6RaFyYmckAIWYGUYr",
"secret_key_secret": "twsec:v1:eyJub25jZSI6Ikx5SjdUWTZqVTIrV0lWK1UiLCJ0YWciOiI5dkF5MVB1MWRwcEZNU3NzVVNETUZRPT0iLCJjaXBoZXJ0ZXh0IjoiUkRCQldZdkE3SksxWG1CMWN2TnZpUnFiSXY5ckNuL3Raanc5K3lIbGwzQkJlbTBLOUV1c0ZRPT0ifQ==",
"buckets": [
"attachments",
"backups",
"invoices",
"pdfs",
"uploads",
"truckwashdev"
]
}
},
"failover": {
"config": {
"enabled": true,
"database_enabled": true,
"redis_enabled": false,
"minio_enabled": true,
"max_status_age_seconds": 90
},
"hosts": {
"database": [
{
"id": 1,
"kind": "database",
"label": "Current database primary",
"host": "23.88.23.183",
"port": 5432,
"database_name": "nnks_db",
"database_index": null,
"username": "root",
"password_secret": "twsec:v1:eyJub25jZSI6ImRBSDRkejROdGNKTm5NNHQiLCJ0YWciOiJFRFNCMmlNRWI3eXZXUDdvT1FIZnB3PT0iLCJjaXBoZXJ0ZXh0IjoiNEpSUGJnSjExVmV1cWIrV3ByZDV0b0l0NlE9PSJ9",
"admin_username": "",
"admin_password_secret": "",
"replication_username": "",
"replication_password_secret": "",
"role": "primary",
"status": "ok",
"replication_source_id": null,
"ssl_mode": "DISABLED",
"options_json": "{\"source\":\"environment\"}",
"last_status_json": "{\"status\":\"ok\",\"replication_percent\":100,\"lag_seconds\":null,\"blockers\":[],\"raw\":{\"server_version\":\"11.8.6-MariaDB-ubu2404-log\",\"gtid_binlog_pos\":\"0-1-1114923\",\"gtid_current_pos\":\"0-1-1114923\",\"gtid_slave_pos\":\"\",\"gtid_strict_mode\":\"ON\",\"log_bin\":\"ON\",\"read_only\":\"OFF\",\"server_id\":\"1\",\"clone_plugin_active\":false},\"checked_at\":\"2026-05-19T00:57:17+02:00\"}",
"last_checked_at": "2026-05-18 22:57:17",
"updated_at": "2026-05-18 22:57:17",
"deleted_at": null
},
{
"id": 3,
"kind": "database",
"label": "mariadb-replica-1",
"host": "65.21.214.30",
"port": 5441,
"database_name": "nnks_db",
"database_index": null,
"username": "nnks_db_user",
"password_secret": "twsec:v1:eyJub25jZSI6IkFzazZBNCsxWFh0L3dUWDEiLCJ0YWciOiJ2WVhNNXhteGpZZ0NHeXEzb0hILzBRPT0iLCJjaXBoZXJ0ZXh0IjoiNjVNaUVwRFVNeDMrdmtUZ2lqelhtbkJnK05HV0p3dlpyUFFkWWhKazdBMD0ifQ==",
"admin_username": "root",
"admin_password_secret": "twsec:v1:eyJub25jZSI6IjVRNkVIdGNEV2JhZXVEc04iLCJ0YWciOiJRZitRUHhQYzM1NE1jVVpYbGFmbzBBPT0iLCJjaXBoZXJ0ZXh0IjoiZXAzbzJuQW0vMldHSkFHSXhGWmM4SkFyeFVnNWt1K1JrcmpxN3gxZUtnYz0ifQ==",
"replication_username": "replication",
"replication_password_secret": "twsec:v1:eyJub25jZSI6IlJUYW1pUHQ2UjhZTFlIUloiLCJ0YWciOiJDaVI5Y251eUVkRHo2RmorYkp3N2NRPT0iLCJjaXBoZXJ0ZXh0IjoiREp5WTRTSS9qVXcyOEFGdVJBTE5BdzVnY2ROVFNiNmR2NkQ5Y1M3RkJSZz0ifQ==",
"role": "replica",
"status": "ok",
"replication_source_id": 1,
"ssl_mode": "DISABLED",
"options_json": "{\"allow_preseeded_replica\":true}",
"last_status_json": "{\"status\":\"ok\",\"replication_percent\":100,\"lag_seconds\":0,\"blockers\":[],\"raw\":{\"server_version\":\"11.8.6-MariaDB-ubu2404-log\",\"gtid_binlog_pos\":\"0-2-1098\",\"gtid_current_pos\":\"0-1-1114860\",\"gtid_slave_pos\":\"0-1-1114860\",\"gtid_strict_mode\":\"ON\",\"log_bin\":\"ON\",\"read_only\":\"ON\",\"server_id\":\"2\",\"clone_plugin_active\":false,\"source_gtid_executed\":\"0-1-1114955\",\"replica_status\":{\"Slave_IO_State\":\"Waiting for master to send event\",\"Master_Host\":\"23.88.23.183\",\"Master_User\":\"replication\",\"Master_Port\":\"5432\",\"Connect_Retry\":\"60\",\"Master_Log_File\":\"mariadb-bin.000017\",\"Read_Master_Log_Pos\":\"237616337\",\"Relay_Log_File\":\"mysqld-relay-bin.000002\",\"Relay_Log_Pos\":\"97422\",\"Relay_Master_Log_File\":\"mariadb-bin.000017\",\"Slave_IO_Running\":\"Yes\",\"Slave_SQL_Running\":\"Yes\",\"Replicate_Do_DB\":\"\",\"Replicate_Ignore_DB\":\"\",\"Replicate_Do_Table\":\"\",\"Replicate_Ignore_Table\":\"nnks_db.edge_gateway_shell_sessions,nnks_db.logs,nnks_db.edge_gateway_log_entries,nnks_db.replication_status_snapshots,nnks_db.system_search_documents,nnks_db.replication_operations,nnks_db.replication_audit_logs,nnks_db.edge_gateway_audit_logs\",\"Replicate_Wild_Do_Table\":\"\",\"Replicate_Wild_Ignore_Table\":\"\",\"Last_Errno\":\"0\",\"Last_Error\":\"\",\"Skip_Counter\":\"0\",\"Exec_Master_Log_Pos\":\"237616337\",\"Relay_Log_Space\":\"97732\",\"Until_Condition\":\"None\",\"Until_Log_File\":\"\",\"Until_Log_Pos\":\"0\",\"Master_SSL_Allowed\":\"Yes\",\"Master_SSL_CA_File\":\"\",\"Master_SSL_CA_Path\":\"\",\"Master_SSL_Cert\":\"\",\"Master_SSL_Cipher\":\"\",\"Master_SSL_Key\":\"\",\"Seconds_Behind_Master\":\"0\",\"Master_SSL_Verify_Server_Cert\":\"Yes\",\"Last_IO_Errno\":\"0\",\"Last_IO_Error\":\"\",\"Last_SQL_Errno\":\"0\",\"Last_SQL_Error\":\"\",\"Replicate_Ignore_Server_Ids\":\"\",\"Master_Server_Id\":\"1\",\"Master_SSL_Crl\":\"\",\"Master_SSL_Crlpath\":\"\",\"Using_Gtid\":\"Slave_Pos\",\"Gtid_IO_Pos\":\"0-1-1114955\",\"Replicate_Do_Domain_Ids\":\"\",\"Replicate_Ignore_Domain_Ids\":\"\",\"Parallel_Mode\":\"optimistic\",\"SQL_Delay\":\"0\",\"SQL_Remaining_Delay\":null,\"Slave_SQL_Running_State\":\"Slave has read all relay log; waiting for more updates\",\"Slave_DDL_Groups\":\"180\",\"Slave_Non_Transactional_Groups\":\"15\",\"Slave_Transactional_Groups\":\"900658\",\"Replicate_Rewrite_DB\":\"\"}},\"checked_at\":\"2026-05-19T00:57:17+02:00\"}",
"last_checked_at": "2026-05-18 22:57:18",
"updated_at": "2026-05-18 22:57:18",
"deleted_at": null
},
{
"id": 8,
"kind": "database",
"label": "mariadb-replica-2",
"host": "65.21.214.30",
"port": 3307,
"database_name": "nnks_db",
"database_index": null,
"username": "nnks_db_user",
"password_secret": "twsec:v1:eyJub25jZSI6IitoVThzV0UxVEEycDhZYkYiLCJ0YWciOiJwZSs4Z2g1QWxKNzdwb3F4c1pQelV3PT0iLCJjaXBoZXJ0ZXh0IjoiUXdFWnhMUGx1ejczbHc0aUpxc2xaeUExOFlRcWwralhGWkdtSWE2TzVHMD0ifQ==",
"admin_username": "root",
"admin_password_secret": "twsec:v1:eyJub25jZSI6Ik9BbElsV0RZSUZpUHRsZmQiLCJ0YWciOiJNczZzWFBueUVneUVNRUh1RGlSQjZnPT0iLCJjaXBoZXJ0ZXh0IjoiUW52Mmh2MUJOQXdIbm12eDg1MnUyeFRsOW9xTUxpOXI4Y1pldXV1bEdEaz0ifQ==",
"replication_username": "replication",
"replication_password_secret": "twsec:v1:eyJub25jZSI6IlJ5aTBVbCtUODFDRVArTWsiLCJ0YWciOiJKMVZ6MVZpQ2lRZU04SkpLYmlKa3hRPT0iLCJjaXBoZXJ0ZXh0IjoiajJTQUl6OW40T0xYTFZXdjhSeHlVWGJSZ2M0ZzY1R0U4ZWdIRHN4UHo5dz0ifQ==",
"role": "replica",
"status": "ok",
"replication_source_id": 1,
"ssl_mode": "DISABLED",
"options_json": "{\"allow_preseeded_replica\":true,\"deployment_provider\":\"coolify\",\"coolify_instance_id\":1,\"coolify_target_id\":1}",
"last_status_json": "{\"status\":\"ok\",\"replication_percent\":100,\"lag_seconds\":0,\"blockers\":[],\"raw\":{\"server_version\":\"11.8.6-MariaDB-ubu2404-log\",\"gtid_binlog_pos\":\"0-2-1098\",\"gtid_current_pos\":\"0-1-1114955\",\"gtid_slave_pos\":\"0-1-1114955\",\"gtid_strict_mode\":\"ON\",\"log_bin\":\"ON\",\"read_only\":\"ON\",\"server_id\":\"2\",\"clone_plugin_active\":false,\"source_gtid_executed\":\"0-1-1114989\",\"replica_status\":{\"Slave_IO_State\":\"Waiting for master to send event\",\"Master_Host\":\"23.88.23.183\",\"Master_User\":\"replication\",\"Master_Port\":\"5432\",\"Connect_Retry\":\"60\",\"Master_Log_File\":\"\",\"Read_Master_Log_Pos\":\"4\",\"Relay_Log_File\":\"mysqld-relay-bin.000001\",\"Relay_Log_Pos\":\"4\",\"Relay_Master_Log_File\":\"\",\"Slave_IO_Running\":\"Yes\",\"Slave_SQL_Running\":\"Yes\",\"Replicate_Do_DB\":\"\",\"Replicate_Ignore_DB\":\"\",\"Replicate_Do_Table\":\"\",\"Replicate_Ignore_Table\":\"nnks_db.edge_gateway_shell_sessions,nnks_db.logs,nnks_db.replication_status_snapshots,nnks_db.edge_gateway_log_entries,nnks_db.edge_gateway_audit_logs,nnks_db.replication_operations,nnks_db.replication_audit_logs,nnks_db.system_search_documents\",\"Replicate_Wild_Do_Table\":\"\",\"Replicate_Wild_Ignore_Table\":\"\",\"Last_Errno\":\"0\",\"Last_Error\":\"\",\"Skip_Counter\":\"0\",\"Exec_Master_Log_Pos\":\"4\",\"Relay_Log_Space\":\"256\",\"Until_Condition\":\"None\",\"Until_Log_File\":\"\",\"Until_Log_Pos\":\"0\",\"Master_SSL_Allowed\":\"Yes\",\"Master_SSL_CA_File\":\"\",\"Master_SSL_CA_Path\":\"\",\"Master_SSL_Cert\":\"\",\"Master_SSL_Cipher\":\"\",\"Master_SSL_Key\":\"\",\"Seconds_Behind_Master\":\"0\",\"Master_SSL_Verify_Server_Cert\":\"Yes\",\"Last_IO_Errno\":\"0\",\"Last_IO_Error\":\"\",\"Last_SQL_Errno\":\"0\",\"Last_SQL_Error\":\"\",\"Replicate_Ignore_Server_Ids\":\"\",\"Master_Server_Id\":\"1\",\"Master_SSL_Crl\":\"\",\"Master_SSL_Crlpath\":\"\",\"Using_Gtid\":\"Slave_Pos\",\"Gtid_IO_Pos\":\"0-1-1114955\",\"Replicate_Do_Domain_Ids\":\"\",\"Replicate_Ignore_Domain_Ids\":\"\",\"Parallel_Mode\":\"optimistic\",\"SQL_Delay\":\"0\",\"SQL_Remaining_Delay\":null,\"Slave_SQL_Running_State\":\"Slave has read all relay log; waiting for more updates\",\"Slave_DDL_Groups\":\"171\",\"Slave_Non_Transactional_Groups\":\"14\",\"Slave_Transactional_Groups\":\"114109\",\"Replicate_Rewrite_DB\":\"\"},\"replication_access_repair\":{\"ok\":true,\"denied_hosts\":[\"10.0.1.13\"],\"grant_hosts\":[\"%\",\"65.21.214.30\",\"65.21.214.%\",\"10.0.1.13\",\"10.0.1.%\"]}},\"checked_at\":\"2026-05-19T00:57:20+02:00\"}",
"last_checked_at": "2026-05-18 22:57:20",
"updated_at": "2026-05-18 22:57:20",
"deleted_at": null
}
],
"redis": [
{
"id": 2,
"kind": "redis",
"label": "Current Redis primary",
"host": "23.88.23.183",
"port": 5433,
"database_name": null,
"database_index": 0,
"username": "default",
"password_secret": "twsec:v1:eyJub25jZSI6Ii9PVTB0bExoend6RWlPRGoiLCJ0YWciOiIxVGRyWEFlVkV4NnpieHIxQVBrcGR3PT0iLCJjaXBoZXJ0ZXh0IjoiZG02ekVoRjI4blZjUWFuN0R5UWRxYWExY0E9PSJ9",
"admin_username": "",
"admin_password_secret": "",
"replication_username": "",
"replication_password_secret": "",
"role": "primary",
"status": "ok",
"replication_source_id": null,
"ssl_mode": null,
"options_json": "{\"source\":\"environment\"}",
"last_status_json": "{\"status\":\"ok\",\"replication_percent\":100,\"lag_seconds\":null,\"blockers\":[],\"raw\":{\"role\":[\"master\",158560415,[]],\"replication\":{\"role\":\"master\",\"connected_slaves\":\"0\",\"master_failover_state\":\"no-failover\",\"master_replid\":\"d66ae5e486a819a693d4dc9cd12883ed8f987c74\",\"master_replid2\":\"0000000000000000000000000000000000000000\",\"master_repl_offset\":\"158560415\",\"second_repl_offset\":\"-1\",\"repl_backlog_active\":\"0\",\"repl_backlog_size\":\"1048576\",\"repl_backlog_first_byte_offset\":\"0\",\"repl_backlog_histlen\":\"0\"}},\"checked_at\":\"2026-05-19T00:57:17+02:00\"}",
"last_checked_at": "2026-05-18 22:57:17",
"updated_at": "2026-05-18 22:57:17",
"deleted_at": null
}
],
"minio": [
{
"id": 4,
"kind": "minio",
"label": "Current MinIO primary",
"host": "162.55.225.220",
"port": 9000,
"database_name": null,
"database_index": null,
"username": "d7u6RaFyYmckAIWYGUYr",
"password_secret": "twsec:v1:eyJub25jZSI6Ikx5SjdUWTZqVTIrV0lWK1UiLCJ0YWciOiI5dkF5MVB1MWRwcEZNU3NzVVNETUZRPT0iLCJjaXBoZXJ0ZXh0IjoiUkRCQldZdkE3SksxWG1CMWN2TnZpUnFiSXY5ckNuL3Raanc5K3lIbGwzQkJlbTBLOUV1c0ZRPT0ifQ==",
"admin_username": "",
"admin_password_secret": "",
"replication_username": "",
"replication_password_secret": "",
"role": "primary",
"status": "ok",
"replication_source_id": null,
"ssl_mode": null,
"options_json": "{\"source\":\"environment\",\"scheme\":\"http\",\"endpoint\":\"http://162.55.225.220:9000\",\"buckets\":[\"attachments\",\"backups\",\"invoices\",\"pdfs\",\"uploads\",\"truckwashdev\"],\"console_port\":9001,\"space_headroom_percent\":20}",
"last_status_json": "{\"status\":\"ok\",\"replication_percent\":100,\"lag_seconds\":null,\"blockers\":[],\"raw\":{\"buckets\":[{\"name\":\"attachments\",\"status\":\"ok\",\"bytes\":null,\"objects\":null,\"expired_bytes\":null,\"expired_objects\":null,\"measured\":false,\"retention_days\":null},{\"name\":\"backups\",\"status\":\"ok\",\"bytes\":null,\"objects\":null,\"expired_bytes\":null,\"expired_objects\":null,\"measured\":false,\"retention_days\":null},{\"name\":\"invoices\",\"status\":\"ok\",\"bytes\":null,\"objects\":null,\"expired_bytes\":null,\"expired_objects\":null,\"measured\":false,\"retention_days\":null},{\"name\":\"pdfs\",\"status\":\"ok\",\"bytes\":null,\"objects\":null,\"expired_bytes\":null,\"expired_objects\":null,\"measured\":false,\"retention_days\":null},{\"name\":\"uploads\",\"status\":\"ok\",\"bytes\":null,\"objects\":null,\"expired_bytes\":null,\"expired_objects\":null,\"measured\":false,\"retention_days\":null},{\"name\":\"truckwashdev\",\"status\":\"ok\",\"bytes\":null,\"objects\":null,\"expired_bytes\":null,\"expired_objects\":null,\"measured\":false,\"retention_days\":null}],\"storage\":{\"source_bytes\":0,\"source_objects\":0,\"measured\":false}},\"checked_at\":\"2026-05-19T00:57:17+02:00\"}",
"last_checked_at": "2026-05-18 22:57:17",
"updated_at": "2026-05-18 22:57:17",
"deleted_at": null
},
{
"id": 9,
"kind": "minio",
"label": "minio-replica-2",
"host": "94.130.142.41",
"port": 9010,
"database_name": null,
"database_index": null,
"username": "twminio38e30e538cf07101a9db00ae",
"password_secret": "twsec:v1:eyJub25jZSI6Ild2eUxvK2ZtdjFNeTQ4bm4iLCJ0YWciOiI5M24rRDM2UnBRaUQvQUkxaDhkSTBBPT0iLCJjaXBoZXJ0ZXh0IjoiM0l1a2dvaEQvS0MyQmZLY0JHVG5wWDlkWWNIUklRK0svUjBodTJ5NEtyTT0ifQ==",
"admin_username": "",
"admin_password_secret": "twsec:v1:eyJub25jZSI6ImRiblNwSEJJWHNXeWtWMXciLCJ0YWciOiI3dTcxZ0FYN1lMclNkWFgwUzNMRHV3PT0iLCJjaXBoZXJ0ZXh0IjoiIn0=",
"replication_username": "",
"replication_password_secret": "twsec:v1:eyJub25jZSI6IkZlQ29vMDY3WVFIb1ZjWDAiLCJ0YWciOiJqN3J5RytlSGs0T2s1Skhyc2gxSW53PT0iLCJjaXBoZXJ0ZXh0IjoiIn0=",
"role": "replica",
"status": "down",
"replication_source_id": 4,
"ssl_mode": "DISABLED",
"options_json": "{\"scheme\":\"http\",\"buckets\":[\"attachments\",\"backups\",\"invoices\",\"pdfs\",\"uploads\",\"truckwashdev\"],\"console_port\":9011,\"space_headroom_percent\":20,\"deployment_provider\":\"coolify\",\"coolify_instance_id\":1,\"endpoint\":\"http://94.130.142.41:9010\",\"coolify_target_id\":2}",
"last_status_json": "{\"status\":\"down\",\"replication_percent\":0,\"lag_seconds\":null,\"blockers\":[\"Class \\\"Aws\\\\S3\\\\S3Client\\\" not found\"],\"raw\":[],\"checked_at\":\"2026-05-19T00:57:20+02:00\"}",
"last_checked_at": "2026-05-18 22:57:21",
"updated_at": "2026-05-18 22:57:21",
"deleted_at": null
},
{
"id": 13,
"kind": "minio",
"label": "minio-replica-1",
"host": "23.88.23.183",
"port": 9010,
"database_name": null,
"database_index": null,
"username": "twminio1a6370c14ab3a949a07a9e16",
"password_secret": "twsec:v1:eyJub25jZSI6ImFzK0tLZlBRYWlERzU5OWQiLCJ0YWciOiJISmtJZFpwaG5JbktnZkxHZ09DcEVBPT0iLCJjaXBoZXJ0ZXh0IjoiWTkxYm1SZ2JXWEJpdGJpeWtUUTAvMHZjSU14Rzg3a2VrcHpNSDk5UzYxRT0ifQ==",
"admin_username": "",
"admin_password_secret": "twsec:v1:eyJub25jZSI6IjZLRm5ETlZUaDNwUU0xZHQiLCJ0YWciOiJnNnB1QmNHbWliMEpuUVA2VFpGcE9nPT0iLCJjaXBoZXJ0ZXh0IjoiIn0=",
"replication_username": "",
"replication_password_secret": "twsec:v1:eyJub25jZSI6Ik5yOU1SN1gybC9pdGVheXYiLCJ0YWciOiJMbk5vWjZmNjRLMVpRemlleHNKVFZRPT0iLCJjaXBoZXJ0ZXh0IjoiIn0=",
"role": "replica",
"status": "down",
"replication_source_id": 4,
"ssl_mode": "DISABLED",
"options_json": "{\"scheme\":\"http\",\"buckets\":[\"attachments\",\"backups\",\"invoices\",\"pdfs\",\"uploads\",\"truckwashdev\"],\"console_port\":9011,\"replication_transfer_limit\":\"25Mi\",\"space_headroom_percent\":20,\"deployment_provider\":\"coolify\",\"coolify_instance_id\":1,\"endpoint\":\"http://23.88.23.183:9010\",\"coolify_target_id\":6}",
"last_status_json": "{\"status\":\"down\",\"replication_percent\":0,\"lag_seconds\":null,\"blockers\":[\"Class \\\"Aws\\\\S3\\\\S3Client\\\" not found\"],\"raw\":[],\"checked_at\":\"2026-05-19T00:57:20+02:00\"}",
"last_checked_at": "2026-05-18 22:57:21",
"updated_at": "2026-05-18 22:57:21",
"deleted_at": null
}
]
}
}
}
@@ -100,7 +100,10 @@ it('serves installer artifacts and recovers gateway runtime status after fresh h
->assertSuccess();
expect($heartbeatResponse->data())
->toHaveKey('status', 'ONLINE');
->toHaveKey('status', 'ONLINE')
->and($heartbeatResponse->data()['broker_url'] ?? null)
->toBeString()
->toContain('/edge-broker');
$recoveredDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']);
@@ -0,0 +1,323 @@
<?php
app_require('classes/coolify_api_client.php');
app_require('classes/coolify_manager.php');
use classes\coolify_api_client;
use classes\coolify_manager;
it('normalizes Coolify API base URLs to the v1 API root', function (): void {
expect(coolify_api_client::normalizeBaseUrl('https://coolify.example.com'))->toBe('https://coolify.example.com/api/v1');
expect(coolify_api_client::normalizeBaseUrl('https://coolify.example.com/api/v1'))->toBe('https://coolify.example.com/api/v1');
expect(coolify_api_client::normalizeBaseUrl(' https://coolify.example.com/ '))->toBe('https://coolify.example.com/api/v1');
});
it('parses generated env files for Coolify service env bulk updates', function (): void {
$env = implode("\n", [
'# generated',
'MARIADB_ROOT_PASSWORD=root-secret',
'MARIADB_PASSWORD=app-secret',
'',
'REDIS_PRIMARY_USERNAME=',
]);
expect(coolify_manager::parseEnvFile($env))->toBe([
'MARIADB_ROOT_PASSWORD' => 'root-secret',
'MARIADB_PASSWORD' => 'app-secret',
'REDIS_PRIMARY_USERNAME' => '',
]);
});
it('prefers public Coolify server hosts over Docker-local addresses', function (): void {
expect(coolify_manager::publicServerHostFromCoolifyServer([
'ip' => 'host.docker.internal',
'public_ip' => '94.130.142.41',
'name' => 'node3.truckwash.io',
]))->toBe('94.130.142.41');
expect(coolify_manager::publicServerHostFromCoolifyServer([
'ip' => 'host.docker.internal',
'name' => 'node3.truckwash.io',
]))->toBe('node3.truckwash.io');
expect(coolify_manager::publicServerHostFromCoolifyServer([
'ip' => 'host.docker.internal',
'name' => 'node3.truckwash.io',
], null, false))->toBeNull();
expect(coolify_manager::publicServerHostFromCoolifyServer([
'ip' => '10.0.0.10',
'name' => 'Production Server',
]))->toBe('10.0.0.10');
expect(coolify_manager::publicServerHostFromCoolifyServer([
'ip' => 'host.docker.internal',
'name' => 'Production Server',
]))->toBeNull();
expect(coolify_manager::publicDnsServerNameFromCoolifyServer([
'ip' => 'host.docker.internal',
'name' => 'node3.truckwash.io',
]))->toBe('node3.truckwash.io');
expect(coolify_manager::publicDnsServerNameFromCoolifyServer([
'ip' => 'host.docker.internal',
'name' => 'Production Server',
]))->toBeNull();
});
it('blocks planned downtime operations against active replication primaries', function (): void {
expect(coolify_manager::blocksPrimaryMutation(['role' => 'primary'], 'deploy'))->toBeTrue();
expect(coolify_manager::blocksPrimaryMutation(['role' => 'primary'], 'restart'))->toBeTrue();
expect(coolify_manager::blocksPrimaryMutation(['role' => 'replica'], 'restart'))->toBeFalse();
expect(coolify_manager::blocksPrimaryMutation(['role' => 'primary'], 'failover'))->toBeFalse();
});
it('allows failed Coolify replica targets to be removed after the service disappears', function (): void {
expect(coolify_manager::targetAllowsReplicaRemoval([
'role' => 'replica',
'deployment_status' => 'reconcile_failed',
'last_reconcile_status' => 'reconcile_failed',
]))->toBeTrue();
expect(coolify_manager::targetAllowsReplicaRemoval([
'role' => 'replica',
'deployment_status' => 'deploying',
'last_reconcile_json' => json_encode(['message' => 'Coolify API request failed: HTTP 404']),
]))->toBeTrue();
expect(coolify_manager::targetAllowsReplicaRemoval([
'role' => 'replica',
'deployment_status' => 'provisioned',
'last_reconcile_status' => 'ok',
]))->toBeFalse();
expect(coolify_manager::targetAllowsReplicaRemoval([
'role' => 'primary',
'deployment_status' => 'reconcile_failed',
]))->toBeFalse();
});
it('retries Coolify maintenance while linked replication provisioning is still incomplete', function (): void {
$method = new ReflectionMethod(coolify_manager::class, 'replicationHostStillNeedsProvisioning');
$method->setAccessible(true);
expect($method->invoke(null, [
'role' => 'replica',
'status' => 'provisioning',
'last_status_json' => json_encode([
'status' => 'provisioning',
'replication_percent' => 99.9,
'blockers' => ['MinIO replica has not caught up.'],
]),
]))->toBeTrue();
expect($method->invoke(null, [
'role' => 'replica',
'status' => 'ok',
'last_status_json' => json_encode([
'status' => 'ok',
'replication_percent' => 100,
'blockers' => [],
]),
]))->toBeFalse();
});
it('plans Hetzner load balancer target and service drift without mutating state', function (): void {
$manager = new coolify_manager();
$method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile');
$method->setAccessible(true);
$plan = $method->invoke($manager, [
'targets' => [
['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']],
],
'services' => [
['protocol' => 'http', 'listen_port' => 80, 'destination_port' => 80, 'proxyprotocol' => false],
],
], [
['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true],
['hostname' => 'node2.truckwash.io', 'target_ip' => '65.21.214.30', 'enabled' => true],
]);
$actionTypes = array_map(static fn(array $action): string => (string)$action['type'], $plan['actions']);
expect($plan['has_drift'])->toBeTrue()
->and($plan['missing_targets'])->toContain('65.21.214.30')
->and($actionTypes)->toContain('add_target')
->and($actionTypes)->toContain('add_service')
->and($plan['missing_services'])->toContain([
'protocol' => 'tcp',
'listen_port' => 443,
'destination_port' => 443,
]);
});
it('does not plan removal of the last Hetzner load balancer target', function (): void {
$manager = new coolify_manager();
$method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile');
$method->setAccessible(true);
$plan = $method->invoke($manager, [
'targets' => [
['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']],
],
'services' => [
['protocol' => 'http', 'listen_port' => 80, 'destination_port' => 80, 'proxyprotocol' => false],
['protocol' => 'tcp', 'listen_port' => 443, 'destination_port' => 443, 'proxyprotocol' => false],
],
], [
['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => false],
]);
expect($plan['actions'][0])
->toHaveKey('type', 'skip_remove_target')
->toHaveKey('reason', 'last_reachable_target_guard');
});
it('plans removal only for disabled or deleted Hetzner load balancer targets', function (): void {
$manager = new coolify_manager();
$method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile');
$method->setAccessible(true);
$plan = $method->invoke($manager, [
'targets' => [
['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']],
['type' => 'ip', 'ip' => ['ip' => '65.21.214.30']],
],
'services' => [
['protocol' => 'http', 'listen_port' => 80, 'destination_port' => 80, 'proxyprotocol' => false],
['protocol' => 'tcp', 'listen_port' => 443, 'destination_port' => 443, 'proxyprotocol' => false],
],
], [
['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true],
['hostname' => 'node2.truckwash.io', 'target_ip' => '65.21.214.30', 'enabled' => true, 'deleted_at' => '2026-05-19 10:00:00'],
]);
expect($plan['actions'])
->toHaveCount(1)
->and($plan['actions'][0])
->toHaveKey('type', 'remove_target')
->toHaveKey('target_ip', '65.21.214.30');
});
it('defines Coolify schema, route permissions, and replication integration hooks', function (): void {
$schema = file_get_contents(app_path('classes/coolify_schema_bootstrap.php'));
$manager = file_get_contents(app_path('classes/coolify_manager.php'));
$route = file_get_contents(app_path('routes/superuserCoolifyRoute.php'));
$replication = file_get_contents(app_path('classes/replication_manager.php'));
$status = file_get_contents(app_path('classes/superuser_system_status_service.php'));
$cron = file_get_contents(app_path('cron/Cron.php'));
$openapi = file_get_contents(app_path('openapi.yaml'));
$coolifyConfig = file_get_contents(app_path('modules/coolify/coolify_c.php'));
$tokenConfig = file_get_contents(app_path('modules/coolify/config/coolify_hetzner_cloud_api_token_c.php'));
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_instances');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_targets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_operations');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_audit_logs');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_instance_gateways');
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'lb_automation_enabled'");
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'lb_automation_mode'");
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'hetzner_load_balancer_id'");
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token'");
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io'");
expect($schema)->toContain('94.130.142.41');
expect($schema)->toContain('65.21.214.30');
expect($schema)->toContain('23.88.23.183');
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'enabled'");
expect($route)->toContain('/superuser/coolify');
expect($route)->toContain('/superuser/coolify/load-balancer');
expect($route)->toContain('/superuser/coolify/load-balancer/reconcile');
expect($route)->toContain('/superuser/coolify/gateways');
expect($route)->toContain('/superuser/coolify/gateways/{id}/test');
expect($route)->toContain('/superuser/coolify/instances/{id}/test');
expect($route)->toContain('/superuser/coolify/instances/{id}/placement');
expect($route)->toContain('/superuser/coolify/targets/{id}/reconcile');
expect($route)->toContain('/superuser/coolify/targets/{id}/deploy');
expect($route)->toContain('/superuser/coolify/targets/{id}/restart');
expect($route)->toContain('/superuser/coolify/targets/{id}/failover');
expect($route)->toContain("requirePermission('superuser_coolify_view')");
expect($route)->toContain("requirePermission('superuser_coolify_manage')");
expect($route)->toContain("requirePermission('superuser_coolify_reconcile')");
expect($route)->toContain("requirePermission('superuser_coolify_failover')");
expect($manager)->toContain("Coolify-managed targets must be deployed as replicas first");
expect($manager)->toContain('ensureFailoverEnabled($kind)');
expect($manager)->toContain("deployment_provider' => 'coolify'");
expect($manager)->toContain('discoverInstancePlacement');
expect($manager)->toContain('applyCoolifyDeploymentDefaults($input, $instance)');
expect($manager)->toContain('resolveCoolifyServerHost');
expect($manager)->toContain('publicServerHostFromCoolifyServer');
expect($manager)->toContain('applyCoolifyPortDefaults');
expect($manager)->toContain('syncReplicationHostPortsForTarget');
expect($manager)->toContain('usedPublicPortsForCoolifyServer');
expect($manager)->toContain('nextAvailablePublicPorts');
expect($manager)->toContain('syncReplicationHostEndpointForTarget');
expect($manager)->toContain('knownPublicHostForCoolifyServer');
expect($manager)->toContain('publicDnsServerNameFromCoolifyServer');
expect($manager)->toContain('resolvedPublicDnsServerHostFromCoolifyServer');
expect($manager)->toContain('recordCreatedResource');
expect($manager)->toContain("'start_requested'");
expect($manager)->toContain('primaryCredentials');
expect($manager)->toContain('primary_admin_password');
expect($manager)->toContain('replication_transfer_limit');
expect($manager)->toContain('startOrRestartService');
expect($manager)->toContain('already running');
expect($manager)->toContain('restart_requested');
expect($manager)->toContain('deferredProvisionResult');
expect($manager)->toContain('isTransientProvisionBlock');
expect($manager)->toContain('provision_deferred');
expect($manager)->toContain('shouldRetryProvisioning');
expect($manager)->toContain('shouldRetryProvisioning($target, $host)');
expect($manager)->toContain('hasRunningReplicationProvisionOperation');
expect($manager)->toContain('replicationHostStillNeedsProvisioning');
expect($manager)->toContain('syncDeploymentStateForReplicationHost');
expect($manager)->toContain('syncLabelForReplicationHost');
expect($manager)->toContain('syncTargetsForReplicationHost');
expect($manager)->toContain('targetAllowsReplicaRemoval');
expect($manager)->toContain('markTargetsRemovedForReplicationHost');
expect($manager)->toContain('replicationHostIsReady');
expect($manager)->toContain("'provisioned'");
expect($manager)->not->toContain('is_container_label_escape_enabled');
expect($manager)->not->toContain("\$payload['type'] = 'docker-compose';");
expect($manager)->toContain('encodedDockerCompose');
expect($manager)->toContain('base64_encode');
expect($manager)->toContain('if (!$update)');
expect($manager)->toContain("'project_uuid' => \$target['project_uuid']");
expect($manager)->toContain('/api/v1/services');
expect($manager)->toContain('/envs/bulk');
expect($manager)->toContain('loadBalancerSummary');
expect($manager)->toContain('reconcileLoadBalancer');
expect($manager)->toContain('target_already_defined');
expect($manager)->toContain('REQUIRED_LOAD_BALANCER_SERVICES');
expect($manager)->toContain('skip_remove_target');
$client = file_get_contents(app_path('classes/coolify_api_client.php'));
expect($client)->toContain("request('GET', '/health', null, false)");
expect($client)->toContain('CURL_HTTP_VERSION_1_1');
expect($client)->toContain('validationErrorSummary');
expect($replication)->toContain('deployment_provider');
expect($replication)->toContain('coolify_manager::deploymentMetadataForReplicationHost');
expect($replication)->toContain('coolify_manager::syncDeploymentStateForReplicationHost');
expect($replication)->toContain('databaseEngineKnown');
expect($status)->toContain("'key' => 'coolify'");
expect($status)->toContain('probeCoolifyModule');
expect($cron)->toContain('CoolifyAvailabilityMonitorCron');
expect($cron)->toContain('CoolifyLoadBalancerReconcileCron');
expect($coolifyConfig)->toContain('[redacted]');
expect($coolifyConfig)->toContain('secret_set');
expect($tokenConfig)->toContain('replication_secret_box::encrypt');
expect($openapi)->toContain('/superuser/coolify:');
expect($openapi)->toContain('operationId: getSuperuserCoolifyLoadBalancer');
expect($openapi)->toContain('operationId: reconcileSuperuserCoolifyLoadBalancer');
expect($openapi)->toContain('operationId: listSuperuserCoolifyGateways');
expect($openapi)->toContain('operationId: testSuperuserCoolifyGateway');
expect($openapi)->toContain('operationId: discoverSuperuserCoolifyInstancePlacement');
expect($openapi)->toContain('operationId: createSuperuserCoolifyTarget');
expect($openapi)->toContain('SuperuserCoolifyTarget');
expect($openapi)->toContain('SuperuserCoolifyLoadBalancer');
expect($openapi)->toContain('SuperuserCoolifyGateway');
});
@@ -0,0 +1,79 @@
<?php
app_require('classes/error_report_schema_bootstrap.php');
app_require('classes/error_report_service.php');
app_require('classes/error_report_store.php');
use classes\error_report_service;
it('redacts sensitive error report payload fields recursively', function (): void {
$payload = [
'Authorization' => 'Bearer secret-token',
'request' => [
'headers' => [
'X-Api-Key' => 'hidden',
'Accept' => 'application/json',
],
'body' => [
'password' => 'not stored',
'safe' => 'visible',
],
],
];
expect(error_report_service::redactPayload($payload))->toBe([
'Authorization' => '[redacted]',
'request' => [
'headers' => [
'X-Api-Key' => '[redacted]',
'Accept' => 'application/json',
],
'body' => [
'password' => '[redacted]',
'safe' => 'visible',
],
],
]);
});
it('validates supported screenshot data uris', function (): void {
$decoded = error_report_service::decodeScreenshotDataUri('data:image/png;base64,' . base64_encode('png-bytes'));
expect($decoded['mime_type'])->toBe('image/png');
expect($decoded['contents'])->toBe('png-bytes');
expect($decoded['size_bytes'])->toBe(strlen('png-bytes'));
expect(fn() => error_report_service::decodeScreenshotDataUri('data:text/plain;base64,' . base64_encode('nope')))
->toThrow(RuntimeException::class, 'Screenshot must be a PNG, JPEG, or WebP data URI.');
});
it('defines error report schema, routes, permissions, storage, and OpenAPI docs', function (): void {
$schema = file_get_contents(app_path('classes/error_report_schema_bootstrap.php'));
$service = file_get_contents(app_path('classes/error_report_service.php'));
$store = file_get_contents(app_path('classes/error_report_store.php'));
$route = file_get_contents(app_path('routes/errorReportRoute.php'));
$openapi = file_get_contents(app_path('openapi.yaml'));
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS error_reports');
expect($schema)->toContain('request_errors_json');
expect($schema)->toContain('vue_errors_json');
expect($schema)->toContain('data_collection_accepted_at');
expect($schema)->toContain('resolved_by_user_id');
expect($service)->toContain('createFromCurrentPrincipal');
expect($service)->toContain('decodeScreenshotDataUri');
expect($service)->toContain('data_collection_accepted');
expect($service)->toContain('request_error_count');
expect($service)->toContain('vue_error_count');
expect($store)->toContain("error-reports/%s/%s.%s");
expect($route)->toContain('/error-reports');
expect($route)->toContain('/superuser/error-reports');
expect($route)->toContain('/superuser/error-reports/{id}/status');
expect($route)->toContain("requirePermission('superuser_error_reports_view')");
expect($route)->toContain("requirePermission('superuser_error_reports_resolve')");
expect($openapi)->toContain('/error-reports:');
expect($openapi)->toContain('ErrorReportSubmissionRequest');
expect($openapi)->toContain('ErrorReportStatusUpdateRequest');
});
@@ -0,0 +1,71 @@
<?php
app_require('classes/response.php');
use classes\response;
function response_request_parameters_response_with_body(string $body): response
{
return new class($body) extends response {
public function __construct(private readonly string $body)
{
}
protected function rawRequestBody(): string
{
return $this->body;
}
};
}
it('reads JSON payloads for DELETE request parameter arrays', function (): void {
$previousMethod = $_SERVER['REQUEST_METHOD'] ?? null;
$previousGet = $_GET;
try {
$_SERVER['REQUEST_METHOD'] = 'DELETE';
$_GET = [];
$response = response_request_parameters_response_with_body(json_encode([
'confirm' => 'delete-coolify-target-3',
'delete_resource' => false,
]));
expect($response->getAllRequestParameters())->toBe([
'confirm' => 'delete-coolify-target-3',
'delete_resource' => false,
]);
expect($response->getRequestParameter('confirm'))->toBe('delete-coolify-target-3');
expect($response->isRequestParameterSet('delete_resource'))->toBeTrue();
} finally {
$_GET = $previousGet;
if ($previousMethod === null) {
unset($_SERVER['REQUEST_METHOD']);
} else {
$_SERVER['REQUEST_METHOD'] = $previousMethod;
}
}
});
it('keeps DELETE query parameters when no JSON body is present', function (): void {
$previousMethod = $_SERVER['REQUEST_METHOD'] ?? null;
$previousGet = $_GET;
try {
$_SERVER['REQUEST_METHOD'] = 'DELETE';
$_GET = ['confirm' => 'delete-coolify-target-5'];
$response = response_request_parameters_response_with_body('');
expect($response->getAllRequestParameters())->toBe([
'confirm' => 'delete-coolify-target-5',
]);
} finally {
$_GET = $previousGet;
if ($previousMethod === null) {
unset($_SERVER['REQUEST_METHOD']);
} else {
$_SERVER['REQUEST_METHOD'] = $previousMethod;
}
}
});
@@ -0,0 +1,23 @@
<?php
it('allows release telemetry headers at PHP-served CORS entry points', function (): void {
$requiredHeaders = [
'X-Release-Trace',
'X-Release-Channel',
'X-Frontend-Version',
];
$files = [
app_path('index.php'),
app_path('modules/washcertificates/index.php'),
];
foreach ($files as $file) {
expect(is_file($file))->toBeTrue();
$content = (string)file_get_contents($file);
foreach ($requiredHeaders as $header) {
expect($content)->toContain($header);
}
}
});
@@ -20,11 +20,17 @@ if (!class_exists('OrderBookingsCompletionOrderDouble')) {
}
public bool $washCertificateAttached = false;
public bool $containsWashCertificate = false;
public function objectChanged(): void
{
}
public function containsWashCertificateItem(): bool
{
return $this->containsWashCertificate;
}
public function hasWashCertificateAttached(): bool
{
return $this->washCertificateAttached;
@@ -98,6 +104,20 @@ it('attaches and emails a wash certificate when a booking is already linked to a
expect($booking->sendCalls)->toBe(1);
});
it('uses the linked pos order wash certificate item added during mobile completion', function (): void {
$order = new OrderBookingsCompletionOrderDouble();
$order->containsWashCertificate = true;
$booking = new OrderBookingsCompletionDouble($order);
$booking->order_id->set(321);
$booking->containsWashCertificate = false;
$booking->completeBooking(77, 'MOBILE-SEAL');
expect($order->getSafetySealValue())->toBe('MOBILE-SEAL');
expect($booking->attachCalls)->toBe(1);
expect($booking->sendCalls)->toBe(1);
});
it('does not create or email a duplicate wash certificate when a linked pos order already has one', function (): void {
$order = new OrderBookingsCompletionOrderDouble();
$order->washCertificateAttached = true;
@@ -0,0 +1,243 @@
<?php
app_require('classes/release_manager.php');
app_require('classes/release_manager_schema_bootstrap.php');
use classes\release_manager;
it('redacts sensitive release timeline payload fields recursively', function (): void {
$payload = [
'Authorization' => 'Bearer secret-token',
'nested' => [
'api_key' => 'key-value',
'safe' => 'visible',
'items' => [
['password' => 'hidden', 'status' => 500],
],
],
];
expect(release_manager::redactPayload($payload))->toBe([
'Authorization' => '[redacted]',
'nested' => [
'api_key' => '[redacted]',
'safe' => 'visible',
'items' => [
['password' => '[redacted]', 'status' => 500],
],
],
]);
});
it('verifies GitHub sha256 webhook signatures', function (): void {
$secret = 'release-webhook-secret';
$payload = '{"ref":"refs/heads/main"}';
$signature = 'sha256=' . hash_hmac('sha256', $payload, $secret);
expect(release_manager::verifyGithubSignature($secret, $payload, $signature))->toBeTrue();
expect(release_manager::verifyGithubSignature($secret, $payload, 'sha256=bad'))->toBeFalse();
expect(release_manager::verifyGithubSignature('', $payload, $signature))->toBeFalse();
});
it('normalizes GitHub repository identifiers for private repository access checks', function (): void {
expect(release_manager::normalizeGithubRepositoryName('truckwash/backend-php'))->toBe('truckwash/backend-php');
expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue');
expect(release_manager::normalizeGithubRepositoryName('git@github.com:truckwash/backend-php.git'))->toBe('truckwash/backend-php');
expect(release_manager::normalizeGithubRepositoryName('not a repository'))->toBe('');
});
it('summarizes failed deployments and blocks promotion until a deployment succeeds', function (): void {
$summary = release_manager::deploymentFailureSummary(
new RuntimeException('Coolify API request failed: HTTP 404'),
[
'app' => 'api',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
'commit_sha' => 'ab31cd6dbb288606a24fcdbcb7bb7d58d843e4f3',
'coolify_service_uuid' => 'api-service',
]
);
expect($summary['category'])->toBe('coolify_target');
expect($summary['stage'])->toBe('provider_target');
expect($summary['promotion_blocked'])->toBeTrue();
expect($summary['evidence']['commit_sha'])->toBe('ab31cd6dbb288606a24fcdbcb7bb7d58d843e4f3');
expect(release_manager::deploymentCanBePromoted('deployed'))->toBeTrue();
expect(release_manager::deploymentCanBePromoted('failed'))->toBeFalse();
$reason = release_manager::deploymentPromotionBlockedReason([
'status' => 'failed',
'result_json' => json_encode(['failure_summary' => $summary]),
]);
expect($reason)->toContain('Deployment failed');
expect($reason)->toContain('HTTP 404');
});
it('uses the selected Coolify project and resolves server UUID from the instance default', function (): void {
$manager = new release_manager();
$method = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
$method->setAccessible(true);
$payload = $method->invoke($manager, [
'channel_slug' => 'internal',
'app' => 'api',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
], [
'coolify_project_uuid' => 'project-selected',
'coolify_deploy_now' => true,
], [
'default_project_uuid' => 'project-default',
'default_environment_name' => 'production',
'default_server_uuid' => 'server-default',
]);
expect($payload['project_uuid'])->toBe('project-selected');
expect($payload['server_uuid'])->toBe('server-default');
expect($payload)->not->toHaveKey('coolify_server_uuid');
});
it('supports isolated stack mode and names new Coolify services explicitly', function (): void {
$manager = new release_manager();
$normalizeMode = new ReflectionMethod(release_manager::class, 'normalizeServiceSetMode');
$normalizeMode->setAccessible(true);
expect($normalizeMode->invoke($manager, ' isolated_stack '))->toBe('isolated_stack');
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
'app' => 'frontend',
'repository' => 'copenhagentruckwash/front-end-vue',
'branch' => 'main',
], [
'coolify_service_name' => 'release-internal-safe-stack-frontend',
'coolify_project_uuid' => 'project-internal',
'coolify_deploy_now' => true,
], [
'default_environment_name' => 'production',
'default_server_uuid' => 'server-default',
]);
expect($payload['name'])->toBe('release-internal-safe-stack-frontend');
expect($payload['project_uuid'])->toBe('project-internal');
});
it('defines release manager schema, routes, permissions, and system-status integration hooks', function (): void {
$schema = file_get_contents(app_path('classes/release_manager_schema_bootstrap.php'));
$manager = file_get_contents(app_path('classes/release_manager.php'));
$route = file_get_contents(app_path('routes/releaseManagerRoute.php'));
$auth = file_get_contents(app_path('routes/authRoute.php'));
$response = file_get_contents(app_path('classes/response.php'));
$status = file_get_contents(app_path('classes/superuser_system_status_service.php'));
$index = file_get_contents(app_path('index.php'));
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channels');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_versions');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channel_versions');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_assignments');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployment_targets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_service_sets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployments');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_bundles');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_replay_targets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_sessions');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_events');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_module_health_snapshots');
expect($schema)->toContain("ensureColumn('release_channel_versions', 'service_set_id'");
expect($schema)->toContain("ensureColumn('release_channel_versions', 'bundle_id'");
expect($schema)->toContain("ensureColumn('release_deployments', 'deployment_kind'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'enabled'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_token'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_api_url'");
expect($schema)->toContain("['stable', 'Stable'");
expect($schema)->toContain("['canary', 'Canary'");
expect($schema)->toContain("['beta', 'Beta'");
expect($schema)->toContain("['internal', 'Internal'");
expect($route)->toContain('/release/bootstrap');
expect($route)->toContain('/release/runtime');
expect($route)->toContain('/release/timeline/events');
expect($route)->toContain('/release/github/webhook');
expect($route)->toContain('/superuser/releases/config');
expect($route)->toContain('/superuser/releases/github/repositories');
expect($route)->toContain('/superuser/releases/github/branches');
expect($route)->toContain('/superuser/releases/github/commits');
expect($route)->toContain('/superuser/releases/github/test');
expect($route)->toContain('/superuser/releases/channels');
expect($route)->toContain('/superuser/releases/assignments');
expect($route)->toContain('/superuser/releases/service-sets');
expect($route)->toContain('/superuser/releases/bundles');
expect($route)->toContain('/superuser/releases/bundles/{id}/deploy');
expect($route)->toContain('/superuser/releases/bundles/{id}/promote');
expect($route)->toContain('/superuser/releases/deployments');
expect($route)->toContain('/superuser/releases/replay-targets');
expect($route)->toContain('/superuser/releases/timeline');
expect($route)->toContain("requirePermission('superuser_release_manager_view')");
expect($route)->toContain("requirePermission('superuser_release_manager_manage')");
expect($route)->toContain("requirePermission('superuser_release_manager_deploy')");
expect($route)->toContain("requirePermission('superuser_release_manager_rollback')");
expect($route)->toContain("requirePermission('superuser_release_manager_replay')");
expect($manager)->toContain('verifyGithubSignature');
expect($manager)->toContain('normalizeGithubRepositoryName');
expect($manager)->toContain('releaseConfig');
expect($manager)->toContain('updateReleaseConfig');
expect($manager)->toContain('github_token_variable');
expect($manager)->toContain('github_token_env_variable');
expect($manager)->toContain('listGithubRepositories');
expect($manager)->toContain('listGithubBranches');
expect($manager)->toContain('listGithubCommits');
expect($manager)->toContain('testGithubRepositoryAccess');
expect($manager)->toContain('githubRepositoryAccess');
expect($manager)->toContain('github_token');
expect($manager)->toContain('commit_mode');
expect($manager)->toContain('restartCoolifyService');
expect($manager)->toContain('deployCoolifyReleaseTarget');
expect($manager)->toContain('listServiceSets');
expect($manager)->toContain('createServiceSet');
expect($manager)->toContain('createBundle');
expect($manager)->toContain('deployBundle');
expect($manager)->toContain('promoteBundle');
expect($manager)->toContain('clone_replica_from_source');
expect($manager)->toContain('register_isolated_empty_service');
expect($manager)->toContain('isolated_stack');
expect($manager)->toContain('create_isolated_empty_stack_service');
expect($manager)->toContain('assertIsolatedStackTarget');
expect($manager)->toContain('must not point at an existing Coolify service');
expect($manager)->toContain("'data_promotion' => false");
expect($manager)->toContain("'replica_failover' => false");
expect($manager)->toContain('deploymentCanBePromoted');
expect($manager)->toContain('deploymentFailureSummary');
expect($manager)->toContain('coolifyProjectSuggestions');
expect($manager)->toContain('releaseCoolifyServerUuid');
expect($manager)->toContain('coolify_project_uuid');
expect($manager)->toContain('releaseCoolifyServicePayload');
expect($manager)->toContain('release_deployment_targets');
expect($manager)->toContain('resolveChannel');
expect($manager)->toContain('capturePolicyFor');
expect($manager)->toContain('channelAvailability');
expect($manager)->toContain("'availability' =>");
expect($manager)->toContain('redactPayload');
expect($manager)->toContain('moduleKeys');
expect($manager)->toContain('releaseSuggestions');
expect($manager)->toContain('load_balancer_domains');
expect($manager)->toContain('appendDomainSuggestion');
expect($manager)->toContain('Coolify SSL requires a DNS domain routed to the load balancer.');
expect($manager)->toContain('coolify_services');
expect($manager)->toContain('coolify_enable_ssl');
expect($manager)->toContain('createService');
expect($manager)->toContain('updateService');
expect($manager)->toContain('channel_presets');
expect($manager)->toContain('target_presets');
expect($auth)->toContain("'release' => (new release_manager())->runtimeForPayload");
expect($response)->toContain('recordBackendFailure');
expect($status)->toContain("'key' => 'releasemanager'");
expect($status)->toContain('probeReleaseManagerModule');
expect($index)->toContain('release_manager::initializeRequestContext');
expect($index)->toContain('X-Release-Trace');
expect($manager)->not->toContain('X-Release-Channel');
});
@@ -0,0 +1,236 @@
<?php
app_require('classes/replication_secret_box.php');
app_require('classes/replication_bootstrap_config.php');
app_require('classes/replica_failover_manager.php');
use classes\replica_failover_manager;
use classes\replication_bootstrap_config;
beforeEach(function (): void {
$GLOBALS['ENCRYPTION_KEY'] = 'replica-failover-test-key';
});
function failover_test_host(string $kind, int $id, string $checkedAt, array $overrides = []): array
{
$base = [
'id' => $id,
'kind' => $kind,
'label' => $kind . ' replica ' . $id,
'host' => $kind . '-replica-' . $id,
'port' => match ($kind) {
'database' => 3306,
'redis' => 6379,
'minio' => 9000,
default => 1,
},
'database_name' => $kind === 'database' ? 'truckwash' : null,
'database_index' => $kind === 'redis' ? 0 : null,
'username' => $kind === 'minio' ? 'access-key' : 'app',
'password_secret' => 'secret',
'admin_username' => '',
'admin_password_secret' => '',
'role' => 'replica',
'status' => 'ok',
'options_json' => $kind === 'minio'
? json_encode(['endpoint' => 'http://minio-replica-' . $id . ':9000', 'buckets' => ['attachments']])
: null,
'last_status_json' => json_encode([
'status' => 'ok',
'replication_percent' => 100,
'blockers' => [],
'checked_at' => $checkedAt,
]),
'last_checked_at' => $checkedAt,
'deleted_at' => null,
];
return array_replace($base, $overrides);
}
it('normalizes failover config defaults and per-kind enablement', function (): void {
$defaults = replica_failover_manager::normalizeConfig([]);
expect($defaults)->toMatchArray([
'enabled' => false,
'database_enabled' => false,
'redis_enabled' => false,
'minio_enabled' => false,
'max_status_age_seconds' => 90,
]);
$config = replica_failover_manager::normalizeConfig([
'enabled' => 'true',
'database_enabled' => '1',
'redis_enabled' => false,
'minio_enabled' => 'yes',
'max_status_age_seconds' => '120',
]);
expect(replica_failover_manager::kindEnabled($config, 'database'))->toBeTrue();
expect(replica_failover_manager::kindEnabled($config, 'redis'))->toBeFalse();
expect(replica_failover_manager::kindEnabled($config, 'minio'))->toBeTrue();
expect($config['max_status_age_seconds'])->toBe(120);
});
it('requires strict fresh 100 percent replica status for candidates', function (): void {
$now = time();
$fresh = failover_test_host('database', 2, date('c', $now - 30));
$stale = failover_test_host('database', 3, date('c', $now - 120));
$notCaughtUp = failover_test_host('database', 4, date('c', $now - 10), [
'last_status_json' => json_encode([
'status' => 'ok',
'replication_percent' => 99.99,
'blockers' => [],
'checked_at' => date('c', $now - 10),
]),
]);
$blocked = failover_test_host('database', 5, date('c', $now - 10), [
'last_status_json' => json_encode([
'status' => 'degraded',
'replication_percent' => 100,
'blockers' => ['lagging'],
'checked_at' => date('c', $now - 10),
]),
]);
expect(replica_failover_manager::snapshotHostIsStrictlyFresh($fresh, 90, $now))->toBeTrue();
expect(replica_failover_manager::snapshotHostIsStrictlyFresh($stale, 90, $now))->toBeFalse();
expect(replica_failover_manager::snapshotHostIsStrictlyFresh($notCaughtUp, 90, $now))->toBeFalse();
expect(replica_failover_manager::snapshotHostIsStrictlyFresh($blocked, 90, $now))->toBeFalse();
});
it('selects the freshest eligible replica for failover', function (): void {
$now = time();
$older = failover_test_host('redis', 2, date('c', $now - 40));
$newer = failover_test_host('redis', 3, date('c', $now - 10));
$wrongKind = failover_test_host('minio', 4, date('c', $now - 5));
$candidate = replica_failover_manager::snapshotFailoverCandidate([$older, $newer, $wrongKind], 'redis', 90, $now);
expect($candidate['id'])->toBe(3);
});
it('promotes enabled startup dependencies from snapshot in dependency order', function (): void {
$path = tempnam(sys_get_temp_dir(), 'failover-snapshot-');
$events = [];
$now = time();
replication_bootstrap_config::writeSnapshot([
'active' => [
'database' => ['host' => 'db-primary', 'database' => 'truckwash', 'user' => 'app', 'password_secret' => 'secret'],
'redis' => ['host' => 'redis-primary', 'database' => 0, 'user' => '', 'password_secret' => 'secret'],
'minio' => ['endpoint' => 'http://minio-primary:9000', 'access_key' => 'access-key', 'secret_key_secret' => 'secret'],
],
'failover' => [
'config' => [
'enabled' => true,
'database_enabled' => true,
'redis_enabled' => true,
'minio_enabled' => true,
'max_status_age_seconds' => 90,
],
'hosts' => [
'database' => [failover_test_host('database', 11, date('c', $now - 10))],
'redis' => [failover_test_host('redis', 12, date('c', $now - 10))],
'minio' => [failover_test_host('minio', 13, date('c', $now - 10))],
],
],
], $path);
try {
$result = replica_failover_manager::applyStartupFailoverFromSnapshot($path, [
'primary_down' => function (string $kind) use (&$events): bool {
$events[] = 'primary:' . $kind;
return true;
},
'candidate_reachable' => function (string $kind) use (&$events): bool {
$events[] = 'reachable:' . $kind;
return true;
},
'promote_candidate' => function (string $kind) use (&$events): void {
$events[] = 'promote:' . $kind;
},
]);
$snapshot = replication_bootstrap_config::loadSnapshot($path);
expect($result['changed'])->toBeTrue();
expect($events)->toBe([
'primary:database',
'reachable:database',
'promote:database',
'primary:redis',
'reachable:redis',
'promote:redis',
'primary:minio',
'reachable:minio',
'promote:minio',
]);
expect($snapshot['active']['database']['id'])->toBe(11);
expect($snapshot['active']['redis']['id'])->toBe(12);
expect($snapshot['active']['minio']['id'])->toBe(13);
expect($snapshot['pending_failovers'])->toHaveCount(3);
} finally {
@unlink($path);
}
});
it('does not promote a disabled dependency during startup failover', function (): void {
$path = tempnam(sys_get_temp_dir(), 'failover-snapshot-');
$events = [];
replication_bootstrap_config::writeSnapshot([
'active' => [
'redis' => ['host' => 'redis-primary', 'database' => 0, 'user' => '', 'password_secret' => 'secret'],
],
'failover' => [
'config' => [
'enabled' => true,
'redis_enabled' => false,
'max_status_age_seconds' => 90,
],
'hosts' => [
'redis' => [failover_test_host('redis', 12, date('c'))],
],
],
], $path);
try {
$result = replica_failover_manager::applyStartupFailoverFromSnapshot($path, [
'primary_down' => function (string $kind) use (&$events): bool {
$events[] = $kind;
return true;
},
]);
expect($result['changed'])->toBeFalse();
expect($result['results']['redis']['reason'])->toBe('disabled');
expect($events)->toBe([]);
} finally {
@unlink($path);
}
});
it('wires the failover module config endpoint and promotion paths', function (): void {
$route = file_get_contents(app_path('routes/moduleConfigRoute.php'));
$module = file_get_contents(app_path('modules/failover/failover_c.php'));
$enabledConfig = file_get_contents(app_path('modules/failover/config/failover_enabled_c.php'));
$manager = file_get_contents(app_path('classes/replication_manager.php'));
$startup = file_get_contents(app_path('classes/replica_failover_manager.php'));
expect($route)->toContain("'/failover/config'");
expect($route)->toContain('modules_failover_config');
expect($enabledConfig)->toContain("'enabled'");
expect($module)->toContain('failover_database_enabled_c::class');
expect($module)->toContain('failover_redis_enabled_c::class');
expect($module)->toContain('failover_minio_enabled_c::class');
expect($module)->toContain('failover_max_status_age_seconds_c::class');
expect($manager)->toContain('promoteDatabaseHostForFailover');
expect($manager)->toContain('promoteRedisHostForFailover');
expect($manager)->toContain('promoteMinioHostForFailover');
expect($manager)->toContain("['REPLICAOF', 'NO', 'ONE']");
expect($manager)->toContain('runAutomaticFailoverMonitor');
expect($startup)->toContain('replication_bootstrap_config::loadSnapshot($path)');
expect($startup)->not->toContain('module_config');
});
@@ -20,6 +20,23 @@ 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);
expect(replication_manager::redisReplicationPercentFromInfo(
['master_repl_offset' => 1000],
['slave_repl_offset' => 750]
))->toBe(75.0);
expect(replication_manager::redisReplicationPercentFromInfo(
['master_repl_offset' => 1000],
['master_sync_in_progress' => '1', 'master_sync_total_bytes' => '1000', 'master_sync_left_bytes' => '250']
))->toBe(75.0);
expect(replication_manager::redisReplicationPercentFromInfo(
['master_repl_offset' => 1000],
['master_sync_in_progress' => '1']
))->toBe(5.0);
expect(replication_manager::redisProvisionProgress(0.0))->toBe(5.0);
expect(replication_manager::redisProvisionProgress(100.0, ['Redis replica link to primary is not up.']))->toBe(99.99);
expect(replication_manager::replicationHealthStatus(true, 'replica', 5.0, []))->toBe('degraded');
expect(replication_manager::replicationHealthStatus(true, 'replica', 5.0, [], false))->toBe('ok');
expect(replication_manager::replicationHealthStatus(true, 'replica', 100.0, []))->toBe('ok');
});
it('computes MariaDB GTID coverage by domain sequence', function (): void {
@@ -62,14 +79,17 @@ it('generates replication-ready MariaDB compose templates without embedding secr
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('"--replicate-ignore-table=nnks_db.system_search_documents"');
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-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('--ignore-table="$${MARIADB_SEED_DATABASE}.system_search_documents"');
expect($template['compose'])->toContain('--no-data "$${MARIADB_SEED_DATABASE}" "$${table}"');
expect($template['compose'])->toContain('touch "$${marker}"');
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}/');
@@ -85,6 +105,19 @@ it('generates replication-ready MariaDB compose templates without embedding secr
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\'');
expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.system_search_documents\'');
});
it('can embed primary admin credentials in generated MariaDB replica env files', function (): void {
$template = replication_manager::composeTemplate([
'kind' => 'database',
'role' => 'replica',
'primary_admin_username' => 'primary-root',
'primary_admin_password' => 'primary-secret',
]);
expect($template['env'])->toContain('MARIADB_PRIMARY_ADMIN_USER=primary-root');
expect($template['env'])->toContain('MARIADB_PRIMARY_ADMIN_PASSWORD=primary-secret');
});
it('detects missing database tables before provisioning a preseeded replica', function (): void {
@@ -112,7 +145,7 @@ it('seeds MariaDB replicas in place instead of requiring container recreation',
expect($content)->toContain("'connect_without_database' => \$usePreseededReplica");
});
it('keeps operational log tables schema-only during MariaDB seeding and replication', function (): void {
it('keeps operational and derived 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');
@@ -121,6 +154,7 @@ it('keeps operational log tables schema-only during MariaDB seeding and replicat
expect($content)->toContain("'replication_status_snapshots'");
expect($content)->toContain("'replication_operations'");
expect($content)->toContain("'replication_audit_logs'");
expect($content)->toContain("'system_search_documents'");
expect($content)->toContain("'skip_data' => \$skipData");
expect($content)->toContain('createMariaDbReplicaTable(');
expect($content)->toContain('SET GLOBAL replicate_ignore_table');
@@ -138,6 +172,20 @@ it('allows failed replicas to be removed without allowing primary or healthy rep
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();
$content = file_get_contents(app_path('classes/replication_manager.php'));
expect($content)->toContain('coolify_manager::replicationHostCanBeRemoved');
expect($content)->toContain('coolify_manager::markTargetsRemovedForReplicationHost');
});
it('supports metadata-only replication host renames', function (): void {
$content = file_get_contents(app_path('classes/replication_manager.php'));
expect($content)->toContain('function renameHost(');
expect($content)->toContain('host_renamed');
expect($content)->toContain('coolify_manager::syncLabelForReplicationHost');
expect($content)->toContain('writeBootstrapSnapshot()');
expect($content)->toContain('Replication host label must be 128 characters or fewer.');
});
it('generates Redis replica compose templates with primary connection placeholders', function (): void {
@@ -178,6 +226,8 @@ it('generates MinIO replica compose templates without embedding secrets', functi
'kind' => 'minio',
'role' => 'replica',
'service_name' => 'MinIO Replica 1',
'host' => 'node2.truckwash.dk',
'scheme' => 'http',
'host_port' => 9010,
'console_port' => 9011,
'buckets' => ['attachments', 'uploads'],
@@ -191,32 +241,277 @@ it('generates MinIO replica compose templates without embedding secrets', functi
expect($template['compose'])->toContain('image: "minio/minio:latest"');
expect($template['compose'])->toContain('MINIO_ROOT_USER: "${MINIO_ROOT_USER:?set MINIO_ROOT_USER}"');
expect($template['compose'])->toContain('MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}"');
expect($template['compose'])->toContain('MINIO_SERVER_URL: "${MINIO_SERVER_URL:-}"');
expect($template['compose'])->toContain('MINIO_BROWSER_REDIRECT_URL: "${MINIO_BROWSER_REDIRECT_URL:-}"');
expect($template['compose'])->toContain('"9010:9000"');
expect($template['compose'])->toContain('"9011:9001"');
expect($template['compose'])->toContain('mc mb --ignore-existing');
expect($template['compose'])->toContain('mc mb --with-lock --ignore-existing');
expect($template['compose'])->toContain('mc version enable');
expect($template['compose'])->toContain('MINIO_PRIMARY_ENDPOINT');
expect($template['compose'])->not->toContain($template['credentials']['password']);
expect($template['env'])->toMatch('/MINIO_ROOT_USER=twminio[a-f0-9]{24}/');
expect($template['env'])->toMatch('/MINIO_ROOT_PASSWORD=[A-Za-z0-9_-]{32}/');
expect($template['env'])->toContain('MINIO_SERVER_URL=http://node2.truckwash.dk:9010');
expect($template['env'])->toContain('MINIO_BROWSER_REDIRECT_URL=http://node2.truckwash.dk:9011');
expect($template['env'])->toContain('MINIO_BUCKETS=attachments,uploads');
expect($template['env'])->toContain('MINIO_REPLICATION_TRANSFER_LIMIT=25Mi');
expect($template['env'])->toContain('MINIO_PRIMARY_ENDPOINT=');
expect($template['credentials']['username'])->toMatch('/^twminio[a-f0-9]{24}$/');
expect($template['credentials']['scheme'])->toBe('http');
expect($template['credentials']['buckets'])->toBe(['attachments', 'uploads']);
expect($template['credentials']['replication_transfer_limit'])->toBe('25Mi');
expect($template['credentials']['space_headroom_percent'])->toBe(20.0);
});
it('keeps MinIO backup replicas bounded to the recent backup window', function (): void {
$template = replication_manager::composeTemplate([
'kind' => 'minio',
'role' => 'replica',
'service_name' => 'minio-replica-1',
'buckets' => ['backups', 'uploads'],
]);
$content = file_get_contents(app_path('classes/replication_manager.php'));
expect(replication_manager::minioBackupReplicaRetentionDays())->toBe(30);
expect($template['compose'])->toContain('mc ilm rule add --expire-days "30" --noncurrent-expire-days "30"');
expect($template['env'])->toContain('MINIO_BACKUP_REPLICA_RETENTION_DAYS=30');
expect($template['steps'])->toContain('The backups bucket is retained on replicas for 30 days; other buckets are fully replicated.');
expect($content)->not->toContain('seedMinioReplicaBackupWindow');
expect($content)->not->toContain("'--newer-than'");
expect($content)->toContain("'--limit-upload'");
expect($content)->toContain("'--limit-download'");
expect($content)->toContain("? 'delete,delete-marker'");
expect($content)->toContain('putBucketLifecycleConfiguration');
expect($content)->toContain('listObjectVersions');
expect($content)->toContain('minioBackupReplicaRetentionConfigured');
expect(replication_manager::minioBackupRetentionBlockers([
'buckets' => [
['name' => 'backups', 'expired_objects' => 2],
],
]))->toBe([
'MinIO backup replica contains 2 backup objects older than 30 days. Run provisioning to prune retained backups.',
]);
});
it('prefills MinIO replica compose primary values from current config when available', function (): void {
$previousMinio = $GLOBALS['MINIO'] ?? null;
$GLOBALS['MINIO'] = [
'endpoint' => 'https://minio-primary.internal:9000',
'access_key' => 'primary-access',
'secret_key' => 'primary-secret',
];
try {
$template = replication_manager::composeTemplate([
'kind' => 'minio',
'role' => 'replica',
'service_name' => 'minio-replica-1',
]);
expect($template['env'])->toContain('MINIO_PRIMARY_ENDPOINT=https://minio-primary.internal:9000');
expect($template['env'])->toContain('MINIO_PRIMARY_ACCESS_KEY=primary-access');
expect($template['env'])->toContain('MINIO_PRIMARY_SECRET_KEY=primary-secret');
expect($template['compose'])->not->toContain('primary-secret');
} finally {
if ($previousMinio === null) {
unset($GLOBALS['MINIO']);
} else {
$GLOBALS['MINIO'] = $previousMinio;
}
}
});
it('computes MinIO free-space and catch-up math safely', function (): void {
expect(replication_manager::minioRequiredFreeBytes(1000))->toBe(1200);
expect(replication_manager::minioByteReplicationPercent(1000, 750))->toBe(75.0);
expect(replication_manager::minioByteReplicationPercent(0, 0))->toBe(100.0);
expect(replication_manager::minioProvisionProgress([
'replication_percent' => 3.2,
'raw' => ['storage' => ['measured' => true]],
]))->toBe(3.2);
expect(replication_manager::minioProvisionProgress([
'replication_percent' => 0,
'raw' => ['storage' => ['measured' => false]],
]))->toBe(5.0);
expect(replication_manager::minioProvisionProgress([
'replication_percent' => 2.5,
'raw' => ['progress_source' => 'minio_replicate_status'],
]))->toBe(2.5);
expect(replication_manager::minioProvisionProgress([
'replication_percent' => 100,
'raw' => ['storage' => ['measured' => true]],
]))->toBe(100.0);
expect(replication_manager::minioProvisionProgress([
'replication_percent' => 100,
'blockers' => ['MinIO replica has not caught up.'],
'raw' => ['storage' => ['measured' => true]],
]))->toBe(99.9);
expect(replication_manager::minioReplicationProgressFromStatusOutput([
'target' => [
'replicated' => ['size' => 750],
'pending' => ['size' => 250],
],
])['replication_percent'])->toBe(75.0);
expect(replication_manager::minioReplicationProgressFromStatusOutput([
'objects' => [
'completed' => 9,
'pending' => 1,
],
])['replication_percent'])->toBe(90.0);
expect(replication_manager::minioReplicationProgressFromStatusOutput([
'status' => 'complete',
])['replication_percent'])->toBe(100.0);
expect(replication_manager::minioReplicationProgressFromStatusOutput(
'{"replicatedSize": 750, "pendingSize": 250}'
)['replication_percent'])->toBe(75.0);
expect(replication_manager::minioReplicationProgressFromStatusOutput(
'{"replicaSize": 750, "pendingSize": 250}'
)['replication_percent'])->toBe(75.0);
expect(replication_manager::minioReplicationProgressFromStatusOutput(
'target-a: 100%, target-b: 5%'
)['replication_percent'])->toBe(5.0);
expect(replication_manager::minioReplicationProgressFromStatusOutput(
'target-a: 100%, target-b: 100%'
)['replication_percent'])->toBe(100.0);
expect(replication_manager::minioReplicationProgressFromStatusOutput([
'completedReplicationSize' => 1000,
'queued' => [
'curr' => ['count' => 0, 'bytes' => 0],
'avg' => ['count' => 42, 'bytes' => 25000000],
'peak' => ['count' => 100, 'bytes' => 50000000],
],
])['replication_percent'])->toBe(100.0);
expect(replication_manager::minioBucketCountsTowardCatchUp('uploads'))->toBeTrue();
expect(replication_manager::minioBucketCountsTowardCatchUp('backups'))->toBeFalse();
$boundedBackupProgress = replication_manager::minioCatchUpProgressFromBucketStatuses([
'uploads' => ['replication_percent' => 100.0, 'blockers' => [], 'stats' => []],
'backups' => ['replication_percent' => 99.74, 'blockers' => ['MinIO replica has not caught up.'], 'stats' => []],
]);
expect($boundedBackupProgress['replication_percent'])->toBe(100.0);
expect($boundedBackupProgress['blockers'])->toBe([]);
expect($boundedBackupProgress['ignored_buckets'])->toBe(['backups']);
$onlyBoundedBackupProgress = replication_manager::minioCatchUpProgressFromBucketStatuses([
'backups' => ['replication_percent' => 5.0, 'blockers' => ['MinIO replica has not caught up.'], 'stats' => []],
]);
expect($onlyBoundedBackupProgress['replication_percent'])->toBe(100.0);
expect($onlyBoundedBackupProgress['basis'])->toBe('bounded_retention_only');
$liveQueueProgress = replication_manager::minioCatchUpProgressFromBucketStatuses([
'uploads' => [
'replication_percent' => 99.98,
'blockers' => ['MinIO replica has not caught up.'],
'stats' => [
'completed_bytes' => 53149249190,
'pending_bytes' => 3439936,
'failed_bytes' => 0,
'total_bytes' => 0,
'completed_count' => 199368,
'pending_count' => 7,
'failed_count' => 0,
'total_count' => 0,
],
],
]);
expect($liveQueueProgress['replication_percent'])->toBe(100.0);
expect($liveQueueProgress['blockers'])->toBe([]);
expect($liveQueueProgress['live_tolerance']['within_tolerance'])->toBeTrue();
expect(replication_manager::minioSpaceBlockers(1199, 1200))->toContain('MinIO target does not have enough free space. Required 1200 bytes, available 1199 bytes.');
expect(replication_manager::minioSpaceBlockers(null, 1200))->toContain('MinIO target free space could not be determined.');
expect(replication_manager::minioSpaceBlockers(null, 1200))->toBe([]);
expect(replication_manager::minioSpaceBlockers(1200, 1200))->toBe([]);
expect(replication_manager::minioAvailableBytesFromAdminInfo([
'servers' => [
['drives' => [['availableSpace' => 4096]]],
],
]))->toBe(4096);
expect(replication_manager::normalizeMinioBuckets('Attachments, uploads backups'))->toBe(['attachments', 'uploads', 'backups']);
expect(replication_manager::minioDefaultReplicationTransferLimit())->toBe('25Mi');
expect(replication_manager::normalizeMinioTransferLimit('25MiB/s'))->toBe('25Mi');
expect(replication_manager::normalizeMinioTransferLimit('100 MB'))->toBe('100M');
expect(replication_manager::normalizeMinioTransferLimit('0'))->toBe('');
});
it('allows the MinIO client binary to be configured explicitly', function (): void {
$previous = getenv('MINIO_MC_BINARY');
putenv('MINIO_MC_BINARY=/opt/minio/mc');
try {
$method = new ReflectionMethod(replication_manager::class, 'minioClientBinary');
$method->setAccessible(true);
expect($method->invoke(null))->toBe('/opt/minio/mc');
} finally {
if ($previous === false) {
putenv('MINIO_MC_BINARY');
} else {
putenv('MINIO_MC_BINARY=' . $previous);
}
}
});
it('supports MinIO client runtime fallback configuration', function (): void {
$previousDownloadUrl = getenv('MINIO_MC_DOWNLOAD_URL');
$previousAutoInstall = getenv('MINIO_MC_AUTO_INSTALL');
$previousCommandTimeout = getenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS');
$previousDownloadTimeout = getenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS');
putenv('MINIO_MC_DOWNLOAD_URL=https://example.test/mc');
putenv('MINIO_MC_AUTO_INSTALL=0');
putenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS=3');
putenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS=4');
try {
$downloadUrl = new ReflectionMethod(replication_manager::class, 'minioClientDownloadUrl');
$downloadUrl->setAccessible(true);
$autoInstall = new ReflectionMethod(replication_manager::class, 'minioClientAutoInstallEnabled');
$autoInstall->setAccessible(true);
$commandTimeout = new ReflectionMethod(replication_manager::class, 'minioClientCommandTimeoutSeconds');
$commandTimeout->setAccessible(true);
$downloadTimeout = new ReflectionMethod(replication_manager::class, 'minioClientDownloadTimeoutSeconds');
$downloadTimeout->setAccessible(true);
$commandLabel = new ReflectionMethod(replication_manager::class, 'minioClientCommandLabel');
$commandLabel->setAccessible(true);
expect($downloadUrl->invoke(null))->toBe('https://example.test/mc');
expect($autoInstall->invoke(null))->toBeFalse();
expect($commandTimeout->invoke(null))->toBe(3);
expect($downloadTimeout->invoke(null))->toBe(4);
expect($commandLabel->invoke(null, [
'alias',
'set',
'target',
'http://minio.example.test:9010',
'access-key',
'secret-key',
]))->toContain('[redacted]');
expect($commandLabel->invoke(null, [
'alias',
'set',
'target',
'http://minio.example.test:9010',
'access-key',
'secret-key',
]))->not->toContain('secret-key');
} finally {
if ($previousDownloadUrl === false) {
putenv('MINIO_MC_DOWNLOAD_URL');
} else {
putenv('MINIO_MC_DOWNLOAD_URL=' . $previousDownloadUrl);
}
if ($previousAutoInstall === false) {
putenv('MINIO_MC_AUTO_INSTALL');
} else {
putenv('MINIO_MC_AUTO_INSTALL=' . $previousAutoInstall);
}
if ($previousCommandTimeout === false) {
putenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS');
} else {
putenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS=' . $previousCommandTimeout);
}
if ($previousDownloadTimeout === false) {
putenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS');
} else {
putenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS=' . $previousDownloadTimeout);
}
}
});
it('wires MinIO replication through routes and bootstrap snapshots', function (): void {
@@ -230,6 +525,35 @@ it('wires MinIO replication through routes and bootstrap snapshots', function ()
expect($manager)->toContain('testMinioHost($host)');
expect($manager)->toContain('minioTargetFreeBytes($host)');
expect($manager)->toContain('minioRequiredFreeBytes');
expect($manager)->toContain('minioReplicationConfiguredForHosts($primary, $host)');
expect($manager)->toContain("'--priority',");
expect($manager)->toContain('minioReplicationTransferLimitArgs');
expect($manager)->toContain('MINIO_PROGRESS_SCAN_INTERVAL_SECONDS');
expect($manager)->toContain('MINIO_INCOMPLETE_PROGRESS_MAX_PERCENT');
expect($manager)->toContain('MINIO_MC_COMMAND_TIMEOUT_SECONDS');
expect($manager)->toContain('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS');
expect($manager)->toContain('proc_terminate($process');
expect($manager)->toContain("'connect_timeout' => self::MINIO_S3_CONNECT_TIMEOUT_SECONDS");
expect($manager)->toContain("'retries' => 0");
expect($manager)->toContain('&& $forceStorageScan;');
expect($manager)->not->toContain('$isPrimary || $forceStorageScan');
expect($manager)->toContain('sanitizePublicLastStatus');
expect($manager)->toContain('MinIO primary object-scan timeouts do not indicate primary availability failure.');
expect($manager)->toContain('minioProvisionProgress($status)');
expect($manager)->toContain('MinIO replica is syncing. Copied');
expect($manager)->toContain("['mb', '--with-lock', '--ignore-existing', 'target/' . \$bucket]");
expect($manager)->toContain('repairMinioTargetBucketObjectLockIfEmpty($configDir, $target, $bucket, $message)');
expect($manager)->toContain('minioBucketHasObjects($target, $bucket)');
expect($manager)->toContain("'skip_storage_scan' => true");
expect($manager)->toContain('lastStatusReplicationPercent($host, 5.0)');
expect($manager)->toContain('completeReadyMinioProvisionOperation');
expect($manager)->toContain('MinIO replication target is caught up.');
expect($manager)->toContain('shouldAdvanceActiveProvisionDuringRefresh');
expect($manager)->toContain('provisionHost((string)$host[\'kind\'], (int)$host[\'id\'])');
expect($manager)->toContain('stale targets do not keep a healthy current target below 100%');
expect($manager)->toContain("'replicate',\n 'status',\n 'source/' . \$bucket");
expect($manager)->toContain("'replicate',\n 'status',\n '--json',\n 'source/' . \$bucket");
expect($manager)->toContain('private function minioBucketStats(');
expect($manager)->toContain("'minio' => [");
expect($routes)->toContain("/superuser/replication/minio");
expect($openapi)->toContain('enum: [database, redis, minio]');
@@ -246,8 +570,9 @@ it('provisions Redis replicas after a connectivity-only preflight and reports sy
expect($content)->toContain("executeRaw(['CONFIG', 'REWRITE'])");
expect($content)->toContain('Redis replication was configured; waiting for the replica to catch up.');
expect($content)->toContain('$onlySyncBlockers');
expect($content)->toContain("'status' => 'running'");
expect($content)->toContain('Redis replica is syncing from the primary.');
expect($content)->toContain('redisProvisionProgress');
expect($content)->toContain('Redis replication is configured and syncing in the background.');
expect($content)->toContain('Redis replication is configured, but the replica is waiting for the primary link.');
});
it('keeps Redis promotion caught-up, durable, and metadata-safe', function (): void {
@@ -285,10 +610,58 @@ it('keeps replication operation progress schema idempotent for existing installs
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('$grantHosts = $this->databaseReplicationGrantHosts($host, $targetStatus);');
expect($content)->toContain('ensureDatabaseReplicationUser($primary, $replicationUser, $replicationPassword, $grantHosts)');
expect($content)->toContain('databaseDeniedAccountHostsFromText');
expect($content)->toContain('Access denied for user');
expect($content)->toContain('foreach ($grantHosts as $grantHost)');
expect($content)->toContain('shouldRepairDatabaseReplicationAccess');
expect($content)->toContain('repairDatabaseReplicationAccess');
expect($content)->toContain('shouldRepairDatabaseReplicationThreads');
expect($content)->toContain('repairDatabaseReplicationThreads');
expect($content)->toContain('refreshDatabaseReplicationConnection');
expect($content)->toContain("CHANGE MASTER TO MASTER_HOST = '%s', MASTER_PORT = %d, MASTER_USER = '%s', MASTER_PASSWORD = '%s', MASTER_USE_GTID = slave_pos");
expect($content)->toContain("CHANGE REPLICATION SOURCE TO SOURCE_HOST = '%s', SOURCE_PORT = %d, SOURCE_USER = '%s', SOURCE_PASSWORD = '%s', SOURCE_AUTO_POSITION = 1");
expect($content)->toContain('restartDatabaseReplicationThreads');
expect($content)->toContain('databaseOnlyReplicationThreadBlockers');
expect($content)->toContain('databaseAccountHostGrantCandidates');
expect($content)->toContain('START SLAVE SQL_THREAD');
expect($content)->toContain('GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO');
});
it('extracts host-specific MariaDB replication account denials', function (): void {
$extract = new ReflectionMethod(replication_manager::class, 'databaseDeniedAccountHostsFromText');
$extract->setAccessible(true);
$normalize = new ReflectionMethod(replication_manager::class, 'normalizeDatabaseAccountHost');
$normalize->setAccessible(true);
$candidates = new ReflectionMethod(replication_manager::class, 'databaseAccountHostGrantCandidates');
$candidates->setAccessible(true);
expect($extract->invoke(null, "Access denied for user 'replication'@'10.0.1.13' (using password: YES)"))
->toBe(['10.0.1.13']);
expect($extract->invoke(null, "Access denied for user 'replication'@'fd9c:738d:4130::d' (using password: YES)"))
->toBe(['fd9c:738d:4130::d']);
expect($normalize->invoke(null, '10.0.1.13'))->toBe('10.0.1.13');
expect($normalize->invoke(null, 'bad host;drop'))->toBeNull();
expect($candidates->invoke(null, '10.0.1.13'))->toBe(['10.0.1.13', '10.0.1.%']);
expect($candidates->invoke(null, 'fd9c:738d:4130::d'))->toBe(['fd9c:738d:4130::d', 'fd9c:738d:4130::%']);
});
it('identifies stopped database replication threads as a restartable status', function (): void {
$onlyThreadBlockers = new ReflectionMethod(replication_manager::class, 'databaseOnlyReplicationThreadBlockers');
$onlyThreadBlockers->setAccessible(true);
expect($onlyThreadBlockers->invoke(null, [
'Database replication IO and SQL threads must both be running.',
'Database replication IO thread is not running.',
'Database replication SQL thread is not running.',
]))->toBeTrue();
expect($onlyThreadBlockers->invoke(null, [
'Database replication IO thread is not running.',
"error reconnecting to master 'replication@23.88.23.183:5432'",
]))->toBeFalse();
});
it('supports MariaDB prerequisites without requiring Oracle MySQL variables', function (): void {
$blockers = replication_manager::databasePrerequisiteBlockers([
'server_version' => '11.8.6-MariaDB-ubu2404',
@@ -13,6 +13,7 @@ it('registers superuser replication endpoints and permissions', function (): voi
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("\$this->patch('/superuser/replication/{kind}/{id}'");
expect($content)->toContain("requirePermission('superuser_replication_view')");
expect($content)->toContain("requirePermission('superuser_replication_manage')");
expect($content)->toContain("requirePermission('superuser_replication_promote')");
@@ -27,9 +28,11 @@ it('documents replication management in openapi', function (): void {
expect($content)->toContain('operationId: generateSuperuserReplicationComposeTemplate');
expect($content)->toContain('operationId: testSuperuserReplicationCredentials');
expect($content)->toContain('operationId: addSuperuserMinioReplicationHost');
expect($content)->toContain('operationId: renameSuperuserReplicationHost');
expect($content)->toContain('enum: [database, redis, minio]');
expect($content)->toContain('space_headroom_percent');
expect($content)->toContain('SuperuserReplicationStatus');
expect($content)->toContain('SuperuserReplicationHostCreateRequest');
expect($content)->toContain('SuperuserReplicationHostRenameRequest');
expect($content)->toContain('SuperuserReplicationComposeTemplateRequest');
});
@@ -0,0 +1,10 @@
<?php
it('returns no-plate LPR results without a failed HTTP status', function (): void {
$route = file_get_contents(app_path('routes/moduleScannerRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain("'reason' => 'no_license_plate_detected'");
expect($route)->toContain('], 200);');
expect($route)->not->toContain("throw new Exception('License plate extraction failed.')");
});
@@ -28,6 +28,8 @@ it('registers the v2 operator-facing edge gateway routes', function (): void {
it('registers PHP edge agent routes for operations and legacy relay command polling', function (): void {
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
$manager = file_get_contents(app_path('classes/edge_gateway_manager.php'));
$agent = file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
expect($route)->toContain("'/edge-agent/install-token/verify'");
expect($route)->toContain("'/edge-agent/install-token/status'");
@@ -57,6 +59,9 @@ it('registers PHP edge agent routes for operations and legacy relay command poll
expect($route)->toContain("'/edge-agent/internal/browser-streams/validate'");
expect($route)->toContain("'/edge-agent/internal/shell-sessions/opened'");
expect($route)->toContain('echo $exception->getMessage()');
expect($manager)->toContain("\$gatewayPayload['broker_url'] = \$this->buildBrokerPublicUrl();");
expect($agent)->toContain('applyBrokerUrlFromControlPlaneResponse');
expect($agent)->toContain("Updated broker URL from control plane heartbeat response.");
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
});
+4 -4
View File
@@ -68,13 +68,13 @@ http {
# Location block for PHP files
location ^~ / {
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
add_header Access-Control-Allow-Credentials true;
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
return 204;
}
@@ -125,13 +125,13 @@ http {
# Location block for PHP files
location ^~ / {
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
add_header Access-Control-Allow-Credentials true;
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
return 204;
}
+2 -2
View File
@@ -50,12 +50,12 @@ http {
# Main application location
location / {
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
add_header Access-Control-Allow-Credentials true;
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
return 204;
}
+3
View File
@@ -104,6 +104,9 @@ http:
- Authorization
- Content-Type
- X-Customer-Number
- X-Release-Trace
- X-Release-Channel
- X-Frontend-Version
api-ratelimit:
rateLimit:
average: 100