Integrate Coolify API client and module for managing Coolify services, enhancing automation and deployment processes.
This commit is contained in:
@@ -57,6 +57,30 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: services/edge-agent/package-lock.json
|
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
|
- name: Install dependencies
|
||||||
working-directory: services/edge-agent
|
working-directory: services/edge-agent
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
@@ -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.middlewares=secure-headers@file,edge-broker-strip"
|
||||||
- "traefik.http.routers.edge-broker-api-io.priority=200"
|
- "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-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.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.entrypoints=websecure-staging"
|
||||||
- "traefik.http.routers.edge-broker-api-staging.tls=true"
|
- "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.tls.certresolver=le_io"
|
||||||
- "traefik.http.routers.api-io.service=caddy"
|
- "traefik.http.routers.api-io.service=caddy"
|
||||||
- "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file"
|
- "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.entrypoints=web"
|
||||||
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
|
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
|
||||||
- "traefik.http.routers.api-http.service=caddy"
|
- "traefik.http.routers.api-http.service=caddy"
|
||||||
|
|||||||
@@ -27,5 +27,5 @@ services:
|
|||||||
## docker compose up -d traefik caddy php1 php2 php3 php4 php5 db redis
|
## docker compose up -d traefik caddy php1 php2 php3 php4 php5 db redis
|
||||||
##
|
##
|
||||||
## Notes:
|
## Notes:
|
||||||
## - Traefik uses Let’s 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 Let’s 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.
|
## - 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
@@ -49,6 +49,13 @@ services:
|
|||||||
- "traefik.http.routers.edge-broker-io.middlewares=secure-headers@file,edge-broker-strip"
|
- "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.priority=200"
|
||||||
- "traefik.http.routers.edge-broker-io.service=edge-broker"
|
- "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.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
|
||||||
- "traefik.http.routers.edge-broker-staging.entrypoints=websecure-staging"
|
- "traefik.http.routers.edge-broker-staging.entrypoints=websecure-staging"
|
||||||
- "traefik.http.routers.edge-broker-staging.tls=true"
|
- "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.middlewares=secure-headers@file,edge-broker-strip"
|
||||||
- "traefik.http.routers.edge-broker-api-io.priority=200"
|
- "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-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.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.entrypoints=websecure-staging"
|
||||||
- "traefik.http.routers.edge-broker-api-staging.tls=true"
|
- "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.tls.certresolver=le_io"
|
||||||
- "traefik.http.routers.api-io.service=caddy"
|
- "traefik.http.routers.api-io.service=caddy"
|
||||||
- "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file"
|
- "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
|
# 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.entrypoints=web"
|
||||||
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
|
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
|
||||||
- "traefik.http.routers.api-http.service=caddy"
|
- "traefik.http.routers.api-http.service=caddy"
|
||||||
|
|||||||
+3
-3
@@ -38,14 +38,14 @@ http {
|
|||||||
location / {
|
location / {
|
||||||
add_header Access-Control-Allow-Origin *;
|
add_header Access-Control-Allow-Origin *;
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
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;
|
add_header Access-Control-Allow-Credentials true;
|
||||||
|
|
||||||
# If OPTIONS method is needed for preflight
|
# If OPTIONS method is needed for preflight
|
||||||
if ($request_method = 'OPTIONS') {
|
if ($request_method = 'OPTIONS') {
|
||||||
add_header Access-Control-Allow-Origin *;
|
add_header Access-Control-Allow-Origin *;
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
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
|
return 204; # No Content
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,4 +65,4 @@ http {
|
|||||||
location ~* \.(cgi|shtml|phtml)$ {
|
location ~* \.(cgi|shtml|phtml)$ {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -52,14 +52,14 @@ http {
|
|||||||
location / {
|
location / {
|
||||||
add_header Access-Control-Allow-Origin *;
|
add_header Access-Control-Allow-Origin *;
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
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;
|
add_header Access-Control-Allow-Credentials true;
|
||||||
|
|
||||||
# If OPTIONS method is needed for preflight
|
# If OPTIONS method is needed for preflight
|
||||||
if ($request_method = 'OPTIONS') {
|
if ($request_method = 'OPTIONS') {
|
||||||
add_header Access-Control-Allow-Origin *;
|
add_header Access-Control-Allow-Origin *;
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
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
|
return 204; # No Content
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,4 +80,4 @@ http {
|
|||||||
# Additional SSL options or configurations can be placed here, if necessary.
|
# Additional SSL options or configurations can be placed here, if necessary.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+332
@@ -72,6 +72,8 @@ tags:
|
|||||||
description: Form submissions and management
|
description: Form submissions and management
|
||||||
- name: Worker
|
- name: Worker
|
||||||
description: System worker status and maintenance
|
description: System worker status and maintenance
|
||||||
|
- name: Error Reports
|
||||||
|
description: Authenticated application error reporting
|
||||||
- name: Plate Scans
|
- name: Plate Scans
|
||||||
description: License plate scanning operations
|
description: License plate scanning operations
|
||||||
- name: Config
|
- name: Config
|
||||||
@@ -90,6 +92,160 @@ tags:
|
|||||||
description: Voice Calls via Bird
|
description: Voice Calls via Bird
|
||||||
|
|
||||||
paths:
|
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
|
||||||
/bird/voice/calls:
|
/bird/voice/calls:
|
||||||
post:
|
post:
|
||||||
@@ -11939,6 +12095,182 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
description: HTTP status code
|
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:
|
SuperuserSystemStatusResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
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-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\.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-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\.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\.stripPrefix\.prefixes=\/edge-broker/);
|
||||||
assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip-local\.stripPrefix\.prefixes=\/api\/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-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\.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-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\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
|
||||||
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
|
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -14,6 +14,7 @@ class response implements response_i
|
|||||||
private array $meta = [];
|
private array $meta = [];
|
||||||
private array $includes = [];
|
private array $includes = [];
|
||||||
private users_o $users_o;
|
private users_o $users_o;
|
||||||
|
private ?array $jsonRequestBody = null;
|
||||||
|
|
||||||
#[NoReturn] public function success(mixed $data, int $status = null): void
|
#[NoReturn] public function success(mixed $data, int $status = null): void
|
||||||
{
|
{
|
||||||
@@ -47,6 +48,11 @@ class response implements response_i
|
|||||||
'data' => $this->get_data()
|
'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([
|
echo json_encode([
|
||||||
'success' => $success,
|
'success' => $success,
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
@@ -175,35 +181,7 @@ class response implements response_i
|
|||||||
|
|
||||||
public function getRequestParameter(string $key): mixed
|
public function getRequestParameter(string $key): mixed
|
||||||
{
|
{
|
||||||
$data = [];
|
return $this->requestParametersForMethod()[$key] ?? null;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,21 +190,7 @@ class response implements response_i
|
|||||||
*/
|
*/
|
||||||
public function getAllRequestParameters(): array
|
public function getAllRequestParameters(): array
|
||||||
{
|
{
|
||||||
$data = [];
|
return $this->requestParametersForMethod();
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,21 +201,44 @@ class response implements response_i
|
|||||||
*/
|
*/
|
||||||
public function isRequestParameterSet(string $key): bool
|
public function isRequestParameterSet(string $key): bool
|
||||||
{
|
{
|
||||||
$data = [];
|
return array_key_exists($key, $this->requestParametersForMethod());
|
||||||
// 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);
|
private function requestParametersForMethod(): array
|
||||||
}
|
{
|
||||||
// Get the request data if the method is GET, DELETE or OPTIONS
|
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
||||||
$data = $_GET;
|
if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
|
||||||
|
return array_replace($_GET, $this->jsonRequestBody());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_array($data)) {
|
if ($method === 'DELETE') {
|
||||||
return false;
|
return array_replace($_GET, $this->jsonRequestBody());
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_key_exists($key, $data);
|
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
|
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' => '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' => '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' => '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' => '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)],
|
['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
|
protected function probeEconomicModule(array $config): array
|
||||||
{
|
{
|
||||||
$appSecretToken = trim((string)($GLOBALS['ECONOMIC_API']['app_secret_token'] ?? ''));
|
$appSecretToken = trim((string)($GLOBALS['ECONOMIC_API']['app_secret_token'] ?? ''));
|
||||||
|
|||||||
@@ -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_secret_box.php';
|
||||||
require_once __DIR__ . '/classes/replication_bootstrap_config.php';
|
require_once __DIR__ . '/classes/replication_bootstrap_config.php';
|
||||||
\classes\replication_bootstrap_config::applyToGlobals(
|
require_once __DIR__ . '/classes/replica_failover_manager.php';
|
||||||
\classes\replication_bootstrap_config::loadSnapshot()
|
$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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use classes\backup_store;
|
|||||||
use classes\economic;
|
use classes\economic;
|
||||||
use classes\economic_transfer_queue;
|
use classes\economic_transfer_queue;
|
||||||
use classes\invoice_period_flag_service;
|
use classes\invoice_period_flag_service;
|
||||||
|
use classes\coolify_manager;
|
||||||
|
use classes\replication_manager;
|
||||||
use classes\redis;
|
use classes\redis;
|
||||||
use classes\system_search_cache;
|
use classes\system_search_cache;
|
||||||
use classes\system_search_document_index;
|
use classes\system_search_document_index;
|
||||||
@@ -68,6 +70,24 @@ $cron_tasks = [
|
|||||||
'next_run' => 0,
|
'next_run' => 0,
|
||||||
'function' => 'syncLogsToDatabase',
|
'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' => [
|
'SyncUserEconomicCustomerDiscounts' => [
|
||||||
'interval' => 180, // 3 minutes
|
'interval' => 180, // 3 minutes
|
||||||
'last_run' => 0,
|
'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
|
function WarmInvoicePeriodManualFlagsCron(): void
|
||||||
{
|
{
|
||||||
(new invoice_period_flag_service())->warmManualFlagsCache();
|
(new invoice_period_flag_service())->warmManualFlagsCache();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ ini_set('zlib.output_compression', false);
|
|||||||
*/
|
*/
|
||||||
const WD = __DIR__;
|
const WD = __DIR__;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/vendor/autoload.php';
|
||||||
require_once 'config.php';
|
require_once 'config.php';
|
||||||
|
|
||||||
/** CORS */
|
/** CORS */
|
||||||
@@ -16,7 +17,7 @@ $allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
|
|||||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||||
header("Access-Control-Allow-Credentials: true");
|
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");
|
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-Origin: " . ($origin ?: '*'));
|
||||||
header("Access-Control-Allow-Credentials: true");
|
header("Access-Control-Allow-Credentials: true");
|
||||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
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');
|
header('Content-Type: application/json');
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
exit;
|
exit;
|
||||||
@@ -169,6 +170,8 @@ spl_autoload_register(function (string $class): void {
|
|||||||
|
|
||||||
use classes\application_write_freeze;
|
use classes\application_write_freeze;
|
||||||
use classes\db;
|
use classes\db;
|
||||||
|
use classes\replication_manager;
|
||||||
|
use classes\release_manager;
|
||||||
use classes\redis;
|
use classes\redis;
|
||||||
use classes\request;
|
use classes\request;
|
||||||
use classes\response;
|
use classes\response;
|
||||||
@@ -196,6 +199,24 @@ try {
|
|||||||
$response->error($e->getMessage(), 500);
|
$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(
|
if (application_write_freeze::shouldBlock(
|
||||||
$_SERVER['REQUEST_METHOD'] ?? 'GET',
|
$_SERVER['REQUEST_METHOD'] ?? 'GET',
|
||||||
$_SERVER['REQUEST_URI'] ?? '/',
|
$_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 = $this->getGateway($gatewayId);
|
||||||
|
$gatewayPayload['broker_url'] = $this->buildBrokerPublicUrl();
|
||||||
edge_gateway_view_cache::syncGateway($gatewayPayload);
|
edge_gateway_view_cache::syncGateway($gatewayPayload);
|
||||||
|
|
||||||
return $gatewayPayload;
|
return $gatewayPayload;
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ class edgegateway_public_broker_url_c
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
'The browser-routable edge broker URL, including the proxy path prefix.',
|
'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,
|
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
|
// Set CORS headers
|
||||||
header("Access-Control-Allow-Origin: *");
|
header("Access-Control-Allow-Origin: *");
|
||||||
header("Access-Control-Allow-Methods: GET, POST");
|
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
|
// Set the timezone
|
||||||
date_default_timezone_set('Europe/Copenhagen');
|
date_default_timezone_set('Europe/Copenhagen');
|
||||||
|
|||||||
@@ -364,11 +364,11 @@ class order_bookings_o extends db
|
|||||||
$this->createOrderItemsBy($user_id);
|
$this->createOrderItemsBy($user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->containsWashCertificateItem()) {
|
$order = $this->getOrder();
|
||||||
|
if (!$this->containsWashCertificateItem() && !$order->containsWashCertificateItem()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$order = $this->getOrder();
|
|
||||||
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal);
|
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal);
|
||||||
if ($normalizedSafetySeal !== null) {
|
if ($normalizedSafetySeal !== null) {
|
||||||
$order->setSafetySealValue($normalizedSafetySeal);
|
$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 $lastHeartbeatAt = 0;
|
||||||
private int $lastMachineSignalPollAt = 0;
|
private int $lastMachineSignalPollAt = 0;
|
||||||
private int $lastMachineSignalMonitorRefreshAt = 0;
|
private int $lastMachineSignalMonitorRefreshAt = 0;
|
||||||
|
private ?array $lastControlPlaneResponse = null;
|
||||||
private string $agentInstanceId;
|
private string $agentInstanceId;
|
||||||
|
|
||||||
public function __construct(string $configPath)
|
public function __construct(string $configPath)
|
||||||
@@ -1283,6 +1284,7 @@ final class TruckwashEdgeAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
$heartbeatSucceededAt = date('c');
|
$heartbeatSucceededAt = date('c');
|
||||||
|
$this->applyBrokerUrlFromControlPlaneResponse($this->lastControlPlaneResponse);
|
||||||
$this->lastHeartbeatAt = time();
|
$this->lastHeartbeatAt = time();
|
||||||
$this->recordSuccessfulSync($heartbeatSucceededAt, [
|
$this->recordSuccessfulSync($heartbeatSucceededAt, [
|
||||||
'last_heartbeat_success_at' => $heartbeatSucceededAt,
|
'last_heartbeat_success_at' => $heartbeatSucceededAt,
|
||||||
@@ -2430,13 +2432,15 @@ final class TruckwashEdgeAgent
|
|||||||
|
|
||||||
private function sendControlPlaneEvent(string $endpoint, array $payload, string $type): bool
|
private function sendControlPlaneEvent(string $endpoint, array $payload, string $type): bool
|
||||||
{
|
{
|
||||||
|
$this->lastControlPlaneResponse = null;
|
||||||
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
|
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
|
||||||
if ($brokerDispatch === true && !$this->shouldPersistControlPlaneEventOverHttp($endpoint)) {
|
if ($brokerDispatch === true && !$this->shouldPersistControlPlaneEventOverHttp($endpoint)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->http->post($endpoint, $payload, 20);
|
$response = $this->http->post($endpoint, $payload, 20);
|
||||||
|
$this->lastControlPlaneResponse = is_array($response) ? $response : null;
|
||||||
$this->recordSuccessfulSync();
|
$this->recordSuccessfulSync();
|
||||||
return true;
|
return true;
|
||||||
} catch (Throwable $throwable) {
|
} 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
|
private function shouldPersistControlPlaneEventOverHttp(string $endpoint): bool
|
||||||
{
|
{
|
||||||
return preg_match('#/edge-agent/gateways/\d+/heartbeat$#', $endpoint) === 1;
|
return preg_match('#/edge-agent/gateways/\d+/heartbeat$#', $endpoint) === 1;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace routes;
|
|||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
use classes\economic;
|
use classes\economic;
|
||||||
use classes\email;
|
use classes\email;
|
||||||
|
use classes\release_manager;
|
||||||
use classes\recaptcha;
|
use classes\recaptcha;
|
||||||
use classes\totp;
|
use classes\totp;
|
||||||
use classes\virkdata;
|
use classes\virkdata;
|
||||||
@@ -827,6 +828,7 @@ class authRoute
|
|||||||
'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(),
|
'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(),
|
||||||
'default_distribution_department_id' => (new economic())->getDefaultDistributionDepartmentId(),
|
'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 */
|
/** Bird config > GET */
|
||||||
$this->get('/bird/config', function () {
|
$this->get('/bird/config', function () {
|
||||||
global $response;
|
global $response;
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ class moduleScannerRoute
|
|||||||
// Success
|
// Success
|
||||||
$response->success(['success' => true, 'license_plate_number' => $lpr_result['license_plate_number']]);
|
$response->success(['success' => true, 'license_plate_number' => $lpr_result['license_plate_number']]);
|
||||||
} else {
|
} else {
|
||||||
throw new Exception('License plate extraction failed.');
|
$response->response(false, [
|
||||||
//$response->error($lpr_result['message'] ?? 'License plate recognition failed.', $lpr_result);
|
'message' => $lpr_result['message'] ?? 'No license plate detected.',
|
||||||
|
'reason' => 'no_license_plate_detected',
|
||||||
|
], 200);
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
// For future use with OpenAI.
|
// For future use with OpenAI.
|
||||||
@@ -73,4 +75,4 @@ class moduleScannerRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
$result = (new replication_manager())->provisionHost(
|
||||||
(string)$this->fromRoute('kind'),
|
(string)$this->fromRoute('kind'),
|
||||||
$this->routeId(),
|
$this->routeId(),
|
||||||
$this->actorUserId()
|
$this->actorUserId(),
|
||||||
|
true
|
||||||
);
|
);
|
||||||
if (($result['ok'] ?? false) !== true) {
|
if (($result['ok'] ?? false) !== true) {
|
||||||
$response->error($result, 409);
|
$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',
|
'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 () {
|
$this->delete('/superuser/replication/{kind}/{id}', function () {
|
||||||
global $response;
|
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();
|
->assertSuccess();
|
||||||
|
|
||||||
expect($heartbeatResponse->data())
|
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']);
|
$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 $washCertificateAttached = false;
|
||||||
|
public bool $containsWashCertificate = false;
|
||||||
|
|
||||||
public function objectChanged(): void
|
public function objectChanged(): void
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function containsWashCertificateItem(): bool
|
||||||
|
{
|
||||||
|
return $this->containsWashCertificate;
|
||||||
|
}
|
||||||
|
|
||||||
public function hasWashCertificateAttached(): bool
|
public function hasWashCertificateAttached(): bool
|
||||||
{
|
{
|
||||||
return $this->washCertificateAttached;
|
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);
|
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 {
|
it('does not create or email a duplicate wash certificate when a linked pos order already has one', function (): void {
|
||||||
$order = new OrderBookingsCompletionOrderDouble();
|
$order = new OrderBookingsCompletionOrderDouble();
|
||||||
$order->washCertificateAttached = true;
|
$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(1000, 750))->toBe(75.0);
|
||||||
expect(replication_manager::redisOffsetPercent(0, 0))->toBe(100.0);
|
expect(replication_manager::redisOffsetPercent(0, 0))->toBe(100.0);
|
||||||
expect(replication_manager::redisOffsetPercent(1000, 1250))->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 {
|
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.logs"');
|
||||||
expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.edge_gateway_log_entries"');
|
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.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('"5433:3306"');
|
||||||
expect($template['compose'])->toContain('mariadb-replica-2-seed');
|
expect($template['compose'])->toContain('mariadb-replica-2-seed');
|
||||||
expect($template['compose'])->toContain('MARIADB_PRIMARY_ADMIN_PASSWORD');
|
expect($template['compose'])->toContain('MARIADB_PRIMARY_ADMIN_PASSWORD');
|
||||||
expect($template['compose'])->toContain('mariadb-dump --host="$MARIADB_PRIMARY_HOST"');
|
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}.logs"');
|
||||||
expect($template['compose'])->toContain('--ignore-table="$MARIADB_SEED_DATABASE.edge_gateway_log_entries"');
|
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}.replication_status_snapshots"');
|
||||||
expect($template['compose'])->toContain('--no-data "$MARIADB_SEED_DATABASE" "$table"');
|
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'])->toContain('${MARIADB_ROOT_PASSWORD:?set MARIADB_ROOT_PASSWORD}');
|
||||||
expect($template['compose'])->not->toContain('<set-a-long-random-root-password>');
|
expect($template['compose'])->not->toContain('<set-a-long-random-root-password>');
|
||||||
expect($template['env'])->toMatch('/MARIADB_ROOT_PASSWORD=[A-Za-z0-9_-]{32}/');
|
expect($template['env'])->toMatch('/MARIADB_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.logs\'');
|
||||||
expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.edge_gateway_log_entries\'');
|
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.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 {
|
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");
|
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'));
|
$content = file_get_contents(app_path('classes/replication_manager.php'));
|
||||||
|
|
||||||
expect($content)->toContain('MARIADB_SCHEMA_ONLY_TABLES');
|
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_status_snapshots'");
|
||||||
expect($content)->toContain("'replication_operations'");
|
expect($content)->toContain("'replication_operations'");
|
||||||
expect($content)->toContain("'replication_audit_logs'");
|
expect($content)->toContain("'replication_audit_logs'");
|
||||||
|
expect($content)->toContain("'system_search_documents'");
|
||||||
expect($content)->toContain("'skip_data' => \$skipData");
|
expect($content)->toContain("'skip_data' => \$skipData");
|
||||||
expect($content)->toContain('createMariaDbReplicaTable(');
|
expect($content)->toContain('createMariaDbReplicaTable(');
|
||||||
expect($content)->toContain('SET GLOBAL replicate_ignore_table');
|
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' => 'inactive', 'status' => 'inactive']))->toBeTrue();
|
||||||
expect(replication_manager::replicationHostCanBeRemoved(['role' => 'replica', 'status' => 'degraded']))->toBeTrue();
|
expect(replication_manager::replicationHostCanBeRemoved(['role' => 'replica', 'status' => 'degraded']))->toBeTrue();
|
||||||
expect(replication_manager::replicationHostCanBeRemoved(['role' => 'replica', 'status' => 'ok']))->toBeFalse();
|
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 {
|
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',
|
'kind' => 'minio',
|
||||||
'role' => 'replica',
|
'role' => 'replica',
|
||||||
'service_name' => 'MinIO Replica 1',
|
'service_name' => 'MinIO Replica 1',
|
||||||
|
'host' => 'node2.truckwash.dk',
|
||||||
|
'scheme' => 'http',
|
||||||
'host_port' => 9010,
|
'host_port' => 9010,
|
||||||
'console_port' => 9011,
|
'console_port' => 9011,
|
||||||
'buckets' => ['attachments', 'uploads'],
|
'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('image: "minio/minio:latest"');
|
||||||
expect($template['compose'])->toContain('MINIO_ROOT_USER: "${MINIO_ROOT_USER:?set MINIO_ROOT_USER}"');
|
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_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('"9010:9000"');
|
||||||
expect($template['compose'])->toContain('"9011:9001"');
|
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('mc version enable');
|
||||||
expect($template['compose'])->toContain('MINIO_PRIMARY_ENDPOINT');
|
expect($template['compose'])->toContain('MINIO_PRIMARY_ENDPOINT');
|
||||||
expect($template['compose'])->not->toContain($template['credentials']['password']);
|
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'])->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_BUCKETS=attachments,uploads');
|
||||||
|
expect($template['env'])->toContain('MINIO_REPLICATION_TRANSFER_LIMIT=25Mi');
|
||||||
expect($template['env'])->toContain('MINIO_PRIMARY_ENDPOINT=');
|
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']['scheme'])->toBe('http');
|
||||||
expect($template['credentials']['buckets'])->toBe(['attachments', 'uploads']);
|
expect($template['credentials']['buckets'])->toBe(['attachments', 'uploads']);
|
||||||
|
expect($template['credentials']['replication_transfer_limit'])->toBe('25Mi');
|
||||||
expect($template['credentials']['space_headroom_percent'])->toBe(20.0);
|
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 {
|
it('computes MinIO free-space and catch-up math safely', function (): void {
|
||||||
expect(replication_manager::minioRequiredFreeBytes(1000))->toBe(1200);
|
expect(replication_manager::minioRequiredFreeBytes(1000))->toBe(1200);
|
||||||
expect(replication_manager::minioByteReplicationPercent(1000, 750))->toBe(75.0);
|
expect(replication_manager::minioByteReplicationPercent(1000, 750))->toBe(75.0);
|
||||||
expect(replication_manager::minioByteReplicationPercent(0, 0))->toBe(100.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(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([
|
expect(replication_manager::minioAvailableBytesFromAdminInfo([
|
||||||
'servers' => [
|
'servers' => [
|
||||||
['drives' => [['availableSpace' => 4096]]],
|
['drives' => [['availableSpace' => 4096]]],
|
||||||
],
|
],
|
||||||
]))->toBe(4096);
|
]))->toBe(4096);
|
||||||
expect(replication_manager::normalizeMinioBuckets('Attachments, uploads backups'))->toBe(['attachments', 'uploads', 'backups']);
|
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 {
|
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('testMinioHost($host)');
|
||||||
expect($manager)->toContain('minioTargetFreeBytes($host)');
|
expect($manager)->toContain('minioTargetFreeBytes($host)');
|
||||||
expect($manager)->toContain('minioRequiredFreeBytes');
|
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($manager)->toContain("'minio' => [");
|
||||||
expect($routes)->toContain("/superuser/replication/minio");
|
expect($routes)->toContain("/superuser/replication/minio");
|
||||||
expect($openapi)->toContain('enum: [database, redis, 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("executeRaw(['CONFIG', 'REWRITE'])");
|
||||||
expect($content)->toContain('Redis replication was configured; waiting for the replica to catch up.');
|
expect($content)->toContain('Redis replication was configured; waiting for the replica to catch up.');
|
||||||
expect($content)->toContain('$onlySyncBlockers');
|
expect($content)->toContain('$onlySyncBlockers');
|
||||||
expect($content)->toContain("'status' => 'running'");
|
expect($content)->toContain('redisProvisionProgress');
|
||||||
expect($content)->toContain('Redis replica is syncing from the primary.');
|
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 {
|
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 {
|
it('creates the generated replication user on the primary during provisioning', function (): void {
|
||||||
$content = file_get_contents(app_path('classes/replication_manager.php'));
|
$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');
|
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 {
|
it('supports MariaDB prerequisites without requiring Oracle MySQL variables', function (): void {
|
||||||
$blockers = replication_manager::databasePrerequisiteBlockers([
|
$blockers = replication_manager::databasePrerequisiteBlockers([
|
||||||
'server_version' => '11.8.6-MariaDB-ubu2404',
|
'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}/test');
|
||||||
expect($content)->toContain('/superuser/replication/{kind}/{id}/provision');
|
expect($content)->toContain('/superuser/replication/{kind}/{id}/provision');
|
||||||
expect($content)->toContain('/superuser/replication/{kind}/{id}/promote');
|
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_view')");
|
||||||
expect($content)->toContain("requirePermission('superuser_replication_manage')");
|
expect($content)->toContain("requirePermission('superuser_replication_manage')");
|
||||||
expect($content)->toContain("requirePermission('superuser_replication_promote')");
|
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: generateSuperuserReplicationComposeTemplate');
|
||||||
expect($content)->toContain('operationId: testSuperuserReplicationCredentials');
|
expect($content)->toContain('operationId: testSuperuserReplicationCredentials');
|
||||||
expect($content)->toContain('operationId: addSuperuserMinioReplicationHost');
|
expect($content)->toContain('operationId: addSuperuserMinioReplicationHost');
|
||||||
|
expect($content)->toContain('operationId: renameSuperuserReplicationHost');
|
||||||
expect($content)->toContain('enum: [database, redis, minio]');
|
expect($content)->toContain('enum: [database, redis, minio]');
|
||||||
expect($content)->toContain('space_headroom_percent');
|
expect($content)->toContain('space_headroom_percent');
|
||||||
expect($content)->toContain('SuperuserReplicationStatus');
|
expect($content)->toContain('SuperuserReplicationStatus');
|
||||||
expect($content)->toContain('SuperuserReplicationHostCreateRequest');
|
expect($content)->toContain('SuperuserReplicationHostCreateRequest');
|
||||||
|
expect($content)->toContain('SuperuserReplicationHostRenameRequest');
|
||||||
expect($content)->toContain('SuperuserReplicationComposeTemplateRequest');
|
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 {
|
it('registers PHP edge agent routes for operations and legacy relay command polling', function (): void {
|
||||||
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
$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/verify'");
|
||||||
expect($route)->toContain("'/edge-agent/install-token/status'");
|
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/browser-streams/validate'");
|
||||||
expect($route)->toContain("'/edge-agent/internal/shell-sessions/opened'");
|
expect($route)->toContain("'/edge-agent/internal/shell-sessions/opened'");
|
||||||
expect($route)->toContain('echo $exception->getMessage()');
|
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-actions/poll'");
|
||||||
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
|
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ http {
|
|||||||
# Location block for PHP files
|
# Location block for PHP files
|
||||||
location ^~ / {
|
location ^~ / {
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
|
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;
|
add_header Access-Control-Allow-Credentials true;
|
||||||
|
|
||||||
if ($request_method = 'OPTIONS') {
|
if ($request_method = 'OPTIONS') {
|
||||||
add_header Access-Control-Allow-Origin *;
|
add_header Access-Control-Allow-Origin *;
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
|
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;
|
return 204;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,13 +125,13 @@ http {
|
|||||||
# Location block for PHP files
|
# Location block for PHP files
|
||||||
location ^~ / {
|
location ^~ / {
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
|
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;
|
add_header Access-Control-Allow-Credentials true;
|
||||||
|
|
||||||
if ($request_method = 'OPTIONS') {
|
if ($request_method = 'OPTIONS') {
|
||||||
add_header Access-Control-Allow-Origin *;
|
add_header Access-Control-Allow-Origin *;
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
|
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;
|
return 204;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,4 +190,4 @@ http {
|
|||||||
|
|
||||||
# Restrict access to the server, if the
|
# Restrict access to the server, if the
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,12 +50,12 @@ http {
|
|||||||
# Main application location
|
# Main application location
|
||||||
location / {
|
location / {
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
|
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;
|
add_header Access-Control-Allow-Credentials true;
|
||||||
if ($request_method = 'OPTIONS') {
|
if ($request_method = 'OPTIONS') {
|
||||||
add_header Access-Control-Allow-Origin *;
|
add_header Access-Control-Allow-Origin *;
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
|
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;
|
return 204;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ http:
|
|||||||
- Authorization
|
- Authorization
|
||||||
- Content-Type
|
- Content-Type
|
||||||
- X-Customer-Number
|
- X-Customer-Number
|
||||||
|
- X-Release-Trace
|
||||||
|
- X-Release-Channel
|
||||||
|
- X-Frontend-Version
|
||||||
api-ratelimit:
|
api-ratelimit:
|
||||||
rateLimit:
|
rateLimit:
|
||||||
average: 100
|
average: 100
|
||||||
|
|||||||
Reference in New Issue
Block a user