Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1c123a840 | ||
|
|
e40c6b6bac | ||
|
|
8a1ec91b9e | ||
|
|
f7ff9f0a3b | ||
|
|
16cfcaf41f | ||
|
|
86fb092c22 | ||
|
|
c5a1271798 | ||
|
|
5ea12f5342 | ||
|
|
44c4b7656f | ||
|
|
24ac681365 | ||
|
|
c2263b8c98 | ||
|
|
cbf3c2d2b9 | ||
|
|
00a8723347 | ||
|
|
ab31cd6dbb | ||
|
|
3261ed8414 | ||
|
|
f53b99ad94 | ||
|
|
0399cb4bb4 | ||
|
|
30ed01e717 | ||
|
|
5965c5d72d | ||
|
|
5f36939833 | ||
|
|
c343b52b57 | ||
|
|
d9813a3fe2 | ||
|
|
c8aba05bc1 | ||
|
|
08ecd237b4 | ||
|
|
9d608bc967 | ||
|
|
dbc195c31d | ||
|
|
aaae6c9536 | ||
|
|
6c40810caf | ||
|
|
70080086da | ||
|
|
c1b66a81cc | ||
|
|
6d4066be1c | ||
|
|
bea7e5697b | ||
|
|
59a6297925 | ||
|
|
0cbc3e9aa5 | ||
|
|
a4c2b4e95a | ||
|
|
22fcb5cd0e | ||
|
|
a71bde3211 | ||
|
|
8bf957b273 | ||
|
|
5875371d13 | ||
|
|
93cac644a1 | ||
|
|
eeccacb2a7 | ||
|
|
690e114d38 | ||
|
|
4dd00cd7a2 | ||
|
|
acdff75311 | ||
|
|
5b94c9407b | ||
|
|
206b487fa2 | ||
|
|
72df2244ca | ||
|
|
55f8f25fd7 | ||
|
|
c73e459d26 | ||
|
|
2aded0812a | ||
|
|
e93d3f30d2 | ||
|
|
ae5cb7c65f | ||
|
|
feb9aac4f7 | ||
|
|
86eec9a51e | ||
|
|
f2e2b9a8f4 | ||
|
|
7bd940de37 | ||
|
|
c95fd3a23c | ||
|
|
99a85878cd | ||
|
|
1dffbe16c4 | ||
|
|
ad9637ebd2 | ||
|
|
0253cfc676 | ||
|
|
15250ad227 | ||
|
|
ca78e8e5f3 | ||
|
|
ab8923bb77 | ||
|
|
0f37bdc288 | ||
|
|
cdbc976b8a | ||
|
|
288627243b | ||
|
|
ebe1299089 | ||
|
|
3adf5957f0 | ||
|
|
eba33d0735 | ||
|
|
def45b1c23 | ||
|
|
38481334c7 | ||
|
|
dce2207243 | ||
|
|
5bfd2743be | ||
|
|
38acbdefa0 | ||
|
|
64292e8761 | ||
|
|
5001cd1811 | ||
|
|
cef53d8ebd | ||
|
|
ca64f2b047 | ||
|
|
ed13f7a8d4 | ||
|
|
2b905df478 | ||
|
|
d9ed1ff9de | ||
|
|
1c647bb78b | ||
|
|
39d4ba0284 | ||
|
|
8a5e6bc302 | ||
|
|
e7220f4f29 | ||
|
|
54bada14f5 |
@@ -49,6 +49,8 @@ ECONOMIC_API_APP_SECRET_TOKEN=
|
||||
|
||||
# Edge broker defaults for shell relay and gateway dispatch.
|
||||
EDGE_BROKER_URL=http://edge-broker:4300
|
||||
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
|
||||
EDGE_AUTH_MODE=manager
|
||||
EDGE_BROKER_SHARED_SECRET=truckwash-edge-dev
|
||||
|
||||
# Redis credentials
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
USE_ENV=true
|
||||
DEBUG=true
|
||||
ENCRYPTION_KEY=ci-test-encryption-key
|
||||
CORS=*
|
||||
CONFIG_TIMEZONE=Europe/Copenhagen
|
||||
|
||||
CONFIG_DB_TARGET=debug
|
||||
CONFIG_DB_HOST=mysql-debug
|
||||
CONFIG_DB_USER=root
|
||||
CONFIG_DB_PASSWORD=debug_root_password
|
||||
CONFIG_DB_DATABASE=nnks_db_debug
|
||||
CONFIG_DB_PORT=3306
|
||||
CONFIG_DB_SSL_MODE=DISABLED
|
||||
CONFIG_DB_DEBUG_HOST=mysql-debug
|
||||
CONFIG_DB_DEBUG_USER=root
|
||||
CONFIG_DB_DEBUG_PASSWORD=debug_root_password
|
||||
CONFIG_DB_DEBUG_DATABASE=nnks_db_debug
|
||||
CONFIG_DB_DEBUG_PORT=3306
|
||||
CONFIG_DB_DEBUG_SSL_MODE=DISABLED
|
||||
|
||||
REDIS_CONFIG_HOST=redis
|
||||
REDIS_CONFIG_USER=default
|
||||
REDIS_CONFIG_DATABASE=0
|
||||
REDIS_CONFIG_PASSWORD=
|
||||
REDIS_CONFIG_PORT=6379
|
||||
REDIS_CONFIG_DEBUG_HOST=redis
|
||||
REDIS_CONFIG_DEBUG_USER=default
|
||||
REDIS_CONFIG_DEBUG_DATABASE=0
|
||||
REDIS_CONFIG_DEBUG_PASSWORD=
|
||||
REDIS_CONFIG_DEBUG_PORT=6379
|
||||
|
||||
ECONOMIC_API_APP_ACCESS_GRANT=ci-test
|
||||
ECONOMIC_API_APP_ACCESS_GRANT2=ci-test-secondary
|
||||
ECONOMIC_API_APP_SECRET_TOKEN=ci-test-secret
|
||||
WORDPRESS_STATIC_TOKEN=ci-test
|
||||
EMAIL_WASH_CERTIFICATE_TOKEN=ci-test
|
||||
WORDPRESS_API_URL=http://localhost
|
||||
MINIO_ENDPOINT=
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
SLACK_DEFAULT_WEBHOOK=
|
||||
|
||||
EDGE_BROKER_URL=http://edge-broker:4300
|
||||
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
|
||||
EDGE_AUTH_MODE=manager
|
||||
EDGE_BROKER_SHARED_SECRET=truckwash-edge-ci
|
||||
EDGE_GATEWAY_VIEW_CACHE_TTL=0
|
||||
TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1
|
||||
@@ -0,0 +1,48 @@
|
||||
USE_ENV=true
|
||||
DEBUG=true
|
||||
ENCRYPTION_KEY=ci-test-encryption-key
|
||||
CORS=*
|
||||
CONFIG_TIMEZONE=Europe/Copenhagen
|
||||
|
||||
CONFIG_DB_TARGET=debug
|
||||
CONFIG_DB_HOST=mysql-debug
|
||||
CONFIG_DB_USER=root
|
||||
CONFIG_DB_PASSWORD=debug_root_password
|
||||
CONFIG_DB_DATABASE=nnks_db_debug
|
||||
CONFIG_DB_PORT=3306
|
||||
CONFIG_DB_SSL_MODE=DISABLED
|
||||
CONFIG_DB_DEBUG_HOST=mysql-debug
|
||||
CONFIG_DB_DEBUG_USER=root
|
||||
CONFIG_DB_DEBUG_PASSWORD=debug_root_password
|
||||
CONFIG_DB_DEBUG_DATABASE=nnks_db_debug
|
||||
CONFIG_DB_DEBUG_PORT=3306
|
||||
CONFIG_DB_DEBUG_SSL_MODE=DISABLED
|
||||
|
||||
REDIS_CONFIG_HOST=redis
|
||||
REDIS_CONFIG_USER=default
|
||||
REDIS_CONFIG_DATABASE=0
|
||||
REDIS_CONFIG_PASSWORD=
|
||||
REDIS_CONFIG_PORT=6379
|
||||
REDIS_CONFIG_DEBUG_HOST=redis
|
||||
REDIS_CONFIG_DEBUG_USER=default
|
||||
REDIS_CONFIG_DEBUG_DATABASE=0
|
||||
REDIS_CONFIG_DEBUG_PASSWORD=
|
||||
REDIS_CONFIG_DEBUG_PORT=6379
|
||||
|
||||
ECONOMIC_API_APP_ACCESS_GRANT=ci-test
|
||||
ECONOMIC_API_APP_ACCESS_GRANT2=ci-test-secondary
|
||||
ECONOMIC_API_APP_SECRET_TOKEN=ci-test-secret
|
||||
WORDPRESS_STATIC_TOKEN=ci-test
|
||||
EMAIL_WASH_CERTIFICATE_TOKEN=ci-test
|
||||
WORDPRESS_API_URL=http://localhost
|
||||
MINIO_ENDPOINT=
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
SLACK_DEFAULT_WEBHOOK=
|
||||
|
||||
EDGE_BROKER_URL=http://edge-broker:4300
|
||||
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
|
||||
EDGE_AUTH_MODE=manager
|
||||
EDGE_BROKER_SHARED_SECRET=truckwash-edge-ci
|
||||
EDGE_GATEWAY_VIEW_CACHE_TTL=0
|
||||
TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1
|
||||
@@ -0,0 +1,82 @@
|
||||
services:
|
||||
traefik:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-traefik"
|
||||
|
||||
redis:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-redis"
|
||||
|
||||
mysql-debug:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-mysql-debug"
|
||||
ports: !reset []
|
||||
|
||||
edge-broker:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
|
||||
labels:
|
||||
- "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker-local-ci.entrypoints=web"
|
||||
- "traefik.http.routers.edge-broker-local-ci.middlewares=secure-headers@file,edge-broker-strip-local"
|
||||
- "traefik.http.routers.edge-broker-local-ci.priority=190"
|
||||
- "traefik.http.routers.edge-broker-local-ci.service=edge-broker"
|
||||
|
||||
caddy:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-caddy"
|
||||
depends_on: !reset []
|
||||
labels:
|
||||
- "traefik.http.routers.local-api-ci.rule=PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.local-api-ci.entrypoints=web"
|
||||
- "traefik.http.routers.local-api-ci.middlewares=strip-api-prefix@file,secure-headers@file"
|
||||
- "traefik.http.routers.local-api-ci.priority=90"
|
||||
- "traefik.http.routers.local-api-ci.service=caddy"
|
||||
volumes:
|
||||
- ci_php_app:/var/www/html
|
||||
|
||||
php1:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-php1"
|
||||
depends_on: !reset []
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
USE_ENV: "true"
|
||||
CONFIG_DB_TARGET: "debug"
|
||||
CONFIG_DB_HOST: "mysql-debug"
|
||||
CONFIG_DB_USER: "root"
|
||||
CONFIG_DB_PASSWORD: "debug_root_password"
|
||||
CONFIG_DB_DATABASE: "nnks_db_debug"
|
||||
CONFIG_DB_PORT: "3306"
|
||||
CONFIG_DB_DEBUG_HOST: "mysql-debug"
|
||||
CONFIG_DB_DEBUG_USER: "root"
|
||||
CONFIG_DB_DEBUG_PASSWORD: "debug_root_password"
|
||||
CONFIG_DB_DEBUG_DATABASE: "nnks_db_debug"
|
||||
CONFIG_DB_DEBUG_PORT: "3306"
|
||||
REDIS_CONFIG_HOST: "redis"
|
||||
REDIS_CONFIG_PORT: "6379"
|
||||
REDIS_CONFIG_DATABASE: "0"
|
||||
REDIS_CONFIG_DEBUG_HOST: "redis"
|
||||
REDIS_CONFIG_DEBUG_PORT: "6379"
|
||||
REDIS_CONFIG_DEBUG_DATABASE: "0"
|
||||
TRUCKWASH_TEST_BLOCK_REAL_SHELLY: "1"
|
||||
EDGE_GATEWAY_VIEW_CACHE_TTL: "0"
|
||||
volumes:
|
||||
- ci_php_app:/var/www/html
|
||||
|
||||
php2:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-php2"
|
||||
volumes:
|
||||
- ci_php_app:/var/www/html
|
||||
|
||||
php3:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-php3"
|
||||
volumes:
|
||||
- ci_php_app:/var/www/html
|
||||
|
||||
php4:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-php4"
|
||||
volumes:
|
||||
- ci_php_app:/var/www/html
|
||||
|
||||
php5:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-php5"
|
||||
volumes:
|
||||
- ci_php_app:/var/www/html
|
||||
|
||||
volumes:
|
||||
ci_php_app:
|
||||
@@ -9,16 +9,21 @@ on:
|
||||
|
||||
jobs:
|
||||
qodana:
|
||||
runs-on: ubuntu-latest
|
||||
# Run on our self-hosted runner to avoid GitHub-hosted Actions budget limits.
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA.
|
||||
fetch-depth: 0 # a full history is required for pull request analysis
|
||||
- name: Prepare Qodana cache directories
|
||||
run: |
|
||||
mkdir -p "${RUNNER_TEMP}/qodana/caches"
|
||||
mkdir -p "${RUNNER_TEMP}/qodana/results"
|
||||
- name: 'Qodana Scan'
|
||||
uses: JetBrains/qodana-action@v2025.3
|
||||
with:
|
||||
|
||||
+148
-197
@@ -5,52 +5,42 @@ on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: Unit (required)
|
||||
# Match the labels exposed by the Coolify-managed GitHub runner.
|
||||
php:
|
||||
name: PHP ${{ matrix.suite }} (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite: [unit, integration, api, legacy]
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: php-${{ github.run_id }}-${{ github.job }}-${{ matrix.suite }}-${{ github.run_attempt }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
if: ${{ matrix.suite == 'unit' }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Check AI workflow sync
|
||||
if: ${{ matrix.suite == 'unit' }}
|
||||
run: node scripts/sync-ai-workflow.mjs --check
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: mysqli, curl, openssl, json, redis, xdebug
|
||||
coverage: xdebug
|
||||
ini-values: variables_order=EGPCS,xdebug.mode=coverage
|
||||
- name: Run PHP ${{ matrix.suite }} suite
|
||||
run: bash scripts/php-ci-test.sh ${{ matrix.suite }}
|
||||
|
||||
- name: Resolve dependencies
|
||||
working-directory: services/nginx/app
|
||||
run: composer update --no-interaction --prefer-dist
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: services/nginx/app
|
||||
run: composer test:unit
|
||||
|
||||
- name: Generate coverage report
|
||||
working-directory: services/nginx/app
|
||||
run: composer test:coverage
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
- name: Upload PHP suite logs
|
||||
if: ${{ failure() }}
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unit-coverage-clover
|
||||
path: services/nginx/app/build/logs/clover.xml
|
||||
name: php-${{ matrix.suite }}-logs
|
||||
path: .tmp/ci-logs/${{ matrix.suite }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 1
|
||||
retention-days: 3
|
||||
|
||||
edge-agent:
|
||||
name: Edge Agent (required)
|
||||
@@ -67,6 +57,30 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: services/edge-agent/package-lock.json
|
||||
|
||||
- name: Install native build tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v make >/dev/null 2>&1 && command -v g++ >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v apt-get >/dev/null 2>&1; then
|
||||
echo "make and g++ are required to install node-pty, but apt-get is not available on this runner." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt_cmd=(apt-get)
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
if ! command -v sudo >/dev/null 2>&1; then
|
||||
echo "make and g++ are missing, and sudo is not available to install them." >&2
|
||||
exit 1
|
||||
fi
|
||||
apt_cmd=(sudo apt-get)
|
||||
fi
|
||||
|
||||
"${apt_cmd[@]}" update
|
||||
"${apt_cmd[@]}" install -y --no-install-recommends build-essential python3
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: services/edge-agent
|
||||
run: npm ci
|
||||
@@ -83,22 +97,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Materialize compose env files
|
||||
env:
|
||||
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
|
||||
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${COMPOSE_ENV}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$COMPOSE_ENV" > .env
|
||||
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
|
||||
cp .github/ci.env .env
|
||||
cp .github/ci.env.staging .env.staging
|
||||
|
||||
- name: Validate compose contracts
|
||||
run: |
|
||||
@@ -123,27 +126,24 @@ jobs:
|
||||
edge-gateway-backend:
|
||||
name: Edge Gateway Backend (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
env:
|
||||
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
|
||||
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
TRAEFIK_WEB_PORT: "18080"
|
||||
TRAEFIK_WEBSECURE_PORT: "18443"
|
||||
TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
|
||||
TRAEFIK_METRICS_PORT: "19100"
|
||||
EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Materialize compose env files
|
||||
env:
|
||||
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
|
||||
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${COMPOSE_ENV}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$COMPOSE_ENV" > .env
|
||||
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
|
||||
cp .github/ci.env .env
|
||||
cp .github/ci.env.staging .env.staging
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -151,154 +151,105 @@ jobs:
|
||||
node-version: 22
|
||||
|
||||
- name: Boot local stack
|
||||
run: docker compose up -d traefik redis mysql-debug edge-broker php1 caddy
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 caddy
|
||||
|
||||
- name: Sync PHP app checkout
|
||||
run: >
|
||||
tar
|
||||
--exclude='./vendor'
|
||||
--exclude='./.phpunit.cache'
|
||||
--exclude='./build/logs'
|
||||
-C services/nginx/app -cf - .
|
||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
||||
|
||||
- name: Resolve dependencies
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress"
|
||||
|
||||
- name: Verify edge gateway test files
|
||||
run: >
|
||||
docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc
|
||||
"cd /var/www/html &&
|
||||
php -r '\$composer = json_decode(file_get_contents(\"composer.json\"), true); echo \"Composer scripts: \", implode(\",\", array_keys(\$composer[\"scripts\"] ?? [])), PHP_EOL;' &&
|
||||
find tests/Api -maxdepth 1 -type f -name 'EdgeGateway*ApiTest.php' -print &&
|
||||
test -f tests/Api/EdgeGatewayAgentApiTest.php &&
|
||||
test -f tests/Api/EdgeGatewayBrokerApiTest.php &&
|
||||
test -f tests/Api/EdgeGatewayOperatorApiTest.php"
|
||||
|
||||
- name: Run edge gateway API tests
|
||||
run: docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api:edge"
|
||||
run: >
|
||||
docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc
|
||||
"cd /var/www/html &&
|
||||
RUN_API_TESTS=1
|
||||
API_TEST_BOOTSTRAP_SCHEMA=1
|
||||
API_TEST_ALLOW_LIVE_DB=1
|
||||
CONFIG_DB_TARGET=debug
|
||||
CONFIG_DB_HOST=mysql-debug
|
||||
CONFIG_DB_USER=\${CONFIG_DB_USER:-root}
|
||||
CONFIG_DB_PASSWORD=\${CONFIG_DB_PASSWORD:-debug_root_password}
|
||||
CONFIG_DB_DATABASE=\${CONFIG_DB_DATABASE:-nnks_db_debug}
|
||||
CONFIG_DB_PORT=3306
|
||||
CONFIG_DB_DEBUG_HOST=mysql-debug
|
||||
CONFIG_DB_DEBUG_USER=\${CONFIG_DB_DEBUG_USER:-root}
|
||||
CONFIG_DB_DEBUG_PASSWORD=\${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
|
||||
CONFIG_DB_DEBUG_DATABASE=\${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
|
||||
CONFIG_DB_DEBUG_PORT=3306
|
||||
API_TEST_REQUEST_TIMEOUT=180
|
||||
EDGE_GATEWAY_VIEW_CACHE_TTL=0
|
||||
EDGE_BROKER_URL=
|
||||
vendor/bin/pest
|
||||
tests/Api/EdgeGatewayAgentApiTest.php
|
||||
tests/Api/EdgeGatewayBrokerApiTest.php
|
||||
tests/Api/EdgeGatewayOperatorApiTest.php
|
||||
--colors=always"
|
||||
|
||||
- name: Run edge gateway integration tests
|
||||
run: docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration:edge"
|
||||
run: >
|
||||
docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc
|
||||
"cd /var/www/html &&
|
||||
RUN_INTEGRATION_TESTS=1
|
||||
CONFIG_DB_TARGET=debug
|
||||
CONFIG_DB_HOST=mysql-debug
|
||||
CONFIG_DB_USER=\${CONFIG_DB_USER:-root}
|
||||
CONFIG_DB_PASSWORD=\${CONFIG_DB_PASSWORD:-debug_root_password}
|
||||
CONFIG_DB_DATABASE=\${CONFIG_DB_DATABASE:-nnks_db_debug}
|
||||
CONFIG_DB_PORT=3306
|
||||
CONFIG_DB_DEBUG_HOST=mysql-debug
|
||||
CONFIG_DB_DEBUG_USER=\${CONFIG_DB_DEBUG_USER:-root}
|
||||
CONFIG_DB_DEBUG_PASSWORD=\${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
|
||||
CONFIG_DB_DEBUG_DATABASE=\${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
|
||||
CONFIG_DB_DEBUG_PORT=3306
|
||||
EDGE_BROKER_URL=
|
||||
vendor/bin/pest tests/Integration/EdgeGateway --colors=always"
|
||||
|
||||
- name: Run edge gateway E2E smoke
|
||||
run: node scripts/edge-gateway-e2e.mjs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose_project="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}"
|
||||
runner="edge-e2e-runner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
docker rm -f "$runner" >/dev/null 2>&1 || true
|
||||
trap 'docker rm -f "$runner" >/dev/null 2>&1 || true' EXIT
|
||||
docker create \
|
||||
--name "$runner" \
|
||||
--network "${compose_project}_default" \
|
||||
-e COMPOSE_FILE="$COMPOSE_FILE" \
|
||||
-e COMPOSE_PROJECT_NAME="$compose_project" \
|
||||
-e TRAEFIK_WEB_PORT="${TRAEFIK_WEB_PORT:-18080}" \
|
||||
-e TRAEFIK_WEBSECURE_PORT="${TRAEFIK_WEBSECURE_PORT:-18443}" \
|
||||
-e TRAEFIK_WEBSECURE_STAGING_PORT="${TRAEFIK_WEBSECURE_STAGING_PORT:-18433}" \
|
||||
-e TRAEFIK_METRICS_PORT="${TRAEFIK_METRICS_PORT:-19100}" \
|
||||
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
|
||||
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
|
||||
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /workspace \
|
||||
node:22-alpine \
|
||||
sh -lc "apk add --no-cache docker-cli docker-cli-compose >/dev/null && node scripts/edge-gateway-e2e.mjs"
|
||||
docker cp . "$runner:/workspace"
|
||||
docker start "$runner" >/dev/null
|
||||
docker logs -f "$runner"
|
||||
exit_code="$(docker wait "$runner")"
|
||||
exit "$exit_code"
|
||||
|
||||
- name: Tear down local stack
|
||||
if: always()
|
||||
run: docker compose down -v
|
||||
|
||||
integration:
|
||||
name: Integration (advisory)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
continue-on-error: true
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
mysql:
|
||||
image: mysql:8
|
||||
env:
|
||||
MYSQL_DATABASE: app_test
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: mysqli, curl, openssl, json, redis
|
||||
ini-values: variables_order=EGPCS
|
||||
|
||||
- name: Resolve dependencies
|
||||
working-directory: services/nginx/app
|
||||
run: composer update --no-interaction --prefer-dist
|
||||
|
||||
- name: Run integration tests
|
||||
working-directory: services/nginx/app
|
||||
env:
|
||||
RUN_INTEGRATION_TESTS: '1'
|
||||
USE_ENV: 'true'
|
||||
DEBUG: '0'
|
||||
ENCRYPTION_KEY: test-key
|
||||
CORS: '*'
|
||||
CONFIG_TIMEZONE: Europe/Copenhagen
|
||||
ECONOMIC_API_APP_ACCESS_GRANT: test
|
||||
ECONOMIC_API_APP_ACCESS_GRANT2: test
|
||||
ECONOMIC_API_APP_SECRET_TOKEN: test
|
||||
WORDPRESS_STATIC_TOKEN: ''
|
||||
EMAIL_WASH_CERTIFICATE_TOKEN: ''
|
||||
WORDPRESS_API_URL: http://localhost
|
||||
MINIO_ENDPOINT: ''
|
||||
MINIO_ACCESS_KEY: ''
|
||||
MINIO_SECRET_KEY: ''
|
||||
SLACK_DEFAULT_WEBHOOK: ''
|
||||
REDIS_CONFIG_HOST: 127.0.0.1
|
||||
REDIS_CONFIG_DATABASE: '0'
|
||||
REDIS_CONFIG_PASSWORD: ''
|
||||
REDIS_CONFIG_PORT: '6379'
|
||||
CONFIG_DB_HOST: 127.0.0.1
|
||||
CONFIG_DB_USER: root
|
||||
CONFIG_DB_PASSWORD: root
|
||||
CONFIG_DB_DATABASE: app_test
|
||||
CONFIG_DB_PORT: '3306'
|
||||
run: composer test:integration
|
||||
|
||||
api:
|
||||
name: API (advisory)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
continue-on-error: true
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
mysql:
|
||||
image: mysql:8
|
||||
env:
|
||||
MYSQL_DATABASE: app_test
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: mysqli, curl, openssl, json, redis
|
||||
ini-values: variables_order=EGPCS
|
||||
|
||||
- name: Resolve dependencies
|
||||
working-directory: services/nginx/app
|
||||
run: composer update --no-interaction --prefer-dist
|
||||
|
||||
- name: Run API tests
|
||||
working-directory: services/nginx/app
|
||||
env:
|
||||
RUN_API_TESTS: '1'
|
||||
API_TEST_BOOTSTRAP_SCHEMA: '1'
|
||||
USE_ENV: 'true'
|
||||
DEBUG: '0'
|
||||
ENCRYPTION_KEY: test-key
|
||||
CORS: '*'
|
||||
CONFIG_TIMEZONE: Europe/Copenhagen
|
||||
ECONOMIC_API_APP_ACCESS_GRANT: test
|
||||
ECONOMIC_API_APP_ACCESS_GRANT2: test
|
||||
ECONOMIC_API_APP_SECRET_TOKEN: test
|
||||
WORDPRESS_STATIC_TOKEN: ''
|
||||
EMAIL_WASH_CERTIFICATE_TOKEN: ''
|
||||
WORDPRESS_API_URL: http://localhost
|
||||
MINIO_ENDPOINT: ''
|
||||
MINIO_ACCESS_KEY: ''
|
||||
MINIO_SECRET_KEY: ''
|
||||
SLACK_DEFAULT_WEBHOOK: ''
|
||||
REDIS_CONFIG_HOST: 127.0.0.1
|
||||
REDIS_CONFIG_DATABASE: '0'
|
||||
REDIS_CONFIG_PASSWORD: ''
|
||||
REDIS_CONFIG_PORT: '6379'
|
||||
CONFIG_DB_HOST: 127.0.0.1
|
||||
CONFIG_DB_USER: root
|
||||
CONFIG_DB_PASSWORD: root
|
||||
CONFIG_DB_DATABASE: app_test
|
||||
CONFIG_DB_PORT: '3306'
|
||||
run: composer test:api
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||
|
||||
+2
-1
@@ -46,7 +46,8 @@ COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Runtime bootstrap: lightweight entrypoint to ensure Composer deps exist when app is bind-mounted
|
||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
|
||||
&& chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Install PHP dependencies through Composer (only where composer.json exists)
|
||||
# Main app dependencies
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
FROM php:8.2.15-fpm
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
$PHPIZE_DEPS \
|
||||
ca-certificates \
|
||||
curl \
|
||||
default-mysql-client \
|
||||
git \
|
||||
imagemagick \
|
||||
libfreetype6-dev \
|
||||
libjpeg62-turbo-dev \
|
||||
libmagickcore-dev \
|
||||
libmagickwand-dev \
|
||||
libonig-dev \
|
||||
libpng-dev \
|
||||
libssl-dev \
|
||||
libxml2-dev \
|
||||
libzip-dev \
|
||||
mariadb-client \
|
||||
nginx \
|
||||
pkg-config \
|
||||
redis-tools \
|
||||
unzip \
|
||||
zip; \
|
||||
update-ca-certificates; \
|
||||
docker-php-ext-configure gd --with-freetype --with-jpeg; \
|
||||
docker-php-ext-install -j"$(nproc)" \
|
||||
bcmath \
|
||||
exif \
|
||||
gd \
|
||||
mbstring \
|
||||
mysqli \
|
||||
pcntl \
|
||||
pdo_mysql \
|
||||
sockets \
|
||||
zip; \
|
||||
pecl install imagick-3.7.0 redis; \
|
||||
docker-php-ext-enable imagick redis; \
|
||||
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false $PHPIZE_DEPS; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY services/nginx/app/ /var/www/html/
|
||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
||||
|
||||
RUN set -eux; \
|
||||
sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
|
||||
chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
|
||||
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html; \
|
||||
if [ -f /var/www/html/modules/washcertificates/composer.json ]; then \
|
||||
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html/modules/washcertificates; \
|
||||
fi; \
|
||||
COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload --no-dev --optimize --no-interaction -d /var/www/html; \
|
||||
php -d display_errors=1 -r 'require "/var/www/html/vendor/autoload.php"; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);'; \
|
||||
chown -R www-data:www-data /var/www/html; \
|
||||
chmod -R 755 /var/www/html
|
||||
|
||||
ENV APP_DIR=/var/www/html \
|
||||
MODULE_DIR=/var/www/html/modules/washcertificates \
|
||||
AUTO_COMPOSER_INSTALL=false \
|
||||
COMPOSER_ALLOW_SUPERUSER=1
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["coolify-api-start"]
|
||||
@@ -70,6 +70,11 @@ When updating `.env` values used by PHP containers (for example e-conomic tokens
|
||||
docker compose up -d --force-recreate php1 php2 php3 php4 php5 php-cron
|
||||
```
|
||||
|
||||
#### Edge Broker Public URL
|
||||
Set `EDGE_PUBLIC_BROKER_URL` to the public route that serves the edge broker, including the path prefix handled by the proxy. Local Traefik uses `http://localhost/api/edge-broker`; production routes use the public broker prefix, for example `https://api.truckwash.dk/edge-broker`.
|
||||
|
||||
The browser terminal connects to the exact advertised `EDGE_PUBLIC_BROKER_URL` plus `/ws/browser-shell`. That URL must be routable through the proxy to the edge-broker service. Do not rely on derived `/api/edge-broker` fallback paths outside the local Traefik setup.
|
||||
|
||||
## Testing
|
||||
|
||||
The project now uses [Pest](https://pestphp.com/) as the primary test runner in `services/nginx/app`.
|
||||
@@ -91,8 +96,8 @@ For local Docker development, run the PHP suites inside `php1`:
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:unit"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:integration"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:api"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:api:edge"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:integration:edge"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer run-script test:api:edge"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer run-script test:integration:edge"
|
||||
```
|
||||
|
||||
Integration tests are opt-in and should be run with required services available:
|
||||
@@ -112,8 +117,8 @@ The dedicated backend regression lane for the PHP edge gateway stack is split in
|
||||
Run the targeted PHP suites inside `php1`:
|
||||
|
||||
```powershell
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:api:edge"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:integration:edge"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer run-script test:api:edge"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer run-script test:integration:edge"
|
||||
```
|
||||
|
||||
Run the full local smoke from `backend-php` on the host:
|
||||
|
||||
@@ -52,6 +52,7 @@ services:
|
||||
dockerfile: services/edge-broker/Dockerfile
|
||||
container_name: edge-broker
|
||||
environment:
|
||||
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager}
|
||||
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
labels:
|
||||
|
||||
@@ -77,6 +77,7 @@ services:
|
||||
dockerfile: services/edge-broker/Dockerfile
|
||||
container_name: edge-broker
|
||||
environment:
|
||||
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager}
|
||||
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
labels:
|
||||
@@ -95,6 +96,13 @@ services:
|
||||
- "traefik.http.routers.edge-broker-api-io.middlewares=secure-headers@file,edge-broker-strip"
|
||||
- "traefik.http.routers.edge-broker-api-io.priority=200"
|
||||
- "traefik.http.routers.edge-broker-api-io.service=edge-broker"
|
||||
- "traefik.http.routers.edge-broker-api-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker-api-v2.entrypoints=websecure"
|
||||
- "traefik.http.routers.edge-broker-api-v2.tls=true"
|
||||
- "traefik.http.routers.edge-broker-api-v2.tls.certresolver=le_io"
|
||||
- "traefik.http.routers.edge-broker-api-v2.middlewares=secure-headers@file,edge-broker-strip"
|
||||
- "traefik.http.routers.edge-broker-api-v2.priority=200"
|
||||
- "traefik.http.routers.edge-broker-api-v2.service=edge-broker"
|
||||
- "traefik.http.routers.edge-broker-api-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker-api-staging.entrypoints=websecure-staging"
|
||||
- "traefik.http.routers.edge-broker-api-staging.tls=true"
|
||||
@@ -152,7 +160,14 @@ services:
|
||||
- "traefik.http.routers.api-io.tls.certresolver=le_io"
|
||||
- "traefik.http.routers.api-io.service=caddy"
|
||||
- "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file"
|
||||
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`)"
|
||||
- "traefik.http.routers.api-v2.rule=Host(`api-v2.truckwash.io`)"
|
||||
- "traefik.http.routers.api-v2.entrypoints=websecure"
|
||||
- "traefik.http.routers.api-v2.tls=true"
|
||||
- "traefik.http.routers.api-v2.tls.domains[0].main=api-v2.truckwash.io"
|
||||
- "traefik.http.routers.api-v2.tls.certresolver=le_io"
|
||||
- "traefik.http.routers.api-v2.service=caddy"
|
||||
- "traefik.http.routers.api-v2.middlewares=secure-headers@file,api-ratelimit@file"
|
||||
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`)"
|
||||
- "traefik.http.routers.api-http.entrypoints=web"
|
||||
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
|
||||
- "traefik.http.routers.api-http.service=caddy"
|
||||
|
||||
@@ -27,5 +27,5 @@ services:
|
||||
## docker compose up -d traefik caddy php1 php2 php3 php4 php5 db redis
|
||||
##
|
||||
## 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.
|
||||
|
||||
+61
-9
@@ -4,11 +4,11 @@ services:
|
||||
image: traefik:2.11
|
||||
container_name: traefik
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "4433:4433"
|
||||
- "${TRAEFIK_WEB_PORT:-80}:80"
|
||||
- "${TRAEFIK_WEBSECURE_PORT:-443}:443"
|
||||
- "${TRAEFIK_WEBSECURE_STAGING_PORT:-4433}:4433"
|
||||
# Prometheus metrics endpoint (local dev)
|
||||
- "9100:9100"
|
||||
- "${TRAEFIK_METRICS_PORT:-9100}:9100"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./services/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||
@@ -34,6 +34,41 @@ services:
|
||||
- "traefik.http.routers.traefik-local.entrypoints=web"
|
||||
- "traefik.http.routers.traefik-local.service=api@internal"
|
||||
- "traefik.http.routers.traefik-local.middlewares=dashboard-allow-local@file,dashboard-auth@file"
|
||||
# Broker API (handled in edge-broker service)
|
||||
- "traefik.http.routers.edge-broker.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker.entrypoints=websecure"
|
||||
- "traefik.http.routers.edge-broker.tls=true"
|
||||
- "traefik.http.routers.edge-broker.tls.certresolver=le"
|
||||
- "traefik.http.routers.edge-broker.middlewares=secure-headers@file,edge-broker-strip"
|
||||
- "traefik.http.routers.edge-broker.priority=200"
|
||||
- "traefik.http.routers.edge-broker.service=edge-broker"
|
||||
- "traefik.http.routers.edge-broker-io.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker-io.entrypoints=websecure"
|
||||
- "traefik.http.routers.edge-broker-io.tls=true"
|
||||
- "traefik.http.routers.edge-broker-io.tls.certresolver=le_io"
|
||||
- "traefik.http.routers.edge-broker-io.middlewares=secure-headers@file,edge-broker-strip"
|
||||
- "traefik.http.routers.edge-broker-io.priority=200"
|
||||
- "traefik.http.routers.edge-broker-io.service=edge-broker"
|
||||
- "traefik.http.routers.edge-broker-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker-v2.entrypoints=websecure"
|
||||
- "traefik.http.routers.edge-broker-v2.tls=true"
|
||||
- "traefik.http.routers.edge-broker-v2.tls.certresolver=le_io"
|
||||
- "traefik.http.routers.edge-broker-v2.middlewares=secure-headers@file,edge-broker-strip"
|
||||
- "traefik.http.routers.edge-broker-v2.priority=200"
|
||||
- "traefik.http.routers.edge-broker-v2.service=edge-broker"
|
||||
- "traefik.http.routers.edge-broker-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker-staging.entrypoints=websecure-staging"
|
||||
- "traefik.http.routers.edge-broker-staging.tls=true"
|
||||
- "traefik.http.routers.edge-broker-staging.tls.certresolver=le_io"
|
||||
- "traefik.http.routers.edge-broker-staging.middlewares=secure-headers@file,edge-broker-strip"
|
||||
- "traefik.http.routers.edge-broker-staging.priority=200"
|
||||
- "traefik.http.routers.edge-broker-staging.service=edge-broker"
|
||||
- "traefik.http.routers.edge-broker-local.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker-local.entrypoints=web"
|
||||
- "traefik.http.routers.edge-broker-local.middlewares=secure-headers@file,edge-broker-strip-local"
|
||||
- "traefik.http.routers.edge-broker-local.priority=200"
|
||||
- "traefik.http.routers.edge-broker-local.service=edge-broker"
|
||||
- "traefik.http.routers.edge-broker-local-secure.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
@@ -86,6 +121,7 @@ services:
|
||||
dockerfile: services/edge-broker/Dockerfile
|
||||
container_name: edge-broker
|
||||
environment:
|
||||
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager}
|
||||
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
labels:
|
||||
@@ -104,6 +140,13 @@ services:
|
||||
- "traefik.http.routers.edge-broker-api-io.middlewares=secure-headers@file,edge-broker-strip"
|
||||
- "traefik.http.routers.edge-broker-api-io.priority=200"
|
||||
- "traefik.http.routers.edge-broker-api-io.service=edge-broker"
|
||||
- "traefik.http.routers.edge-broker-api-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker-api-v2.entrypoints=websecure"
|
||||
- "traefik.http.routers.edge-broker-api-v2.tls=true"
|
||||
- "traefik.http.routers.edge-broker-api-v2.tls.certresolver=le_io"
|
||||
- "traefik.http.routers.edge-broker-api-v2.middlewares=secure-headers@file,edge-broker-strip"
|
||||
- "traefik.http.routers.edge-broker-api-v2.priority=200"
|
||||
- "traefik.http.routers.edge-broker-api-v2.service=edge-broker"
|
||||
- "traefik.http.routers.edge-broker-api-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker-api-staging.entrypoints=websecure-staging"
|
||||
- "traefik.http.routers.edge-broker-api-staging.tls=true"
|
||||
@@ -143,7 +186,7 @@ services:
|
||||
- php5
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./services/caddy:/etc/caddy:ro
|
||||
- ./services/caddy/logs:/var/log/caddy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
@@ -163,8 +206,16 @@ services:
|
||||
- "traefik.http.routers.api-io.tls.certresolver=le_io"
|
||||
- "traefik.http.routers.api-io.service=caddy"
|
||||
- "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file"
|
||||
# Public API (.io load-balanced gateway)
|
||||
- "traefik.http.routers.api-v2.rule=Host(`api-v2.truckwash.io`)"
|
||||
- "traefik.http.routers.api-v2.entrypoints=websecure"
|
||||
- "traefik.http.routers.api-v2.tls=true"
|
||||
- "traefik.http.routers.api-v2.tls.domains[0].main=api-v2.truckwash.io"
|
||||
- "traefik.http.routers.api-v2.tls.certresolver=le_io"
|
||||
- "traefik.http.routers.api-v2.service=caddy"
|
||||
- "traefik.http.routers.api-v2.middlewares=secure-headers@file,api-ratelimit@file"
|
||||
# HTTP to HTTPS redirect for both API domains
|
||||
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`)"
|
||||
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`)"
|
||||
- "traefik.http.routers.api-http.entrypoints=web"
|
||||
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
|
||||
- "traefik.http.routers.api-http.service=caddy"
|
||||
@@ -200,9 +251,10 @@ services:
|
||||
container_name: caddy-staging
|
||||
depends_on:
|
||||
- php-staging
|
||||
command: ["caddy", "run", "--config", "/etc/caddy/Caddyfile-staging", "--adapter", "caddyfile"]
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/caddy/Caddyfile-staging:/etc/caddy/Caddyfile:ro
|
||||
- ./services/nginx/staging:/var/www/html
|
||||
- ./services/caddy:/etc/caddy:ro
|
||||
- ./services/caddy/logs-staging:/var/log/caddy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
@@ -357,7 +409,7 @@ services:
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/nginx/staging:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
- ./services/php/logs-staging:/var/log/php
|
||||
|
||||
|
||||
+2
-2
@@ -38,14 +38,14 @@ http {
|
||||
location / {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
|
||||
add_header Access-Control-Allow-Credentials true;
|
||||
|
||||
# If OPTIONS method is needed for preflight
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
|
||||
return 204; # No Content
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -52,14 +52,14 @@ http {
|
||||
location / {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
|
||||
add_header Access-Control-Allow-Credentials true;
|
||||
|
||||
# If OPTIONS method is needed for preflight
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
|
||||
return 204; # No Content
|
||||
}
|
||||
|
||||
|
||||
+1037
-5
File diff suppressed because it is too large
Load Diff
+244
-15
@@ -75,6 +75,211 @@ function normalizeBaseUrl(url) {
|
||||
return String(url || "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function baseUrlWithHost(baseUrl, host, port = null) {
|
||||
const url = new URL(normalizeBaseUrl(baseUrl));
|
||||
url.hostname = host;
|
||||
if (port !== null) {
|
||||
url.port = port;
|
||||
}
|
||||
return normalizeBaseUrl(url.toString());
|
||||
}
|
||||
|
||||
function directCaddyBaseUrl(baseUrl) {
|
||||
const url = new URL(normalizeBaseUrl(baseUrl));
|
||||
url.hostname = "caddy";
|
||||
url.port = "";
|
||||
if (url.pathname === "/api" || url.pathname === "/api/") {
|
||||
url.pathname = "/";
|
||||
}
|
||||
return normalizeBaseUrl(url.toString());
|
||||
}
|
||||
|
||||
function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
||||
const websocketUrl = new URL(String(rawUrl));
|
||||
const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl));
|
||||
|
||||
if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") {
|
||||
websocketUrl.hostname = "edge-broker";
|
||||
websocketUrl.port = "4300";
|
||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
}
|
||||
|
||||
return websocketUrl.toString();
|
||||
}
|
||||
|
||||
async function readDefaultGatewayHost() {
|
||||
if (process.platform === "win32") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const routeTable = await fs.readFile("/proc/net/route", "utf8");
|
||||
const route = routeTable
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim().split(/\s+/))
|
||||
.find((fields) => fields[1] === "00000000" && /^[0-9A-Fa-f]{8}$/.test(fields[2] || ""));
|
||||
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gateway = route[2];
|
||||
const octets = [
|
||||
gateway.slice(6, 8),
|
||||
gateway.slice(4, 6),
|
||||
gateway.slice(2, 4),
|
||||
gateway.slice(0, 2),
|
||||
].map((octet) => Number.parseInt(octet, 16));
|
||||
|
||||
if (octets.some((octet) => !Number.isInteger(octet)) || octets.every((octet) => octet === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return octets.join(".");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function composeNetworkName(composeProject) {
|
||||
return `${composeProject}_default`;
|
||||
}
|
||||
|
||||
async function readCurrentContainerRef() {
|
||||
if (process.platform === "win32") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
const envHostname = String(process.env.HOSTNAME || "").trim();
|
||||
if (envHostname !== "") {
|
||||
candidates.push(envHostname);
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = (await fs.readFile("/etc/hostname", "utf8")).trim();
|
||||
if (hostname !== "") {
|
||||
candidates.push(hostname);
|
||||
}
|
||||
} catch {
|
||||
// Not running in a container with /etc/hostname available.
|
||||
}
|
||||
|
||||
try {
|
||||
const cgroup = await fs.readFile("/proc/self/cgroup", "utf8");
|
||||
const matches = cgroup.match(/[0-9a-f]{64}/gi) || [];
|
||||
candidates.push(...matches);
|
||||
} catch {
|
||||
// cgroup metadata is optional in local development.
|
||||
}
|
||||
|
||||
return candidates.find((candidate) => /^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,127}$/.test(candidate)) || null;
|
||||
}
|
||||
|
||||
async function connectCurrentContainerToComposeNetwork(rootDir, composeProject) {
|
||||
const containerRef = await readCurrentContainerRef();
|
||||
if (!containerRef) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const inspection = await runCommand("docker", ["inspect", containerRef], {
|
||||
cwd: rootDir,
|
||||
allowFailure: true,
|
||||
});
|
||||
if (inspection.code !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const networkName = composeNetworkName(composeProject);
|
||||
const connection = await runCommand("docker", ["network", "connect", networkName, containerRef], {
|
||||
cwd: rootDir,
|
||||
allowFailure: true,
|
||||
});
|
||||
const stderr = String(connection.stderr || "");
|
||||
if (connection.code === 0) {
|
||||
process.stdout.write(`Attached runner container ${containerRef} to ${networkName}.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/already exists|already connected/i.test(stderr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function disconnectCurrentContainerFromComposeNetwork(rootDir, composeProject) {
|
||||
const containerRef = await readCurrentContainerRef();
|
||||
if (!containerRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runCommand("docker", ["network", "disconnect", composeNetworkName(composeProject), containerRef], {
|
||||
cwd: rootDir,
|
||||
allowFailure: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function readComposeServiceHost(rootDir, composeProject, serviceName) {
|
||||
const ps = await runCommand("docker", composeArgs(composeProject, ["ps", "-q", serviceName]), {
|
||||
cwd: rootDir,
|
||||
allowFailure: true,
|
||||
});
|
||||
const containerId = String(ps.stdout || "").trim().split(/\s+/).find(Boolean);
|
||||
const containerRefs = [
|
||||
...(containerId ? [containerId] : []),
|
||||
serviceName,
|
||||
];
|
||||
|
||||
for (const containerRef of [...new Set(containerRefs)]) {
|
||||
const inspection = await runCommand("docker", [
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
|
||||
containerRef,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
allowFailure: true,
|
||||
});
|
||||
const host = String(inspection.stdout || "").trim().split(/\s+/).find((value) => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value));
|
||||
|
||||
if (host) {
|
||||
return host;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function candidateApiBaseUrls(baseUrl, rootDir, composeProject, useComposeNetwork = false) {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
const candidates = [normalized];
|
||||
const url = new URL(normalized);
|
||||
|
||||
if (["localhost", "127.0.0.1", "::1"].includes(url.hostname)) {
|
||||
if (rootDir && composeProject) {
|
||||
if (useComposeNetwork) {
|
||||
candidates.push(directCaddyBaseUrl(normalized));
|
||||
candidates.push(baseUrlWithHost(normalized, "traefik", ""));
|
||||
}
|
||||
|
||||
const traefikHost = await readComposeServiceHost(rootDir, composeProject, "traefik");
|
||||
if (traefikHost) {
|
||||
candidates.push(baseUrlWithHost(normalized, traefikHost, ""));
|
||||
}
|
||||
}
|
||||
|
||||
const gatewayHost = await readDefaultGatewayHost();
|
||||
if (gatewayHost) {
|
||||
candidates.push(baseUrlWithHost(normalized, gatewayHost));
|
||||
}
|
||||
|
||||
candidates.push(baseUrlWithHost(normalized, "host.docker.internal"));
|
||||
}
|
||||
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
async function waitForCondition(predicate, { timeoutMs = 30_000, intervalMs = 500, message = "Timed out" } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
@@ -96,26 +301,30 @@ async function ensureComposeServices(rootDir, composeProject) {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForApiReady(baseUrl, attempts = 60) {
|
||||
const root = normalizeBaseUrl(baseUrl);
|
||||
async function waitForApiReady(baseUrl, rootDir, composeProject, useComposeNetwork = false, attempts = 60) {
|
||||
const candidates = await candidateApiBaseUrls(baseUrl, rootDir, composeProject, useComposeNetwork);
|
||||
let lastError = "API never responded";
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(`${root}/ping`);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
for (const root of candidates) {
|
||||
try {
|
||||
const response = await fetch(`${root}/ping`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
if (response.ok) {
|
||||
return root;
|
||||
}
|
||||
|
||||
lastError = `Unexpected ping status ${response.status}`;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
lastError = `${root}/ping returned HTTP ${response.status}`;
|
||||
} catch (error) {
|
||||
lastError = `${root}/ping failed: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
throw new Error(`API did not become ready at ${root}/ping: ${lastError}`);
|
||||
throw new Error(`API did not become ready at ${candidates.map((candidate) => `${candidate}/ping`).join(", ")}: ${lastError}`);
|
||||
}
|
||||
|
||||
function parseLastJsonLine(output) {
|
||||
@@ -301,6 +510,10 @@ function closeSocket(socket) {
|
||||
socket.close();
|
||||
}
|
||||
|
||||
function shouldCopyGatewayConfig() {
|
||||
return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_COPY_CONFIG || "").trim());
|
||||
}
|
||||
|
||||
function collectMessages(rows) {
|
||||
return Array.isArray(rows)
|
||||
? rows
|
||||
@@ -345,7 +558,7 @@ async function main() {
|
||||
const containerName = `truckwash-edge-e2e-${runId}`;
|
||||
const configDir = path.join(rootDir, ".tmp", "edge-gateway-e2e", runId);
|
||||
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
|
||||
const baseUrl = process.env.EDGE_GATEWAY_E2E_BASE_URL || DEFAULT_HOST_API_URL;
|
||||
let baseUrl = process.env.EDGE_GATEWAY_E2E_BASE_URL || DEFAULT_HOST_API_URL;
|
||||
const composeProject =
|
||||
process.env.EDGE_GATEWAY_E2E_COMPOSE_PROJECT
|
||||
|| path.basename(rootDir);
|
||||
@@ -354,10 +567,13 @@ async function main() {
|
||||
let gatewayId = null;
|
||||
let streamSocket = null;
|
||||
let shellSocket = null;
|
||||
let runnerNetworkAttached = false;
|
||||
|
||||
try {
|
||||
await ensureComposeServices(rootDir, composeProject);
|
||||
await waitForApiReady(baseUrl);
|
||||
runnerNetworkAttached = await connectCurrentContainerToComposeNetwork(rootDir, composeProject);
|
||||
baseUrl = await waitForApiReady(baseUrl, rootDir, composeProject, runnerNetworkAttached);
|
||||
process.stdout.write(`Using API base URL ${baseUrl}\n`);
|
||||
|
||||
fixture = await runPhpFixture(rootDir, composeProject, "create");
|
||||
const authToken = String(fixture.auth_token || "");
|
||||
@@ -381,6 +597,8 @@ async function main() {
|
||||
"start",
|
||||
"--install-token",
|
||||
installToken,
|
||||
"--host-api-url",
|
||||
baseUrl,
|
||||
"--container-name",
|
||||
containerName,
|
||||
"--config-dir",
|
||||
@@ -388,6 +606,7 @@ async function main() {
|
||||
"--heartbeat-seconds",
|
||||
"3",
|
||||
"--skip-compose-up",
|
||||
...(shouldCopyGatewayConfig() ? ["--copy-config"] : []),
|
||||
], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
@@ -468,7 +687,10 @@ async function main() {
|
||||
scopes: ["overview", "tasks", "logs", "statistics"],
|
||||
},
|
||||
});
|
||||
const streamWsUrl = buildSocketUrl(String(streamSession?.data?.ws_url || ""), String(streamSession?.data?.token || ""));
|
||||
const streamWsUrl = buildSocketUrl(
|
||||
resolveBrokerWebSocketUrl(String(streamSession?.data?.ws_url || ""), baseUrl),
|
||||
String(streamSession?.data?.token || "")
|
||||
);
|
||||
streamSocket = new WebSocketImpl(streamWsUrl);
|
||||
const streamMessages = collectSocketMessages(streamSocket);
|
||||
|
||||
@@ -596,7 +818,10 @@ async function main() {
|
||||
rows: 40,
|
||||
},
|
||||
});
|
||||
const shellWsUrl = buildSocketUrl(String(shellSession?.data?.ws_url || ""), String(shellSession?.data?.token || ""));
|
||||
const shellWsUrl = buildSocketUrl(
|
||||
resolveBrokerWebSocketUrl(String(shellSession?.data?.ws_url || ""), baseUrl),
|
||||
String(shellSession?.data?.token || "")
|
||||
);
|
||||
shellSocket = new WebSocketImpl(shellWsUrl);
|
||||
const shellMessages = collectSocketMessages(shellSocket);
|
||||
|
||||
@@ -668,6 +893,10 @@ async function main() {
|
||||
}
|
||||
|
||||
await fs.rm(configDir, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
if (runnerNetworkAttached) {
|
||||
await disconnectCurrentContainerFromComposeNetwork(rootDir, composeProject).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
suite="${1:-}"
|
||||
case "$suite" in
|
||||
unit|integration|api|legacy|all)
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 <unit|integration|api|legacy|all>" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
|
||||
project_suffix="$(date +%s)-$$"
|
||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
|
||||
|
||||
log_dir=".tmp/ci-logs/$suite"
|
||||
mkdir -p "$log_dir"
|
||||
|
||||
env_backup_dir=".tmp/php-ci-env-backup-$project_suffix"
|
||||
mkdir -p "$env_backup_dir"
|
||||
had_env=0
|
||||
had_env_staging=0
|
||||
if [ -f .env ]; then
|
||||
cp .env "$env_backup_dir/env"
|
||||
had_env=1
|
||||
fi
|
||||
if [ -f .env.staging ]; then
|
||||
cp .env.staging "$env_backup_dir/env.staging"
|
||||
had_env_staging=1
|
||||
fi
|
||||
|
||||
cp .github/ci.env .env
|
||||
cp .github/ci.env.staging .env.staging
|
||||
|
||||
collect_logs() {
|
||||
status="$1"
|
||||
if [ "$status" -eq 0 ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$log_dir"
|
||||
docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true
|
||||
docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true
|
||||
docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true
|
||||
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
status="$?"
|
||||
collect_logs "$status"
|
||||
docker compose $compose_files down -v >/dev/null 2>&1 || true
|
||||
if [ "$had_env" -eq 1 ]; then
|
||||
cp "$env_backup_dir/env" .env
|
||||
else
|
||||
rm -f .env
|
||||
fi
|
||||
if [ "$had_env_staging" -eq 1 ]; then
|
||||
cp "$env_backup_dir/env.staging" .env.staging
|
||||
else
|
||||
rm -f .env.staging
|
||||
fi
|
||||
rm -rf "$env_backup_dir"
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
docker compose $compose_files up -d redis mysql-debug php1
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc '
|
||||
set -eu
|
||||
for i in $(seq 1 90); do
|
||||
if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \
|
||||
-h "${CONFIG_DB_HOST:-mysql-debug}" \
|
||||
-P "${CONFIG_DB_PORT:-3306}" \
|
||||
-u "${CONFIG_DB_USER:-root}" \
|
||||
ping --silent >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Timed out waiting for mysql-debug" >&2
|
||||
exit 1
|
||||
'
|
||||
|
||||
tar \
|
||||
--exclude='./vendor' \
|
||||
--exclude='./.phpunit.cache' \
|
||||
--exclude='./build/logs' \
|
||||
-C services/nginx/app -cf - . \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
"cd /var/www/html && composer test:ci:$suite"
|
||||
+44
-22
@@ -56,6 +56,7 @@ Options:
|
||||
--tail <lines> Log lines for the logs action. Default: 200
|
||||
--skip-compose-up Do not start the local Docker Compose stack before start.
|
||||
--skip-build Do not rebuild the test gateway image before start.
|
||||
--copy-config Copy generated config into the container instead of bind mounting it.
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -150,6 +151,7 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
||||
tail: "200",
|
||||
skipComposeUp: false,
|
||||
skipBuild: false,
|
||||
copyConfig: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < rest.length; index += 1) {
|
||||
@@ -204,6 +206,9 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
||||
case "--skip-build":
|
||||
options.skipBuild = true;
|
||||
break;
|
||||
case "--copy-config":
|
||||
options.copyConfig = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
options.help = true;
|
||||
@@ -361,35 +366,51 @@ async function startContainer({
|
||||
containerName,
|
||||
hostname,
|
||||
configDir,
|
||||
copyConfig,
|
||||
}) {
|
||||
const networkName = resolveComposeNetworkName(rootDir);
|
||||
const mountedConfigDir = toDockerMountPath(configDir);
|
||||
const containerConfigPath = copyConfig
|
||||
? `/tmp/${DEFAULT_CONFIG_FILE_NAME}`
|
||||
: `/config/${DEFAULT_CONFIG_FILE_NAME}`;
|
||||
const createArgs = [
|
||||
copyConfig ? "create" : "run",
|
||||
...(copyConfig ? [] : ["-d"]),
|
||||
"--name",
|
||||
containerName,
|
||||
"--hostname",
|
||||
hostname,
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
networkName,
|
||||
...(copyConfig ? [] : ["-v", `${mountedConfigDir}:/config`]),
|
||||
imageTag,
|
||||
"--config",
|
||||
containerConfigPath,
|
||||
];
|
||||
|
||||
await removeContainer(containerName);
|
||||
await runCommand(
|
||||
"docker",
|
||||
[
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
containerName,
|
||||
"--hostname",
|
||||
hostname,
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
networkName,
|
||||
"-v",
|
||||
`${mountedConfigDir}:/config`,
|
||||
imageTag,
|
||||
"--config",
|
||||
`/config/${DEFAULT_CONFIG_FILE_NAME}`,
|
||||
],
|
||||
{
|
||||
await runCommand("docker", createArgs, {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (copyConfig) {
|
||||
await runCommand("docker", [
|
||||
"cp",
|
||||
path.join(configDir, DEFAULT_CONFIG_FILE_NAME),
|
||||
`${containerName}:${containerConfigPath}`,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
await runCommand("docker", ["start", containerName], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function printStatus({ imageTag, configDir, containerName }) {
|
||||
@@ -469,6 +490,7 @@ async function main() {
|
||||
containerName: options.containerName,
|
||||
hostname: options.hostname,
|
||||
configDir,
|
||||
copyConfig: options.copyConfig,
|
||||
});
|
||||
|
||||
process.stdout.write(`Test gateway container started.
|
||||
|
||||
@@ -52,6 +52,12 @@ test("parseArgs accepts help without an explicit action", () => {
|
||||
assert.equal(options.help, true);
|
||||
});
|
||||
|
||||
test("parseArgs accepts copy config mode", () => {
|
||||
const options = parseArgs(["start", "--copy-config"]);
|
||||
|
||||
assert.equal(options.copyConfig, true);
|
||||
});
|
||||
|
||||
test("buildGatewayConfig applies defaults for a dockerized gateway", () => {
|
||||
const config = buildGatewayConfig({});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
worker_processes auto;
|
||||
pid /run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
client_max_body_size 64m;
|
||||
|
||||
gzip on;
|
||||
gzip_types application/json application/javascript application/xml text/css text/plain;
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
root /var/www/html;
|
||||
index index.php;
|
||||
|
||||
location / {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_read_timeout 60s;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_param X_REQUEST_ID $http_x_request_id;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known) {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
mkdir -p /run/nginx
|
||||
php-fpm -D
|
||||
exec nginx -g "daemon off;"
|
||||
@@ -3,9 +3,7 @@ FROM php:8.2-cli
|
||||
WORKDIR /opt/truckwash-edge-agent
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends ca-certificates; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
||||
|
||||
COPY services/nginx/app/resources/edge-gateway-agent/ ./
|
||||
|
||||
|
||||
Vendored
+293
-35
@@ -17,6 +17,7 @@ const DEFAULT_UPDATE_VERIFICATION_TIMEOUT_SECONDS = 45;
|
||||
const DEFAULT_UPDATE_VERIFY_INTERVAL_MS = 500;
|
||||
const DEFAULT_UPDATE_RESTART_GRACE_MS = 150;
|
||||
const DEFAULT_BROKER_RECONNECT_DELAY_MS = 1500;
|
||||
const DEFAULT_SHELLY_LOCAL_HTTP_TIMEOUT_MS = 1200;
|
||||
const UPDATE_VERIFY_COMMAND = "post-update-verify";
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
@@ -137,12 +138,15 @@ function buildTransportHeartbeatState(brokerState = {}) {
|
||||
status: "ONLINE",
|
||||
metadata: {
|
||||
command_transport: brokerConnected ? "BROKER_FAST_PATH" : "API_POLLING",
|
||||
shell_transport: brokerConnected ? "BROKER_FAST_PATH" : "API_POLLING",
|
||||
broker_connected: brokerConnected,
|
||||
broker_url: brokerState.url || null,
|
||||
broker_last_error: brokerState.lastError || null,
|
||||
broker_last_connected_at: brokerState.lastConnectedAt || null,
|
||||
broker_last_disconnected_at: brokerState.lastDisconnectedAt || null,
|
||||
broker_disconnect_reason: brokerState.disconnectReason || null,
|
||||
broker_last_close_code: brokerState.lastCloseCode ?? null,
|
||||
broker_last_close_clean: brokerState.lastCloseClean ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -177,6 +181,30 @@ function buildBrokerSocketUrl(brokerUrl, gatewayId, token) {
|
||||
return `${baseUrl}/ws/agent?${search.toString()}`;
|
||||
}
|
||||
|
||||
function redactBrokerSocketUrl(value) {
|
||||
try {
|
||||
const parsed = new URL(String(value || ""));
|
||||
for (const key of ["token", "agentToken", "agent_token"]) {
|
||||
if (parsed.searchParams.has(key)) {
|
||||
parsed.searchParams.set(key, "***");
|
||||
}
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return String(value || "").replace(/([?&](?:token|agentToken|agent_token)=)[^&]+/gi, "$1***");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBrokerSocketError(error, socketUrl) {
|
||||
const message = error instanceof Error && error.message
|
||||
? error.message
|
||||
: error?.message
|
||||
? String(error.message)
|
||||
: "Broker connection failed";
|
||||
const redactedUrl = redactBrokerSocketUrl(socketUrl);
|
||||
return redactedUrl ? `${message} (${redactedUrl})` : message;
|
||||
}
|
||||
|
||||
async function readSocketMessageText(data) {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
@@ -425,8 +453,64 @@ export async function claimIfNeeded(config, configPath, fetchImpl = fetch) {
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
async function fetchJson(url, fetchImpl = fetch) {
|
||||
const response = await fetchImpl(url);
|
||||
function makeHttpTimeoutError(timeoutMs) {
|
||||
const error = new Error(`HTTP request timed out after ${timeoutMs}ms`);
|
||||
error.code = "EDGE_AGENT_HTTP_TIMEOUT";
|
||||
return error;
|
||||
}
|
||||
|
||||
function isHttpTimeoutError(error) {
|
||||
return error?.code === "EDGE_AGENT_HTTP_TIMEOUT";
|
||||
}
|
||||
|
||||
function resolveShellyLocalHttpTimeoutMs(options = {}) {
|
||||
const configured = Number(options.timeoutMs ?? process.env.EDGE_SHELLY_LOCAL_HTTP_TIMEOUT_MS);
|
||||
if (Number.isFinite(configured) && configured > 0) {
|
||||
return Math.max(50, Math.floor(configured));
|
||||
}
|
||||
|
||||
return DEFAULT_SHELLY_LOCAL_HTTP_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function resolveRelayToggleAfterSeconds(payload = {}) {
|
||||
const configured = Number(payload.toggleAfter ?? payload.toggle_after ?? payload.timer);
|
||||
if (!Number.isFinite(configured) || configured <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.floor(configured);
|
||||
}
|
||||
|
||||
async function fetchJson(url, fetchImpl = fetch, options = {}) {
|
||||
const timeoutMs = Number(options.timeoutMs || 0);
|
||||
let timeout = null;
|
||||
let controller = null;
|
||||
const requestOptions = {};
|
||||
if (timeoutMs > 0 && typeof AbortController !== "undefined") {
|
||||
controller = new AbortController();
|
||||
requestOptions.signal = controller.signal;
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
const fetchPromise = Promise.resolve().then(() => fetchImpl(url, requestOptions));
|
||||
response = timeoutMs > 0
|
||||
? await Promise.race([
|
||||
fetchPromise,
|
||||
new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller?.abort();
|
||||
reject(makeHttpTimeoutError(timeoutMs));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
])
|
||||
: await fetchPromise;
|
||||
} finally {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
@@ -451,13 +535,99 @@ function expandCandidateIps(options = {}) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeShellyDeviceGeneration(value) {
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
|
||||
return inferShellyDeviceGenerationFromString(value);
|
||||
}
|
||||
|
||||
function inferShellyDeviceGenerationFromString(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicit = normalized.match(/\bgen(?:eration)?\s*([1-9]\d*)\b/i);
|
||||
if (explicit) {
|
||||
return Number(explicit[1]);
|
||||
}
|
||||
|
||||
const upper = normalized.toUpperCase();
|
||||
const sSeries = upper.match(/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/);
|
||||
if (sSeries) {
|
||||
return Number(sSeries[1]);
|
||||
}
|
||||
|
||||
if (/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i.test(normalized) || /\bSP[A-Z0-9]+-[A-Z0-9-]+\b/.test(upper)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/.test(upper)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveShellyDeviceGeneration(identity = {}) {
|
||||
for (const candidate of [
|
||||
identity.gen,
|
||||
identity.generation,
|
||||
identity.device_generation,
|
||||
identity.capabilities?.generation,
|
||||
]) {
|
||||
const generation = normalizeShellyDeviceGeneration(candidate);
|
||||
if (generation !== null) {
|
||||
return generation;
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of [
|
||||
identity.model,
|
||||
identity.type,
|
||||
identity.app,
|
||||
identity.name,
|
||||
identity.id,
|
||||
identity.mac,
|
||||
]) {
|
||||
const generation = inferShellyDeviceGenerationFromString(candidate);
|
||||
if (generation !== null) {
|
||||
return generation;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveShellyCommandGeneration(payload = {}) {
|
||||
return resolveShellyDeviceGeneration({
|
||||
gen: payload.gen ?? payload.generation ?? payload.deviceGeneration ?? payload.device_generation,
|
||||
device_generation: payload.device_generation,
|
||||
capabilities: payload.capabilities,
|
||||
model: payload.model ?? payload.deviceModel ?? payload.device_model ?? payload.deviceType ?? payload.device_type,
|
||||
type: payload.type ?? payload.deviceType ?? payload.device_type,
|
||||
app: payload.app,
|
||||
name: payload.name ?? payload.deviceName ?? payload.device_name,
|
||||
id: payload.deviceId ?? payload.device_id ?? payload.relayId ?? payload.relay_id,
|
||||
mac: payload.mac,
|
||||
});
|
||||
}
|
||||
|
||||
export async function discoverShellyDevices(options = {}, fetchImpl = fetch) {
|
||||
const candidateIps = expandCandidateIps(options);
|
||||
const discovered = [];
|
||||
|
||||
await Promise.all(candidateIps.map(async (ip) => {
|
||||
try {
|
||||
const identity = await fetchJson(`http://${ip}/shelly`, fetchImpl);
|
||||
const identity = await fetchJson(`http://${ip}/shelly`, fetchImpl, {
|
||||
timeoutMs: resolveShellyLocalHttpTimeoutMs(options),
|
||||
});
|
||||
discovered.push({
|
||||
id: identity.mac || identity.id || ip,
|
||||
device_id: identity.mac || identity.id || ip,
|
||||
@@ -466,7 +636,7 @@ export async function discoverShellyDevices(options = {}, fetchImpl = fetch) {
|
||||
channel_count: Number(identity.num_outputs || identity.num_switches || 1),
|
||||
online: true,
|
||||
capabilities: {
|
||||
generation: identity.gen || null,
|
||||
generation: resolveShellyDeviceGeneration(identity),
|
||||
},
|
||||
metadata: identity,
|
||||
});
|
||||
@@ -481,24 +651,41 @@ export async function discoverShellyDevices(options = {}, fetchImpl = fetch) {
|
||||
export async function getRelayStatus(payload, fetchImpl = fetch) {
|
||||
const ip = payload.localIp || payload.local_ip || payload.ip;
|
||||
const channel = Number.isInteger(payload.channel) ? payload.channel : 0;
|
||||
const timeoutMs = resolveShellyLocalHttpTimeoutMs(payload);
|
||||
if (!ip) {
|
||||
throw new Error("Missing relay local IP");
|
||||
}
|
||||
|
||||
const attempts = [
|
||||
async () => {
|
||||
const rpc = await fetchJson(`http://${ip}/rpc/Switch.GetStatus?id=${channel}`, fetchImpl, { timeoutMs });
|
||||
return {
|
||||
online: true,
|
||||
on: Boolean(rpc.output),
|
||||
raw: rpc,
|
||||
};
|
||||
},
|
||||
async () => {
|
||||
const legacy = await fetchJson(`http://${ip}/relay/${channel}`, fetchImpl, { timeoutMs });
|
||||
return {
|
||||
online: true,
|
||||
on: Boolean(legacy.ison ?? legacy.output),
|
||||
raw: legacy,
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const rpc = await fetchJson(`http://${ip}/rpc/Switch.GetStatus?id=${channel}`, fetchImpl);
|
||||
return {
|
||||
online: true,
|
||||
on: Boolean(rpc.output),
|
||||
raw: rpc,
|
||||
};
|
||||
} catch {
|
||||
const legacy = await fetchJson(`http://${ip}/relay/${channel}`, fetchImpl);
|
||||
return {
|
||||
online: true,
|
||||
on: Boolean(legacy.ison ?? legacy.output),
|
||||
raw: legacy,
|
||||
};
|
||||
return await Promise.any(attempts.map((attempt) => attempt()));
|
||||
} catch (error) {
|
||||
const errors = Array.isArray(error?.errors) ? error.errors : [error];
|
||||
const message = errors
|
||||
.map((entry) => entry?.message || String(entry))
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
const relayStatusError = new Error(message ? `Unable to read relay status: ${message}` : "Unable to read relay status");
|
||||
relayStatusError.cause = error;
|
||||
throw relayStatusError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,24 +693,59 @@ export async function setRelayState(payload, fetchImpl = fetch) {
|
||||
const ip = payload.localIp || payload.local_ip || payload.ip;
|
||||
const channel = Number.isInteger(payload.channel) ? payload.channel : 0;
|
||||
const on = Boolean(payload.on);
|
||||
const timeoutMs = resolveShellyLocalHttpTimeoutMs(payload);
|
||||
const toggleAfter = resolveRelayToggleAfterSeconds(payload);
|
||||
const timerQuery = toggleAfter === null ? "" : `&toggle_after=${encodeURIComponent(String(toggleAfter))}`;
|
||||
const legacyTimerQuery = toggleAfter === null ? "" : `&timer=${encodeURIComponent(String(toggleAfter))}`;
|
||||
const deviceGeneration = resolveShellyCommandGeneration(payload);
|
||||
if (!ip) {
|
||||
throw new Error("Missing relay local IP");
|
||||
}
|
||||
|
||||
try {
|
||||
const rpc = await fetchJson(`http://${ip}/rpc/Switch.Set?id=${channel}&on=${on ? "true" : "false"}`, fetchImpl);
|
||||
const runRpcSwitch = async () => {
|
||||
const rpc = await fetchJson(`http://${ip}/rpc/Switch.Set?id=${channel}&on=${on ? "true" : "false"}${timerQuery}`, fetchImpl, { timeoutMs });
|
||||
return {
|
||||
online: true,
|
||||
on: Boolean(rpc.output ?? on),
|
||||
raw: rpc,
|
||||
};
|
||||
} catch {
|
||||
const legacy = await fetchJson(`http://${ip}/relay/${channel}?turn=${on ? "on" : "off"}`, fetchImpl);
|
||||
};
|
||||
const runLegacySwitch = async () => {
|
||||
const legacy = await fetchJson(`http://${ip}/relay/${channel}?turn=${on ? "on" : "off"}${legacyTimerQuery}`, fetchImpl, { timeoutMs });
|
||||
return {
|
||||
online: true,
|
||||
on: Boolean(legacy.ison ?? legacy.output ?? on),
|
||||
raw: legacy,
|
||||
};
|
||||
};
|
||||
|
||||
if (toggleAfter !== null) {
|
||||
const attempts = deviceGeneration === 1
|
||||
? [runLegacySwitch, runRpcSwitch]
|
||||
: [runRpcSwitch, runLegacySwitch];
|
||||
|
||||
let lastError = null;
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
return await attempt();
|
||||
} catch (error) {
|
||||
if (isHttpTimeoutError(error)) {
|
||||
throw error;
|
||||
}
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Unable to switch relay");
|
||||
}
|
||||
|
||||
try {
|
||||
return await runRpcSwitch();
|
||||
} catch (error) {
|
||||
if (isHttpTimeoutError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return await runLegacySwitch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1122,12 +1344,13 @@ export function createShellBridge(sendMessage, { createPtyProcess = createDefaul
|
||||
sessions.set(sessionId, sessionRecord);
|
||||
sendMessage({ type: "SHELL_OPENED", sessionId });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
sendMessage({
|
||||
type: "SHELL_OUTPUT",
|
||||
sessionId,
|
||||
data: `Failed to start root shell: ${error instanceof Error ? error.message : String(error)}\r\n`,
|
||||
data: `Failed to start root shell: ${message}\r\n`,
|
||||
});
|
||||
sendMessage({ type: "SHELL_EXIT", sessionId, code: 1 });
|
||||
sendMessage({ type: "SHELL_EXIT", sessionId, code: 1, reason: "shell_spawn_failed", message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1462,13 +1685,15 @@ function normalizeShellEvent(message) {
|
||||
}
|
||||
|
||||
if (message.type === "SHELL_EXIT") {
|
||||
const reason = String(message.reason || "agent_exit");
|
||||
return {
|
||||
sessionId: String(sessionId),
|
||||
event: {
|
||||
type: "CLOSED",
|
||||
payload: {
|
||||
code: Number.isFinite(message.code) ? message.code : 0,
|
||||
reason: "agent_exit",
|
||||
reason,
|
||||
message: message.message ? String(message.message) : null,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1599,6 +1824,8 @@ function createBrokerBridge({
|
||||
disconnectReason: null,
|
||||
lastConnectedAt: null,
|
||||
lastDisconnectedAt: null,
|
||||
lastCloseCode: null,
|
||||
lastCloseClean: null,
|
||||
};
|
||||
|
||||
let stopped = false;
|
||||
@@ -1656,6 +1883,8 @@ function createBrokerBridge({
|
||||
state.connected = true;
|
||||
state.lastError = null;
|
||||
state.disconnectReason = null;
|
||||
state.lastCloseCode = null;
|
||||
state.lastCloseClean = null;
|
||||
state.lastConnectedAt = formatUpdateTimestamp();
|
||||
};
|
||||
|
||||
@@ -1709,14 +1938,19 @@ function createBrokerBridge({
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
state.lastError = "Broker connection failed";
|
||||
socket.onerror = (error) => {
|
||||
state.lastError = normalizeBrokerSocketError(error, socketUrl);
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
socket = null;
|
||||
state.connected = false;
|
||||
state.disconnectReason = event.reason || "broker_disconnected";
|
||||
state.lastCloseCode = Number.isFinite(Number(event.code)) ? Number(event.code) : null;
|
||||
state.lastCloseClean = typeof event.wasClean === "boolean" ? event.wasClean : null;
|
||||
if (state.lastCloseCode && state.lastCloseCode !== 1000 && !state.lastError) {
|
||||
state.lastError = `Broker websocket closed with code ${state.lastCloseCode}`;
|
||||
}
|
||||
state.lastDisconnectedAt = formatUpdateTimestamp();
|
||||
if (!stopped) {
|
||||
scheduleReconnect();
|
||||
@@ -1764,19 +1998,18 @@ export async function startAgent({
|
||||
const commandPollTimeoutSeconds = Number(config.commandPollTimeoutSeconds || 20);
|
||||
const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs || 1000);
|
||||
const brokerReconnectDelayMs = Number(config.brokerReconnectDelayMs || DEFAULT_BROKER_RECONNECT_DELAY_MS);
|
||||
const shellActionPollTimeoutSeconds = Number(config.shellActionPollTimeoutSeconds || 20);
|
||||
const shellActionPollRetryDelayMs = Number(config.shellActionPollRetryDelayMs || 1000);
|
||||
|
||||
let stopped = false;
|
||||
let cpuSnapshot = null;
|
||||
let lastHeartbeatLatencyMs = null;
|
||||
void createShellBridgeImpl;
|
||||
let brokerBridge = null;
|
||||
const shell = {
|
||||
open: async () => {},
|
||||
input: () => {},
|
||||
resize: () => {},
|
||||
close: () => {},
|
||||
dispose: () => {},
|
||||
};
|
||||
const shellEventPublisher = createShellEventPublisher(config, fetchImpl);
|
||||
const shell = createShellBridgeImpl((message) => {
|
||||
brokerBridge?.send(message);
|
||||
shellEventPublisher.publish(message);
|
||||
});
|
||||
brokerBridge = createBrokerBridge({
|
||||
config,
|
||||
shell,
|
||||
@@ -1841,8 +2074,32 @@ export async function startAgent({
|
||||
}
|
||||
};
|
||||
|
||||
const runShellActionPollLoop = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
const action = await pollShellActionJob(config, fetchImpl, shellActionPollTimeoutSeconds);
|
||||
if (stopped) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await processPolledShellAction(config, action, shell, fetchImpl);
|
||||
} catch {
|
||||
if (stopped) {
|
||||
break;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, shellActionPollRetryDelayMs));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await sendTransportHeartbeat();
|
||||
const commandPollPromise = runCommandPollLoop();
|
||||
const shellActionPollPromise = runShellActionPollLoop();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
sendTransportHeartbeat().catch(() => {});
|
||||
@@ -1853,8 +2110,9 @@ export async function startAgent({
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
brokerBridge?.stop();
|
||||
await shellEventPublisher.drain();
|
||||
shell.dispose();
|
||||
await Promise.allSettled([commandPollPromise]);
|
||||
await Promise.allSettled([commandPollPromise, shellActionPollPromise]);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildStatusReport,
|
||||
claimIfNeeded,
|
||||
createShellBridge,
|
||||
discoverShellyDevices,
|
||||
finalizePendingUpdateOnStartup,
|
||||
getAgentStatus,
|
||||
getRelayStatus,
|
||||
@@ -120,6 +121,157 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
|
||||
assert.equal(switched.on, false);
|
||||
});
|
||||
|
||||
test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => {
|
||||
const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
|
||||
assert.equal(String(url), "http://192.168.1.2/shelly");
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
id: "shelly1minig3-e4b3231f6410",
|
||||
mac: "E4B3231F6410",
|
||||
model: "S3SW-001X8EU",
|
||||
type: "Shelly 1 Mini Gen3",
|
||||
num_switches: 1,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(inventory.length, 1);
|
||||
assert.equal(inventory[0].device_id, "E4B3231F6410");
|
||||
assert.equal(inventory[0].model, "S3SW-001X8EU");
|
||||
assert.equal(inventory[0].capabilities.generation, 3);
|
||||
});
|
||||
|
||||
test("relay status reads use the legacy endpoint when the RPC status endpoint stalls", async () => {
|
||||
const urls = [];
|
||||
const fakeFetch = async (url) => {
|
||||
urls.push(String(url));
|
||||
if (String(url).includes("Switch.GetStatus")) {
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
if (String(url) === "http://10.1.0.31/relay/0") {
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { ison: false };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
|
||||
const status = await getRelayStatus({ localIp: "10.1.0.31", channel: 0, timeoutMs: 50 }, fakeFetch);
|
||||
|
||||
assert.equal(status.online, true);
|
||||
assert.equal(status.on, false);
|
||||
assert.deepEqual(urls.sort(), [
|
||||
"http://10.1.0.31/relay/0",
|
||||
"http://10.1.0.31/rpc/Switch.GetStatus?id=0",
|
||||
]);
|
||||
assert.ok(Date.now() - startedAt < 250);
|
||||
});
|
||||
|
||||
test("relay switch commands fail quickly when the local Shelly request stalls", async () => {
|
||||
let calls = 0;
|
||||
const hangingFetch = async () => {
|
||||
calls += 1;
|
||||
return new Promise(() => {});
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
|
||||
await assert.rejects(
|
||||
() => setRelayState({ localIp: "10.1.0.31", channel: 0, on: true, timeoutMs: 50 }, hangingFetch),
|
||||
/HTTP request timed out after 50ms/
|
||||
);
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.ok(Date.now() - startedAt < 500);
|
||||
});
|
||||
|
||||
test("relay switch commands pass timer values to local Shelly APIs", async () => {
|
||||
const legacyUrls = [];
|
||||
const legacyFetch = async (url) => {
|
||||
legacyUrls.push(String(url));
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { ison: true, has_timer: true, timer_duration: 3 };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
await setRelayState({
|
||||
localIp: "10.1.0.31",
|
||||
channel: 0,
|
||||
on: true,
|
||||
toggleAfter: 3,
|
||||
deviceGeneration: 1,
|
||||
}, legacyFetch);
|
||||
|
||||
assert.equal(
|
||||
legacyUrls[0],
|
||||
"http://10.1.0.31/relay/0?turn=on&timer=3"
|
||||
);
|
||||
|
||||
const gen3Urls = [];
|
||||
const gen3Fetch = async (url) => {
|
||||
gen3Urls.push(String(url));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { output: true };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
await setRelayState({
|
||||
localIp: "10.1.0.31",
|
||||
channel: 0,
|
||||
on: true,
|
||||
toggle_after: 3,
|
||||
device_generation: 3,
|
||||
}, gen3Fetch);
|
||||
|
||||
assert.equal(
|
||||
gen3Urls[0],
|
||||
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3"
|
||||
);
|
||||
|
||||
const fallbackUrls = [];
|
||||
const legacyFallbackFetch = async (url) => {
|
||||
fallbackUrls.push(String(url));
|
||||
if (String(url).includes("/rpc/")) {
|
||||
throw new Error("RPC switch endpoint unsupported");
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { ison: true, has_timer: true, timer_duration: 3 };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
await setRelayState({
|
||||
localIp: "10.1.0.31",
|
||||
channel: 0,
|
||||
on: true,
|
||||
toggle_after: 3,
|
||||
device_model: "S3SW-001X8EU",
|
||||
}, legacyFallbackFetch);
|
||||
|
||||
assert.deepEqual(fallbackUrls, [
|
||||
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3",
|
||||
"http://10.1.0.31/relay/0?turn=on&timer=3",
|
||||
]);
|
||||
});
|
||||
|
||||
test("runUpdate stages a pending verification restart after installing new artifacts", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-update-"));
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
@@ -473,6 +625,39 @@ test("shell bridge proxies PTY output, input, resize, close, and dispose events"
|
||||
assert.ok(messages.some((message) => message.type === "SHELL_EXIT" && message.code === 0));
|
||||
});
|
||||
|
||||
test("shell bridge reports structured spawn failures", async () => {
|
||||
const messages = [];
|
||||
const shell = createShellBridge(
|
||||
(message) => messages.push(message),
|
||||
{
|
||||
createPtyProcess: async () => {
|
||||
throw new Error("node-pty unavailable");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await shell.open({ sessionId: "spawn-failure-shell" });
|
||||
|
||||
assert.ok(
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "SHELL_OUTPUT" &&
|
||||
message.sessionId === "spawn-failure-shell" &&
|
||||
/Failed to start root shell: node-pty unavailable/.test(message.data)
|
||||
)
|
||||
);
|
||||
assert.ok(
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "SHELL_EXIT" &&
|
||||
message.sessionId === "spawn-failure-shell" &&
|
||||
message.code === 1 &&
|
||||
message.reason === "shell_spawn_failed" &&
|
||||
message.message === "node-pty unavailable"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test("startAgent reports API polling metadata, executes polled commands, and uploads shell events", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-"));
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
@@ -709,6 +894,7 @@ test("startAgent reports API polling metadata, executes polled commands, and upl
|
||||
assert.equal(commandResultPosts[0].body.ok, true);
|
||||
assert.equal(Array.isArray(commandResultPosts[0].body.payload.inventory), true);
|
||||
assert.equal(commandResultPosts[0].body.payload.inventory[0].device_id, "AA:BB:CC:DD:EE:FF");
|
||||
assert.equal(commandResultPosts[0].body.payload.inventory[0].capabilities.generation, 2);
|
||||
|
||||
assert.ok(shellCalls.some((call) => call.type === "open"));
|
||||
assert.ok(shellCalls.some((call) => call.type === "close"));
|
||||
|
||||
+288
-28
@@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
const DEFAULT_SHELL_OPEN_TIMEOUT_MS = 15000;
|
||||
|
||||
function parseJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let raw = "";
|
||||
@@ -53,7 +55,7 @@ function resolveAuthMode(options = {}, managerUrl = "") {
|
||||
if (process.env.EDGE_AUTH_MODE) {
|
||||
return process.env.EDGE_AUTH_MODE;
|
||||
}
|
||||
return managerUrl ? "manager" : "stub";
|
||||
return "manager";
|
||||
}
|
||||
|
||||
function parseScopes(value) {
|
||||
@@ -106,11 +108,53 @@ function sendJson(ws, payload) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function currentTimestamp() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error, fallback = "Connection step failed") {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
const message = String(error || "").trim();
|
||||
return message || fallback;
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
|
||||
const statusText = {
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
500: "Internal Server Error",
|
||||
503: "Service Unavailable",
|
||||
}[statusCode] || "WebSocket Upgrade Rejected";
|
||||
const body = JSON.stringify({
|
||||
ok: false,
|
||||
error_code: errorCode,
|
||||
message,
|
||||
details,
|
||||
});
|
||||
|
||||
socket.end(
|
||||
[
|
||||
`HTTP/1.1 ${statusCode} ${statusText}`,
|
||||
"content-type: application/json; charset=utf-8",
|
||||
`content-length: ${Buffer.byteLength(body)}`,
|
||||
"connection: close",
|
||||
"",
|
||||
body,
|
||||
].join("\r\n")
|
||||
);
|
||||
}
|
||||
|
||||
export function createBrokerServer(options = {}) {
|
||||
const sharedSecret = options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "";
|
||||
const managerUrl = resolveManagerUrl(options);
|
||||
const authMode = resolveAuthMode(options, managerUrl);
|
||||
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
|
||||
const shellOpenTimeoutMs = options.shellOpenTimeoutMs ?? DEFAULT_SHELL_OPEN_TIMEOUT_MS;
|
||||
|
||||
const agents = new Map();
|
||||
const pendingCommands = new Map();
|
||||
@@ -134,7 +178,11 @@ export function createBrokerServer(options = {}) {
|
||||
});
|
||||
const json = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(json?.data?.message || json?.message || json?.error || `HTTP ${response.status}`);
|
||||
const error = new Error(json?.data?.message || json?.message || json?.error || `HTTP ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.code = json?.data?.error_code || json?.error_code || null;
|
||||
error.details = json?.data?.diagnostics || json?.diagnostics || null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return json?.data ?? json;
|
||||
@@ -164,11 +212,12 @@ export function createBrokerServer(options = {}) {
|
||||
options.closeShellSession ||
|
||||
(authMode === "stub"
|
||||
? async () => ({})
|
||||
: async (_id, token, transcript, reason) =>
|
||||
: async (_id, token, transcript, reason, details = {}) =>
|
||||
managerRequest("/edge-agent/internal/shell-sessions/close", {
|
||||
token,
|
||||
transcript,
|
||||
reason,
|
||||
...details,
|
||||
}));
|
||||
const validateBrowserStream =
|
||||
options.validateBrowserStream ||
|
||||
@@ -241,29 +290,66 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const closeBrowserShellSession = async (sessionRecord, reason) => {
|
||||
const closeBrowserShellSession = async (sessionRecord, reason, details = {}) => {
|
||||
try {
|
||||
await closeShellSession(
|
||||
sessionRecord.session.id,
|
||||
sessionRecord.ws.sessionToken,
|
||||
sessionRecord.transcript,
|
||||
reason
|
||||
reason,
|
||||
{
|
||||
message: sessionRecord.closedMessage || null,
|
||||
code: sessionRecord.closedCode ?? null,
|
||||
stage: sessionRecord.closedStage || null,
|
||||
broker_connection_id: sessionRecord.agentConnectionId || null,
|
||||
details: sessionRecord.closedDetails || {},
|
||||
...details,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// Preserve socket teardown even when the manager callback is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
const clearShellOpenTimer = (sessionRecord) => {
|
||||
if (sessionRecord?.openTimer) {
|
||||
clearTimeout(sessionRecord.openTimer);
|
||||
sessionRecord.openTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const closeBrowserShellSocket = (sessionRecord, reason, message = null, code = 1000, details = {}) => {
|
||||
sessionRecord.closedReason = reason;
|
||||
sessionRecord.closedMessage = message || null;
|
||||
sessionRecord.closedCode = code;
|
||||
sessionRecord.closedDetails = details && typeof details === "object" ? details : {};
|
||||
sessionRecord.closedStage = String(sessionRecord.closedDetails.stage || (sessionRecord.opened ? "shell_active" : "shell_open"));
|
||||
clearShellOpenTimer(sessionRecord);
|
||||
if (sessionRecord.ws.readyState >= 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(sessionRecord.ws, {
|
||||
type: "closed",
|
||||
reason,
|
||||
code,
|
||||
...(message ? { message } : {}),
|
||||
...(Object.keys(sessionRecord.closedDetails).length ? { details: sessionRecord.closedDetails } : {}),
|
||||
});
|
||||
sessionRecord.ws.close(code, reason);
|
||||
};
|
||||
|
||||
const markGatewayShellSessionsClosed = (gatewayId, reason) => {
|
||||
for (const sessionRecord of browserShellSessions.values()) {
|
||||
if (String(sessionRecord.session.gateway_id) !== String(gatewayId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sessionRecord.closedReason = reason;
|
||||
if (sessionRecord.ws.readyState < 2) {
|
||||
sessionRecord.ws.close();
|
||||
}
|
||||
closeBrowserShellSocket(sessionRecord, reason, "Gateway agent disconnected from the broker.", 1011, {
|
||||
stage: "agent_disconnect",
|
||||
gateway_id: gatewayId,
|
||||
shell_session_id: sessionRecord.session.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -320,9 +406,76 @@ export function createBrokerServer(options = {}) {
|
||||
return syncPromise;
|
||||
};
|
||||
|
||||
const sendAgentWelcome = (ws) =>
|
||||
sendJson(ws, {
|
||||
type: "WELCOME",
|
||||
target: "edge-agent",
|
||||
gatewayId: String(ws.gatewayId || ""),
|
||||
connectionId: ws.connectionId || null,
|
||||
agentInstanceId: ws.agentInstanceId || null,
|
||||
serverTime: currentTimestamp(),
|
||||
broker: {
|
||||
authMode,
|
||||
},
|
||||
});
|
||||
|
||||
const sendAgentConnectionProgress = (ws, stage, status, message, details = {}) =>
|
||||
sendJson(ws, {
|
||||
type: "CONNECTION_PROGRESS",
|
||||
gatewayId: String(ws.gatewayId || ""),
|
||||
connectionId: ws.connectionId || null,
|
||||
stage,
|
||||
status,
|
||||
message,
|
||||
...details,
|
||||
serverTime: currentTimestamp(),
|
||||
});
|
||||
|
||||
const sendAgentConnectionError = (ws, stage, error, details = {}) =>
|
||||
sendJson(ws, {
|
||||
type: "CONNECTION_ERROR",
|
||||
gatewayId: String(ws.gatewayId || ""),
|
||||
connectionId: ws.connectionId || null,
|
||||
stage,
|
||||
status: "failed",
|
||||
message: normalizeErrorMessage(error),
|
||||
error: normalizeErrorMessage(error),
|
||||
...details,
|
||||
serverTime: currentTimestamp(),
|
||||
});
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (req.method === "GET" && url.pathname === "/api/health") {
|
||||
jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
service: "edge-broker",
|
||||
auth_mode: authMode,
|
||||
manager_url_configured: Boolean(managerUrl),
|
||||
shared_secret_configured: Boolean(sharedSecret),
|
||||
agents_connected: agents.size,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") {
|
||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, {
|
||||
ok: false,
|
||||
error: "Forbidden",
|
||||
shared_secret_required: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
shared_secret_required: Boolean(sharedSecret),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) {
|
||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, { error: "Forbidden" });
|
||||
@@ -398,7 +551,17 @@ export function createBrokerServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gatewayInfo = await validateAgent({ gatewayId, token, headers: req.headers });
|
||||
let gatewayInfo;
|
||||
try {
|
||||
gatewayInfo = await validateAgent({ gatewayId, token, headers: req.headers });
|
||||
} catch (error) {
|
||||
const status = Number(error?.status) === 403 ? 403 : Number(error?.status) === 401 ? 401 : 503;
|
||||
rejectUpgrade(socket, status, error?.code || "agent_validation_failed", normalizeErrorMessage(error, "Gateway agent could not be validated."), {
|
||||
stage: "agent_validate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
const existing = agents.get(gatewayId);
|
||||
if (existing && existing.readyState < 2) {
|
||||
@@ -410,6 +573,9 @@ export function createBrokerServer(options = {}) {
|
||||
ws.agentInstanceId = agentInstanceId || null;
|
||||
ws.connectionId = randomUUID();
|
||||
agents.set(gatewayId, ws);
|
||||
wss.emit("connection", ws, req);
|
||||
sendAgentWelcome(ws);
|
||||
sendAgentConnectionProgress(ws, "presence", "started", "Reporting broker presence to the edge manager.");
|
||||
reportGatewayPresence(gatewayId, {
|
||||
status: "connected",
|
||||
connectionId: ws.connectionId,
|
||||
@@ -417,15 +583,30 @@ export function createBrokerServer(options = {}) {
|
||||
remote_address: req.socket.remoteAddress || null,
|
||||
agent_instance_id: ws.agentInstanceId,
|
||||
},
|
||||
}).catch(() => {});
|
||||
})
|
||||
.then(() => {
|
||||
sendAgentConnectionProgress(ws, "presence", "succeeded", "Broker presence was reported.");
|
||||
})
|
||||
.catch((error) => {
|
||||
sendAgentConnectionError(ws, "presence", error);
|
||||
});
|
||||
broadcastGatewayEvent(gatewayId, {
|
||||
type: "presence.changed",
|
||||
gatewayId,
|
||||
status: "connected",
|
||||
connectionId: ws.connectionId,
|
||||
});
|
||||
syncGatewayBacklog(gatewayId, ws).catch(() => {});
|
||||
wss.emit("connection", ws, req);
|
||||
sendAgentConnectionProgress(ws, "backlog", "started", "Synchronizing queued gateway work.");
|
||||
syncGatewayBacklog(gatewayId, ws)
|
||||
.then((result) => {
|
||||
sendAgentConnectionProgress(ws, "backlog", "succeeded", "Queued gateway work was synchronized.", {
|
||||
queued: Boolean(result?.queued),
|
||||
dispatchCount: Array.isArray(result?.dispatch) ? result.dispatch.length : 0,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
sendAgentConnectionError(ws, "backlog", error);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -433,11 +614,20 @@ export function createBrokerServer(options = {}) {
|
||||
if (url.pathname === "/ws/browser-shell") {
|
||||
const token = String(url.searchParams.get("token") || "");
|
||||
if (token === "") {
|
||||
socket.destroy();
|
||||
rejectUpgrade(socket, 400, "shell_session_token_missing", "Missing shell session token.");
|
||||
return;
|
||||
}
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = await validateShellSession({ token, headers: req.headers });
|
||||
} catch (error) {
|
||||
rejectUpgrade(socket, Number(error?.status) === 403 ? 403 : 401, error?.code || "shell_session_invalid", normalizeErrorMessage(error, "Shell session could not be validated."), {
|
||||
stage: "shell_session_validate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await validateShellSession({ token, headers: req.headers });
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.sessionToken = token;
|
||||
ws.sessionInfo = session;
|
||||
@@ -446,11 +636,20 @@ export function createBrokerServer(options = {}) {
|
||||
session,
|
||||
transcript: "",
|
||||
closedReason: null,
|
||||
closedMessage: null,
|
||||
closedCode: null,
|
||||
closedDetails: {},
|
||||
closedStage: null,
|
||||
opened: false,
|
||||
openTimer: null,
|
||||
agentConnectionId: null,
|
||||
};
|
||||
browserShellSessions.set(String(session.id), sessionRecord);
|
||||
wss.emit("connection", ws, req);
|
||||
|
||||
const agent = agents.get(String(session.gateway_id));
|
||||
if (agent && agent.readyState === 1) {
|
||||
sessionRecord.agentConnectionId = agent.connectionId || null;
|
||||
sendJson(agent, {
|
||||
type: "OPEN_ROOT_SHELL",
|
||||
payload: {
|
||||
@@ -463,11 +662,34 @@ export function createBrokerServer(options = {}) {
|
||||
shellArgs: session.shell_args ?? session.metadata?.shell_args ?? [],
|
||||
},
|
||||
});
|
||||
sessionRecord.openTimer = setTimeout(() => {
|
||||
closeBrowserShellSocket(
|
||||
sessionRecord,
|
||||
"shell_open_timeout",
|
||||
"Gateway agent did not confirm that the shell opened before the broker timeout.",
|
||||
1011,
|
||||
{
|
||||
stage: "shell_open",
|
||||
timeout_ms: shellOpenTimeoutMs,
|
||||
gateway_id: session.gateway_id,
|
||||
shell_session_id: session.id,
|
||||
}
|
||||
);
|
||||
}, Math.max(250, shellOpenTimeoutMs));
|
||||
sessionRecord.openTimer.unref?.();
|
||||
} else {
|
||||
sessionRecord.closedReason = "agent_offline";
|
||||
ws.close();
|
||||
closeBrowserShellSocket(
|
||||
sessionRecord,
|
||||
"agent_offline",
|
||||
"Gateway agent is not connected to the broker.",
|
||||
1011,
|
||||
{
|
||||
stage: "agent_lookup",
|
||||
gateway_id: session.gateway_id,
|
||||
shell_session_id: session.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -499,8 +721,8 @@ export function createBrokerServer(options = {}) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
socket.destroy();
|
||||
} catch (error) {
|
||||
rejectUpgrade(socket, 500, "websocket_upgrade_failed", normalizeErrorMessage(error, "WebSocket upgrade failed."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -534,18 +756,34 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
|
||||
if (message.type === "TELEMETRY") {
|
||||
const payload = message.payload || {};
|
||||
const ingested = await ingestTelemetry(String(ws.gatewayId), payload);
|
||||
const payload = {
|
||||
...(message.payload || {}),
|
||||
broker_connection_id: ws.connectionId || null,
|
||||
broker_agent_instance_id: ws.agentInstanceId || null,
|
||||
};
|
||||
let ingested = null;
|
||||
let ingestError = null;
|
||||
try {
|
||||
ingested = await ingestTelemetry(String(ws.gatewayId), payload);
|
||||
} catch (error) {
|
||||
ingestError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const fallbackStatistics = {
|
||||
system_metrics: payload?.metadata?.system_metrics || {},
|
||||
container_health: payload?.metadata?.container_health || {},
|
||||
};
|
||||
broadcastGatewayEvent(String(ws.gatewayId), {
|
||||
type: "gateway.telemetry",
|
||||
gatewayId: String(ws.gatewayId),
|
||||
telemetry: payload,
|
||||
gateway: ingested?.gateway || ingested || null,
|
||||
error: ingestError,
|
||||
});
|
||||
broadcastGatewayEvent(String(ws.gatewayId), {
|
||||
type: "stats.updated",
|
||||
gatewayId: String(ws.gatewayId),
|
||||
statistics: ingested?.statistics || ingested || null,
|
||||
statistics: ingested?.statistics || ingested || fallbackStatistics,
|
||||
error: ingestError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -606,17 +844,35 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
|
||||
if (message.type === "SHELL_OPENED") {
|
||||
sessionRecord.opened = true;
|
||||
sessionRecord.agentConnectionId = ws.connectionId || sessionRecord.agentConnectionId || null;
|
||||
clearShellOpenTimer(sessionRecord);
|
||||
await markShellSessionOpened(sessionRecord.ws.sessionToken, ws.connectionId || null).catch(() => {});
|
||||
sendJson(sessionRecord.ws, { type: "opened" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "SHELL_EXIT") {
|
||||
sendJson(sessionRecord.ws, { type: "closed", code: message.code ?? 0 });
|
||||
await closeBrowserShellSession(sessionRecord, "agent_exit");
|
||||
const reason = String(message.reason || "agent_exit");
|
||||
const parsedExitCode = Number(message.code);
|
||||
const exitCode = Number.isFinite(parsedExitCode) ? parsedExitCode : 0;
|
||||
const closeMessage = message.message ? String(message.message) : null;
|
||||
clearShellOpenTimer(sessionRecord);
|
||||
sendJson(sessionRecord.ws, {
|
||||
type: "closed",
|
||||
reason,
|
||||
code: exitCode,
|
||||
...(closeMessage ? { message: closeMessage } : {}),
|
||||
});
|
||||
await closeBrowserShellSession(sessionRecord, reason, {
|
||||
message: closeMessage,
|
||||
code: exitCode,
|
||||
stage: sessionRecord.opened ? "shell_active" : "shell_start",
|
||||
broker_connection_id: ws.connectionId || null,
|
||||
});
|
||||
browserShellSessions.delete(String(message.sessionId));
|
||||
if (sessionRecord.ws.readyState < 2) {
|
||||
sessionRecord.ws.close();
|
||||
sessionRecord.ws.close(1000, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,7 +952,7 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", async (_code, buffer) => {
|
||||
ws.on("close", async (code, buffer) => {
|
||||
const closeReason = buffer?.toString?.("utf8") || null;
|
||||
|
||||
if (ws.gatewayId) {
|
||||
@@ -732,7 +988,11 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
const sessionRecord = browserShellSessions.get(sessionId);
|
||||
if (sessionRecord) {
|
||||
await closeBrowserShellSession(sessionRecord, sessionRecord.closedReason || "browser_closed");
|
||||
await closeBrowserShellSession(sessionRecord, sessionRecord.closedReason || "browser_closed", {
|
||||
code,
|
||||
stage: sessionRecord.closedStage || "browser_socket_close",
|
||||
message: sessionRecord.closedMessage || null,
|
||||
});
|
||||
browserShellSessions.delete(sessionId);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import net from "node:net";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import { createBrokerServer } from "../server.mjs";
|
||||
|
||||
function waitForMessage(socket) {
|
||||
return new Promise((resolve) => {
|
||||
socket.once("message", (raw) => resolve(JSON.parse(raw.toString())));
|
||||
});
|
||||
}
|
||||
|
||||
function collectMessages(socket) {
|
||||
const messages = [];
|
||||
socket.on("message", (raw) => {
|
||||
@@ -24,6 +19,32 @@ function waitForClose(socket) {
|
||||
});
|
||||
}
|
||||
|
||||
function rawUpgradeRequest(port, path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port }, () => {
|
||||
socket.write(
|
||||
[
|
||||
`GET ${path} HTTP/1.1`,
|
||||
`Host: 127.0.0.1:${port}`,
|
||||
"Connection: Upgrade",
|
||||
"Upgrade: websocket",
|
||||
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
|
||||
"Sec-WebSocket-Version: 13",
|
||||
"",
|
||||
"",
|
||||
].join("\r\n")
|
||||
);
|
||||
});
|
||||
let response = "";
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk) => {
|
||||
response += chunk;
|
||||
});
|
||||
socket.on("end", () => resolve(response));
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, description = "condition" } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
@@ -38,6 +59,51 @@ async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, descripti
|
||||
throw new Error(`Timed out waiting for ${description}`);
|
||||
}
|
||||
|
||||
test("broker defaults to manager auth and fails closed when manager URL is missing", async () => {
|
||||
const previousEnv = {
|
||||
EDGE_AUTH_MODE: process.env.EDGE_AUTH_MODE,
|
||||
EDGE_MANAGER_URL: process.env.EDGE_MANAGER_URL,
|
||||
EDGE_PUBLIC_API_URL: process.env.EDGE_PUBLIC_API_URL,
|
||||
};
|
||||
delete process.env.EDGE_AUTH_MODE;
|
||||
delete process.env.EDGE_MANAGER_URL;
|
||||
delete process.env.EDGE_PUBLIC_API_URL;
|
||||
|
||||
let broker;
|
||||
try {
|
||||
broker = createBrokerServer({ sharedSecret: "secret" });
|
||||
assert.equal(broker.state.authMode, "manager");
|
||||
assert.equal(broker.state.managerUrl, "");
|
||||
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
const shellResponse = await rawUpgradeRequest(port, "/ws/browser-shell?token=session-token");
|
||||
const agentResponse = await rawUpgradeRequest(port, "/ws/agent?gatewayId=701&token=agent-token");
|
||||
|
||||
assert.doesNotMatch(shellResponse, /101 Switching Protocols/);
|
||||
assert.match(shellResponse, /^HTTP\/1\.1 401 Unauthorized/m);
|
||||
assert.match(shellResponse, /"error_code":"shell_session_invalid"/);
|
||||
assert.match(shellResponse, /Edge manager URL is not configured/);
|
||||
|
||||
assert.doesNotMatch(agentResponse, /101 Switching Protocols/);
|
||||
assert.match(agentResponse, /^HTTP\/1\.1 503 Service Unavailable/m);
|
||||
assert.match(agentResponse, /"error_code":"agent_validation_failed"/);
|
||||
assert.match(agentResponse, /"stage":"agent_validate"/);
|
||||
assert.match(agentResponse, /Edge manager URL is not configured/);
|
||||
} finally {
|
||||
if (broker) {
|
||||
await broker.close();
|
||||
}
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("broker dispatches commands to connected agents", async () => {
|
||||
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "secret", commandTimeoutMs: 2000 });
|
||||
const address = await broker.listen(0);
|
||||
@@ -78,6 +144,48 @@ test("broker dispatches commands to connected agents", async () => {
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker exposes health and shared-secret diagnostics", async () => {
|
||||
const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" });
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const healthResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
const healthJson = await healthResponse.json();
|
||||
|
||||
assert.equal(healthResponse.status, 200);
|
||||
assert.equal(healthJson.ok, true);
|
||||
assert.equal(healthJson.service, "edge-broker");
|
||||
assert.equal(healthJson.auth_mode, "manager");
|
||||
assert.equal(healthJson.manager_url_configured, true);
|
||||
assert.equal(healthJson.shared_secret_configured, true);
|
||||
|
||||
const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-edge-broker-secret": "wrong-secret",
|
||||
},
|
||||
});
|
||||
const invalidSecretJson = await invalidSecretResponse.json();
|
||||
|
||||
assert.equal(invalidSecretResponse.status, 403);
|
||||
assert.equal(invalidSecretJson.ok, false);
|
||||
assert.equal(invalidSecretJson.shared_secret_required, true);
|
||||
|
||||
const validSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-edge-broker-secret": "secret",
|
||||
},
|
||||
});
|
||||
const validSecretJson = await validSecretResponse.json();
|
||||
|
||||
assert.equal(validSecretResponse.status, 200);
|
||||
assert.equal(validSecretJson.ok, true);
|
||||
assert.equal(validSecretJson.shared_secret_required, true);
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker bridges browser shell sessions through the connected agent", async () => {
|
||||
const closedSessions = [];
|
||||
const broker = createBrokerServer({
|
||||
@@ -109,7 +217,7 @@ test("broker bridges browser shell sessions through the connected agent", async
|
||||
|
||||
assert.ok(browserMessages.some((message) => message.type === "opened"));
|
||||
assert.ok(browserMessages.some((message) => message.type === "output" && /root@pi/.test(message.data)));
|
||||
assert.ok(browserMessages.some((message) => message.type === "closed" && message.code === 0));
|
||||
assert.ok(browserMessages.some((message) => message.type === "closed" && message.reason === "agent_exit" && message.code === 0));
|
||||
assert.equal(closedSessions.length, 1);
|
||||
assert.equal(closedSessions[0].reason, "agent_exit");
|
||||
|
||||
@@ -132,7 +240,10 @@ test("broker forwards browser shell input, resize, and close events to the agent
|
||||
const agentMessages = collectMessages(agent);
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`);
|
||||
await new Promise((resolve) => browser.once("open", resolve));
|
||||
await waitForMessage(agent);
|
||||
await waitFor(
|
||||
() => agentMessages.some((message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-2"),
|
||||
{ description: "agent shell open request" }
|
||||
);
|
||||
|
||||
browser.send(JSON.stringify({ type: "input", data: "ls\r" }));
|
||||
browser.send(JSON.stringify({ type: "resize", cols: 140, rows: 44 }));
|
||||
@@ -177,15 +288,101 @@ test("broker closes browser shell sessions immediately when no agent is connecte
|
||||
const port = address.port;
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`);
|
||||
const browserMessages = collectMessages(browser);
|
||||
await new Promise((resolve) => browser.once("open", resolve));
|
||||
await waitForClose(browser);
|
||||
await waitFor(() => closedSessions.length === 1, { description: "offline shell session close callback" });
|
||||
|
||||
assert.ok(
|
||||
browserMessages.some(
|
||||
(message) =>
|
||||
message.type === "closed" &&
|
||||
message.reason === "agent_offline" &&
|
||||
/not connected to the broker/i.test(message.message)
|
||||
)
|
||||
);
|
||||
assert.deepEqual(closedSessions, [{ transcript: "", reason: "agent_offline" }]);
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker rejects browser shell upgrades without a token using HTTP diagnostics", async () => {
|
||||
const broker = createBrokerServer({ authMode: "stub" });
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const response = await rawUpgradeRequest(port, "/ws/browser-shell");
|
||||
|
||||
assert.match(response, /^HTTP\/1\.1 400 Bad Request/m);
|
||||
assert.match(response, /"error_code":"shell_session_token_missing"/);
|
||||
assert.match(response, /"message":"Missing shell session token\."/);
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker rejects invalid browser shell upgrades without leaking the token", async () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
validateShellSession: async () => {
|
||||
const error = new Error("Shell session expired");
|
||||
error.status = 401;
|
||||
error.code = "shell_session_expired";
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
const rawToken = "session-token-secret";
|
||||
|
||||
const response = await rawUpgradeRequest(port, `/ws/browser-shell?token=${rawToken}`);
|
||||
|
||||
assert.match(response, /^HTTP\/1\.1 401 Unauthorized/m);
|
||||
assert.match(response, /"error_code":"shell_session_expired"/);
|
||||
assert.doesNotMatch(response, new RegExp(rawToken));
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker closes browser shell sessions when the agent never reports shell opened", async () => {
|
||||
const closedSessions = [];
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
shellOpenTimeoutMs: 30,
|
||||
validateShellSession: async () => ({ id: "shell-timeout", gateway_id: "701", reason: "diagnostic" }),
|
||||
closeShellSession: async (_id, _token, transcript, reason, details) => {
|
||||
closedSessions.push({ transcript, reason, details });
|
||||
},
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||
const agentMessages = collectMessages(agent);
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`);
|
||||
const browserMessages = collectMessages(browser);
|
||||
await new Promise((resolve) => browser.once("open", resolve));
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
agentMessages.some(
|
||||
(message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-timeout"
|
||||
),
|
||||
{ description: "agent shell open request before timeout" }
|
||||
);
|
||||
await waitForClose(browser);
|
||||
await waitFor(() => closedSessions.length === 1, { description: "timeout shell session close callback" });
|
||||
|
||||
assert.ok(browserMessages.some((message) => message.type === "closed" && message.reason === "shell_open_timeout"));
|
||||
assert.equal(closedSessions[0].reason, "shell_open_timeout");
|
||||
assert.equal(closedSessions[0].details.stage, "shell_open");
|
||||
assert.equal(closedSessions[0].details.code, 1011);
|
||||
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker closes browser shell sessions when the agent disconnects before shell open", async () => {
|
||||
const closedSessions = [];
|
||||
const broker = createBrokerServer({
|
||||
@@ -203,19 +400,137 @@ test("broker closes browser shell sessions when the agent disconnects before she
|
||||
|
||||
const agentMessages = collectMessages(agent);
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`);
|
||||
const browserMessages = collectMessages(browser);
|
||||
await new Promise((resolve) => browser.once("open", resolve));
|
||||
await waitForMessage(agent);
|
||||
await waitFor(
|
||||
() => agentMessages.some((message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-4"),
|
||||
{ description: "agent shell open request before disconnect" }
|
||||
);
|
||||
|
||||
agent.terminate();
|
||||
await waitForClose(browser);
|
||||
await waitFor(() => closedSessions.length === 1, { description: "disconnect shell session close callback" });
|
||||
|
||||
assert.ok(agentMessages.some((message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-4"));
|
||||
assert.ok(browserMessages.some((message) => message.type === "closed" && message.reason === "agent_disconnected"));
|
||||
assert.deepEqual(closedSessions, [{ transcript: "", reason: "agent_disconnected" }]);
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker sends an agent welcome before connection progress and backlog dispatch", async () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
reportGatewayPresence: async () => ({}),
|
||||
requestGatewayBacklog: async () => ({
|
||||
dispatch: [
|
||||
{
|
||||
type: "TASK_DISPATCH",
|
||||
taskType: "OPERATION",
|
||||
operation: {
|
||||
id: 91,
|
||||
type: "DISCOVERY",
|
||||
request: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||
const messages = collectMessages(agent);
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
|
||||
await waitFor(
|
||||
() => messages.some((message) => message.type === "TASK_DISPATCH" && message.operation?.id === 91),
|
||||
{ description: "backlog dispatch after connection welcome" }
|
||||
);
|
||||
|
||||
assert.equal(messages[0].type, "WELCOME");
|
||||
assert.equal(messages[0].gatewayId, "701");
|
||||
assert.equal(typeof messages[0].connectionId, "string");
|
||||
assert.ok(messages[0].connectionId.length > 0);
|
||||
|
||||
const firstProgressIndex = messages.findIndex((message) => message.type === "CONNECTION_PROGRESS");
|
||||
const dispatchIndex = messages.findIndex((message) => message.type === "TASK_DISPATCH");
|
||||
assert.ok(firstProgressIndex > 0);
|
||||
assert.ok(dispatchIndex > firstProgressIndex);
|
||||
assert.ok(
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "CONNECTION_PROGRESS" &&
|
||||
message.stage === "presence" &&
|
||||
message.status === "started"
|
||||
)
|
||||
);
|
||||
assert.ok(
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "CONNECTION_PROGRESS" &&
|
||||
message.stage === "backlog" &&
|
||||
message.status === "started"
|
||||
)
|
||||
);
|
||||
assert.ok(
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "CONNECTION_PROGRESS" &&
|
||||
message.stage === "backlog" &&
|
||||
message.status === "succeeded" &&
|
||||
message.dispatchCount === 1
|
||||
)
|
||||
);
|
||||
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker sends connection errors to agents after the welcome message", async () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
reportGatewayPresence: async () => {
|
||||
throw new Error("presence callback unavailable");
|
||||
},
|
||||
requestGatewayBacklog: async () => {
|
||||
throw new Error("backlog sync unavailable");
|
||||
},
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||
const messages = collectMessages(agent);
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
|
||||
await waitFor(
|
||||
() => messages.filter((message) => message.type === "CONNECTION_ERROR").length >= 2,
|
||||
{ description: "agent connection error notifications" }
|
||||
);
|
||||
|
||||
assert.equal(messages[0].type, "WELCOME");
|
||||
assert.ok(
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "CONNECTION_ERROR" &&
|
||||
message.stage === "presence" &&
|
||||
message.error === "presence callback unavailable"
|
||||
)
|
||||
);
|
||||
assert.ok(
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "CONNECTION_ERROR" &&
|
||||
message.stage === "backlog" &&
|
||||
message.error === "backlog sync unavailable"
|
||||
)
|
||||
);
|
||||
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker syncs queued gateway backlog on agent connect and manual sync", async () => {
|
||||
const backlogRequests = [];
|
||||
const broker = createBrokerServer({
|
||||
@@ -274,6 +589,7 @@ test("broker syncs queued gateway backlog on agent connect and manual sync", asy
|
||||
});
|
||||
|
||||
test("broker fans out telemetry, task, log, and presence updates to browser gateway streams", async () => {
|
||||
const telemetryPayloads = [];
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
|
||||
@@ -282,7 +598,10 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
|
||||
gateway_id: "701",
|
||||
scopes: ["overview", "tasks", "logs", "statistics"],
|
||||
}),
|
||||
ingestTelemetry: async (_gatewayId, payload) => ({ gateway: { id: 701, metadata: payload.metadata || {} } }),
|
||||
ingestTelemetry: async (_gatewayId, payload) => {
|
||||
telemetryPayloads.push(payload);
|
||||
return { gateway: { id: 701, metadata: payload.metadata || {} } };
|
||||
},
|
||||
ingestTaskEvent: async (_gatewayId, operationId, payload) => ({
|
||||
id: operationId,
|
||||
status: "IN_PROGRESS",
|
||||
@@ -345,6 +664,7 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
|
||||
assert.ok(browserMessages.some((message) => message.type === "stats.updated"));
|
||||
assert.ok(browserMessages.some((message) => message.type === "task.updated" && message.operationId === 41));
|
||||
assert.ok(browserMessages.some((message) => message.type === "log.append" && /heartbeat/.test(message.entry?.message)));
|
||||
assert.equal(telemetryPayloads[0]?.broker_connection_id?.length > 0, true);
|
||||
|
||||
browser.terminate();
|
||||
agent.terminate();
|
||||
@@ -380,3 +700,59 @@ test("broker survives telemetry ingestion failures for stale gateways", async ()
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker still fans out telemetry when manager ingestion fails", async () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
|
||||
validateBrowserStream: async () => ({
|
||||
id: "stream-telemetry-fallback",
|
||||
gateway_id: "701",
|
||||
scopes: ["overview", "statistics"],
|
||||
}),
|
||||
ingestTelemetry: async () => {
|
||||
throw new Error("manager unavailable");
|
||||
},
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-gateway-stream?token=stream-token`);
|
||||
const browserMessages = collectMessages(browser);
|
||||
await new Promise((resolve) => browser.once("open", resolve));
|
||||
|
||||
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
|
||||
agent.send(
|
||||
JSON.stringify({
|
||||
type: "TELEMETRY",
|
||||
payload: {
|
||||
status: "ONLINE",
|
||||
metadata: {
|
||||
system_metrics: {
|
||||
cpu_usage_pct: 31,
|
||||
},
|
||||
container_health: {
|
||||
state: "ONLINE",
|
||||
summary: "6/6 containers healthy",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => browserMessages.some((message) => message.type === "gateway.telemetry" && message.error === "manager unavailable"),
|
||||
{ description: "telemetry fanout after ingest failure" }
|
||||
);
|
||||
assert.ok(
|
||||
browserMessages.some(
|
||||
(message) => message.type === "stats.updated" && message.statistics?.system_metrics?.cpu_usage_pct === 31
|
||||
)
|
||||
);
|
||||
|
||||
browser.terminate();
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ const traefikSource = [
|
||||
function readComposeServiceBlock(composeSource, serviceName) {
|
||||
const escapedServiceName = serviceName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const servicePattern = new RegExp(
|
||||
`^\\s{2}${escapedServiceName}:\\n([\\s\\S]*?)(?=^\\s{2}[A-Za-z0-9_-]+:|^volumes:|^networks:|\\Z)`,
|
||||
`^[ ]{2}${escapedServiceName}:\\r?\\n([\\s\\S]*?)(?=^[ ]{2}[A-Za-z0-9_-]+:|^volumes:|^networks:|(?![\\s\\S]))`,
|
||||
"m"
|
||||
);
|
||||
const match = composeSource.match(servicePattern);
|
||||
@@ -39,11 +39,13 @@ test("traefik does not expose a dedicated public edge broker port", () => {
|
||||
test("base docker compose routes edge broker traffic through traefik", () => {
|
||||
const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
|
||||
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.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-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-v2\.rule=Host\(`api-v2\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip\.stripPrefix\.prefixes=\/edge-broker/);
|
||||
assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip-local\.stripPrefix\.prefixes=\/api\/edge-broker/);
|
||||
@@ -53,6 +55,7 @@ test("base docker compose routes edge broker traffic through traefik", () => {
|
||||
test("example docker compose routes edge broker traffic through traefik", () => {
|
||||
const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
|
||||
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.example\.com`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
|
||||
@@ -62,11 +65,13 @@ test("example docker compose routes edge broker traffic through traefik", () =>
|
||||
test("standalone production compose routes edge broker traffic through traefik", () => {
|
||||
const serviceBlock = readComposeServiceBlock(standaloneProdComposeSource, "edge-broker");
|
||||
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.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-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-v2\.rule=Host\(`api-v2\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -11868,3 +11868,255 @@
|
||||
[Thu Apr 23 19:30:02 2026] 127.0.0.1:38996 Closing
|
||||
[Thu Apr 23 19:30:10 2026] 127.0.0.1:59878 Accepted
|
||||
[Thu Apr 23 19:30:16 2026] 127.0.0.1:59878 Closing
|
||||
[Mon Apr 27 15:43:00 2026] PHP 8.2.15 Development Server (http://127.0.0.1:39017) started
|
||||
[Mon Apr 27 15:43:00 2026] 127.0.0.1:43538 Accepted
|
||||
[Mon Apr 27 15:43:02 2026] 127.0.0.1:43538 Closing
|
||||
[Mon Apr 27 15:43:02 2026] 127.0.0.1:59780 Accepted
|
||||
[Mon Apr 27 15:43:09 2026] 127.0.0.1:59780 Closing
|
||||
[Mon Apr 27 15:43:09 2026] 127.0.0.1:59790 Accepted
|
||||
[Mon Apr 27 15:43:21 2026] 127.0.0.1:59790 Closing
|
||||
[Mon Apr 27 15:43:21 2026] 127.0.0.1:55448 Accepted
|
||||
[Mon Apr 27 15:43:37 2026] 127.0.0.1:55448 Closing
|
||||
[Mon Apr 27 15:43:37 2026] 127.0.0.1:39854 Accepted
|
||||
[Mon Apr 27 15:43:49 2026] 127.0.0.1:39854 Closing
|
||||
[Mon Apr 27 15:43:49 2026] 127.0.0.1:40450 Accepted
|
||||
[Mon Apr 27 15:44:00 2026] 127.0.0.1:40450 Closing
|
||||
[Mon Apr 27 15:44:00 2026] 127.0.0.1:37108 Accepted
|
||||
[Mon Apr 27 15:44:04 2026] 127.0.0.1:37108 Closing
|
||||
[Mon Apr 27 15:44:04 2026] 127.0.0.1:51378 Accepted
|
||||
[Mon Apr 27 15:44:20 2026] 127.0.0.1:51378 Closing
|
||||
[Mon Apr 27 15:44:20 2026] 127.0.0.1:57924 Accepted
|
||||
[Mon Apr 27 15:44:26 2026] 127.0.0.1:57924 Closing
|
||||
[Mon Apr 27 15:44:26 2026] 127.0.0.1:36924 Accepted
|
||||
[Mon Apr 27 15:44:33 2026] 127.0.0.1:36924 Closing
|
||||
[Mon Apr 27 15:44:33 2026] 127.0.0.1:41488 Accepted
|
||||
[Mon Apr 27 15:44:50 2026] 127.0.0.1:41488 Closing
|
||||
[Mon Apr 27 15:44:50 2026] 127.0.0.1:42138 Accepted
|
||||
[Mon Apr 27 15:45:06 2026] 127.0.0.1:42138 Closing
|
||||
[Mon Apr 27 15:45:06 2026] 127.0.0.1:56450 Accepted
|
||||
[Mon Apr 27 15:45:24 2026] 127.0.0.1:56450 Closing
|
||||
[Mon Apr 27 15:45:31 2026] 127.0.0.1:50392 Accepted
|
||||
[Mon Apr 27 15:45:45 2026] 127.0.0.1:50392 Closing
|
||||
[Mon Apr 27 15:45:51 2026] 127.0.0.1:44926 Accepted
|
||||
[Mon Apr 27 15:46:21 2026] 127.0.0.1:44926 Closing
|
||||
[Mon Apr 27 15:46:21 2026] 127.0.0.1:34068 Accepted
|
||||
[Mon Apr 27 15:46:29 2026] PHP 8.2.15 Development Server (http://127.0.0.1:39779) started
|
||||
[Mon Apr 27 15:46:29 2026] 127.0.0.1:36812 Accepted
|
||||
[Mon Apr 27 15:46:30 2026] 127.0.0.1:34068 Closing
|
||||
[Mon Apr 27 15:46:30 2026] 127.0.0.1:45262 Accepted
|
||||
[Mon Apr 27 15:46:31 2026] 127.0.0.1:36812 Closing
|
||||
[Mon Apr 27 15:46:31 2026] 127.0.0.1:53156 Accepted
|
||||
[Mon Apr 27 15:46:39 2026] 127.0.0.1:45262 Closing
|
||||
[Mon Apr 27 15:46:39 2026] 127.0.0.1:45266 Accepted
|
||||
[Mon Apr 27 15:46:44 2026] 127.0.0.1:53156 Closing
|
||||
[Mon Apr 27 15:46:52 2026] 127.0.0.1:45266 Closing
|
||||
[Mon Apr 27 15:46:52 2026] 127.0.0.1:37766 Accepted
|
||||
[Mon Apr 27 15:47:10 2026] PHP 8.2.15 Development Server (http://127.0.0.1:39511) started
|
||||
[Mon Apr 27 15:47:11 2026] 127.0.0.1:41892 Accepted
|
||||
[Mon Apr 27 15:47:14 2026] 127.0.0.1:41892 Closing
|
||||
[Mon Apr 27 15:47:14 2026] 127.0.0.1:41904 Accepted
|
||||
[Mon Apr 27 15:47:17 2026] 127.0.0.1:37766 Closing
|
||||
[Mon Apr 27 15:47:17 2026] 127.0.0.1:36058 Accepted
|
||||
[Mon Apr 27 15:47:19 2026] 127.0.0.1:41904 Closing
|
||||
[Mon Apr 27 15:47:19 2026] 127.0.0.1:36924 Accepted
|
||||
[Mon Apr 27 15:47:26 2026] 127.0.0.1:36058 Closing
|
||||
[Mon Apr 27 15:47:26 2026] 127.0.0.1:51896 Accepted
|
||||
[Mon Apr 27 15:47:31 2026] 127.0.0.1:36924 Closing
|
||||
[Mon Apr 27 15:47:31 2026] 127.0.0.1:34276 Accepted
|
||||
[Mon Apr 27 15:47:46 2026] 127.0.0.1:34276 Closing
|
||||
[Mon Apr 27 15:47:46 2026] 127.0.0.1:37246 Accepted
|
||||
[Mon Apr 27 15:47:57 2026] 127.0.0.1:51896 Closing
|
||||
[Mon Apr 27 15:47:59 2026] 127.0.0.1:37246 Closing
|
||||
[Mon Apr 27 15:47:59 2026] 127.0.0.1:36472 Accepted
|
||||
[Mon Apr 27 15:48:03 2026] 127.0.0.1:33350 Accepted
|
||||
[Mon Apr 27 15:48:07 2026] 127.0.0.1:36472 Closing
|
||||
[Mon Apr 27 15:48:07 2026] 127.0.0.1:56178 Accepted
|
||||
[Mon Apr 27 15:48:07 2026] 127.0.0.1:33350 Closing
|
||||
[Mon Apr 27 15:48:10 2026] 127.0.0.1:40096 Accepted
|
||||
[Mon Apr 27 15:48:13 2026] 127.0.0.1:56178 Closing
|
||||
[Mon Apr 27 15:48:13 2026] 127.0.0.1:55944 Accepted
|
||||
[Mon Apr 27 15:48:19 2026] 127.0.0.1:40096 Closing
|
||||
[Mon Apr 27 15:48:19 2026] 127.0.0.1:40104 Accepted
|
||||
[Mon Apr 27 15:48:28 2026] 127.0.0.1:40104 Closing
|
||||
[Mon Apr 27 15:48:28 2026] 127.0.0.1:55654 Accepted
|
||||
[Mon Apr 27 15:48:30 2026] 127.0.0.1:55944 Closing
|
||||
[Mon Apr 27 15:48:30 2026] 127.0.0.1:41444 Accepted
|
||||
[Mon Apr 27 15:48:33 2026] 127.0.0.1:55654 Closing
|
||||
[Mon Apr 27 15:48:33 2026] 127.0.0.1:54932 Accepted
|
||||
[Mon Apr 27 15:48:36 2026] 127.0.0.1:41444 Closing
|
||||
[Mon Apr 27 15:48:36 2026] 127.0.0.1:41450 Accepted
|
||||
[Mon Apr 27 15:48:39 2026] 127.0.0.1:54932 Closing
|
||||
[Mon Apr 27 15:48:39 2026] 127.0.0.1:54942 Accepted
|
||||
[Mon Apr 27 15:48:46 2026] 127.0.0.1:41450 Closing
|
||||
[Mon Apr 27 15:48:46 2026] 127.0.0.1:42874 Accepted
|
||||
[Mon Apr 27 15:48:46 2026] 127.0.0.1:54942 Closing
|
||||
[Mon Apr 27 15:48:46 2026] 127.0.0.1:49554 Accepted
|
||||
[Mon Apr 27 15:49:03 2026] 127.0.0.1:49554 Closing
|
||||
[Mon Apr 27 15:49:03 2026] 127.0.0.1:51798 Accepted
|
||||
[Mon Apr 27 15:49:04 2026] 127.0.0.1:42874 Closing
|
||||
[Mon Apr 27 15:49:04 2026] 127.0.0.1:56176 Accepted
|
||||
[Mon Apr 27 15:49:11 2026] 127.0.0.1:51798 Closing
|
||||
[Mon Apr 27 15:49:11 2026] 127.0.0.1:47548 Accepted
|
||||
[Mon Apr 27 15:49:22 2026] 127.0.0.1:56176 Closing
|
||||
[Mon Apr 27 15:49:22 2026] 127.0.0.1:52542 Accepted
|
||||
[Mon Apr 27 15:49:24 2026] 127.0.0.1:47548 Closing
|
||||
[Mon Apr 27 15:49:24 2026] 127.0.0.1:39848 Accepted
|
||||
[Mon Apr 27 15:49:36 2026] 127.0.0.1:39848 Closing
|
||||
[Mon Apr 27 15:49:43 2026] 127.0.0.1:60636 Accepted
|
||||
[Mon Apr 27 15:49:43 2026] 127.0.0.1:52542 Closing
|
||||
[Mon Apr 27 15:49:50 2026] 127.0.0.1:43864 Accepted
|
||||
[Mon Apr 27 15:50:03 2026] 127.0.0.1:43864 Closing
|
||||
[Mon Apr 27 15:50:07 2026] 127.0.0.1:60636 Closing
|
||||
[Mon Apr 27 15:50:07 2026] 127.0.0.1:48092 Accepted
|
||||
[Mon Apr 27 15:50:11 2026] 127.0.0.1:38378 Accepted
|
||||
[Mon Apr 27 15:50:26 2026] 127.0.0.1:48092 Closing
|
||||
[Mon Apr 27 15:50:26 2026] 127.0.0.1:51390 Accepted
|
||||
[Mon Apr 27 15:50:41 2026] 127.0.0.1:38378 Closing
|
||||
[Mon Apr 27 15:50:49 2026] 127.0.0.1:38158 Accepted
|
||||
[Mon Apr 27 15:50:54 2026] 127.0.0.1:51390 Closing
|
||||
[Mon Apr 27 15:50:54 2026] 127.0.0.1:33842 Accepted
|
||||
[Mon Apr 27 15:50:56 2026] 127.0.0.1:38158 Closing
|
||||
[Mon Apr 27 15:51:00 2026] 127.0.0.1:36832 Accepted
|
||||
[Mon Apr 27 15:51:13 2026] 127.0.0.1:36832 Closing
|
||||
[Mon Apr 27 15:51:13 2026] 127.0.0.1:39172 Accepted
|
||||
[Mon Apr 27 15:51:19 2026] 127.0.0.1:33842 Closing
|
||||
[Mon Apr 27 15:51:19 2026] 127.0.0.1:47144 Accepted
|
||||
[Mon Apr 27 15:51:25 2026] 127.0.0.1:39172 Closing
|
||||
[Mon Apr 27 15:51:25 2026] 127.0.0.1:33908 Accepted
|
||||
[Mon Apr 27 15:51:32 2026] 127.0.0.1:47144 Closing
|
||||
[Mon Apr 27 15:51:32 2026] 127.0.0.1:35586 Accepted
|
||||
[Mon Apr 27 15:51:39 2026] 127.0.0.1:33908 Closing
|
||||
[Mon Apr 27 15:51:39 2026] 127.0.0.1:49634 Accepted
|
||||
[Mon Apr 27 15:51:49 2026] 127.0.0.1:49634 Closing
|
||||
[Mon Apr 27 15:51:49 2026] 127.0.0.1:35846 Accepted
|
||||
[Mon Apr 27 15:52:02 2026] 127.0.0.1:35846 Closing
|
||||
[Mon Apr 27 15:52:02 2026] 127.0.0.1:40002 Accepted
|
||||
[Mon Apr 27 15:52:03 2026] 127.0.0.1:35586 Closing
|
||||
[Mon Apr 27 15:52:09 2026] 127.0.0.1:38974 Accepted
|
||||
[Mon Apr 27 15:52:22 2026] 127.0.0.1:40002 Closing
|
||||
[Mon Apr 27 15:52:22 2026] 127.0.0.1:39160 Accepted
|
||||
[Mon Apr 27 15:52:22 2026] 127.0.0.1:38974 Closing
|
||||
[Mon Apr 27 15:52:23 2026] 127.0.0.1:50508 Accepted
|
||||
[Mon Apr 27 15:52:31 2026] 127.0.0.1:50508 Closing
|
||||
[Mon Apr 27 15:52:31 2026] 127.0.0.1:34868 Accepted
|
||||
[Mon Apr 27 15:52:33 2026] 127.0.0.1:39160 Closing
|
||||
[Mon Apr 27 15:52:33 2026] 127.0.0.1:51124 Accepted
|
||||
[Mon Apr 27 15:52:45 2026] 127.0.0.1:34868 Closing
|
||||
[Mon Apr 27 15:52:51 2026] 127.0.0.1:51124 Closing
|
||||
[Mon Apr 27 15:52:51 2026] 127.0.0.1:38142 Accepted
|
||||
[Mon Apr 27 15:52:58 2026] 127.0.0.1:40286 Accepted
|
||||
[Mon Apr 27 15:53:08 2026] 127.0.0.1:38142 Closing
|
||||
[Mon Apr 27 15:53:15 2026] 127.0.0.1:47064 Accepted
|
||||
[Mon Apr 27 15:53:43 2026] 127.0.0.1:47064 Closing
|
||||
[Mon Apr 27 15:53:43 2026] 127.0.0.1:40368 Accepted
|
||||
[Mon Apr 27 15:54:10 2026] 127.0.0.1:40286 Closing
|
||||
[Mon Apr 27 15:54:12 2026] 127.0.0.1:40368 Closing
|
||||
[Mon Apr 27 15:54:12 2026] 127.0.0.1:59302 Accepted
|
||||
[Mon Apr 27 15:54:44 2026] 127.0.0.1:59302 Closing
|
||||
[Mon Apr 27 15:54:51 2026] 127.0.0.1:55188 Accepted
|
||||
[Mon Apr 27 15:55:05 2026] 127.0.0.1:55188 Closing
|
||||
[Mon Apr 27 15:55:06 2026] 127.0.0.1:54998 Accepted
|
||||
[Mon Apr 27 15:55:14 2026] 127.0.0.1:54998 Closing
|
||||
[Mon Apr 27 15:55:14 2026] 127.0.0.1:55388 Accepted
|
||||
[Mon Apr 27 15:55:27 2026] 127.0.0.1:55388 Closing
|
||||
[Mon Apr 27 15:55:38 2026] 127.0.0.1:58096 Accepted
|
||||
[Mon Apr 27 15:56:40 2026] 127.0.0.1:58096 Closing
|
||||
[Mon Apr 27 15:57:41 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43345) started
|
||||
[Mon Apr 27 15:57:41 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45337) started
|
||||
[Mon Apr 27 15:57:41 2026] 127.0.0.1:54434 Accepted
|
||||
[Mon Apr 27 15:57:41 2026] 127.0.0.1:42766 Accepted
|
||||
[Mon Apr 27 15:57:46 2026] 127.0.0.1:42766 Closing
|
||||
[Mon Apr 27 15:57:46 2026] 127.0.0.1:54434 Closing
|
||||
[Mon Apr 27 15:57:46 2026] 127.0.0.1:42782 Accepted
|
||||
[Mon Apr 27 15:57:46 2026] 127.0.0.1:54438 Accepted
|
||||
[Mon Apr 27 15:57:54 2026] 127.0.0.1:54438 Closing
|
||||
[Mon Apr 27 15:57:54 2026] 127.0.0.1:43210 Accepted
|
||||
[Mon Apr 27 15:58:08 2026] 127.0.0.1:43210 Closing
|
||||
[Mon Apr 27 15:58:08 2026] 127.0.0.1:37286 Accepted
|
||||
[Mon Apr 27 15:58:10 2026] 127.0.0.1:42782 Closing
|
||||
[Mon Apr 27 15:58:10 2026] 127.0.0.1:57164 Accepted
|
||||
[Mon Apr 27 15:58:25 2026] 127.0.0.1:37286 Closing
|
||||
[Mon Apr 27 15:58:25 2026] 127.0.0.1:33692 Accepted
|
||||
[Mon Apr 27 15:58:31 2026] 127.0.0.1:57164 Closing
|
||||
[Mon Apr 27 15:58:31 2026] 127.0.0.1:41728 Accepted
|
||||
[Mon Apr 27 15:58:41 2026] 127.0.0.1:33692 Closing
|
||||
[Mon Apr 27 15:58:41 2026] 127.0.0.1:44756 Accepted
|
||||
[Mon Apr 27 15:58:52 2026] 127.0.0.1:44756 Closing
|
||||
[Mon Apr 27 15:58:52 2026] 127.0.0.1:41988 Accepted
|
||||
[Mon Apr 27 15:58:59 2026] 127.0.0.1:41988 Closing
|
||||
[Mon Apr 27 15:58:59 2026] 127.0.0.1:34238 Accepted
|
||||
[Mon Apr 27 15:59:01 2026] 127.0.0.1:41728 Closing
|
||||
[Mon Apr 27 15:59:01 2026] 127.0.0.1:41632 Accepted
|
||||
[Mon Apr 27 15:59:10 2026] 127.0.0.1:41632 Closing
|
||||
[Mon Apr 27 15:59:10 2026] 127.0.0.1:56798 Accepted
|
||||
[Mon Apr 27 15:59:17 2026] 127.0.0.1:34238 Closing
|
||||
[Mon Apr 27 15:59:17 2026] 127.0.0.1:51968 Accepted
|
||||
[Mon Apr 27 15:59:24 2026] 127.0.0.1:51968 Closing
|
||||
[Mon Apr 27 15:59:24 2026] 127.0.0.1:38554 Accepted
|
||||
[Mon Apr 27 15:59:28 2026] 127.0.0.1:56798 Closing
|
||||
[Mon Apr 27 15:59:28 2026] 127.0.0.1:37742 Accepted
|
||||
[Mon Apr 27 15:59:33 2026] 127.0.0.1:38554 Closing
|
||||
[Mon Apr 27 15:59:33 2026] 127.0.0.1:60924 Accepted
|
||||
[Mon Apr 27 15:59:52 2026] 127.0.0.1:60924 Closing
|
||||
[Mon Apr 27 15:59:52 2026] 127.0.0.1:43252 Accepted
|
||||
[Mon Apr 27 15:59:55 2026] 127.0.0.1:37742 Closing
|
||||
[Mon Apr 27 15:59:55 2026] 127.0.0.1:43598 Accepted
|
||||
[Mon Apr 27 16:00:08 2026] 127.0.0.1:43598 Closing
|
||||
[Mon Apr 27 16:00:08 2026] 127.0.0.1:45400 Accepted
|
||||
[Mon Apr 27 16:00:15 2026] 127.0.0.1:43252 Closing
|
||||
[Mon Apr 27 16:00:15 2026] 127.0.0.1:33680 Accepted
|
||||
[Mon Apr 27 16:00:28 2026] 127.0.0.1:45400 Closing
|
||||
[Mon Apr 27 16:00:28 2026] 127.0.0.1:58188 Accepted
|
||||
[Mon Apr 27 16:00:38 2026] 127.0.0.1:33680 Closing
|
||||
[Mon Apr 27 16:00:43 2026] 127.0.0.1:58188 Closing
|
||||
[Mon Apr 27 16:00:43 2026] 127.0.0.1:52096 Accepted
|
||||
[Mon Apr 27 16:01:17 2026] 127.0.0.1:52096 Closing
|
||||
[Mon Apr 27 16:02:10 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37323) started
|
||||
[Mon Apr 27 16:02:10 2026] 127.0.0.1:35510 Accepted
|
||||
[Mon Apr 27 16:02:14 2026] 127.0.0.1:35510 Closing
|
||||
[Mon Apr 27 16:02:14 2026] 127.0.0.1:35512 Accepted
|
||||
[Mon Apr 27 16:02:34 2026] 127.0.0.1:35512 Closing
|
||||
[Mon Apr 27 16:02:35 2026] 127.0.0.1:50544 Accepted
|
||||
[Mon Apr 27 16:02:54 2026] 127.0.0.1:50544 Closing
|
||||
[Mon Apr 27 16:02:54 2026] 127.0.0.1:34284 Accepted
|
||||
[Mon Apr 27 16:03:23 2026] 127.0.0.1:34284 Closing
|
||||
[Mon Apr 27 16:03:23 2026] 127.0.0.1:58504 Accepted
|
||||
[Mon Apr 27 16:03:35 2026] 127.0.0.1:58504 Closing
|
||||
[Mon Apr 27 16:03:35 2026] 127.0.0.1:57200 Accepted
|
||||
[Mon Apr 27 16:03:55 2026] 127.0.0.1:57200 Closing
|
||||
[Mon Apr 27 16:03:56 2026] 127.0.0.1:37528 Accepted
|
||||
[Mon Apr 27 16:04:24 2026] 127.0.0.1:37528 Closing
|
||||
[Mon Apr 27 16:04:24 2026] 127.0.0.1:41972 Accepted
|
||||
[Mon Apr 27 16:04:48 2026] 127.0.0.1:41972 Closing
|
||||
[Mon Apr 27 16:04:48 2026] 127.0.0.1:40764 Accepted
|
||||
[Mon Apr 27 16:05:10 2026] 127.0.0.1:40764 Closing
|
||||
[Mon Apr 27 16:05:10 2026] 127.0.0.1:51052 Accepted
|
||||
[Mon Apr 27 16:05:25 2026] 127.0.0.1:51052 Closing
|
||||
[Mon Apr 27 16:05:25 2026] 127.0.0.1:49526 Accepted
|
||||
[Mon Apr 27 16:05:58 2026] 127.0.0.1:49526 Closing
|
||||
[Mon Apr 27 16:05:58 2026] 127.0.0.1:54380 Accepted
|
||||
[Mon Apr 27 16:06:10 2026] 127.0.0.1:54380 Closing
|
||||
[Mon Apr 27 16:06:10 2026] 127.0.0.1:38446 Accepted
|
||||
[Mon Apr 27 16:06:33 2026] 127.0.0.1:38446 Closing
|
||||
[Mon Apr 27 16:07:01 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46263) started
|
||||
[Mon Apr 27 16:07:01 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44107) started
|
||||
[Mon Apr 27 16:07:01 2026] 127.0.0.1:57266 Accepted
|
||||
[Mon Apr 27 16:07:01 2026] 127.0.0.1:47356 Accepted
|
||||
[Mon Apr 27 16:07:05 2026] 127.0.0.1:57266 Closing
|
||||
[Mon Apr 27 16:07:05 2026] 127.0.0.1:57282 Accepted
|
||||
[Mon Apr 27 16:07:05 2026] 127.0.0.1:47356 Closing
|
||||
[Mon Apr 27 16:07:05 2026] 127.0.0.1:47362 Accepted
|
||||
[Mon Apr 27 16:07:21 2026] 127.0.0.1:47362 Closing
|
||||
[Mon Apr 27 16:07:37 2026] 127.0.0.1:57282 Closing
|
||||
[Mon Apr 27 16:07:38 2026] 127.0.0.1:41372 Accepted
|
||||
[Mon Apr 27 16:07:53 2026] 127.0.0.1:41372 Closing
|
||||
[Mon Apr 27 16:07:53 2026] 127.0.0.1:39536 Accepted
|
||||
[Mon Apr 27 16:08:09 2026] 127.0.0.1:39536 Closing
|
||||
[Mon Apr 27 16:08:09 2026] 127.0.0.1:33514 Accepted
|
||||
[Mon Apr 27 16:08:28 2026] 127.0.0.1:33514 Closing
|
||||
[Mon Apr 27 16:08:28 2026] 127.0.0.1:59450 Accepted
|
||||
[Mon Apr 27 16:08:58 2026] 127.0.0.1:59450 Closing
|
||||
[Mon Apr 27 16:08:58 2026] 127.0.0.1:40070 Accepted
|
||||
[Mon Apr 27 16:09:11 2026] 127.0.0.1:40070 Closing
|
||||
[Mon Apr 27 16:09:12 2026] 127.0.0.1:41964 Accepted
|
||||
[Mon Apr 27 16:09:54 2026] 127.0.0.1:41964 Closing
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class application_write_freeze
|
||||
{
|
||||
public static function freeze(string $reason, string $owner, int $ttlSeconds = 300): void
|
||||
{
|
||||
$payload = [
|
||||
'reason' => $reason,
|
||||
'owner' => $owner,
|
||||
'created_at' => date('c'),
|
||||
'expires_at' => date('c', time() + max(30, $ttlSeconds)),
|
||||
];
|
||||
|
||||
self::writeState($payload);
|
||||
}
|
||||
|
||||
public static function unfreeze(?string $owner = null): void
|
||||
{
|
||||
$state = self::state();
|
||||
if ($owner !== null && isset($state['owner']) && $state['owner'] !== $owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = self::statePath();
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public static function state(): array
|
||||
{
|
||||
$path = self::statePath();
|
||||
if (!is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$state = json_decode((string)file_get_contents($path), true);
|
||||
if (!is_array($state)) {
|
||||
@unlink($path);
|
||||
return [];
|
||||
}
|
||||
|
||||
$expiresAt = strtotime((string)($state['expires_at'] ?? ''));
|
||||
if ($expiresAt !== false && $expiresAt < time()) {
|
||||
@unlink($path);
|
||||
return [];
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
public static function isFrozen(): bool
|
||||
{
|
||||
return self::state() !== [];
|
||||
}
|
||||
|
||||
public static function shouldBlock(string $method, string $uri, bool $isCronOrCli): bool
|
||||
{
|
||||
if (!self::isFrozen()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($isCronOrCli) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$method = strtoupper($method);
|
||||
if (in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
||||
return !str_starts_with($path, '/superuser/replication');
|
||||
}
|
||||
|
||||
private static function writeState(array $state): void
|
||||
{
|
||||
$path = self::statePath();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException('Could not create write-freeze directory.');
|
||||
}
|
||||
|
||||
$tempPath = tempnam($dir, 'write-freeze-');
|
||||
if ($tempPath === false) {
|
||||
throw new RuntimeException('Could not create write-freeze temp file.');
|
||||
}
|
||||
|
||||
try {
|
||||
file_put_contents($tempPath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL, LOCK_EX);
|
||||
if (!rename($tempPath, $path)) {
|
||||
throw new RuntimeException('Could not atomically replace write-freeze state.');
|
||||
}
|
||||
} finally {
|
||||
if (is_file($tempPath)) {
|
||||
@unlink($tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function statePath(): string
|
||||
{
|
||||
$root = defined('WD') ? WD : dirname(__DIR__);
|
||||
return $root . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'application-write-freeze.json';
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,11 @@ use interfaces\shelly_transport_i;
|
||||
|
||||
class cloud_shelly_transport implements shelly_transport_i
|
||||
{
|
||||
public function __construct(private readonly ?shelly $client = null)
|
||||
public function __construct(
|
||||
private readonly ?shelly $client = null,
|
||||
private readonly bool $logRelaySignals = true,
|
||||
private readonly ?edge_gateway_manager $manager = null
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -30,6 +34,47 @@ class cloud_shelly_transport implements shelly_transport_i
|
||||
|
||||
public function sendPostRequest(string $endpoint, array $data, ?int $department_id = null): array|object|null
|
||||
{
|
||||
return $this->client()->sendPostRequest($endpoint, $data);
|
||||
try {
|
||||
$response = $this->client()->sendPostRequest($endpoint, $data);
|
||||
$this->logRelaySignal($endpoint, $data, $department_id, $response, null);
|
||||
return $response;
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logRelaySignal($endpoint, $data, $department_id, null, $exception);
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function logRelaySignal(
|
||||
string $endpoint,
|
||||
array $data,
|
||||
?int $department_id,
|
||||
array|object|null $response,
|
||||
?\Throwable $exception
|
||||
): void {
|
||||
if (!$this->logRelaySignals || $department_id === null || $department_id <= 0 || !$this->isRelayEndpoint($endpoint)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->manager()->appendRelayTransportLog(
|
||||
$department_id,
|
||||
$endpoint,
|
||||
$data,
|
||||
$response,
|
||||
'cloud',
|
||||
$exception?->getMessage()
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function isRelayEndpoint(string $endpoint): bool
|
||||
{
|
||||
return in_array($endpoint, ['/v2/devices/api/get', '/v2/devices/api/set/switch'], true);
|
||||
}
|
||||
|
||||
private function manager(): edge_gateway_manager
|
||||
{
|
||||
return $this->manager ?? new edge_gateway_manager();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,246 @@
|
||||
<?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 listGithubApps(): array
|
||||
{
|
||||
return $this->request('GET', '/github-apps');
|
||||
}
|
||||
|
||||
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 createPrivateGithubAppApplication(array $payload): array
|
||||
{
|
||||
return $this->request('POST', '/applications/private-github-app', $payload);
|
||||
}
|
||||
|
||||
public function getApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function updateApplication(string $uuid, array $payload): array
|
||||
{
|
||||
return $this->request('PATCH', '/applications/' . rawurlencode($uuid), $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 deployResource(string $uuid, bool $force = false): array
|
||||
{
|
||||
$path = '/deploy?uuid=' . rawurlencode($uuid) . '&force=' . ($force ? 'true' : 'false');
|
||||
return $this->request('GET', $path);
|
||||
}
|
||||
|
||||
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 restartApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . 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,237 @@
|
||||
<?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::ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path', '', '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,94 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class customer_name_cache_payload_builder
|
||||
{
|
||||
/**
|
||||
* @return array{name:string}|null
|
||||
*/
|
||||
public static function build(mixed $cached_name, ?string $fallback_name): ?array
|
||||
{
|
||||
$cached_name = self::normalizePayload($cached_name);
|
||||
$name = self::extractName($cached_name);
|
||||
if ($name !== null) {
|
||||
return ['name' => $name];
|
||||
}
|
||||
|
||||
$fallback_name = self::normalizeName($fallback_name);
|
||||
if ($fallback_name !== null) {
|
||||
return ['name' => $fallback_name];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function normalizePayload(mixed $payload): mixed
|
||||
{
|
||||
if (!is_string($payload)) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$trimmed = trim($payload);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($trimmed);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
private static function extractName(mixed $payload): ?string
|
||||
{
|
||||
if (is_string($payload)) {
|
||||
return self::normalizeName($payload);
|
||||
}
|
||||
|
||||
if (!is_object($payload) && !is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['name', 'customerName', 'customer_name', 'displayName', 'display_name'] as $key) {
|
||||
$name = self::normalizeName(self::payloadValue($payload, $key));
|
||||
if ($name !== null) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['customer', 'data', 'economic_customer'] as $key) {
|
||||
$name = self::extractName(self::payloadValue($payload, $key));
|
||||
if ($name !== null) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function payloadValue(mixed $payload, string $key): mixed
|
||||
{
|
||||
if (is_object($payload) && property_exists($payload, $key)) {
|
||||
return $payload->{$key};
|
||||
}
|
||||
|
||||
if (is_array($payload) && array_key_exists($key, $payload)) {
|
||||
return $payload[$key];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function normalizeName(mixed $name): ?string
|
||||
{
|
||||
if (!is_string($name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = trim($name);
|
||||
return $name === '' ? null : $name;
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,14 @@ class db
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
$this->conn->close();
|
||||
if (!isset($this->conn)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->conn->close();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
public function get(string $table, int $id)
|
||||
|
||||
@@ -76,11 +76,13 @@ class department_daily_report_complaints_schema_bootstrap
|
||||
dimension INT NOT NULL DEFAULT 0,
|
||||
branding INT NOT NULL DEFAULT 0,
|
||||
visible TINYINT(1) NOT NULL DEFAULT 1,
|
||||
archived TINYINT(1) NOT NULL DEFAULT 0,
|
||||
longitude DECIMAL(10,7) NOT NULL DEFAULT 0,
|
||||
latitude DECIMAL(10,7) NOT NULL DEFAULT 0,
|
||||
order_priority INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_departments_archived (archived)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for department lifecycle metadata.
|
||||
*/
|
||||
class departments_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
private const ARCHIVED_INDEX = 'idx_departments_archived';
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::tableExists($db, 'departments')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'departments', 'archived')) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
ADD COLUMN archived TINYINT(1) NOT NULL DEFAULT 0
|
||||
AFTER visible"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
ADD INDEX " . self::ARCHIVED_INDEX . " (archived)"
|
||||
);
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$table}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $table, string $column): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$column = self::escapeIdentifier($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function indexExists(object $db, string $table, string $index): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$index = self::escapeIdentifier($index);
|
||||
$result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escapeIdentifier(string $value): string
|
||||
{
|
||||
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ use interfaces\economic_i;
|
||||
class economic implements economic_i
|
||||
{
|
||||
public const DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE = 'Transactions for the configured draft customer cannot be exported to e-conomic.';
|
||||
public const DEFAULT_DISTRIBUTION_DEPARTMENT_ID = 1;
|
||||
|
||||
/**
|
||||
* Configuration of the economic module
|
||||
@@ -133,6 +134,14 @@ class economic implements economic_i
|
||||
return $customer_number > 0 ? $customer_number : null;
|
||||
}
|
||||
|
||||
public function getDefaultDistributionDepartmentId(): int
|
||||
{
|
||||
$value = $this->config->default_department_id->getVariableValue();
|
||||
$department_id = (int)$value;
|
||||
|
||||
return $department_id > 0 ? $department_id : self::DEFAULT_DISTRIBUTION_DEPARTMENT_ID;
|
||||
}
|
||||
|
||||
public function isDraftCustomerNumber(?int $customer_number): bool
|
||||
{
|
||||
$configured_customer_number = $this->getTransactionDraftCustomerNumber();
|
||||
@@ -156,9 +165,17 @@ class economic implements economic_i
|
||||
/**
|
||||
* Create a customer in e-conomic and return the raw upstream payload.
|
||||
*/
|
||||
public function createCustomer(int $customer_number, string $name, int $cvr_number, string $email, int $phone): object
|
||||
public function createCustomer(
|
||||
int $customer_number,
|
||||
string $name,
|
||||
int $cvr_number,
|
||||
string $email,
|
||||
int $phone,
|
||||
?int $mobile_phone = null,
|
||||
object|array|null $company_information = null
|
||||
): object
|
||||
{
|
||||
return $this->customers->customers->create([
|
||||
$payload = [
|
||||
'customerNumber' => $customer_number,
|
||||
'corporateIdentificationNumber' => (string)$cvr_number,
|
||||
'customerGroup' => [
|
||||
@@ -170,11 +187,60 @@ class economic implements economic_i
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'phone' => $phone,
|
||||
'telephoneAndFaxNumber' => (string)$phone,
|
||||
'mobilePhone' => (string)($mobile_phone ?? $phone),
|
||||
'currency' => 'DKK',
|
||||
'vatZone' => [
|
||||
'vatZoneNumber' => 1,
|
||||
]
|
||||
]);
|
||||
];
|
||||
|
||||
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
|
||||
|
||||
return $this->customers->customers->create($payload);
|
||||
}
|
||||
|
||||
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
|
||||
{
|
||||
if ($company_information === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
$field_map = [
|
||||
'address' => 'address',
|
||||
'zipcode' => 'zip',
|
||||
'city' => 'city',
|
||||
'website' => 'website',
|
||||
];
|
||||
|
||||
foreach ($field_map as $source_field => $economic_field) {
|
||||
$value = $this->companyInformationValue($company_information, $source_field);
|
||||
if ($value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload[$economic_field] = $value;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function companyInformationValue(object|array $company_information, string $field): ?string
|
||||
{
|
||||
if (is_array($company_information)) {
|
||||
$value = $company_information[$field] ?? null;
|
||||
} else {
|
||||
$value = $company_information->{$field} ?? null;
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string)$value);
|
||||
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -154,6 +154,132 @@ class economic_transfer_queue
|
||||
return max(0, (int)$row['total']);
|
||||
}
|
||||
|
||||
public function listMonitorJobsForUser(int $user_id, int $limit = 50, ?string $transfer_type = null): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$user_id = max(0, $user_id);
|
||||
$limit = max(1, min(100, $limit));
|
||||
try {
|
||||
$normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== ''
|
||||
? $this->validateTransferType($transfer_type)
|
||||
: null;
|
||||
} catch (Exception) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$transfer_condition = '';
|
||||
if ($normalized_transfer_type !== null) {
|
||||
$transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'";
|
||||
}
|
||||
|
||||
$sql = "SELECT q.*
|
||||
FROM economic_transfer_queue_jobs q
|
||||
LEFT JOIN economic_transfer_queue_job_dismissals d
|
||||
ON d.queue_job_id = q.id
|
||||
AND d.user_id = $user_id
|
||||
AND d.dismissed_status = q.status
|
||||
WHERE 1 = 1
|
||||
$transfer_condition
|
||||
AND (
|
||||
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
|
||||
OR d.queue_job_id IS NULL
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') THEN 0 ELSE 1 END,
|
||||
q.id DESC
|
||||
LIMIT $limit";
|
||||
$result = $db->query($sql);
|
||||
if (!$result instanceof mysqli_result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$jobs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$jobs[] = $this->normalizeJobRow($row);
|
||||
}
|
||||
return $jobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function dismissTerminalJobForUser(int $job_id, int $user_id): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$job_id = max(0, $job_id);
|
||||
$user_id = max(0, $user_id);
|
||||
if ($job_id < 1 || $user_id < 1) {
|
||||
throw new Exception('Queue job and user are required');
|
||||
}
|
||||
|
||||
$job = $this->getJobById($job_id);
|
||||
if ($job === null) {
|
||||
throw new Exception('Queue job not found');
|
||||
}
|
||||
|
||||
$status = strtoupper((string)($job['status'] ?? ''));
|
||||
if (!in_array($status, [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
|
||||
throw new Exception('Only completed or failed queue jobs can be dismissed');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at)
|
||||
VALUES (?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()"
|
||||
);
|
||||
if (!$stmt) {
|
||||
throw new Exception('Failed to prepare queue dismissal statement');
|
||||
}
|
||||
|
||||
$stmt->bind_param('iis', $job_id, $user_id, $status);
|
||||
if (!$stmt->execute()) {
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to dismiss queue job');
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
public function dismissTerminalJobsForUser(int $user_id, ?string $transfer_type = null): int
|
||||
{
|
||||
global $db;
|
||||
|
||||
$user_id = max(0, $user_id);
|
||||
if ($user_id < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== ''
|
||||
? $this->validateTransferType($transfer_type)
|
||||
: null;
|
||||
} catch (Exception) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$transfer_condition = '';
|
||||
if ($normalized_transfer_type !== null) {
|
||||
$transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'";
|
||||
}
|
||||
|
||||
$sql = "INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at)
|
||||
SELECT q.id, $user_id, q.status, NOW()
|
||||
FROM economic_transfer_queue_jobs q
|
||||
LEFT JOIN economic_transfer_queue_job_dismissals d
|
||||
ON d.queue_job_id = q.id
|
||||
AND d.user_id = $user_id
|
||||
AND d.dismissed_status = q.status
|
||||
WHERE q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
|
||||
$transfer_condition
|
||||
AND d.queue_job_id IS NULL
|
||||
ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()";
|
||||
$db->query($sql);
|
||||
return max(0, (int)($db->affected_rows ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -193,6 +319,8 @@ class economic_transfer_queue
|
||||
throw new Exception('Failed to retry queue job');
|
||||
}
|
||||
|
||||
$this->clearDismissalsForJob($job_id);
|
||||
|
||||
$job = $this->getJobById($job_id);
|
||||
if ($job === null) {
|
||||
throw new Exception('Retry updated job could not be loaded');
|
||||
@@ -480,6 +608,18 @@ class economic_transfer_queue
|
||||
];
|
||||
}
|
||||
|
||||
private function clearDismissalsForJob(int $job_id): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$job_id = max(0, $job_id);
|
||||
if ($job_id < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("DELETE FROM economic_transfer_queue_job_dismissals WHERE queue_job_id = $job_id");
|
||||
}
|
||||
|
||||
/**
|
||||
* Release jobs stuck in PROCESSING due to crashes or killed workers.
|
||||
*/
|
||||
|
||||
@@ -27,12 +27,20 @@ class economic_transfer_queue_details_summary
|
||||
'customer_number' => self::toPositiveInt(
|
||||
$result['customer_number']
|
||||
?? $result['user']['customer_number']
|
||||
?? $payload['customer_number']
|
||||
?? $payload['customer']['customer_number']
|
||||
?? null
|
||||
),
|
||||
'name' => self::toNonEmptyString(
|
||||
$result['customer_name']
|
||||
?? $result['user']['customer_name']
|
||||
?? $result['user']['display_name']
|
||||
?? $result['user']['name']
|
||||
?? $result['user']['company_name']
|
||||
?? $payload['customer_name']
|
||||
?? $payload['customer']['customer_name']
|
||||
?? $payload['customer']['display_name']
|
||||
?? $payload['customer']['name']
|
||||
?? null
|
||||
),
|
||||
],
|
||||
|
||||
@@ -42,6 +42,18 @@ class economic_transfer_queue_schema_bootstrap
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_dismissals (
|
||||
queue_job_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
dismissed_status VARCHAR(32) NOT NULL,
|
||||
dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (queue_job_id, user_id),
|
||||
INDEX idx_economic_transfer_queue_job_dismissals_user_status (user_id, dismissed_status),
|
||||
INDEX idx_economic_transfer_queue_job_dismissals_job (queue_job_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -892,6 +892,7 @@ class economic_v2_distribution_service
|
||||
|
||||
$weight_total = array_sum($eligible_weights);
|
||||
if (abs($weight_total) <= self::EPSILON) {
|
||||
$fallback_department_id = $this->getFallbackDistributionDepartmentId();
|
||||
$warnings[] = 'Booked department 75 '
|
||||
. $source_category
|
||||
. ' amount for customer '
|
||||
@@ -900,11 +901,15 @@ class economic_v2_distribution_service
|
||||
. $month_key
|
||||
. ' on invoice(s) '
|
||||
. $invoice_ids
|
||||
. ' has no redistribution basis and remains undistributed.';
|
||||
. ' has no redistribution basis and was assigned to fallback department '
|
||||
. $fallback_department_id
|
||||
. '.';
|
||||
|
||||
return [
|
||||
'department_distribution' => [],
|
||||
'undistributed_net_amount' => $booked_amount,
|
||||
'department_distribution' => [
|
||||
$fallback_department_id => $booked_amount,
|
||||
],
|
||||
'undistributed_net_amount' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1061,6 +1066,9 @@ class economic_v2_distribution_service
|
||||
} elseif (!$this->isOrderEligible($order)) {
|
||||
continue;
|
||||
}
|
||||
$distribution_department_id = $system_order_fallback
|
||||
? $this->getFallbackDistributionDepartmentId()
|
||||
: $department_id;
|
||||
|
||||
$fixed_version = $this->versioning->resolveFixedPricingVersionAt($customer_number, $created_at);
|
||||
if ($fixed_version === null) {
|
||||
@@ -1090,14 +1098,14 @@ class economic_v2_distribution_service
|
||||
$order_original_price = $this->calculateOrderOriginalPrice(
|
||||
$order_items_by_order_id[$order_id] ?? [],
|
||||
$customer_number,
|
||||
$department_id,
|
||||
$distribution_department_id,
|
||||
$created_at
|
||||
);
|
||||
$groups[$group_key]['original_price'] += $order_original_price;
|
||||
if (!isset($groups[$group_key]['department_totals'][$department_id])) {
|
||||
$groups[$group_key]['department_totals'][$department_id] = 0.0;
|
||||
if (!isset($groups[$group_key]['department_totals'][$distribution_department_id])) {
|
||||
$groups[$group_key]['department_totals'][$distribution_department_id] = 0.0;
|
||||
}
|
||||
$groups[$group_key]['department_totals'][$department_id] += $order_original_price;
|
||||
$groups[$group_key]['department_totals'][$distribution_department_id] += $order_original_price;
|
||||
$groups[$group_key]['order_ids'][] = $order_id;
|
||||
|
||||
if (!isset($customer_transactions[$customer_number][$order_id])) {
|
||||
@@ -1141,12 +1149,15 @@ class economic_v2_distribution_service
|
||||
} elseif (!$this->isOrderEligible($order)) {
|
||||
continue;
|
||||
}
|
||||
$distribution_department_id = $system_order_fallback
|
||||
? $this->getFallbackDistributionDepartmentId()
|
||||
: $department_id;
|
||||
|
||||
$month_key = substr($created_at, 0, 7);
|
||||
if (!isset($customer_department_month_map[$customer_number][$month_key][$department_id])) {
|
||||
$customer_department_month_map[$customer_number][$month_key][$department_id] = 0;
|
||||
if (!isset($customer_department_month_map[$customer_number][$month_key][$distribution_department_id])) {
|
||||
$customer_department_month_map[$customer_number][$month_key][$distribution_department_id] = 0;
|
||||
}
|
||||
$customer_department_month_map[$customer_number][$month_key][$department_id]++;
|
||||
$customer_department_month_map[$customer_number][$month_key][$distribution_department_id]++;
|
||||
|
||||
$candidates = $this->buildWashSubscriptionCandidates(
|
||||
$order,
|
||||
@@ -1193,10 +1204,10 @@ class economic_v2_distribution_service
|
||||
];
|
||||
}
|
||||
|
||||
if (!isset($groups[$group_key]['distribution'][$department_id])) {
|
||||
$groups[$group_key]['distribution'][$department_id] = 0;
|
||||
if (!isset($groups[$group_key]['distribution'][$distribution_department_id])) {
|
||||
$groups[$group_key]['distribution'][$distribution_department_id] = 0;
|
||||
}
|
||||
$groups[$group_key]['distribution'][$department_id]++;
|
||||
$groups[$group_key]['distribution'][$distribution_department_id]++;
|
||||
$groups[$group_key]['order_ids'][] = $order_id;
|
||||
$matched_order = true;
|
||||
}
|
||||
@@ -1394,6 +1405,9 @@ class economic_v2_distribution_service
|
||||
): array {
|
||||
$distribution = [];
|
||||
$department_counts = $customer_department_month_map[$customer_number][$month_key] ?? [];
|
||||
if (!empty($department_counts)) {
|
||||
$department_counts = $this->normalizeFallbackDepartmentCounts($department_counts);
|
||||
}
|
||||
if (!empty($department_counts)) {
|
||||
$total = (float)array_sum($department_counts);
|
||||
foreach ($department_counts as $department_id => $count) {
|
||||
@@ -1402,20 +1416,61 @@ class economic_v2_distribution_service
|
||||
return $distribution;
|
||||
}
|
||||
|
||||
$default_department = 1;
|
||||
try {
|
||||
$default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment();
|
||||
if (!empty($default)) {
|
||||
$default_department = (int)$default;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// fall back to department 1
|
||||
}
|
||||
$customer_default_department_id = $this->getCustomerDefaultDepartmentId($customer_number);
|
||||
$default_department = $customer_default_department_id !== null && $customer_default_department_id > 0
|
||||
? $customer_default_department_id
|
||||
: $this->getFallbackDistributionDepartmentId();
|
||||
|
||||
$distribution[$default_department] = $monthly_price;
|
||||
return $distribution;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string,int|float> $department_counts
|
||||
* @return array<int,int|float>
|
||||
*/
|
||||
private function normalizeFallbackDepartmentCounts(array $department_counts): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($department_counts as $department_id => $count) {
|
||||
$department_id = (int)$department_id;
|
||||
if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) {
|
||||
$department_id = $this->getFallbackDistributionDepartmentId();
|
||||
}
|
||||
if (!isset($normalized[$department_id])) {
|
||||
$normalized[$department_id] = 0;
|
||||
}
|
||||
$normalized[$department_id] += $count;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
protected function getCustomerDefaultDepartmentId(int $customer_number): ?int
|
||||
{
|
||||
try {
|
||||
$default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment();
|
||||
return !empty($default) ? (int)$default : null;
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getFallbackDistributionDepartmentId(): int
|
||||
{
|
||||
try {
|
||||
$department_id = (new economic())->getDefaultDistributionDepartmentId();
|
||||
} catch (\Throwable $e) {
|
||||
$department_id = economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID;
|
||||
}
|
||||
|
||||
if ($department_id <= 0 || $department_id === self::SYSTEM_ORDER_DEPARTMENT_ID) {
|
||||
return economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID;
|
||||
}
|
||||
|
||||
return $department_id;
|
||||
}
|
||||
|
||||
private function normalizeSubscriptionGroupAllocation(array $distribution, float $monthly_price): array
|
||||
{
|
||||
if (empty($distribution)) {
|
||||
|
||||
@@ -48,4 +48,42 @@ class edgegateway implements universal_module_i
|
||||
$configured = trim((string)$this->config->default_update_window->getVariableValue());
|
||||
return $configured !== '' ? $configured : '02:00-04:00';
|
||||
}
|
||||
|
||||
public function brokerUrl(): string
|
||||
{
|
||||
$configured = trim((string)$this->config->broker_url->getVariableValue());
|
||||
if ($configured !== '') {
|
||||
return rtrim($configured, '/');
|
||||
}
|
||||
|
||||
$fallback = trim((string)(getenv('EDGE_BROKER_URL') ?: ''));
|
||||
return $fallback !== '' ? rtrim($fallback, '/') : '';
|
||||
}
|
||||
|
||||
public function publicBrokerUrl(): string
|
||||
{
|
||||
$configured = trim((string)$this->config->public_broker_url->getVariableValue());
|
||||
if ($configured !== '') {
|
||||
return rtrim($configured, '/');
|
||||
}
|
||||
|
||||
$fallback = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: ''));
|
||||
return $fallback !== '' ? rtrim($fallback, '/') : '';
|
||||
}
|
||||
|
||||
public function brokerAuthMode(): string
|
||||
{
|
||||
$configured = trim((string)$this->config->broker_auth_mode->getVariableValue());
|
||||
return $configured !== '' ? $configured : 'manager';
|
||||
}
|
||||
|
||||
public function brokerSharedSecret(): string
|
||||
{
|
||||
$configured = trim((string)$this->config->broker_shared_secret->getVariableValue());
|
||||
if ($configured !== '') {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
return trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,6 @@ use Exception;
|
||||
use forms\form_helper_c;
|
||||
use forms\objects\book_interior_wash_f;
|
||||
use forms\objects\book_wash_f;
|
||||
use forms\objects\complete_booking_f;
|
||||
use forms\objects\generate_booking_wash_certificate_f;
|
||||
use objects\form_submissions_o;
|
||||
use traits\form_t;
|
||||
|
||||
@@ -30,25 +28,12 @@ class form
|
||||
* @var book_wash_f $book_wash The BOOK_WASH form
|
||||
*/
|
||||
public book_wash_f $book_wash;
|
||||
/**
|
||||
* The GENERATE_BOOKING_CERTIFICATE form
|
||||
* @var generate_booking_wash_certificate_f $generate_booking_wash_certificate The GENERATE_BOOKING_CERTIFICATE form
|
||||
*/
|
||||
public generate_booking_wash_certificate_f $generate_booking_wash_certificate;
|
||||
|
||||
/**
|
||||
* The COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE form
|
||||
* @var complete_booking_f $complete_booking_without_wash_certificate The COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE form
|
||||
*/
|
||||
public complete_booking_f $complete_booking_without_wash_certificate;
|
||||
public $last_submitted_form;
|
||||
public form_submissions_o $form_submission;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->book_wash = new book_wash_f();
|
||||
$this->generate_booking_wash_certificate = new generate_booking_wash_certificate_f();
|
||||
$this->complete_booking_without_wash_certificate = new complete_booking_f();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,10 @@ use interfaces\shelly_transport_i;
|
||||
|
||||
class gateway_shelly_transport implements shelly_transport_i
|
||||
{
|
||||
public function __construct(private readonly ?edge_gateway_manager $manager = null)
|
||||
public function __construct(
|
||||
private readonly ?edge_gateway_manager $manager = null,
|
||||
private readonly bool $localOnly = false
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -52,7 +55,9 @@ class gateway_shelly_transport implements shelly_transport_i
|
||||
|
||||
$result = [];
|
||||
foreach ($ids as $logicalRelayId) {
|
||||
$status = $this->manager()->dispatchRelayStatus($departmentId, $logicalRelayId);
|
||||
$status = $this->localOnly
|
||||
? $this->manager()->dispatchRelayStatusLocalOnly($departmentId, $logicalRelayId)
|
||||
: $this->manager()->dispatchRelayStatus($departmentId, $logicalRelayId);
|
||||
$result[] = $this->normalizeRelayPayload($logicalRelayId, $status);
|
||||
}
|
||||
|
||||
@@ -69,12 +74,24 @@ class gateway_shelly_transport implements shelly_transport_i
|
||||
throw new Exception('Shelly gateway switch requests require an id');
|
||||
}
|
||||
|
||||
$status = $this->manager()->dispatchRelaySwitch(
|
||||
$departmentId,
|
||||
$logicalRelayId,
|
||||
(bool)($data['on'] ?? false)
|
||||
$toggleAfter = $this->normalizeToggleAfter(
|
||||
$data['toggle_after'] ?? $data['toggleAfter'] ?? $data['timer'] ?? null
|
||||
);
|
||||
|
||||
$status = $this->localOnly
|
||||
? $this->manager()->dispatchRelaySwitchLocalOnlyWithTimer(
|
||||
$departmentId,
|
||||
$logicalRelayId,
|
||||
(bool)($data['on'] ?? false),
|
||||
$toggleAfter
|
||||
)
|
||||
: $this->manager()->dispatchRelaySwitchWithTimer(
|
||||
$departmentId,
|
||||
$logicalRelayId,
|
||||
(bool)($data['on'] ?? false),
|
||||
$toggleAfter
|
||||
);
|
||||
|
||||
return [$this->normalizeRelayPayload($logicalRelayId, $status)];
|
||||
}
|
||||
|
||||
@@ -89,6 +106,7 @@ class gateway_shelly_transport implements shelly_transport_i
|
||||
|
||||
return [
|
||||
'id' => $logicalRelayId,
|
||||
'relay_id' => $logicalRelayId,
|
||||
'online' => $online,
|
||||
'on' => $on,
|
||||
'status' => [
|
||||
@@ -97,6 +115,7 @@ class gateway_shelly_transport implements shelly_transport_i
|
||||
],
|
||||
],
|
||||
'binding' => (array)($status['binding'] ?? []),
|
||||
'execution' => (array)($status['execution'] ?? []),
|
||||
'raw' => (array)($status['raw'] ?? []),
|
||||
];
|
||||
}
|
||||
@@ -105,4 +124,14 @@ class gateway_shelly_transport implements shelly_transport_i
|
||||
{
|
||||
return $this->manager ?? new edge_gateway_manager();
|
||||
}
|
||||
|
||||
private function normalizeToggleAfter(mixed $toggleAfter): ?int
|
||||
{
|
||||
if ($toggleAfter === null || $toggleAfter === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$seconds = (int)$toggleAfter;
|
||||
return $seconds > 0 ? $seconds : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?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 $options = []): array
|
||||
{
|
||||
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_service', self::servicePayload(
|
||||
$protocol,
|
||||
$listenPort,
|
||||
$destinationPort,
|
||||
$options
|
||||
));
|
||||
}
|
||||
|
||||
public function updateService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array
|
||||
{
|
||||
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/update_service', self::servicePayload(
|
||||
$protocol,
|
||||
$listenPort,
|
||||
$destinationPort,
|
||||
$options
|
||||
));
|
||||
}
|
||||
|
||||
private static function servicePayload(string $protocol, int $listenPort, int $destinationPort, array $options): array
|
||||
{
|
||||
$payload = [
|
||||
'protocol' => strtolower($protocol),
|
||||
'listen_port' => $listenPort,
|
||||
'destination_port' => $destinationPort,
|
||||
'proxyprotocol' => false,
|
||||
];
|
||||
|
||||
foreach (['health_check', 'http'] as $key) {
|
||||
if (isset($options[$key]) && is_array($options[$key])) {
|
||||
$payload[$key] = $options[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for superuser invoice-period flags.
|
||||
*/
|
||||
class invoice_period_flag_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS invoice_period_flags (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
severity VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
target_type VARCHAR(64) NOT NULL,
|
||||
target_id BIGINT NOT NULL,
|
||||
field VARCHAR(64) NULL,
|
||||
customer_number INT NULL,
|
||||
order_id BIGINT NULL,
|
||||
order_item_id BIGINT NULL,
|
||||
invoice_collection_id BIGINT NULL,
|
||||
xlvask_usage_log_id BIGINT NULL,
|
||||
definition_key VARCHAR(128) NULL,
|
||||
fingerprint VARCHAR(191) NULL,
|
||||
reason TEXT NULL,
|
||||
status_reason TEXT NULL,
|
||||
context_json JSON NULL,
|
||||
created_by INT NULL,
|
||||
status_changed_by INT NULL,
|
||||
status_changed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_invoice_period_flags_auto_fingerprint (source, fingerprint),
|
||||
KEY idx_invoice_period_flags_target (target_type, target_id, status),
|
||||
KEY idx_invoice_period_flags_customer_status (customer_number, status),
|
||||
KEY idx_invoice_period_flags_source_status (source, status),
|
||||
KEY idx_invoice_period_flags_order (order_id),
|
||||
KEY idx_invoice_period_flags_order_item (order_item_id),
|
||||
KEY idx_invoice_period_flags_invoice_collection (invoice_collection_id),
|
||||
KEY idx_invoice_period_flags_xlvask (xlvask_usage_log_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
products_schema_bootstrap::ensureTables();
|
||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -155,7 +155,7 @@ class motorapi implements motorapi_i
|
||||
// Get the cached result from the log/local database/cache
|
||||
$motorapi_lookups = new motorapi_lookups_o();
|
||||
// Add the cached value to the meta
|
||||
$response->add_meta('cached', true);
|
||||
self::addCachedMetaIfPossible($response);
|
||||
$cleaned_result = self::cleanJSON($motorapi_lookups->getCachedResult($licensePlate)->result->value());
|
||||
$object = json_decode($cleaned_result);
|
||||
if ($object === null) {
|
||||
@@ -165,6 +165,13 @@ class motorapi implements motorapi_i
|
||||
return json_decode($cleaned_result);
|
||||
}
|
||||
|
||||
public static function addCachedMetaIfPossible(mixed $response): void
|
||||
{
|
||||
if (is_object($response) && method_exists($response, 'add_meta')) {
|
||||
$response->add_meta('cached', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @throws Exception If the module is not enabled, the license plate is invalid, the daily limit is exceeded, or the secret key is invalid
|
||||
|
||||
@@ -34,11 +34,58 @@ class openai implements openai_i
|
||||
*/
|
||||
public function requireModuleEnabled(): void
|
||||
{
|
||||
if (!(bool)$this->config->enabled->getVariableValue()) {
|
||||
if (!$this->config->enabled->isTrue()) {
|
||||
throw new Exception('OpenAI module is not enabled.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a structured JSON text task to the OpenAI Responses API.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function jsonTask(string $schemaName, string $prompt, array $payload, array $schema, float $temperature = 0.1): array
|
||||
{
|
||||
$this->requireModuleEnabled();
|
||||
|
||||
$data = [
|
||||
'model' => $this->model,
|
||||
'input' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => [
|
||||
[
|
||||
'type' => 'input_text',
|
||||
'text' => $prompt . "\n\nData:\n" . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'text' => [
|
||||
'format' => [
|
||||
'type' => 'json_schema',
|
||||
'name' => $schemaName,
|
||||
'schema' => $schema,
|
||||
'strict' => true,
|
||||
],
|
||||
],
|
||||
'temperature' => $temperature,
|
||||
];
|
||||
|
||||
$response = $this->sendRequest($data);
|
||||
$output = $response['output'][0]['content'][0]['text'] ?? null;
|
||||
if (!is_string($output) || $output === '') {
|
||||
throw new Exception('Invalid response format from OpenAI API. (Missing text field)');
|
||||
}
|
||||
|
||||
$decoded = json_decode($output, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
|
||||
throw new Exception('Error parsing JSON response: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
protected function getLPRSchema(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use PDO;
|
||||
|
||||
class order_reference_suggestions_service
|
||||
{
|
||||
private const DEFAULT_LIMIT = 10;
|
||||
private const MAX_LIMIT = 25;
|
||||
private const MAX_SOURCE_ROWS = 500;
|
||||
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private array $columnExistsCache = [];
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* search?: mixed,
|
||||
* department_id?: mixed,
|
||||
* customer_id?: mixed,
|
||||
* reg_1?: mixed,
|
||||
* reg_2?: mixed,
|
||||
* reg_3?: mixed,
|
||||
* limit?: mixed
|
||||
* } $criteria
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function suggest(array $criteria): array
|
||||
{
|
||||
$departmentId = $this->toPositiveInt($criteria['department_id'] ?? null);
|
||||
if ($departmentId === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$search = $this->normalizeText($criteria['search'] ?? '');
|
||||
$customerId = $this->toPositiveInt($criteria['customer_id'] ?? null);
|
||||
$plates = $this->normalizePlates([
|
||||
$criteria['reg_1'] ?? '',
|
||||
$criteria['reg_2'] ?? '',
|
||||
$criteria['reg_3'] ?? '',
|
||||
]);
|
||||
$limit = $this->clampLimit($criteria['limit'] ?? self::DEFAULT_LIMIT);
|
||||
|
||||
$rows = [
|
||||
...$this->fetchBookingRows($departmentId, $search),
|
||||
...$this->fetchOrderRows($departmentId, $search),
|
||||
...$this->fetchVehicleRows($customerId, $plates, $search),
|
||||
];
|
||||
|
||||
$suggestions = $this->aggregateRows($rows, $search, $customerId, $plates);
|
||||
usort($suggestions, [$this, 'sortSuggestions']);
|
||||
|
||||
return array_slice($suggestions, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fetchBookingRows(int $departmentId, string $search): array
|
||||
{
|
||||
$where = [
|
||||
'department = :department_id',
|
||||
'reference IS NOT NULL',
|
||||
"TRIM(reference) <> ''",
|
||||
];
|
||||
if ($this->tableHasColumn('order_bookings', 'deleted_at')) {
|
||||
array_unshift($where, 'deleted_at IS NULL');
|
||||
}
|
||||
$params = ['department_id' => $departmentId];
|
||||
|
||||
if ($search !== '') {
|
||||
$where[] = 'LOWER(reference) LIKE :search';
|
||||
$params['search'] = '%' . $this->lower($search) . '%';
|
||||
}
|
||||
|
||||
$sql = "SELECT
|
||||
'booking' AS source,
|
||||
id AS origin_id,
|
||||
TRIM(reference) AS reference,
|
||||
datetime AS source_created_at,
|
||||
datetime AS used_at,
|
||||
customer_number AS customer_id,
|
||||
department AS department_id,
|
||||
reg_1,
|
||||
reg_2,
|
||||
reg_3
|
||||
FROM order_bookings
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY datetime DESC, id DESC
|
||||
LIMIT :source_limit";
|
||||
|
||||
return $this->fetchRows($sql, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fetchOrderRows(int $departmentId, string $search): array
|
||||
{
|
||||
$where = [
|
||||
'department_id = :department_id',
|
||||
'reference IS NOT NULL',
|
||||
"TRIM(reference) <> ''",
|
||||
];
|
||||
if ($this->tableHasColumn('orders', 'deleted_at')) {
|
||||
array_unshift($where, 'deleted_at IS NULL');
|
||||
}
|
||||
$params = ['department_id' => $departmentId];
|
||||
|
||||
if ($search !== '') {
|
||||
$where[] = 'LOWER(reference) LIKE :search';
|
||||
$params['search'] = '%' . $this->lower($search) . '%';
|
||||
}
|
||||
|
||||
$sql = "SELECT
|
||||
'order' AS source,
|
||||
id AS origin_id,
|
||||
TRIM(reference) AS reference,
|
||||
created_at AS source_created_at,
|
||||
created_at AS used_at,
|
||||
customer_id,
|
||||
department_id,
|
||||
reg_1,
|
||||
reg_2,
|
||||
reg_3
|
||||
FROM orders
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :source_limit";
|
||||
|
||||
return $this->fetchRows($sql, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $plates
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fetchVehicleRows(?int $customerId, array $plates, string $search): array
|
||||
{
|
||||
$contextWhere = [];
|
||||
$params = [];
|
||||
|
||||
if ($customerId !== null) {
|
||||
$contextWhere[] = 'customer_id = :customer_id';
|
||||
$params['customer_id'] = $customerId;
|
||||
}
|
||||
|
||||
foreach ($plates as $index => $plate) {
|
||||
$key = 'plate_' . $index;
|
||||
$contextWhere[] = "UPPER(REPLACE(reg, ' ', '')) = :$key";
|
||||
$params[$key] = $plate;
|
||||
}
|
||||
|
||||
if ($contextWhere === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$where = [
|
||||
'reference IS NOT NULL',
|
||||
"TRIM(reference) <> ''",
|
||||
'(' . implode(' OR ', $contextWhere) . ')',
|
||||
];
|
||||
if ($this->tableHasColumn('customer_vehicles', 'deleted_at')) {
|
||||
array_unshift($where, 'deleted_at IS NULL');
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
$where[] = 'LOWER(reference) LIKE :search';
|
||||
$params['search'] = '%' . $this->lower($search) . '%';
|
||||
}
|
||||
|
||||
$sql = "SELECT
|
||||
'vehicle' AS source,
|
||||
id AS origin_id,
|
||||
TRIM(reference) AS reference,
|
||||
created_at AS source_created_at,
|
||||
created_at AS used_at,
|
||||
customer_id,
|
||||
NULL AS department_id,
|
||||
reg AS reg_1,
|
||||
'' AS reg_2,
|
||||
'' AS reg_3
|
||||
FROM customer_vehicles
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :source_limit";
|
||||
|
||||
return $this->fetchRows($sql, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fetchRows(string $sql, array $params): array
|
||||
{
|
||||
$pdo = db::getPDO();
|
||||
$statement = $pdo->prepare($sql);
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
$statement->bindValue(':' . $key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||
}
|
||||
$statement->bindValue(':source_limit', self::MAX_SOURCE_ROWS, PDO::PARAM_INT);
|
||||
$statement->execute();
|
||||
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
private function tableHasColumn(string $table, string $column): bool
|
||||
{
|
||||
$cacheKey = $table . '.' . $column;
|
||||
if (array_key_exists($cacheKey, $this->columnExistsCache)) {
|
||||
return $this->columnExistsCache[$cacheKey];
|
||||
}
|
||||
|
||||
$pdo = db::getPDO();
|
||||
$statement = $pdo->prepare(
|
||||
'SELECT COUNT(*) AS total
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = :table_name
|
||||
AND COLUMN_NAME = :column_name'
|
||||
);
|
||||
$statement->bindValue(':table_name', $table, PDO::PARAM_STR);
|
||||
$statement->bindValue(':column_name', $column, PDO::PARAM_STR);
|
||||
$statement->execute();
|
||||
|
||||
$this->columnExistsCache[$cacheKey] = ((int)$statement->fetchColumn()) > 0;
|
||||
return $this->columnExistsCache[$cacheKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @param array<int, string> $plates
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function aggregateRows(array $rows, string $search, ?int $customerId, array $plates): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$reference = $this->normalizeText($row['reference'] ?? '');
|
||||
if ($reference === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $this->lower($reference);
|
||||
if (!isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'reference' => $reference,
|
||||
'rows' => [],
|
||||
'usage_count' => 0,
|
||||
'last_used_at' => null,
|
||||
'context_boost' => 0,
|
||||
'section' => 'other',
|
||||
];
|
||||
}
|
||||
|
||||
$section = $this->contextSection($row, $customerId, $plates);
|
||||
$groups[$key]['usage_count']++;
|
||||
$groups[$key]['rows'][] = $row;
|
||||
$groups[$key]['last_used_at'] = $this->maxDate(
|
||||
$groups[$key]['last_used_at'],
|
||||
$this->normalizeDate($row['used_at'] ?? null)
|
||||
);
|
||||
$groups[$key]['context_boost'] = max(
|
||||
$groups[$key]['context_boost'],
|
||||
$this->contextBoost($row, $customerId, $plates)
|
||||
);
|
||||
$groups[$key]['section'] = $this->bestSection(
|
||||
(string)$groups[$key]['section'],
|
||||
$section
|
||||
);
|
||||
}
|
||||
|
||||
$suggestions = [];
|
||||
foreach ($groups as $group) {
|
||||
$bestRow = $this->bestOriginRow($group['rows']);
|
||||
if ($bestRow === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = (string)($bestRow['source'] ?? 'order');
|
||||
$usageCount = (int)$group['usage_count'];
|
||||
$score = $this->matchScore((string)$group['reference'], $search)
|
||||
+ (int)$group['context_boost']
|
||||
+ $this->sectionScore((string)$group['section'])
|
||||
+ $this->sourceScore($source)
|
||||
+ min($usageCount, 20) * 5;
|
||||
|
||||
$suggestions[] = [
|
||||
'source' => $source,
|
||||
'section' => (string)$group['section'],
|
||||
'reference' => (string)$group['reference'],
|
||||
'source_created_at' => $this->normalizeDate($bestRow['source_created_at'] ?? null),
|
||||
'last_used_at' => $group['last_used_at'],
|
||||
'usage_count' => $usageCount,
|
||||
'origin_id' => (int)($bestRow['origin_id'] ?? 0),
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function bestOriginRow(array $rows): ?array
|
||||
{
|
||||
usort($rows, function (array $left, array $right): int {
|
||||
$sourceCompare = $this->sourceScore((string)($right['source'] ?? ''))
|
||||
<=> $this->sourceScore((string)($left['source'] ?? ''));
|
||||
if ($sourceCompare !== 0) {
|
||||
return $sourceCompare;
|
||||
}
|
||||
|
||||
$dateCompare = strcmp(
|
||||
(string)$this->normalizeDate($right['source_created_at'] ?? null),
|
||||
(string)$this->normalizeDate($left['source_created_at'] ?? null)
|
||||
);
|
||||
if ($dateCompare !== 0) {
|
||||
return $dateCompare;
|
||||
}
|
||||
|
||||
return ((int)($right['origin_id'] ?? 0)) <=> ((int)($left['origin_id'] ?? 0));
|
||||
});
|
||||
|
||||
return $rows[0] ?? null;
|
||||
}
|
||||
|
||||
private function sortSuggestions(array $left, array $right): int
|
||||
{
|
||||
$scoreCompare = ((int)($right['score'] ?? 0)) <=> ((int)($left['score'] ?? 0));
|
||||
if ($scoreCompare !== 0) {
|
||||
return $scoreCompare;
|
||||
}
|
||||
|
||||
$usageCompare = ((int)($right['usage_count'] ?? 0)) <=> ((int)($left['usage_count'] ?? 0));
|
||||
if ($usageCompare !== 0) {
|
||||
return $usageCompare;
|
||||
}
|
||||
|
||||
$sectionCompare = $this->sectionScore((string)($right['section'] ?? ''))
|
||||
<=> $this->sectionScore((string)($left['section'] ?? ''));
|
||||
if ($sectionCompare !== 0) {
|
||||
return $sectionCompare;
|
||||
}
|
||||
|
||||
$dateCompare = strcmp((string)($right['last_used_at'] ?? ''), (string)($left['last_used_at'] ?? ''));
|
||||
if ($dateCompare !== 0) {
|
||||
return $dateCompare;
|
||||
}
|
||||
|
||||
$referenceCompare = strcmp((string)($left['reference'] ?? ''), (string)($right['reference'] ?? ''));
|
||||
if ($referenceCompare !== 0) {
|
||||
return $referenceCompare;
|
||||
}
|
||||
|
||||
return $this->sourceScore((string)($right['source'] ?? '')) <=> $this->sourceScore((string)($left['source'] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $plates
|
||||
*/
|
||||
private function contextBoost(array $row, ?int $customerId, array $plates): int
|
||||
{
|
||||
$score = 0;
|
||||
if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) {
|
||||
$score += 80;
|
||||
}
|
||||
|
||||
if ($this->rowMatchesAnyPlate($row, $plates)) {
|
||||
$score += 90;
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $plates
|
||||
*/
|
||||
private function contextSection(array $row, ?int $customerId, array $plates): string
|
||||
{
|
||||
if ($this->rowMatchesAnyPlate($row, $plates)) {
|
||||
return 'this_vehicle';
|
||||
}
|
||||
|
||||
if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) {
|
||||
return 'other_customer_vehicle';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $plates
|
||||
*/
|
||||
private function rowMatchesAnyPlate(array $row, array $plates): bool
|
||||
{
|
||||
$rowPlates = $this->normalizePlates([
|
||||
$row['reg_1'] ?? '',
|
||||
$row['reg_2'] ?? '',
|
||||
$row['reg_3'] ?? '',
|
||||
]);
|
||||
|
||||
return $plates !== [] && array_intersect($plates, $rowPlates) !== [];
|
||||
}
|
||||
|
||||
private function matchScore(string $reference, string $search): int
|
||||
{
|
||||
if ($search === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$referenceKey = $this->lower($reference);
|
||||
$searchKey = $this->lower($search);
|
||||
|
||||
if ($referenceKey === $searchKey) {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
if (str_starts_with($referenceKey, $searchKey)) {
|
||||
return 600;
|
||||
}
|
||||
|
||||
if (str_contains($referenceKey, $searchKey)) {
|
||||
return 300;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function sourceScore(string $source): int
|
||||
{
|
||||
return match ($source) {
|
||||
'booking' => 30,
|
||||
'order' => 20,
|
||||
'vehicle' => 10,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
private function sectionScore(string $section): int
|
||||
{
|
||||
return match ($section) {
|
||||
'this_vehicle' => 40,
|
||||
'other_customer_vehicle' => 20,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
private function bestSection(string $left, string $right): string
|
||||
{
|
||||
return $this->sectionScore($right) > $this->sectionScore($left) ? $right : $left;
|
||||
}
|
||||
|
||||
private function clampLimit(mixed $value): int
|
||||
{
|
||||
$limit = $this->toPositiveInt($value) ?? self::DEFAULT_LIMIT;
|
||||
return max(1, min($limit, self::MAX_LIMIT));
|
||||
}
|
||||
|
||||
private function toPositiveInt(mixed $value): ?int
|
||||
{
|
||||
$parsed = filter_var($value, FILTER_VALIDATE_INT);
|
||||
return is_int($parsed) && $parsed > 0 ? $parsed : null;
|
||||
}
|
||||
|
||||
private function normalizeText(mixed $value): string
|
||||
{
|
||||
return trim((string)($value ?? ''));
|
||||
}
|
||||
|
||||
private function lower(string $value): string
|
||||
{
|
||||
return function_exists('mb_strtolower') ? mb_strtolower($value) : strtolower($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $values
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizePlates(array $values): array
|
||||
{
|
||||
$plates = [];
|
||||
foreach ($values as $value) {
|
||||
$plate = strtoupper(preg_replace('/\s+/', '', (string)($value ?? '')));
|
||||
if ($plate !== '') {
|
||||
$plates[] = $plate;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($plates));
|
||||
}
|
||||
|
||||
private function normalizeDate(mixed $value): ?string
|
||||
{
|
||||
$date = trim((string)($value ?? ''));
|
||||
return $date === '' || $date === '0000-00-00 00:00:00' ? null : $date;
|
||||
}
|
||||
|
||||
private function maxDate(?string $left, ?string $right): ?string
|
||||
{
|
||||
if ($left === null) {
|
||||
return $right;
|
||||
}
|
||||
if ($right === null) {
|
||||
return $left;
|
||||
}
|
||||
|
||||
return strcmp($right, $left) > 0 ? $right : $left;
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,39 @@ class orders_schema_bootstrap
|
||||
);
|
||||
}
|
||||
|
||||
self::backfillBookingPoDefaults($db);
|
||||
|
||||
self::ensureIndex($db, 'orders', 'idx_orders_period_customer_created_deleted', 'customer_id, created_at, deleted_at');
|
||||
self::ensureIndex($db, 'orders', 'idx_orders_period_created_deleted_customer', 'created_at, deleted_at, customer_id');
|
||||
self::ensureIndex($db, 'order_items', 'idx_order_items_order_deleted', 'order_id, deleted_at');
|
||||
self::ensureIndex($db, 'customer_attributes', 'idx_customer_attributes_attribute_user', 'attribute, user_id');
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function backfillBookingPoDefaults(object $db): void
|
||||
{
|
||||
if (
|
||||
!self::tableExists($db, 'order_bookings')
|
||||
|| !self::columnExists($db, 'orders', 'booking_id')
|
||||
|| !self::columnExists($db, 'orders', 'po')
|
||||
|| !self::columnExists($db, 'order_bookings', 'po')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"UPDATE orders o
|
||||
INNER JOIN order_bookings b ON b.id = o.booking_id
|
||||
SET o.po = b.po
|
||||
WHERE o.booking_id IS NOT NULL
|
||||
AND o.booking_id > 0
|
||||
AND (o.po IS NULL OR TRIM(o.po) = '')
|
||||
AND b.po IS NOT NULL
|
||||
AND TRIM(b.po) <> ''"
|
||||
);
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
@@ -69,6 +99,46 @@ class orders_schema_bootstrap
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function ensureIndex(object $db, string $table, string $index, string $columns): void
|
||||
{
|
||||
if (
|
||||
!self::tableExists($db, $table)
|
||||
|| self::indexExists($db, $table, $index)
|
||||
|| !self::columnsExist($db, $table, $columns)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = self::escapeIdentifier($table);
|
||||
$index = self::escapeIdentifier($index);
|
||||
$db->query("ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})");
|
||||
}
|
||||
|
||||
private static function columnsExist(object $db, string $table, string $columns): bool
|
||||
{
|
||||
foreach (explode(',', $columns) as $column) {
|
||||
$column = trim($column, " \t\n\r\0\x0B`");
|
||||
if ($column === '' || !self::columnExists($db, $table, $column)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function indexExists(object $db, string $table, string $index): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$index = self::escapeIdentifier($index);
|
||||
$result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escapeIdentifier(string $value): string
|
||||
{
|
||||
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for product metadata used outside the core product form.
|
||||
*/
|
||||
class products_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::tableExists($db, 'products')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'products', 'max_quantity_per_order')) {
|
||||
$db->query(
|
||||
"ALTER TABLE products
|
||||
ADD COLUMN max_quantity_per_order INT NULL DEFAULT NULL
|
||||
AFTER order_priority"
|
||||
);
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$table}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $table, string $column): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$column = self::escapeIdentifier($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escapeIdentifier(string $value): string
|
||||
{
|
||||
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
||||
}
|
||||
}
|
||||
@@ -364,6 +364,178 @@ class redis implements redis_i
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function cache_invoice_period_manual_flags(array $flags): self
|
||||
{
|
||||
$this->set_array('invoice_period_manual_flags', $flags);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get_invoice_period_manual_flags(): array|null
|
||||
{
|
||||
return $this->get_array('invoice_period_manual_flags');
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function clear_invoice_period_manual_flags(): self
|
||||
{
|
||||
$this->delete('invoice_period_manual_flags');
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function invoicePeriodCacheKey(string $prefix, string $dateFrom, string $dateTo): string
|
||||
{
|
||||
return $prefix . ':' . $dateFrom . ':' . $dateTo;
|
||||
}
|
||||
|
||||
private function workfeedEmployeeNameCacheKey(string $employeeId): string
|
||||
{
|
||||
return 'workfeed_employee_name:' . rawurlencode($employeeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function cache_invoice_period_automatic_flags(string $dateFrom, string $dateTo, array $flags): self
|
||||
{
|
||||
$this->set_array($this->invoicePeriodCacheKey('invoice_period_automatic_flags', $dateFrom, $dateTo), $flags);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get_invoice_period_automatic_flags(string $dateFrom, string $dateTo): array|null
|
||||
{
|
||||
return $this->get_array($this->invoicePeriodCacheKey('invoice_period_automatic_flags', $dateFrom, $dateTo));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function clear_invoice_period_automatic_flags(string $dateFrom, string $dateTo): self
|
||||
{
|
||||
$this->delete($this->invoicePeriodCacheKey('invoice_period_automatic_flags', $dateFrom, $dateTo));
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function cache_invoice_period_order_item_rows(string $dateFrom, string $dateTo, array $rows): self
|
||||
{
|
||||
$this->set_array($this->invoicePeriodCacheKey('invoice_period_order_item_rows', $dateFrom, $dateTo), $rows);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get_invoice_period_order_item_rows(string $dateFrom, string $dateTo): array|null
|
||||
{
|
||||
return $this->get_array($this->invoicePeriodCacheKey('invoice_period_order_item_rows', $dateFrom, $dateTo));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function clear_invoice_period_order_item_rows(string $dateFrom, string $dateTo): self
|
||||
{
|
||||
$this->delete($this->invoicePeriodCacheKey('invoice_period_order_item_rows', $dateFrom, $dateTo));
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function cache_workfeed_employee_name(string $employeeId, string $employeeName, int $ttl = 86400): self
|
||||
{
|
||||
$normalizedEmployeeId = trim($employeeId);
|
||||
if ($normalizedEmployeeId === '') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$normalizedEmployeeName = trim($employeeName);
|
||||
if ($normalizedEmployeeName === '') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$key = $this->workfeedEmployeeNameCacheKey($normalizedEmployeeId);
|
||||
$this->set($key, $normalizedEmployeeName);
|
||||
$this->expire($key, $ttl);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get_workfeed_employee_name(string $employeeId): string|null
|
||||
{
|
||||
$normalizedEmployeeId = trim($employeeId);
|
||||
if ($normalizedEmployeeId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $this->get($this->workfeedEmployeeNameCacheKey($normalizedEmployeeId));
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string)$value);
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function clear_workfeed_employee_name(string $employeeId): self
|
||||
{
|
||||
$normalizedEmployeeId = trim($employeeId);
|
||||
if ($normalizedEmployeeId === '') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->delete($this->workfeedEmployeeNameCacheKey($normalizedEmployeeId));
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function enqueue_invoice_period_warming(string $dateFrom, string $dateTo): self
|
||||
{
|
||||
$this->get_client()->sadd('invoice_period_warming_queue', [$dateFrom . '|' . $dateTo]);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function consume_invoice_period_warming_queue(): array
|
||||
{
|
||||
$client = $this->get_client();
|
||||
$members = $client->smembers('invoice_period_warming_queue');
|
||||
if (!empty($members)) {
|
||||
$client->del('invoice_period_warming_queue');
|
||||
}
|
||||
$periods = [];
|
||||
foreach ($members as $member) {
|
||||
$parts = explode('|', (string)$member, 2);
|
||||
if (count($parts) === 2 && $parts[0] !== '' && $parts[1] !== '') {
|
||||
$periods[] = ['dateFrom' => $parts[0], 'dateTo' => $parts[1]];
|
||||
}
|
||||
}
|
||||
return $periods;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
@@ -471,6 +643,10 @@ class redis implements redis_i
|
||||
|
||||
public function mget(array $array_map): array
|
||||
{
|
||||
if (empty($array_map)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get multiple keys from Redis
|
||||
return $this->redis->mget($array_map);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,460 @@
|
||||
<?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,
|
||||
device_type VARCHAR(16) NULL,
|
||||
browser_name VARCHAR(64) NULL,
|
||||
browser_version VARCHAR(64) NULL,
|
||||
os_name VARCHAR(64) NULL,
|
||||
os_version VARCHAR(64) NULL,
|
||||
viewport_width INT NULL,
|
||||
viewport_height INT NULL,
|
||||
device_pixel_ratio DECIMAL(6,3) NULL,
|
||||
frontend_version_label VARCHAR(128) NULL,
|
||||
frontend_commit_sha VARCHAR(128) NULL,
|
||||
api_version_label VARCHAR(128) NULL,
|
||||
api_commit_sha VARCHAR(128) NULL,
|
||||
last_route_path VARCHAR(255) 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_device (device_type),
|
||||
INDEX idx_release_timeline_release (frontend_version_label, api_version_label),
|
||||
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::ensureColumn('release_timeline_sessions', 'device_type', 'VARCHAR(16) NULL AFTER api_version_id');
|
||||
self::ensureColumn('release_timeline_sessions', 'browser_name', 'VARCHAR(64) NULL AFTER device_type');
|
||||
self::ensureColumn('release_timeline_sessions', 'browser_version', 'VARCHAR(64) NULL AFTER browser_name');
|
||||
self::ensureColumn('release_timeline_sessions', 'os_name', 'VARCHAR(64) NULL AFTER browser_version');
|
||||
self::ensureColumn('release_timeline_sessions', 'os_version', 'VARCHAR(64) NULL AFTER os_name');
|
||||
self::ensureColumn('release_timeline_sessions', 'viewport_width', 'INT NULL AFTER os_version');
|
||||
self::ensureColumn('release_timeline_sessions', 'viewport_height', 'INT NULL AFTER viewport_width');
|
||||
self::ensureColumn('release_timeline_sessions', 'device_pixel_ratio', 'DECIMAL(6,3) NULL AFTER viewport_height');
|
||||
self::ensureColumn('release_timeline_sessions', 'frontend_version_label', 'VARCHAR(128) NULL AFTER device_pixel_ratio');
|
||||
self::ensureColumn('release_timeline_sessions', 'frontend_commit_sha', 'VARCHAR(128) NULL AFTER frontend_version_label');
|
||||
self::ensureColumn('release_timeline_sessions', 'api_version_label', 'VARCHAR(128) NULL AFTER frontend_commit_sha');
|
||||
self::ensureColumn('release_timeline_sessions', 'api_commit_sha', 'VARCHAR(128) NULL AFTER api_version_label');
|
||||
self::ensureColumn('release_timeline_sessions', 'last_route_path', 'VARCHAR(255) NULL AFTER api_commit_sha');
|
||||
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_device', 'device_type');
|
||||
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_release', 'frontend_version_label, api_version_label');
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
private static function ensureIndex(string $table, string $index, string $columns): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
|
||||
if ($table === '' || $index === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexSql = $db->escape_string($index);
|
||||
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||
}
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class replication_bootstrap_config
|
||||
{
|
||||
public static function snapshotPath(): string
|
||||
{
|
||||
return self::storageDir() . DIRECTORY_SEPARATOR . 'replication-bootstrap.json';
|
||||
}
|
||||
|
||||
public static function loadSnapshot(?string $path = null): array
|
||||
{
|
||||
$path = $path ?: self::snapshotPath();
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)file_get_contents($path), true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
public static function writeSnapshot(array $snapshot, ?string $path = null): void
|
||||
{
|
||||
$path = $path ?: self::snapshotPath();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException('Could not create replication bootstrap snapshot directory.');
|
||||
}
|
||||
|
||||
$snapshot = array_replace([
|
||||
'version' => 1,
|
||||
'generated_at' => date('c'),
|
||||
], $snapshot);
|
||||
|
||||
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Could not encode replication bootstrap snapshot.');
|
||||
}
|
||||
|
||||
$tempPath = tempnam($dir, 'replication-bootstrap-');
|
||||
if ($tempPath === false) {
|
||||
throw new RuntimeException('Could not create replication bootstrap snapshot temp file.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (file_put_contents($tempPath, $json . PHP_EOL, LOCK_EX) === false) {
|
||||
throw new RuntimeException('Could not write replication bootstrap snapshot.');
|
||||
}
|
||||
|
||||
if (!rename($tempPath, $path)) {
|
||||
throw new RuntimeException('Could not atomically replace replication bootstrap snapshot.');
|
||||
}
|
||||
} finally {
|
||||
if (is_file($tempPath)) {
|
||||
@unlink($tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function applyToGlobals(array $snapshot): void
|
||||
{
|
||||
try {
|
||||
$active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : [];
|
||||
$database = self::activeDatabaseConfigFromSnapshot($active['database'] ?? null);
|
||||
$redis = self::activeRedisConfigFromSnapshot($active['redis'] ?? null);
|
||||
$minio = self::activeMinioConfigFromSnapshot($active['minio'] ?? null);
|
||||
|
||||
if ($database !== null) {
|
||||
$GLOBALS['CONFIG_DB'] = array_merge($GLOBALS['CONFIG_DB'] ?? [], $database);
|
||||
}
|
||||
|
||||
if ($redis !== null) {
|
||||
$GLOBALS['REDIS_CONFIG'] = array_merge($GLOBALS['REDIS_CONFIG'] ?? [], $redis);
|
||||
}
|
||||
|
||||
if ($minio !== null) {
|
||||
$GLOBALS['MINIO'] = array_merge($GLOBALS['MINIO'] ?? [], $minio);
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[replication-bootstrap] Falling back to environment configuration: ' . $throwable->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static function activeDatabaseConfigFromSnapshot(mixed $config): ?array
|
||||
{
|
||||
if (!is_array($config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = trim((string)($config['host'] ?? ''));
|
||||
$database = trim((string)($config['database'] ?? ''));
|
||||
$user = trim((string)($config['user'] ?? ''));
|
||||
if ($host === '' || $database === '' || $user === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'user' => $user,
|
||||
'password' => replication_secret_box::decrypt($config['password_secret'] ?? ''),
|
||||
'database' => $database,
|
||||
'port' => (int)($config['port'] ?? 3306) ?: 3306,
|
||||
'ssl_mode' => (string)($config['ssl_mode'] ?? 'DISABLED'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function activeRedisConfigFromSnapshot(mixed $config): ?array
|
||||
{
|
||||
if (!is_array($config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = trim((string)($config['host'] ?? ''));
|
||||
if ($host === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'user' => (string)($config['user'] ?? ''),
|
||||
'password' => replication_secret_box::decrypt($config['password_secret'] ?? ''),
|
||||
'database' => (int)($config['database'] ?? 0),
|
||||
'port' => (int)($config['port'] ?? 6379) ?: 6379,
|
||||
];
|
||||
}
|
||||
|
||||
public static function activeMinioConfigFromSnapshot(mixed $config): ?array
|
||||
{
|
||||
if (!is_array($config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$endpoint = trim((string)($config['endpoint'] ?? ''));
|
||||
$accessKey = trim((string)($config['access_key'] ?? $config['user'] ?? ''));
|
||||
if ($endpoint === '' || $accessKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'endpoint' => $endpoint,
|
||||
'access_key' => $accessKey,
|
||||
'secret_key' => replication_secret_box::decrypt($config['secret_key_secret'] ?? $config['password_secret'] ?? ''),
|
||||
'buckets' => is_array($config['buckets'] ?? null) ? array_values($config['buckets']) : ($config['buckets'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
private static function storageDir(): string
|
||||
{
|
||||
$root = defined('WD') ? WD : dirname(__DIR__);
|
||||
return $root . DIRECTORY_SEPARATOR . 'storage';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class replication_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS replication_hosts (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
label VARCHAR(128) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
port INT UNSIGNED NOT NULL,
|
||||
database_name VARCHAR(128) NULL,
|
||||
database_index INT NULL,
|
||||
username VARCHAR(128) NULL,
|
||||
password_secret TEXT NULL,
|
||||
admin_username VARCHAR(128) NULL,
|
||||
admin_password_secret TEXT NULL,
|
||||
replication_username VARCHAR(128) NULL,
|
||||
replication_password_secret TEXT NULL,
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'replica',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
||||
replication_source_id BIGINT UNSIGNED NULL,
|
||||
ssl_mode VARCHAR(32) NULL,
|
||||
options_json LONGTEXT NULL,
|
||||
last_status_json LONGTEXT NULL,
|
||||
last_checked_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_replication_hosts_kind_role (kind, role),
|
||||
INDEX idx_replication_hosts_kind_status (kind, status),
|
||||
INDEX idx_replication_hosts_source (replication_source_id),
|
||||
INDEX idx_replication_hosts_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS replication_operations (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
host_id BIGINT UNSIGNED NOT NULL,
|
||||
operation VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
progress_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
message VARCHAR(512) NULL,
|
||||
error_message TEXT NULL,
|
||||
context_json LONGTEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_replication_operations_host (host_id),
|
||||
INDEX idx_replication_operations_kind_status (kind, status),
|
||||
INDEX idx_replication_operations_operation (operation)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS replication_status_snapshots (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
host_id BIGINT UNSIGNED NOT NULL,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
replication_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
lag_seconds INT NULL,
|
||||
blockers_json LONGTEXT NULL,
|
||||
raw_status_json LONGTEXT NULL,
|
||||
checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_replication_status_host_checked (host_id, checked_at),
|
||||
INDEX idx_replication_status_kind_checked (kind, checked_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS replication_audit_logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
host_id BIGINT UNSIGNED NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
severity VARCHAR(16) NOT NULL DEFAULT 'info',
|
||||
context_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_replication_audit_kind_host (kind, host_id),
|
||||
INDEX idx_replication_audit_action (action),
|
||||
INDEX idx_replication_audit_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
self::ensureColumn('replication_operations', 'progress_percent', "DECIMAL(5,2) NOT NULL DEFAULT 0.00");
|
||||
self::ensureColumn('replication_operations', 'message', 'VARCHAR(512) NULL');
|
||||
self::ensureColumn('replication_operations', 'context_json', 'LONGTEXT NULL');
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class replication_secret_box
|
||||
{
|
||||
private const PREFIX = 'twsec:v1:';
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
|
||||
public static function encrypt(string $plaintext): string
|
||||
{
|
||||
$nonce = random_bytes(12);
|
||||
$tag = '';
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER,
|
||||
self::key(),
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag,
|
||||
'',
|
||||
16
|
||||
);
|
||||
|
||||
if ($ciphertext === false || $tag === '') {
|
||||
throw new RuntimeException('Secret encryption failed.');
|
||||
}
|
||||
|
||||
return self::PREFIX . base64_encode(json_encode([
|
||||
'nonce' => base64_encode($nonce),
|
||||
'tag' => base64_encode($tag),
|
||||
'ciphertext' => base64_encode($ciphertext),
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
public static function decrypt(?string $secret): string
|
||||
{
|
||||
$secret = (string)$secret;
|
||||
if ($secret === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!str_starts_with($secret, self::PREFIX)) {
|
||||
return $secret;
|
||||
}
|
||||
|
||||
$payload = json_decode(base64_decode(substr($secret, strlen(self::PREFIX)), true) ?: '', true);
|
||||
if (!is_array($payload)) {
|
||||
throw new RuntimeException('Encrypted secret payload is invalid.');
|
||||
}
|
||||
|
||||
$nonce = base64_decode((string)($payload['nonce'] ?? ''), true);
|
||||
$tag = base64_decode((string)($payload['tag'] ?? ''), true);
|
||||
$ciphertext = base64_decode((string)($payload['ciphertext'] ?? ''), true);
|
||||
|
||||
if ($nonce === false || $tag === false || $ciphertext === false) {
|
||||
throw new RuntimeException('Encrypted secret payload is incomplete.');
|
||||
}
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER,
|
||||
self::key(),
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('Secret decryption failed.');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
public static function mask(?string $value): string
|
||||
{
|
||||
$value = (string)$value;
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$length = strlen($value);
|
||||
if ($length <= 4) {
|
||||
return str_repeat('*', $length);
|
||||
}
|
||||
|
||||
return substr($value, 0, 2) . str_repeat('*', max(4, $length - 4)) . substr($value, -2);
|
||||
}
|
||||
|
||||
private static function key(): string
|
||||
{
|
||||
$keyMaterial = (string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: '');
|
||||
if (trim($keyMaterial) === '') {
|
||||
throw new RuntimeException('ENCRYPTION_KEY is required for replication secret encryption.');
|
||||
}
|
||||
|
||||
return hash('sha256', $keyMaterial, true);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ class response implements response_i
|
||||
private array $meta = [];
|
||||
private array $includes = [];
|
||||
private users_o $users_o;
|
||||
private ?array $jsonRequestBody = null;
|
||||
|
||||
#[NoReturn] public function success(mixed $data, int $status = null): void
|
||||
{
|
||||
@@ -47,6 +48,11 @@ class response implements response_i
|
||||
'data' => $this->get_data()
|
||||
]);
|
||||
}
|
||||
try {
|
||||
release_manager::recordBackendFailure($success, $data, $status ?? ($success ? 200 : 400));
|
||||
} catch (\Throwable) {
|
||||
// Release failure telemetry is best-effort and must not block responses.
|
||||
}
|
||||
echo json_encode([
|
||||
'success' => $success,
|
||||
'data' => $data,
|
||||
@@ -175,35 +181,7 @@ class response implements response_i
|
||||
|
||||
public function getRequestParameter(string $key): mixed
|
||||
{
|
||||
$data = [];
|
||||
// Get the request data if the method is POST, PUT or PATCH
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
}
|
||||
// Get the request data if the method is GET, DELETE or OPTIONS
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
$data = $_GET;
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
|
||||
// If the data key is not set, try to get it from the opposite method
|
||||
if (!array_key_exists($key, $data)) {
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
|
||||
$data = $_GET;
|
||||
} else {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
|
||||
// Return the data
|
||||
return $data[$key] ?? null;
|
||||
return $this->requestParametersForMethod()[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,21 +190,7 @@ class response implements response_i
|
||||
*/
|
||||
public function getAllRequestParameters(): array
|
||||
{
|
||||
$data = [];
|
||||
// Get the request data if the method is POST, PUT or PATCH
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
}
|
||||
// Get the request data if the method is GET, DELETE or OPTIONS
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
$data = $_GET;
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $data;
|
||||
return $this->requestParametersForMethod();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,21 +201,44 @@ class response implements response_i
|
||||
*/
|
||||
public function isRequestParameterSet(string $key): bool
|
||||
{
|
||||
$data = [];
|
||||
// Get the request data if the method is POST, PUT or PATCH
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
}
|
||||
// Get the request data if the method is GET, DELETE or OPTIONS
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
$data = $_GET;
|
||||
return array_key_exists($key, $this->requestParametersForMethod());
|
||||
}
|
||||
|
||||
private function requestParametersForMethod(): array
|
||||
{
|
||||
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
|
||||
if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
|
||||
return array_replace($_GET, $this->jsonRequestBody());
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
return false;
|
||||
if ($method === 'DELETE') {
|
||||
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
|
||||
|
||||
@@ -114,6 +114,31 @@ class selfserve_schema_bootstrap
|
||||
INDEX idx_selfserve_wash_session_events_session (session_id),
|
||||
INDEX idx_selfserve_wash_session_events_type (event_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS department_selfserve_studio_layouts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
department_id INT NOT NULL,
|
||||
user_id INT NULL,
|
||||
layout_json JSON NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_department_selfserve_studio_layouts_department_user (department_id, user_id),
|
||||
INDEX idx_department_selfserve_studio_layouts_department_updated (department_id, updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS department_selfserve_studio_virtual_hardware (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
department_id INT NOT NULL,
|
||||
config_json JSON NOT NULL,
|
||||
created_by INT NULL,
|
||||
updated_by INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
UNIQUE KEY uniq_selfserve_vhw_department (department_id),
|
||||
INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
@@ -125,6 +150,11 @@ class selfserve_schema_bootstrap
|
||||
'machine_type_id',
|
||||
'ALTER TABLE department_lanes ADD COLUMN machine_type_id INT NULL AFTER dynamic_image_id'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'department_lanes',
|
||||
'selfserve_enabled',
|
||||
'ALTER TABLE department_lanes ADD COLUMN selfserve_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER machine_type_id'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'department_selfserve_conditions',
|
||||
'machine_type_id',
|
||||
|
||||
@@ -16,6 +16,10 @@ class shelly implements shelly_i
|
||||
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
|
||||
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
|
||||
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
|
||||
/**
|
||||
* @var array<int,array<string,mixed>>
|
||||
*/
|
||||
private static array $blocked_request_log = [];
|
||||
|
||||
/**
|
||||
* Configuration of the shelly module
|
||||
@@ -38,6 +42,19 @@ class shelly implements shelly_i
|
||||
$this->shelly_search = new shelly_search_a();
|
||||
}
|
||||
|
||||
public static function resetBlockedRequestLog(): void
|
||||
{
|
||||
self::$blocked_request_log = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public static function blockedRequestLog(): array
|
||||
{
|
||||
return self::$blocked_request_log;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @throws Exception If the module is not enabled, the license plate is invalid, the daily limit is exceeded, or the secret key is invalid
|
||||
@@ -146,6 +163,7 @@ class shelly implements shelly_i
|
||||
* -H 'Content-Type: application/json' \
|
||||
* -d '<BODY>'
|
||||
*/
|
||||
$this->guardRealShellyRequest('POST', $endpoint, $data);
|
||||
// Require the module to be enabled
|
||||
self::requireModuleEnabled();
|
||||
self::requireValidSecretKey();
|
||||
@@ -192,6 +210,7 @@ class shelly implements shelly_i
|
||||
*/
|
||||
function sendGetRequest(string $endpoint, array $data): array|object|null
|
||||
{
|
||||
$this->guardRealShellyRequest('GET', $endpoint, $data);
|
||||
self::requireModuleEnabled();
|
||||
self::requireValidSecretKey();
|
||||
self::requireValidServerURL();
|
||||
@@ -238,6 +257,40 @@ class shelly implements shelly_i
|
||||
return $url . $separator . http_build_query($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $data
|
||||
* @throws Exception
|
||||
*/
|
||||
private function guardRealShellyRequest(string $method, string $endpoint, array $data): void
|
||||
{
|
||||
if (!$this->shouldBlockRealShellyRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record = [
|
||||
'method' => strtoupper($method),
|
||||
'endpoint' => $endpoint,
|
||||
'data' => $data,
|
||||
'at' => date('c'),
|
||||
];
|
||||
self::$blocked_request_log[] = $record;
|
||||
|
||||
$log_path = trim((string)(getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG') ?: ''));
|
||||
if ($log_path !== '') {
|
||||
$encoded = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (is_string($encoded)) {
|
||||
@file_put_contents($log_path, $encoded . PHP_EOL, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Exception('Real Shelly requests are blocked in test mode: ' . strtoupper($method) . ' ' . $endpoint);
|
||||
}
|
||||
|
||||
private function shouldBlockRealShellyRequest(): bool
|
||||
{
|
||||
return trim((string)(getenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY') ?: '')) === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
|
||||
@@ -190,7 +190,12 @@ class shelly_relay_inventory
|
||||
$device['_dev_info']['code'] ?? null,
|
||||
$device['code'] ?? null,
|
||||
]);
|
||||
$device_model = $this->extractDeviceModel($device, $device_code, $catalog_entry);
|
||||
if ($device_code === '' && $device_model !== null) {
|
||||
$device_code = $device_model;
|
||||
}
|
||||
$device_type = $this->extractDeviceType($device, $device_code, $catalog_entry);
|
||||
$device_generation = $this->extractDeviceGeneration($device, $device_code, $device_type, $catalog_entry);
|
||||
$control_type = $this->extractControlType($device);
|
||||
$control_name = $this->extractControlName($device);
|
||||
$online = $this->extractOnlineState($device);
|
||||
@@ -198,6 +203,7 @@ class shelly_relay_inventory
|
||||
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
|
||||
}
|
||||
$status_color = $this->extractStatusColor($online);
|
||||
$local_ip = $this->extractLocalIp($device, $catalog_entry);
|
||||
|
||||
return [
|
||||
'id' => $device_id,
|
||||
@@ -213,8 +219,11 @@ class shelly_relay_inventory
|
||||
'cloud_name' => $cloud_name !== '' ? $cloud_name : null,
|
||||
'device_type' => $device_type,
|
||||
'code' => $device_code !== '' ? $device_code : null,
|
||||
'device_model' => $device_model,
|
||||
'device_generation' => $device_generation,
|
||||
'control_type' => $control_type,
|
||||
'control_name' => $control_name !== '' ? $control_name : null,
|
||||
'local_ip' => $local_ip,
|
||||
'status_color' => $status_color,
|
||||
'online' => $online,
|
||||
];
|
||||
@@ -293,19 +302,155 @@ class shelly_relay_inventory
|
||||
*/
|
||||
private function extractDeviceType(array $device, string $device_code, ?array $catalog_entry = null): ?string
|
||||
{
|
||||
$device_type = $this->extractFirstString([
|
||||
$device['_dev_info']['model'] ?? null,
|
||||
$candidates = [
|
||||
$device['_dev_info']['type'] ?? null,
|
||||
$device['model'] ?? null,
|
||||
$device['type'] ?? null,
|
||||
$device['settings']['device']['type'] ?? null,
|
||||
$device['_dev_info']['model'] ?? null,
|
||||
$device['model'] ?? null,
|
||||
$device['_dev_info']['app'] ?? null,
|
||||
$device['app'] ?? null,
|
||||
$catalog_entry['type'] ?? null,
|
||||
$device_code,
|
||||
]);
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$normalized = trim((string)($candidate ?? ''));
|
||||
if ($normalized !== '' && !$this->looksLikeShellyModelCode($normalized)) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
$device_type = $this->extractFirstString($candidates);
|
||||
|
||||
return $device_type !== '' ? $device_type : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $device
|
||||
*/
|
||||
private function extractDeviceModel(array $device, string $device_code, ?array $catalog_entry = null): ?string
|
||||
{
|
||||
$candidates = [
|
||||
$device_code,
|
||||
$device['_dev_info']['model'] ?? null,
|
||||
$device['model'] ?? null,
|
||||
$catalog_entry['type'] ?? null,
|
||||
$catalog_entry['model'] ?? null,
|
||||
$device['_dev_info']['type'] ?? null,
|
||||
$device['type'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$normalized = trim((string)($candidate ?? ''));
|
||||
if ($normalized !== '' && $this->looksLikeShellyModelCode($normalized)) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $device
|
||||
*/
|
||||
private function extractDeviceGeneration(
|
||||
array $device,
|
||||
string $device_code,
|
||||
?string $device_type,
|
||||
?array $catalog_entry = null
|
||||
): ?int {
|
||||
foreach ([
|
||||
$device['_dev_info']['gen'] ?? null,
|
||||
$device['_dev_info']['generation'] ?? null,
|
||||
$device['gen'] ?? null,
|
||||
$device['generation'] ?? null,
|
||||
$device['settings']['device']['gen'] ?? null,
|
||||
$device['settings']['device']['generation'] ?? null,
|
||||
$device['status']['sys']['gen'] ?? null,
|
||||
$device['status']['sys']['generation'] ?? null,
|
||||
$catalog_entry['gen'] ?? null,
|
||||
$catalog_entry['generation'] ?? null,
|
||||
] as $candidate) {
|
||||
$generation = $this->normalizeDeviceGeneration($candidate);
|
||||
if ($generation !== null) {
|
||||
return $generation;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
$device_code,
|
||||
$device_type,
|
||||
$device['_dev_info']['model'] ?? null,
|
||||
$device['_dev_info']['type'] ?? null,
|
||||
$device['_dev_info']['app'] ?? null,
|
||||
$device['model'] ?? null,
|
||||
$device['type'] ?? null,
|
||||
$device['app'] ?? null,
|
||||
$catalog_entry['type'] ?? null,
|
||||
$catalog_entry['model'] ?? null,
|
||||
] as $candidate) {
|
||||
$generation = $this->inferDeviceGenerationFromString((string)($candidate ?? ''));
|
||||
if ($generation !== null) {
|
||||
return $generation;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeDeviceGeneration(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return $value > 0 ? $value : null;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
$generation = (int)$value;
|
||||
return $generation > 0 ? $generation : null;
|
||||
}
|
||||
|
||||
return $this->inferDeviceGenerationFromString((string)($value ?? ''));
|
||||
}
|
||||
|
||||
private function inferDeviceGenerationFromString(string $value): ?int
|
||||
{
|
||||
$normalized = trim($value);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $matches) === 1) {
|
||||
return (int)$matches[1];
|
||||
}
|
||||
|
||||
$upper = strtoupper($normalized);
|
||||
if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $matches) === 1) {
|
||||
return (int)$matches[1];
|
||||
}
|
||||
|
||||
if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1
|
||||
|| preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function looksLikeShellyModelCode(string $value): bool
|
||||
{
|
||||
$normalized = strtoupper(trim($value));
|
||||
if ($normalized === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('/^(?:S[1-9]|SP|SN|SH)[A-Z0-9]+-[A-Z0-9-]+$/', $normalized) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $device
|
||||
*/
|
||||
@@ -352,6 +497,44 @@ class shelly_relay_inventory
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $device
|
||||
* @param array<string,mixed>|null $catalog_entry
|
||||
*/
|
||||
private function extractLocalIp(array $device, ?array $catalog_entry = null): ?string
|
||||
{
|
||||
$candidates = [
|
||||
$device['local_ip'] ?? null,
|
||||
$device['localIp'] ?? null,
|
||||
$device['ip'] ?? null,
|
||||
$device['_dev_info']['local_ip'] ?? null,
|
||||
$device['_dev_info']['localIp'] ?? null,
|
||||
$device['_dev_info']['ip'] ?? null,
|
||||
$device['wifi_sta']['ip'] ?? null,
|
||||
$device['wifi']['ip'] ?? null,
|
||||
$device['eth']['ip'] ?? null,
|
||||
$device['status']['wifi_sta']['ip'] ?? null,
|
||||
$device['status']['wifi']['ip'] ?? null,
|
||||
$device['status']['eth']['ip'] ?? null,
|
||||
$device['status']['sta_ip'] ?? null,
|
||||
$device['settings']['wifi_sta']['ip'] ?? null,
|
||||
$device['settings']['wifi']['ip'] ?? null,
|
||||
$device['settings']['eth']['ip'] ?? null,
|
||||
$catalog_entry['local_ip'] ?? null,
|
||||
$catalog_entry['localIp'] ?? null,
|
||||
$catalog_entry['ip'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$normalized = trim((string)($candidate ?? ''));
|
||||
if ($normalized !== '' && filter_var($normalized, FILTER_VALIDATE_IP) !== false) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function buildRelayLabel(
|
||||
?string $device_type,
|
||||
string $cloud_name,
|
||||
|
||||
@@ -17,8 +17,22 @@ class shelly_transport_resolver
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolveForDepartment(int $departmentId): shelly_transport_i
|
||||
public function resolveForDepartment(int $departmentId, ?string $transportOverride = null): shelly_transport_i
|
||||
{
|
||||
$override = strtolower(trim((string)$transportOverride));
|
||||
$explicitLocalOverride = in_array($override, ['local', edge_gateway_manager::TRANSPORT_MODE_GATEWAY], true);
|
||||
if ($override === 'local') {
|
||||
$override = edge_gateway_manager::TRANSPORT_MODE_GATEWAY;
|
||||
}
|
||||
|
||||
if ($override === edge_gateway_manager::TRANSPORT_MODE_GATEWAY) {
|
||||
return $this->gatewayTransport ?? new gateway_shelly_transport($this->manager(), $explicitLocalOverride);
|
||||
}
|
||||
|
||||
if ($override === edge_gateway_manager::TRANSPORT_MODE_CLOUD) {
|
||||
return $this->cloudTransport ?? new cloud_shelly_transport();
|
||||
}
|
||||
|
||||
$mode = $this->manager()->getDepartmentTransportMode($departmentId);
|
||||
if ($mode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY) {
|
||||
return $this->gatewayTransport ?? new gateway_shelly_transport($this->manager());
|
||||
|
||||
@@ -27,6 +27,9 @@ class superuser_system_status_service
|
||||
$dependencies['database']['status'] ?? 'down',
|
||||
$dependencies['redis']['status'] ?? 'down',
|
||||
$dependencies['minio']['status'] ?? 'down',
|
||||
$dependencies['database']['replication']['status'] ?? 'not_configured',
|
||||
$dependencies['redis']['replication']['status'] ?? 'not_configured',
|
||||
$dependencies['minio']['replication']['status'] ?? 'not_configured',
|
||||
];
|
||||
foreach ($modules as $module) {
|
||||
if (($module['enabled'] ?? false) === true) {
|
||||
@@ -295,6 +298,16 @@ class superuser_system_status_service
|
||||
$database = $this->probeDatabase();
|
||||
$redis = $this->probeRedis();
|
||||
$minio = $this->probeMinio();
|
||||
try {
|
||||
$replicationManager = new replication_manager();
|
||||
$database['replication'] = $replicationManager->dependencyReplication('database');
|
||||
$redis['replication'] = $replicationManager->dependencyReplication('redis');
|
||||
$minio['replication'] = $replicationManager->dependencyReplication('minio');
|
||||
} catch (Throwable $throwable) {
|
||||
$database['replication'] = $this->replicationStatusFallback('database', $throwable);
|
||||
$redis['replication'] = $this->replicationStatusFallback('redis', $throwable);
|
||||
$minio['replication'] = $this->replicationStatusFallback('minio', $throwable);
|
||||
}
|
||||
|
||||
if (($redis['status'] ?? '') === 'down') {
|
||||
$this->pushWarning(
|
||||
@@ -320,6 +333,19 @@ class superuser_system_status_service
|
||||
];
|
||||
}
|
||||
|
||||
private function replicationStatusFallback(string $kind, Throwable $throwable): array
|
||||
{
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'min_percent' => 0.0,
|
||||
'average_percent' => 0.0,
|
||||
'replicas' => [],
|
||||
'blockers' => [
|
||||
'Replication status for ' . $kind . ' could not be loaded: ' . $throwable->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function probeCpu(array &$warnings): array
|
||||
{
|
||||
$checkedAt = date('c');
|
||||
@@ -503,7 +529,7 @@ class superuser_system_status_service
|
||||
|
||||
protected function minioBuckets(): array
|
||||
{
|
||||
return ['attachments', 'backups', 'invoices', 'pdfs', 'uploads', 'truckwashdev'];
|
||||
return replication_manager::normalizeMinioBuckets($GLOBALS['MINIO']['buckets'] ?? ['attachments', 'backups', 'invoices', 'pdfs', 'uploads', 'truckwashdev']);
|
||||
}
|
||||
|
||||
protected function collectModules(bool $force, array &$warnings): array
|
||||
@@ -816,11 +842,89 @@ class superuser_system_status_service
|
||||
['key' => 'licenseplaterecognizer', 'module' => 'licenseplaterecognizer', 'enabled_variable' => 'enabled', 'required' => ['api_key'], 'probe' => fn(array $config): array => $this->probeLicensePlateRecognizerModule($config)],
|
||||
['key' => 'virkdata', 'module' => 'virkdata', 'enabled_variable' => 'enabled', 'required' => ['secret_key']],
|
||||
['key' => 'shelly', 'module' => 'shelly', 'enabled_variable' => 'enabled', 'required' => ['server_url', 'secret_key'], 'probe' => fn(array $config): array => $this->probeShellyModule($config)],
|
||||
['key' => 'coolify', 'module' => 'Coolify', 'enabled_variable' => 'enabled', 'required' => [], 'probe' => fn(array $config): array => $this->probeCoolifyModule($config)],
|
||||
['key' => 'releasemanager', 'module' => 'ReleaseManager', 'enabled_variable' => 'enabled', 'required' => [], 'always_enabled' => true, 'probe' => fn(array $config): array => $this->probeReleaseManagerModule($config)],
|
||||
['key' => 'selfserve', 'module' => 'selfserve', 'enabled_variable' => 'enabled', 'required' => ['machine_wash_minutes_included', 'minute_product'], 'probe' => fn(array $config): array => $this->probeSelfserveModule($config)],
|
||||
['key' => 'bird', 'module' => 'bird', 'enabled_variable' => 'enabled', 'required' => ['server_url', 'api_key', 'channelId', 'workplaceId'], 'probe' => fn(array $config): array => $this->probeBirdModule($config)],
|
||||
];
|
||||
}
|
||||
|
||||
protected function probeReleaseManagerModule(array $config): array
|
||||
{
|
||||
return (new release_manager())->healthProbe();
|
||||
}
|
||||
|
||||
protected function probeCoolifyModule(array $config): array
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
|
||||
try {
|
||||
$summary = (new coolify_manager())->summary();
|
||||
$instances = is_array($summary['instances'] ?? null) ? $summary['instances'] : [];
|
||||
$targets = is_array($summary['targets'] ?? null) ? $summary['targets'] : [];
|
||||
|
||||
if ($instances === []) {
|
||||
return [
|
||||
'status' => 'not_configured',
|
||||
'status_reason' => 'Coolify is enabled, but no Coolify API instance is configured.',
|
||||
'status_reason_key' => 'coolify_instances_missing',
|
||||
'status_reason_params' => [],
|
||||
'checked_at' => date('c'),
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
}
|
||||
|
||||
$downInstances = array_values(array_filter($instances, static fn(array $instance): bool => ($instance['status'] ?? 'unknown') === 'down'));
|
||||
$blockedTargets = array_values(array_filter($targets, static function (array $target): bool {
|
||||
$state = (string)($target['availability_state'] ?? 'degraded');
|
||||
return $state === 'destructive_action_required' || str_contains($state, 'blocked');
|
||||
}));
|
||||
$failedTargets = array_values(array_filter($targets, static function (array $target): bool {
|
||||
return in_array((string)($target['deployment_status'] ?? ''), ['reconcile_failed', 'restart_failed', 'provision_blocked'], true);
|
||||
}));
|
||||
|
||||
$status = 'ok';
|
||||
$reason = 'Coolify deployment state is available.';
|
||||
$reasonKey = 'coolify_available';
|
||||
if ($downInstances !== []) {
|
||||
$status = 'down';
|
||||
$reason = 'One or more Coolify API instances are unreachable.';
|
||||
$reasonKey = 'coolify_instances_down';
|
||||
} elseif ($blockedTargets !== [] || $failedTargets !== []) {
|
||||
$status = 'degraded';
|
||||
$reason = 'One or more Coolify targets need operator attention before availability can be protected.';
|
||||
$reasonKey = 'coolify_targets_need_attention';
|
||||
} elseif ($targets === []) {
|
||||
$status = 'degraded';
|
||||
$reason = 'Coolify is connected, but no replicated infrastructure targets are managed yet.';
|
||||
$reasonKey = 'coolify_targets_missing';
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'status_reason' => $reason,
|
||||
'status_reason_key' => $reasonKey,
|
||||
'status_reason_params' => [
|
||||
'instances' => count($instances),
|
||||
'targets' => count($targets),
|
||||
'blocked_targets' => count($blockedTargets),
|
||||
'failed_targets' => count($failedTargets),
|
||||
],
|
||||
'checked_at' => date('c'),
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
} catch (Throwable $throwable) {
|
||||
return [
|
||||
'status' => 'down',
|
||||
'status_reason' => 'Coolify module probe failed: ' . $throwable->getMessage(),
|
||||
'status_reason_key' => 'coolify_probe_failed',
|
||||
'status_reason_params' => ['error' => $throwable->getMessage()],
|
||||
'checked_at' => date('c'),
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
protected function probeEconomicModule(array $config): array
|
||||
{
|
||||
$appSecretToken = trim((string)($GLOBALS['ECONOMIC_API']['app_secret_token'] ?? ''));
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
final class workfeed_employee_name_formatter
|
||||
{
|
||||
/**
|
||||
* @param array<int,string> $firstNamePaths
|
||||
* @param array<int,string> $lastNamePaths
|
||||
* @param array<int,string> $fallbackNamePaths
|
||||
*/
|
||||
public static function fromRecord(
|
||||
mixed $record,
|
||||
array $firstNamePaths,
|
||||
array $lastNamePaths,
|
||||
array $fallbackNamePaths = [],
|
||||
?string $employeeId = null
|
||||
): ?string {
|
||||
$firstName = self::firstTextValueByPath($record, $firstNamePaths);
|
||||
$lastName = self::firstTextValueByPath($record, $lastNamePaths);
|
||||
|
||||
$schemaName = self::joinNameParts($firstName, $lastName);
|
||||
if ($schemaName !== null) {
|
||||
return $schemaName;
|
||||
}
|
||||
|
||||
foreach ($fallbackNamePaths as $path) {
|
||||
$name = self::normalizeTextValue(self::valueByPath($record, $path));
|
||||
if ($name !== null && !self::isMissingDisplayName($name, $employeeId)) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function isMissingDisplayName(?string $employeeName, ?string $employeeId = null): bool
|
||||
{
|
||||
if ($employeeName === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($employeeId !== null && strcasecmp($employeeName, 'Employee ' . $employeeId) === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return strcasecmp($employeeName, 'Unknown employee') === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $paths
|
||||
*/
|
||||
private static function firstTextValueByPath(mixed $record, array $paths): ?string
|
||||
{
|
||||
foreach ($paths as $path) {
|
||||
$value = self::normalizeTextValue(self::valueByPath($record, $path));
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function joinNameParts(?string $firstName, ?string $lastName): ?string
|
||||
{
|
||||
$name = trim((string)($firstName ?? '') . ' ' . (string)($lastName ?? ''));
|
||||
|
||||
return $name !== '' ? $name : null;
|
||||
}
|
||||
|
||||
private static function valueByPath(mixed $record, string $path): mixed
|
||||
{
|
||||
$segments = explode('.', $path);
|
||||
$value = $record;
|
||||
foreach ($segments as $segment) {
|
||||
if (is_array($value) && array_key_exists($segment, $value)) {
|
||||
$value = $value[$segment];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_object($value) && isset($value->{$segment})) {
|
||||
$value = $value->{$segment};
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function normalizeTextValue(mixed $value): ?string
|
||||
{
|
||||
if (!is_scalar($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string)$value);
|
||||
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
}
|
||||
@@ -21,16 +21,27 @@ final class workfeed_shift_time_resolver
|
||||
return null;
|
||||
}
|
||||
|
||||
$has_approval = self::hasShiftApproval($record);
|
||||
$update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
|
||||
$can_use_saved_bounds = $has_approval || $update_time !== null;
|
||||
|
||||
$actual_start = self::firstDateTimeFromPaths($record, [
|
||||
'checkIn.time',
|
||||
'checkIn',
|
||||
'actualStart',
|
||||
'actualStartTime',
|
||||
'clockIn',
|
||||
'clockInTime',
|
||||
'start',
|
||||
'startTime',
|
||||
'from',
|
||||
'approval.originalStart',
|
||||
]);
|
||||
if ($actual_start === null && $can_use_saved_bounds) {
|
||||
$actual_start = self::firstDateTimeFromPaths($record, [
|
||||
'start',
|
||||
'startTime',
|
||||
'from',
|
||||
]);
|
||||
}
|
||||
|
||||
$scheduled_end = self::firstDateTimeFromPaths($record, [
|
||||
'approval.originalEnd',
|
||||
'end',
|
||||
@@ -38,17 +49,33 @@ final class workfeed_shift_time_resolver
|
||||
'to',
|
||||
]);
|
||||
$actual_only_end = self::firstDateTimeFromPaths($record, [
|
||||
'checkOut.time',
|
||||
'checkOut',
|
||||
'actualEnd',
|
||||
'actualEndTime',
|
||||
'clockOut',
|
||||
'clockOutTime',
|
||||
]);
|
||||
$current_end = self::firstDateTimeFromPaths($record, [
|
||||
'end',
|
||||
'endTime',
|
||||
'to',
|
||||
$check_in_punch = self::firstDateTimeFromPaths($record, [
|
||||
'checkIn.time',
|
||||
'checkIn',
|
||||
]);
|
||||
$saved_actual_end = $actual_only_end ?? $current_end;
|
||||
$check_out_punch = self::firstDateTimeFromPaths($record, [
|
||||
'checkOut.time',
|
||||
'checkOut',
|
||||
]);
|
||||
$saved_actual_end = $actual_only_end;
|
||||
if ($saved_actual_end === null && $can_use_saved_bounds) {
|
||||
$saved_actual_end = self::firstDateTimeFromPaths($record, [
|
||||
'end',
|
||||
'endTime',
|
||||
'to',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($saved_actual_end === null && $check_in_punch !== null && $check_out_punch === null) {
|
||||
$saved_actual_end = new DateTime();
|
||||
}
|
||||
|
||||
if ($actual_start === null || $scheduled_end === null || $saved_actual_end === null) {
|
||||
return null;
|
||||
@@ -63,7 +90,7 @@ final class workfeed_shift_time_resolver
|
||||
'actualStart' => $actual_start,
|
||||
'scheduledEnd' => $scheduled_end,
|
||||
'actualEnd' => $actual_end,
|
||||
'hasApproval' => self::hasShiftApproval($record),
|
||||
'hasApproval' => $has_approval,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for XL Vask usage-log review state.
|
||||
*/
|
||||
class xlvask_usage_logs_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::tableExists($db, 'xlvask_usage_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_by', 'INT NULL AFTER ignored_at');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_reason', 'TEXT NULL AFTER ignored_by');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_total_net_amount', 'DECIMAL(12,2) NULL AFTER ignored_reason');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_primary_product_name', 'VARCHAR(255) NULL AFTER cached_total_net_amount');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_amount_at', 'DATETIME NULL AFTER cached_primary_product_name');
|
||||
self::ensureAutomationTables($db);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureAutomationTables(object $db): void
|
||||
{
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_suggestions` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`usage_log_id` INT NOT NULL,
|
||||
`wash_id` VARCHAR(128) NOT NULL,
|
||||
`signature_hash` CHAR(64) NOT NULL,
|
||||
`signature_json` LONGTEXT NULL,
|
||||
`action` VARCHAR(32) NOT NULL,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'suggested',
|
||||
`confidence` DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
|
||||
`source` VARCHAR(32) NOT NULL DEFAULT 'deterministic',
|
||||
`matched_order_id` INT NULL,
|
||||
`created_order_id` INT NULL,
|
||||
`proposed_order_json` LONGTEXT NULL,
|
||||
`candidate_order_json` LONGTEXT NULL,
|
||||
`reason` TEXT NULL,
|
||||
`created_by` INT NULL,
|
||||
`decided_by` INT NULL,
|
||||
`decided_at` DATETIME NULL,
|
||||
`executed_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_xlvask_automation_usage` (`usage_log_id`),
|
||||
KEY `idx_xlvask_automation_wash` (`wash_id`),
|
||||
KEY `idx_xlvask_automation_signature` (`signature_hash`),
|
||||
KEY `idx_xlvask_automation_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`usage_log_id` INT NULL,
|
||||
`wash_id` VARCHAR(128) NULL,
|
||||
`signature_hash` CHAR(64) NOT NULL,
|
||||
`signature_json` LONGTEXT NULL,
|
||||
`action` VARCHAR(32) NOT NULL,
|
||||
`decision` VARCHAR(32) NOT NULL,
|
||||
`order_id` INT NULL,
|
||||
`reason` TEXT NULL,
|
||||
`created_by` INT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_xlvask_feedback_signature_action` (`signature_hash`, `action`),
|
||||
KEY `idx_xlvask_feedback_usage` (`usage_log_id`),
|
||||
KEY `idx_xlvask_feedback_decision` (`decision`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_openai_cache` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`cache_key` CHAR(64) NOT NULL,
|
||||
`schema_name` VARCHAR(96) NOT NULL,
|
||||
`input_json` LONGTEXT NOT NULL,
|
||||
`result_json` LONGTEXT NOT NULL,
|
||||
`hits` INT NOT NULL DEFAULT 0,
|
||||
`last_hit_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`),
|
||||
KEY `idx_xlvask_openai_cache_schema` (`schema_name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
}
|
||||
|
||||
private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void
|
||||
{
|
||||
if (!self::columnExists($db, $table, $column)) {
|
||||
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
|
||||
}
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$table}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $table, string $column): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$column = self::escapeIdentifier($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escapeIdentifier(string $value): string
|
||||
{
|
||||
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
|
||||
"test:api": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php -r \"putenv('RUN_API_TESTS=1'); passthru('vendor/bin/pest --testsuite=Api --colors=always', $exitCode); exit($exitCode);\""
|
||||
"@php -r \"putenv('RUN_API_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); passthru('vendor/bin/pest --testsuite=Api --colors=always', $exitCode); exit($exitCode);\""
|
||||
],
|
||||
"test:api:edge": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
@@ -15,6 +15,26 @@
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php -r \"putenv('RUN_INTEGRATION_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); putenv('EDGE_BROKER_URL'); passthru('vendor/bin/pest tests/Integration/EdgeGateway --colors=always', $exitCode); exit($exitCode);\""
|
||||
],
|
||||
"test:ci:unit": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php unit"
|
||||
],
|
||||
"test:ci:integration": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php integration"
|
||||
],
|
||||
"test:ci:api": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php api"
|
||||
],
|
||||
"test:ci:legacy": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php legacy"
|
||||
],
|
||||
"test:ci:all": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php all"
|
||||
],
|
||||
"test:coverage": [
|
||||
"@php -r \"is_dir('build/logs') || mkdir('build/logs', 0777, true);\"",
|
||||
"@php -r \"if (extension_loaded('pcov')) { passthru('php -d pcov.enabled=1 vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } if (extension_loaded('xdebug')) { passthru('php -d xdebug.mode=coverage vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } fwrite(STDERR, 'No coverage driver available. Enable pcov or xdebug for test:coverage.' . PHP_EOL); exit(1);\""
|
||||
@@ -49,6 +69,10 @@
|
||||
"modules/",
|
||||
"routes/",
|
||||
"statistics/"
|
||||
],
|
||||
"exclude-from-classmap": [
|
||||
"modules/*/vendor/",
|
||||
"modules/*/vendor/**"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
|
||||
Generated
+3
-3
@@ -7530,7 +7530,7 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
@@ -7539,6 +7539,6 @@
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.6.0"
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
@@ -163,3 +163,14 @@ if (strtolower(trim((string)($_ENV['USE_ENV'] ?? getenv('USE_ENV') ?? ''))) ===
|
||||
// Throw an error if the environment variables are not set
|
||||
throw new Exception('Environment variables are not set');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/classes/replication_secret_box.php';
|
||||
require_once __DIR__ . '/classes/replication_bootstrap_config.php';
|
||||
require_once __DIR__ . '/classes/replica_failover_manager.php';
|
||||
$replicationBootstrapSnapshot = \classes\replication_bootstrap_config::loadSnapshot();
|
||||
\classes\replication_bootstrap_config::applyToGlobals($replicationBootstrapSnapshot);
|
||||
try {
|
||||
\classes\replica_failover_manager::applyStartupFailoverFromSnapshot();
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[replication-bootstrap] Startup failover skipped: ' . $throwable->getMessage());
|
||||
}
|
||||
|
||||
@@ -4,10 +4,16 @@
|
||||
use classes\backup_store;
|
||||
use classes\economic;
|
||||
use classes\economic_transfer_queue;
|
||||
use classes\invoice_period_flag_service;
|
||||
use classes\coolify_manager;
|
||||
use classes\replication_manager;
|
||||
use classes\redis;
|
||||
use classes\system_search_cache;
|
||||
use classes\system_search_document_index;
|
||||
use classes\system_search_economic_customer_index;
|
||||
use classes\system_search_registry;
|
||||
use classes\workfeed;
|
||||
use classes\workfeed_employee_name_formatter;
|
||||
use classes\xlvask;
|
||||
use classes\slack as Slack;
|
||||
use classes\email as Email;
|
||||
@@ -29,6 +35,7 @@ use routes\moduleWeatherAPIRoute;
|
||||
require_once __DIR__ . '/../classes/economic_transfer_executor.php';
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
||||
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
|
||||
|
||||
if (!defined('WD')) {
|
||||
exit;
|
||||
@@ -63,6 +70,24 @@ $cron_tasks = [
|
||||
'next_run' => 0,
|
||||
'function' => 'syncLogsToDatabase',
|
||||
],
|
||||
'ReplicaFailoverMonitorCron' => [
|
||||
'interval' => 60, // 1 minute
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'ReplicaFailoverMonitorCron',
|
||||
],
|
||||
'CoolifyAvailabilityMonitorCron' => [
|
||||
'interval' => 60, // 1 minute
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'CoolifyAvailabilityMonitorCron',
|
||||
],
|
||||
'CoolifyLoadBalancerReconcileCron' => [
|
||||
'interval' => 60, // 1 minute
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'CoolifyLoadBalancerReconcileCron',
|
||||
],
|
||||
'SyncUserEconomicCustomerDiscounts' => [
|
||||
'interval' => 180, // 3 minutes
|
||||
'last_run' => 0,
|
||||
@@ -123,6 +148,12 @@ $cron_tasks = [
|
||||
'next_run' => 0,
|
||||
'function' => 'PreloadDepartmentWeatherResponsesCron',
|
||||
],
|
||||
'WarmWorkfeedEmployeeNamesCron' => [
|
||||
'interval' => 21600, // 6 hours
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'WarmWorkfeedEmployeeNamesCron',
|
||||
],
|
||||
'GoalsProgressAlertsCron' => [
|
||||
'interval' => 60, // check every minute
|
||||
'last_run' => 0,
|
||||
@@ -135,8 +166,343 @@ $cron_tasks = [
|
||||
'next_run' => 0,
|
||||
'function' => 'PruneSystemSessionActivityCron',
|
||||
],
|
||||
'WarmInvoicePeriodManualFlagsCron' => [
|
||||
'interval' => 300, // 5 minutes
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'WarmInvoicePeriodManualFlagsCron',
|
||||
],
|
||||
'WarmInvoicePeriodAutomaticFlagsCron' => [
|
||||
'interval' => 300, // 5 minutes
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'WarmInvoicePeriodAutomaticFlagsCron',
|
||||
],
|
||||
];
|
||||
|
||||
function ReplicaFailoverMonitorCron(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!($db instanceof \classes\db)) {
|
||||
warn('ReplicaFailoverMonitorCron skipped: database connection is unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = (new replication_manager())->runAutomaticFailoverMonitor();
|
||||
$promoted = array_filter(
|
||||
$result['results'] ?? [],
|
||||
static fn(array $entry): bool => ($entry['status'] ?? '') === 'promoted'
|
||||
);
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] ReplicaFailoverMonitorCron: "
|
||||
. count($promoted) . " promotions.\n";
|
||||
} catch (Throwable $throwable) {
|
||||
warn('ReplicaFailoverMonitorCron failed: ' . $throwable->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function CoolifyAvailabilityMonitorCron(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!($db instanceof \classes\db)) {
|
||||
warn('CoolifyAvailabilityMonitorCron skipped: database connection is unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = (new coolify_manager())->runAvailabilityMaintenance();
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyAvailabilityMonitorCron: "
|
||||
. count($result['targets'] ?? []) . " targets checked.\n";
|
||||
} catch (Throwable $throwable) {
|
||||
warn('CoolifyAvailabilityMonitorCron failed: ' . $throwable->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function CoolifyLoadBalancerReconcileCron(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!($db instanceof \classes\db)) {
|
||||
warn('CoolifyLoadBalancerReconcileCron skipped: database connection is unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$manager = new coolify_manager();
|
||||
if (!$manager->loadBalancerAutomationEnabled()) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: skipped.\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $manager->reconcileLoadBalancer(false);
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: "
|
||||
. count($result['applied'] ?? []) . " applied, "
|
||||
. count($result['skipped'] ?? []) . " skipped.\n";
|
||||
} catch (Throwable $throwable) {
|
||||
warn('CoolifyLoadBalancerReconcileCron failed: ' . $throwable->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function WarmInvoicePeriodManualFlagsCron(): void
|
||||
{
|
||||
(new invoice_period_flag_service())->warmManualFlagsCache();
|
||||
}
|
||||
|
||||
function WarmInvoicePeriodAutomaticFlagsCron(): void
|
||||
{
|
||||
$service = new invoice_period_flag_service();
|
||||
$now = new DateTime();
|
||||
$previousMonth = new DateTime('first day of previous month');
|
||||
|
||||
$toWarm = [];
|
||||
foreach ([$now, $previousMonth] as $date) {
|
||||
$dateFrom = $date->format('Y-m-01');
|
||||
$dateTo = $date->format('Y-m-t');
|
||||
$toWarm[$dateFrom . '|' . $dateTo] = ['dateFrom' => $dateFrom, 'dateTo' => $dateTo];
|
||||
}
|
||||
|
||||
try {
|
||||
$queued = (new redis())->consume_invoice_period_warming_queue();
|
||||
foreach ($queued as $period) {
|
||||
$key = $period['dateFrom'] . '|' . $period['dateTo'];
|
||||
$toWarm[$key] = $period;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
foreach ($toWarm as $period) {
|
||||
$service->warmOrderItemRowsForPeriod($period['dateFrom'], $period['dateTo']);
|
||||
$service->warmAutomaticFlagsForPeriod($period['dateFrom'], $period['dateTo']);
|
||||
}
|
||||
}
|
||||
|
||||
function WarmWorkfeedEmployeeNamesCron(): void
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
warn('WarmWorkfeedEmployeeNamesCron skipped: Redis is unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
$start = microtime(true);
|
||||
|
||||
$ttlRaw = getenv('WORKFEED_EMPLOYEE_NAME_CACHE_TTL');
|
||||
$ttl = max(
|
||||
60,
|
||||
(int)(
|
||||
$ttlRaw !== false && trim((string)$ttlRaw) !== ''
|
||||
? $ttlRaw
|
||||
: 86400
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
$employeesResponse = (new workfeed())->listEmployees();
|
||||
} catch (Throwable $e) {
|
||||
warn('WarmWorkfeedEmployeeNamesCron failed to list employees: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$employees = normalizeWorkfeedEmployeeWarmupCollection($employeesResponse);
|
||||
if ($employees === []) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: no employees returned.\n";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$cache = new redis();
|
||||
} catch (Throwable $e) {
|
||||
warn('WarmWorkfeedEmployeeNamesCron failed to initialize cache: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$cachedCount = 0;
|
||||
$skippedCount = 0;
|
||||
foreach ($employees as $employee) {
|
||||
$identity = extractWorkfeedEmployeeWarmupIdentity($employee);
|
||||
$employeeIds = extractWorkfeedEmployeeWarmupIds($employee);
|
||||
$employeeName = $identity['name'] ?? null;
|
||||
if ($employeeIds === [] || $employeeName === null) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($employeeIds as $employeeId) {
|
||||
$cache->cache_workfeed_employee_name($employeeId, $employeeName, $ttl);
|
||||
$cachedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$durationMs = (int)round((microtime(true) - $start) * 1000);
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: cached " . $cachedCount
|
||||
. " employees, skipped " . $skippedCount . " in " . $durationMs . "ms.\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,mixed>
|
||||
*/
|
||||
function normalizeWorkfeedEmployeeWarmupCollection(mixed $raw): array
|
||||
{
|
||||
if (is_array($raw)) {
|
||||
return array_values($raw);
|
||||
}
|
||||
|
||||
if ($raw instanceof Traversable) {
|
||||
return array_values(iterator_to_array($raw, false));
|
||||
}
|
||||
|
||||
if (!is_object($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$record = get_object_vars($raw);
|
||||
foreach (['data', 'items', 'employees', 'results'] as $key) {
|
||||
$nested = $record[$key] ?? null;
|
||||
$normalized = normalizeWorkfeedEmployeeWarmupCollection($nested);
|
||||
if ($normalized !== []) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return [$raw];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:?string,name:?string}
|
||||
*/
|
||||
function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array
|
||||
{
|
||||
$employeeIds = extractWorkfeedEmployeeWarmupIds($employee);
|
||||
|
||||
$record = is_object($employee)
|
||||
? get_object_vars($employee)
|
||||
: (is_array($employee) ? $employee : []);
|
||||
|
||||
if ($record === []) {
|
||||
return [
|
||||
'id' => null,
|
||||
'name' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$employeeId = $employeeIds[0] ?? null;
|
||||
|
||||
$employeeName = workfeed_employee_name_formatter::fromRecord($record, [
|
||||
'firstname',
|
||||
'firstName',
|
||||
'first_name',
|
||||
'employee.firstname',
|
||||
'employee.firstName',
|
||||
'employee.first_name',
|
||||
'user.firstname',
|
||||
'user.firstName',
|
||||
'user.first_name',
|
||||
], [
|
||||
'lastname',
|
||||
'lastName',
|
||||
'last_name',
|
||||
'employee.lastname',
|
||||
'employee.lastName',
|
||||
'employee.last_name',
|
||||
'user.lastname',
|
||||
'user.lastName',
|
||||
'user.last_name',
|
||||
], [
|
||||
'employeeName',
|
||||
'employee.name',
|
||||
'employee.fullName',
|
||||
'employee.full_name',
|
||||
'employee.displayName',
|
||||
'employee.display_name',
|
||||
'name',
|
||||
'fullName',
|
||||
'full_name',
|
||||
'displayName',
|
||||
'display_name',
|
||||
'user.name',
|
||||
'user.fullName',
|
||||
'user.full_name',
|
||||
'user.displayName',
|
||||
'user.display_name',
|
||||
], $employeeId);
|
||||
|
||||
return [
|
||||
'id' => $employeeId,
|
||||
'name' => $employeeName,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
function extractWorkfeedEmployeeWarmupIds(mixed $employee): array
|
||||
{
|
||||
$record = is_object($employee)
|
||||
? get_object_vars($employee)
|
||||
: (is_array($employee) ? $employee : []);
|
||||
|
||||
if ($record === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$employeeIds = [];
|
||||
foreach ([
|
||||
'employeeID',
|
||||
'employeeId',
|
||||
'id',
|
||||
'uuid',
|
||||
'employee.id',
|
||||
'employee.employeeID',
|
||||
'employee.employeeId',
|
||||
'employee.uuid',
|
||||
'employeeUUID',
|
||||
'employee_uuid',
|
||||
'user.id',
|
||||
'userId',
|
||||
] as $path) {
|
||||
$employeeId = normalizeWarmupTextValue(getWarmupRecordValueByPath($record, $path));
|
||||
if ($employeeId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$employeeIds[$employeeId] = true;
|
||||
}
|
||||
|
||||
return array_keys($employeeIds);
|
||||
}
|
||||
|
||||
function getWarmupRecordValueByPath(array $record, string $path): mixed
|
||||
{
|
||||
$segments = explode('.', $path);
|
||||
$value = $record;
|
||||
foreach ($segments as $segment) {
|
||||
if (is_array($value) && array_key_exists($segment, $value)) {
|
||||
$value = $value[$segment];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_object($value) && isset($value->{$segment})) {
|
||||
$value = $value->{$segment};
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
function normalizeWarmupTextValue(mixed $value): ?string
|
||||
{
|
||||
if (is_string($value) || is_numeric($value)) {
|
||||
$normalized = trim((string)$value);
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkUnfulfilledBookings(): void
|
||||
{
|
||||
// This is deactivated for now, as it is not wanted.
|
||||
@@ -493,7 +859,7 @@ function collectDynamicImageTaskGroupsForLane(array $laneRow): array
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $taskRows
|
||||
* @return array<int,array{dynamic_image_id:int,buttons:array<int>|null,current_step:int,only_current_step:bool,vehicle_type:int|null}>
|
||||
* @return array<int,array{dynamic_image_id:int,buttons:array<int|string>|null,current_step:int,only_current_step:bool,vehicle_type:int|null}>
|
||||
*/
|
||||
function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicleType, array $taskRows): array
|
||||
{
|
||||
@@ -515,7 +881,7 @@ function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicl
|
||||
$buttons = parseDynamicImageButtons($row['buttons'] ?? null);
|
||||
if ($buttons !== []) {
|
||||
$buttonSets[] = $buttons;
|
||||
$runningButtons = mergeUniqueIntValues($runningButtons, $buttons);
|
||||
$runningButtons = mergeUniqueButtonValues($runningButtons, $buttons);
|
||||
$buttonSets[] = $runningButtons;
|
||||
}
|
||||
}
|
||||
@@ -554,7 +920,7 @@ function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicl
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $buttons
|
||||
* @param array<int|string>|null $buttons
|
||||
*/
|
||||
function buildDynamicImageCacheKey(array $variant): string
|
||||
{
|
||||
@@ -575,7 +941,7 @@ function buildDynamicImageCacheKey(array $variant): string
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $buttons
|
||||
* @param array<int|string>|null $buttons
|
||||
*/
|
||||
function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $currentStep, bool $onlyCurrentStep): ?string
|
||||
{
|
||||
@@ -621,7 +987,7 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return array<int>
|
||||
* @return array<int|string>
|
||||
*/
|
||||
function parseDynamicImageButtons(mixed $value): array
|
||||
{
|
||||
@@ -630,8 +996,7 @@ function parseDynamicImageButtons(mixed $value): array
|
||||
}
|
||||
|
||||
try {
|
||||
$normalized = department_selfserve_tasks_o::normalizeButtonsInput($value);
|
||||
return array_values(array_map('intval', $normalized));
|
||||
return department_selfserve_tasks_o::normalizeButtonsInput($value);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
@@ -662,17 +1027,22 @@ function normalizeDynamicImageVehicleType(mixed $value): ?int
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int> $base
|
||||
* @param array<int> $append
|
||||
* @return array<int>
|
||||
* @param array<int|string> $base
|
||||
* @param array<int|string> $append
|
||||
* @return array<int|string>
|
||||
*/
|
||||
function mergeUniqueIntValues(array $base, array $append): array
|
||||
function mergeUniqueButtonValues(array $base, array $append): array
|
||||
{
|
||||
$result = $base;
|
||||
$seen = [];
|
||||
foreach ($result as $value) {
|
||||
$seen[(is_int($value) ? 'int:' : 'string:') . (string)$value] = true;
|
||||
}
|
||||
foreach ($append as $value) {
|
||||
$intValue = (int)$value;
|
||||
if (!in_array($intValue, $result, true)) {
|
||||
$result[] = $intValue;
|
||||
$key = (is_int($value) ? 'int:' : 'string:') . (string)$value;
|
||||
if (!isset($seen[$key])) {
|
||||
$seen[$key] = true;
|
||||
$result[] = $value;
|
||||
}
|
||||
}
|
||||
return array_values($result);
|
||||
|
||||
@@ -8,16 +8,18 @@ ini_set('zlib.output_compression', false);
|
||||
*/
|
||||
const WD = __DIR__;
|
||||
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
require_once 'config.php';
|
||||
|
||||
/** CORS */
|
||||
$corsAllowedHeaders = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
|
||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
||||
}
|
||||
|
||||
// OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
|
||||
@@ -25,8 +27,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(200);
|
||||
exit;
|
||||
@@ -167,14 +169,16 @@ spl_autoload_register(function (string $class): void {
|
||||
}
|
||||
});
|
||||
|
||||
use classes\application_write_freeze;
|
||||
use classes\db;
|
||||
use classes\replication_manager;
|
||||
use classes\release_manager;
|
||||
use classes\redis;
|
||||
use classes\request;
|
||||
use classes\response;
|
||||
use classes\router;
|
||||
|
||||
// Start the session
|
||||
$router = new router();
|
||||
$response = new response();
|
||||
$request = new request();
|
||||
$db = new db($CONFIG_DB);
|
||||
@@ -195,7 +199,49 @@ try {
|
||||
$response->error($e->getMessage(), 500);
|
||||
}
|
||||
|
||||
try {
|
||||
release_manager::initializeRequestContext();
|
||||
$releaseIngressPath = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '');
|
||||
release_manager::normalizeReleaseApiIngressPath(
|
||||
preg_match('#^/[A-Za-z0-9_-]{1,64}/api(?:/|$)#', $releaseIngressPath) === 1
|
||||
? (new release_manager())->enabledReleaseChannelSlugs()
|
||||
: []
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[release-manager] Could not initialize request context or normalize ingress path: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$router = new router();
|
||||
|
||||
try {
|
||||
$replicationBootstrapSnapshotForRequest = replication_bootstrap_config::loadSnapshot();
|
||||
$pendingStartupFailovers = is_array($replicationBootstrapSnapshotForRequest['pending_failovers'] ?? null)
|
||||
? $replicationBootstrapSnapshotForRequest['pending_failovers']
|
||||
: [];
|
||||
if ($pendingStartupFailovers !== []) {
|
||||
(new replication_manager())->syncStartupFailoversFromSnapshot();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('[replication-bootstrap] Could not sync startup failover metadata: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
if (application_write_freeze::shouldBlock(
|
||||
$_SERVER['REQUEST_METHOD'] ?? 'GET',
|
||||
$_SERVER['REQUEST_URI'] ?? '/',
|
||||
php_sapi_name() === 'cli' || isset($_GET['internalCronCall'])
|
||||
)) {
|
||||
$freezeState = application_write_freeze::state();
|
||||
if (php_sapi_name() === 'cli') {
|
||||
fwrite(STDERR, 'Application writes are frozen: ' . (string)($freezeState['reason'] ?? 'replication promotion') . PHP_EOL);
|
||||
exit(75);
|
||||
}
|
||||
|
||||
$response->error([
|
||||
'message' => 'Application writes are temporarily frozen.',
|
||||
'reason' => $freezeState['reason'] ?? null,
|
||||
'expires_at' => $freezeState['expires_at'] ?? null,
|
||||
], 503);
|
||||
}
|
||||
|
||||
// If the program was called from the command line, run the cli script
|
||||
if (php_sapi_name() === 'cli' || isset($_GET['internalCronCall'])) {
|
||||
|
||||
@@ -290,6 +290,98 @@ interface redis_i
|
||||
*/
|
||||
public function clear_auth_session(string $token): self;
|
||||
|
||||
/**
|
||||
* Cache invoice period manual flags payload
|
||||
* @param array $flags
|
||||
* @return self
|
||||
*/
|
||||
public function cache_invoice_period_manual_flags(array $flags): self;
|
||||
|
||||
/**
|
||||
* Get cached invoice period manual flags payload
|
||||
* @return array|null
|
||||
*/
|
||||
public function get_invoice_period_manual_flags(): array|null;
|
||||
|
||||
/**
|
||||
* Clear cached invoice period manual flags payload
|
||||
* @return self
|
||||
*/
|
||||
public function clear_invoice_period_manual_flags(): self;
|
||||
|
||||
/**
|
||||
* Cache invoice period automatic flags payload for a date range
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @param array $flags
|
||||
* @return self
|
||||
*/
|
||||
public function cache_invoice_period_automatic_flags(string $dateFrom, string $dateTo, array $flags): self;
|
||||
|
||||
/**
|
||||
* Get cached invoice period automatic flags payload for a date range
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @return array|null
|
||||
*/
|
||||
public function get_invoice_period_automatic_flags(string $dateFrom, string $dateTo): array|null;
|
||||
|
||||
/**
|
||||
* Clear cached invoice period automatic flags payload for a date range
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @return self
|
||||
*/
|
||||
public function clear_invoice_period_automatic_flags(string $dateFrom, string $dateTo): self;
|
||||
|
||||
/**
|
||||
* Cache invoice period order item rows for a date range
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @param array $rows
|
||||
* @return self
|
||||
*/
|
||||
public function cache_invoice_period_order_item_rows(string $dateFrom, string $dateTo, array $rows): self;
|
||||
|
||||
/**
|
||||
* Get cached invoice period order item rows for a date range
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @return array|null
|
||||
*/
|
||||
public function get_invoice_period_order_item_rows(string $dateFrom, string $dateTo): array|null;
|
||||
|
||||
/**
|
||||
* Clear cached invoice period order item rows for a date range
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @return self
|
||||
*/
|
||||
public function clear_invoice_period_order_item_rows(string $dateFrom, string $dateTo): self;
|
||||
|
||||
/**
|
||||
* Cache Workfeed employee display name by employee id
|
||||
* @param string $employeeId
|
||||
* @param string $employeeName
|
||||
* @param int $ttl
|
||||
* @return self
|
||||
*/
|
||||
public function cache_workfeed_employee_name(string $employeeId, string $employeeName, int $ttl = 86400): self;
|
||||
|
||||
/**
|
||||
* Get cached Workfeed employee display name by employee id
|
||||
* @param string $employeeId
|
||||
* @return string|null
|
||||
*/
|
||||
public function get_workfeed_employee_name(string $employeeId): string|null;
|
||||
|
||||
/**
|
||||
* Clear cached Workfeed employee display name by employee id
|
||||
* @param string $employeeId
|
||||
* @return self
|
||||
*/
|
||||
public function clear_workfeed_employee_name(string $employeeId): self;
|
||||
|
||||
/**
|
||||
* Cache a permission evaluation
|
||||
* @param string $cache_key
|
||||
@@ -312,4 +404,18 @@ interface redis_i
|
||||
* @return self
|
||||
*/
|
||||
public function clear_permission(string $cache_key): self;
|
||||
|
||||
/**
|
||||
* Enqueue a period for automatic flags warming.
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @return self
|
||||
*/
|
||||
public function enqueue_invoice_period_warming(string $dateFrom, string $dateTo): self;
|
||||
|
||||
/**
|
||||
* Consume all queued warming periods and clear the queue.
|
||||
* @return array<int,array{dateFrom:string,dateTo:string}>
|
||||
*/
|
||||
public function consume_invoice_period_warming_queue(): array;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ class machine_1 extends dynamicimages_image
|
||||
const IMAGE_BUTTON_HIGHLIGHTED_GREY = 'machine_1_button_highlighted_grey.png';
|
||||
const IMAGE_BUTTON_HIGHLIGHTED_COMPLETED = 'machine_1_button_highlighted_completed_grey.png';
|
||||
const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png';
|
||||
const BUTTON_RESET = 'reset';
|
||||
const BUTTON_START = 'start';
|
||||
// Thumb
|
||||
public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees)
|
||||
public int $thumb_size = 1550; // height and width of the thumb
|
||||
@@ -72,10 +74,9 @@ class machine_1 extends dynamicimages_image
|
||||
$this->drawProgramWheel();
|
||||
$this->drawThumb();
|
||||
$this->drawStepThumb();
|
||||
$this->drawHighlightedResetButton();
|
||||
$this->drawHighlightedButtons();
|
||||
$deferredStartButtons = $this->drawHighlightedButtonSequence();
|
||||
$this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0);
|
||||
$this->drawHighlightedStartButton();
|
||||
$this->drawDeferredHighlightedButtons($deferredStartButtons);
|
||||
$this->drawCropOutLine(true);
|
||||
}
|
||||
|
||||
@@ -174,11 +175,188 @@ class machine_1 extends dynamicimages_image
|
||||
return self::IMAGE_BUTTON_HIGHLIGHTED_GREY;
|
||||
}
|
||||
|
||||
private function isHighlightedButton(int|string $button): bool
|
||||
{
|
||||
foreach ($this->highlighted_buttons as $highlightedButton) {
|
||||
if (is_int($button)) {
|
||||
if (is_numeric($highlightedButton) && (int)$highlightedButton === $button) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($highlightedButton) && strtolower(trim($highlightedButton)) === $button) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function normalizeHighlightedButtonToken(mixed $button): int|string|null
|
||||
{
|
||||
if (is_string($button)) {
|
||||
$trimmed = trim($button);
|
||||
$specialButton = strtolower($trimmed);
|
||||
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START) {
|
||||
return $specialButton;
|
||||
}
|
||||
|
||||
if ($trimmed !== '' && ctype_digit($trimmed)) {
|
||||
$button = (int)$trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_int($button)) {
|
||||
return $button >= 0 && $button < ($this->button_rows * $this->button_columns) ? $button : null;
|
||||
}
|
||||
|
||||
if (is_numeric($button) && (int)$button == $button) {
|
||||
$button = (int)$button;
|
||||
return $button >= 0 && $button < ($this->button_rows * $this->button_columns) ? $button : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getOrderedHighlightedButtonTokens(): array
|
||||
{
|
||||
$tokens = [];
|
||||
foreach ($this->highlighted_buttons as $button) {
|
||||
$token = $this->normalizeHighlightedButtonToken($button);
|
||||
if ($token !== null) {
|
||||
$tokens[] = $token;
|
||||
}
|
||||
}
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
private function getRegularButtonCoordinates(int $buttonIndex): ?array
|
||||
{
|
||||
if ($buttonIndex < 0 || $buttonIndex >= ($this->button_rows * $this->button_columns)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = intdiv($buttonIndex, $this->button_columns);
|
||||
$col = $buttonIndex % $this->button_columns;
|
||||
$x = 2815 + ($col * ($this->button_highlight_size + $this->button_columns_spacing));
|
||||
$y = 1265 + ($row * ($this->button_highlight_size + $this->button_rows_spacing));
|
||||
if ($row === $this->button_rows - 1) {
|
||||
$y += $this->button_last_row_spacing_buffer;
|
||||
}
|
||||
|
||||
return [
|
||||
'x' => $x,
|
||||
'y' => $y,
|
||||
'size' => $this->button_highlight_size,
|
||||
];
|
||||
}
|
||||
|
||||
private function getHighlightedButtonCoordinates(int|string $button): ?array
|
||||
{
|
||||
if ($button === self::BUTTON_RESET) {
|
||||
return [
|
||||
'x' => 1736,
|
||||
'y' => 599,
|
||||
'size' => $this->reset_button_highlight_size,
|
||||
];
|
||||
}
|
||||
|
||||
if ($button === self::BUTTON_START) {
|
||||
return [
|
||||
'x' => 4965,
|
||||
'y' => 1980,
|
||||
'size' => $this->start_button_highlight_size,
|
||||
];
|
||||
}
|
||||
|
||||
return is_int($button) ? $this->getRegularButtonCoordinates($button) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function buildHighlightedButtonDraw(int|string $button): ?array
|
||||
{
|
||||
$coordinates = $this->getHighlightedButtonCoordinates($button);
|
||||
if ($coordinates === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName()));
|
||||
$tmp->resize($coordinates['size'], $coordinates['size']);
|
||||
$tmp = $this->drawStepCounterOnButton($tmp);
|
||||
if ($this->only_generate_current_step && $this->button_counter != $this->current_step + 1) {
|
||||
if (method_exists($tmp, 'clearMemoryImage')) {
|
||||
$tmp->clearMemoryImage();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'asset' => $tmp,
|
||||
'x' => $coordinates['x'],
|
||||
'y' => $coordinates['y'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function drawHighlightedButtonDraw(array $draw): void
|
||||
{
|
||||
$tmp = $draw['asset'];
|
||||
$this->drawAsset($tmp, (int)$draw['x'], (int)$draw['y']);
|
||||
if (method_exists($tmp, 'clearMemoryImage')) {
|
||||
$tmp->clearMemoryImage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array{asset: dynamicimages_asset, x: int, y: int}>
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function drawHighlightedButtonSequence(): array
|
||||
{
|
||||
$deferredStartButtons = [];
|
||||
foreach ($this->getOrderedHighlightedButtonTokens() as $button) {
|
||||
$draw = $this->buildHighlightedButtonDraw($button);
|
||||
if ($draw === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($button === self::BUTTON_START) {
|
||||
$deferredStartButtons[] = $draw;
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->drawHighlightedButtonDraw($draw);
|
||||
}
|
||||
|
||||
return $deferredStartButtons;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{asset: dynamicimages_asset, x: int, y: int}> $deferredStartButtons
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function drawDeferredHighlightedButtons(array $deferredStartButtons): void
|
||||
{
|
||||
foreach ($deferredStartButtons as $draw) {
|
||||
$this->drawHighlightedButtonDraw($draw);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function drawHighlightedStartButton(): void
|
||||
{
|
||||
if (!$this->isHighlightedButton(self::BUTTON_START)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$x = 4965; // X position for the start button
|
||||
$y = 1980; // Y position for the start button
|
||||
$tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName()));
|
||||
@@ -200,6 +378,10 @@ class machine_1 extends dynamicimages_image
|
||||
*/
|
||||
public function drawHighlightedResetButton(): void
|
||||
{
|
||||
if (!$this->isHighlightedButton(self::BUTTON_RESET)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$x = 1736; // X position for the reset button
|
||||
$y = 599; // Y position for the reset button
|
||||
$tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName()));
|
||||
@@ -404,7 +586,7 @@ class machine_1 extends dynamicimages_image
|
||||
for ($col = 0; $col < $this->button_columns; $col++) {
|
||||
// If the current button position is not in the highlighted buttons array, skip it
|
||||
$buttonIndex = $row * $this->button_columns + $col;
|
||||
if (!in_array($buttonIndex, $this->highlighted_buttons, true)) {
|
||||
if (!$this->isHighlightedButton($buttonIndex)) {
|
||||
continue;
|
||||
}
|
||||
// Calculate the position for the current button
|
||||
@@ -517,12 +699,8 @@ class machine_1 extends dynamicimages_image
|
||||
$rad = deg2rad($angle);
|
||||
$stepX = $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2);
|
||||
$stepY = $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2);
|
||||
$tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName()));
|
||||
$tmp = new dynamicimages_asset($this->getAssetPath(self::IMAGE_BUTTON_HIGHLIGHTED_BLUE));
|
||||
$tmp->resize($stepSize, $stepSize);
|
||||
$tmp = $this->drawStepCounterOnButton($tmp);
|
||||
if ($this->only_generate_current_step && $this->button_counter != $this->current_step +1) {
|
||||
return;
|
||||
}
|
||||
$this->drawAsset($tmp, $stepX, $stepY);
|
||||
// Free memory used by temporary asset image, if any
|
||||
if (method_exists($tmp, 'clearMemoryImage')) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class economic_default_department_id_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'economic',
|
||||
'defaultDepartmentId',
|
||||
'int',
|
||||
false,
|
||||
null,
|
||||
'Fallback department id used when customers have no standard department for invoicing distribution',
|
||||
'1',
|
||||
false,
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ require_once WD . '/modules/economic/config/economic_admin_fee_monthly_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_admin_fee_order_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_fee_product_id_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_transaction_draft_customer_number_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_default_department_id_c.php';
|
||||
|
||||
use config\economic_invoice_layout_c;
|
||||
use config\economic_payment_terms_c;
|
||||
use config\economic_admin_fee_monthly_c;
|
||||
use config\economic_admin_fee_order_c;
|
||||
use config\economic_fee_product_id_c;
|
||||
use config\economic_default_department_id_c;
|
||||
use config\economic_transaction_draft_customer_number_c;
|
||||
use traits\module_config_t;
|
||||
|
||||
@@ -23,6 +25,7 @@ class economic_c
|
||||
public economic_admin_fee_monthly_c $admin_fee_monthly;
|
||||
public economic_admin_fee_order_c $admin_fee_order;
|
||||
public economic_fee_product_id_c $fee_product_id;
|
||||
public economic_default_department_id_c $default_department_id;
|
||||
public economic_transaction_draft_customer_number_c $transaction_draft_customer_number;
|
||||
|
||||
public function __construct()
|
||||
@@ -34,6 +37,7 @@ class economic_c
|
||||
economic_admin_fee_monthly_c::class,
|
||||
economic_admin_fee_order_c::class,
|
||||
economic_fee_product_id_c::class,
|
||||
economic_default_department_id_c::class,
|
||||
economic_transaction_draft_customer_number_c::class,
|
||||
]);
|
||||
$this->invoice_layout = new economic_invoice_layout_c();
|
||||
@@ -41,6 +45,7 @@ class economic_c
|
||||
$this->admin_fee_monthly = new economic_admin_fee_monthly_c();
|
||||
$this->admin_fee_order = new economic_admin_fee_order_c();
|
||||
$this->fee_product_id = new economic_fee_product_id_c();
|
||||
$this->default_department_id = new economic_default_department_id_c();
|
||||
$this->transaction_draft_customer_number = new economic_transaction_draft_customer_number_c();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,8 @@ class economic_m
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_ENCODING => '',
|
||||
CURLOPT_MAXREDIRS => 10,
|
||||
CURLOPT_TIMEOUT => 0,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
|
||||
+35
-4
@@ -41,7 +41,18 @@ class edge_gateway_department_workspace_service
|
||||
continue;
|
||||
}
|
||||
|
||||
$workspace = $this->buildDepartmentWorkspace($departmentId, false, $departmentRow);
|
||||
try {
|
||||
$workspace = $this->buildDepartmentWorkspace($departmentId, false, $departmentRow);
|
||||
} catch (Exception $exception) {
|
||||
if ($exception->getMessage() === 'Department not found') {
|
||||
$this->manager()->deleteOrphanedGatewaysForDepartment($departmentId);
|
||||
$this->clearDepartmentCache();
|
||||
continue;
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$summaries[] = $workspace['summary'];
|
||||
}
|
||||
|
||||
@@ -253,6 +264,7 @@ class edge_gateway_department_workspace_service
|
||||
'relay_machine_cleaner_id' => $lane->relay_machine_cleaner_id->value() === null ? null : (string)$lane->relay_machine_cleaner_id->value(),
|
||||
'dynamic_image_id' => $lane->dynamic_image_id->value() === null ? null : (int)$lane->dynamic_image_id->value(),
|
||||
'machine_type_id' => $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value(),
|
||||
'selfserve_enabled' => $lane->isSelfServeEnabled(),
|
||||
'status' => $laneStatus,
|
||||
'self_serve_products' => $lane->getSelfServeLaneProducts(),
|
||||
'relay_slots' => $relaySlots,
|
||||
@@ -374,7 +386,10 @@ class edge_gateway_department_workspace_service
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
$readyLanes = array_values(array_filter($lanes, static function (array $lane): bool {
|
||||
$enabledLanes = array_values(array_filter($lanes, static function (array $lane): bool {
|
||||
return ($lane['selfserve_enabled'] ?? true) !== false;
|
||||
}));
|
||||
$readyLanes = array_values(array_filter($enabledLanes, static function (array $lane): bool {
|
||||
return (string)($lane['binding_coverage']['state'] ?? 'UNKNOWN') === 'READY';
|
||||
}));
|
||||
|
||||
@@ -393,12 +408,13 @@ class edge_gateway_department_workspace_service
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'lane_count' => count($lanes),
|
||||
'enabled_lanes' => count($enabledLanes),
|
||||
'ready_lanes' => count($readyLanes),
|
||||
'configured_task_count' => count($taskRows),
|
||||
'configured_product_count' => count($productIds),
|
||||
'readiness_state' => !$enabled
|
||||
? 'DISABLED'
|
||||
: (count($lanes) === 0 ? 'UNCONFIGURED' : (count($readyLanes) === count($lanes) ? 'READY' : 'PARTIAL')),
|
||||
: (count($enabledLanes) === 0 ? 'UNCONFIGURED' : (count($readyLanes) === count($enabledLanes) ? 'READY' : 'PARTIAL')),
|
||||
'links' => [
|
||||
'studio' => '/admin/' . (int)$department->id . '/modules/self-serve/studio',
|
||||
'legacy' => '/superuser/selfserve',
|
||||
@@ -583,7 +599,7 @@ class edge_gateway_department_workspace_service
|
||||
}
|
||||
}
|
||||
|
||||
if (($selfServe['enabled'] ?? false) && (int)($selfServe['ready_lanes'] ?? 0) < (int)($selfServe['lane_count'] ?? 0)) {
|
||||
if (($selfServe['enabled'] ?? false) && (int)($selfServe['ready_lanes'] ?? 0) < (int)($selfServe['enabled_lanes'] ?? 0)) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'code' => 'SELFSERVE_PARTIAL_READY',
|
||||
@@ -897,4 +913,19 @@ class edge_gateway_department_workspace_service
|
||||
{
|
||||
return $this->manager ?? new edge_gateway_manager();
|
||||
}
|
||||
|
||||
private function clearDepartmentCache(): void
|
||||
{
|
||||
try {
|
||||
if (!defined('redis')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$redis = constant('redis');
|
||||
if (is_object($redis) && method_exists($redis, 'clear_departments')) {
|
||||
$redis->clear_departments();
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,8 @@ class edge_gateway_operation_exception extends Exception
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly string $errorCode,
|
||||
public readonly int $status = 400
|
||||
public readonly int $status = 400,
|
||||
public readonly array $details = []
|
||||
) {
|
||||
parent::__construct($message, $status);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user