Compare commits

...
61 changed files with 9185 additions and 485 deletions
+8 -3
View File
@@ -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:
+131 -124
View File
@@ -22,25 +22,35 @@ jobs:
- name: Check AI workflow sync
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: Materialize compose env files
env:
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
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
- name: Boot php1 test stack
run: docker compose up -d redis mysql-debug php1
- name: Resolve dependencies
working-directory: services/nginx/app
run: composer update --no-interaction --prefer-dist
run: docker compose exec -T php1 sh -lc "cd /var/www/html && composer update --no-interaction --prefer-dist"
- name: Run unit tests
working-directory: services/nginx/app
run: composer test:unit
run: docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"
- name: Generate coverage report
working-directory: services/nginx/app
run: composer test:coverage
run: |
docker compose exec -T php1 sh -lc "cd /var/www/html && if php -m | grep -Eq '^(pcov|xdebug)$'; then composer test:coverage; else echo 'Skipping coverage: no pcov/xdebug extension available in php1 image.'; fi"
- name: Upload coverage artifact
if: ${{ github.event_name == 'pull_request' }}
@@ -52,6 +62,10 @@ jobs:
if-no-files-found: warn
retention-days: 1
- name: Tear down php1 test stack
if: always()
run: docker compose down -v
edge-agent:
name: Edge Agent (required)
runs-on: [self-hosted, Linux, X64, default]
@@ -120,139 +134,132 @@ jobs:
working-directory: services/edge-broker
run: npm test
integration:
name: Integration (advisory)
edge-gateway-backend:
name: Edge Gateway Backend (required)
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
env:
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: Setup PHP
uses: shivammathur/setup-php@v2
- name: Materialize compose env files
env:
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
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
- name: Setup Node.js
uses: actions/setup-node@v4
with:
php-version: '8.2'
extensions: mysqli, curl, openssl, json, redis
ini-values: variables_order=EGPCS
node-version: 22
- name: Boot local stack
run: docker compose up -d traefik redis mysql-debug edge-broker php1 caddy
- name: Run edge gateway API tests
run: docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api:edge"
- name: Run edge gateway integration tests
run: docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration:edge"
- name: Run edge gateway E2E smoke
run: node scripts/edge-gateway-e2e.mjs
- 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
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 }}
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
- name: Boot integration stack
run: docker compose up -d redis mysql-debug php1
- name: Resolve dependencies
working-directory: services/nginx/app
run: composer update --no-interaction --prefer-dist
run: docker compose exec -T php1 sh -lc "cd /var/www/html && 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
run: docker compose exec -T -e RUN_INTEGRATION_TESTS=1 php1 sh -lc "cd /var/www/html && composer test:integration"
- name: Tear down integration stack
if: always()
run: docker compose down -v
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: Materialize compose env files
env:
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
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
- name: Boot API stack
run: docker compose up -d redis mysql-debug php1
- name: Resolve dependencies
working-directory: services/nginx/app
run: composer update --no-interaction --prefer-dist
run: docker compose exec -T php1 sh -lc "cd /var/www/html && 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 exec -T -e RUN_API_TESTS=1 -e API_TEST_BOOTSTRAP_SCHEMA=1 php1 sh -lc "cd /var/www/html && composer test:api"
- name: Tear down API stack
if: always()
run: docker compose down -v
+1
View File
@@ -11,3 +11,4 @@
.env
/services/caddy/logs*
/.tmp/
/.env.staging
+24
View File
@@ -91,6 +91,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"
```
Integration tests are opt-in and should be run with required services available:
@@ -100,6 +102,28 @@ $env:RUN_INTEGRATION_TESTS='1'
composer test:integration
```
### Edge Gateway Regression Coverage
The dedicated backend regression lane for the PHP edge gateway stack is split into:
- API contract tests for operator, agent, and broker-facing routes
- DB-backed integration tests for install sessions, heartbeats, tasks, logs, statistics, and shell persistence
- a local dockerized smoke that runs the real PHP edge agent against the local backend and broker
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"
```
Run the full local smoke from `backend-php` on the host:
```powershell
node .\scripts\edge-gateway-e2e.mjs
```
The E2E smoke expects the local compose stack and Docker daemon to be available. It boots a disposable gateway container, waits for a real heartbeat, validates live operations and telemetry, and verifies browser shell transcript persistence.
### Public Staging Edge-Gateway Smoke
`api.truckwash.io:4433` is the public staging ingress. For Edge Gateways v2, the router must serve the canonical artifacts from `services/nginx/app/resources/edge-gateway-agent`, not from the legacy `dist/agent.mjs` output or a separate runtime mount.
+16 -2
View File
@@ -52,9 +52,23 @@ services:
dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker
environment:
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
ports:
- "4300:4300"
labels:
- "traefik.enable=true"
- "traefik.http.routers.edge-broker-api.rule=Host(`api.example.com`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api.tls=true"
- "traefik.http.routers.edge-broker-api.tls.certresolver=le"
- "traefik.http.routers.edge-broker-api.middlewares=edge-broker-strip"
- "traefik.http.routers.edge-broker-api.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=edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local.service=edge-broker"
- "traefik.http.middlewares.edge-broker-strip.stripPrefix.prefixes=/edge-broker"
- "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker"
- "traefik.http.services.edge-broker.loadbalancer.server.port=4300"
caddy:
image: caddy:2.7.6-alpine
+425
View File
@@ -0,0 +1,425 @@
services:
traefik:
image: traefik:2.11
container_name: traefik
ports:
- "80:80"
- "443:443"
- "4433:4433"
- "9100:9100"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./services/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./services/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
- ./services/traefik/acme.json:/acme.json
- ./services/traefik/acme-io.json:/acme-io.json
labels:
- "traefik.enable=true"
- "traefik.http.routers.traefik.rule=Host(`traefik.truckwash.dk`)"
- "traefik.http.routers.traefik.entrypoints=websecure"
- "traefik.http.routers.traefik.tls=true"
- "traefik.http.routers.traefik.tls.certresolver=le"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=dashboard-allow-local@file,dashboard-auth@file"
- "traefik.http.routers.traefik-http.rule=Host(`traefik.truckwash.dk`)"
- "traefik.http.routers.traefik-http.entrypoints=web"
- "traefik.http.routers.traefik-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.traefik-http.service=api@internal"
- "traefik.http.routers.traefik-local.rule=Host(`traefik.localhost`)"
- "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"
redis:
image: redis:7
container_name: redis
volumes:
- nnks_redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
redis-staging:
image: redis:7
container_name: redis-staging
volumes:
- nnks_redis_staging:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
mysql-debug:
image: mysql:8.4
container_name: mysql-debug
environment:
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
ports:
- "3307:3306"
volumes:
- db_debug_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "MYSQL_PWD=$$MYSQL_ROOT_PASSWORD mysqladmin -u root ping --silent"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
edge-broker:
build:
context: .
dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker
environment:
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
labels:
- "traefik.enable=true"
- "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api.tls=true"
- "traefik.http.routers.edge-broker-api.tls.certresolver=le"
- "traefik.http.routers.edge-broker-api.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api.priority=200"
- "traefik.http.routers.edge-broker-api.service=edge-broker"
- "traefik.http.routers.edge-broker-api-io.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-io.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api-io.tls=true"
- "traefik.http.routers.edge-broker-api-io.tls.certresolver=le_io"
- "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-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"
- "traefik.http.routers.edge-broker-api-staging.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-staging.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-staging.priority=200"
- "traefik.http.routers.edge-broker-api-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`)"
- "traefik.http.routers.edge-broker-local-secure.entrypoints=websecure"
- "traefik.http.routers.edge-broker-local-secure.tls=true"
- "traefik.http.routers.edge-broker-local-secure.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local-secure.priority=200"
- "traefik.http.routers.edge-broker-local-secure.service=edge-broker"
- "traefik.http.routers.edge-broker-local-staging.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-local-staging.tls=true"
- "traefik.http.routers.edge-broker-local-staging.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local-staging.priority=200"
- "traefik.http.routers.edge-broker-local-staging.service=edge-broker"
- "traefik.http.middlewares.edge-broker-strip.stripPrefix.prefixes=/edge-broker"
- "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker"
- "traefik.http.services.edge-broker.loadbalancer.server.port=4300"
caddy:
image: caddy:2.7.6-alpine
container_name: caddy
depends_on:
- php1
- php2
- php3
- php4
- php5
volumes:
- ./services/nginx/app:/var/www/html
- ./services/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./services/caddy/logs:/var/log/caddy
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.truckwash.dk`)"
- "traefik.http.routers.api.entrypoints=websecure"
- "traefik.http.routers.api.tls=true"
- "traefik.http.routers.api.tls.domains[0].main=api.truckwash.dk"
- "traefik.http.routers.api.tls.certresolver=le"
- "traefik.http.routers.api.service=caddy"
- "traefik.http.routers.api.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.api-io.rule=Host(`api.truckwash.io`)"
- "traefik.http.routers.api-io.entrypoints=websecure"
- "traefik.http.routers.api-io.tls=true"
- "traefik.http.routers.api-io.tls.domains[0].main=api.truckwash.io"
- "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-http.entrypoints=web"
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.api-http.service=caddy"
- "traefik.http.routers.local.rule=Host(`localhost`)"
- "traefik.http.routers.local.entrypoints=web"
- "traefik.http.routers.local.service=caddy"
- "traefik.http.routers.local.middlewares=secure-headers@file"
- "traefik.http.routers.local-secure.rule=Host(`localhost`)"
- "traefik.http.routers.local-secure.entrypoints=websecure"
- "traefik.http.routers.local-secure.tls=true"
- "traefik.http.routers.local-secure.service=caddy"
- "traefik.http.routers.local-secure.middlewares=secure-headers@file"
- "traefik.http.routers.local-api.rule=Host(`localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.local-api.entrypoints=web"
- "traefik.http.routers.local-api.middlewares=strip-api-prefix@file,secure-headers@file"
- "traefik.http.routers.local-api.service=caddy"
- "traefik.http.routers.local-api.priority=100"
- "traefik.http.routers.local-api-secure.rule=Host(`localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.local-api-secure.entrypoints=websecure"
- "traefik.http.routers.local-api-secure.tls=true"
- "traefik.http.routers.local-api-secure.middlewares=strip-api-prefix@file,secure-headers@file"
- "traefik.http.routers.local-api-secure.service=caddy"
- "traefik.http.routers.local-api-secure.priority=100"
- "traefik.http.services.caddy.loadbalancer.server.port=80"
caddy-staging:
image: caddy:2.7.6-alpine
container_name: caddy-staging
depends_on:
- php-staging
volumes:
- ./services/nginx/staging:/var/www/html
- ./services/caddy/Caddyfile-staging:/etc/caddy/Caddyfile:ro
- ./services/caddy/logs-staging:/var/log/caddy
labels:
- "traefik.enable=true"
- "traefik.http.routers.api-staging.rule=Host(`api.truckwash.io`)"
- "traefik.http.routers.api-staging.entrypoints=websecure-staging"
- "traefik.http.routers.api-staging.tls=true"
- "traefik.http.routers.api-staging.tls.domains[0].main=api.truckwash.io"
- "traefik.http.routers.api-staging.tls.certresolver=le_io"
- "traefik.http.routers.api-staging.service=caddy-staging"
- "traefik.http.routers.api-staging.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.local-staging.rule=Host(`localhost`)"
- "traefik.http.routers.local-staging.entrypoints=websecure-staging"
- "traefik.http.routers.local-staging.service=caddy-staging"
- "traefik.http.routers.local-staging.middlewares=secure-headers@file"
- "traefik.http.routers.local-staging-secure.rule=Host(`localhost`)"
- "traefik.http.routers.local-staging-secure.entrypoints=websecure-staging"
- "traefik.http.routers.local-staging-secure.tls=true"
- "traefik.http.routers.local-staging-secure.service=caddy-staging"
- "traefik.http.routers.local-staging-secure.middlewares=secure-headers@file"
- "traefik.http.routers.local-staging-api.rule=Host(`localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.local-staging-api.entrypoints=websecure-staging"
- "traefik.http.routers.local-staging-api.middlewares=strip-api-prefix@file,secure-headers@file"
- "traefik.http.routers.local-staging-api.service=caddy-staging"
- "traefik.http.routers.local-staging-api.priority=100"
- "traefik.http.routers.local-staging-api-secure.rule=Host(`localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.local-staging-api-secure.entrypoints=websecure-staging"
- "traefik.http.routers.local-staging-api-secure.tls=true"
- "traefik.http.routers.local-staging-api-secure.middlewares=strip-api-prefix@file,secure-headers@file"
- "traefik.http.routers.local-staging-api-secure.service=caddy-staging"
- "traefik.http.routers.local-staging-api-secure.priority=100"
- "traefik.http.services.caddy-staging.loadbalancer.server.port=80"
php1:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php1
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "true"
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/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php2:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php2
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
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/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php3:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php3
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
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/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php4:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php4
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
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/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php5:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php5
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
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/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php-staging:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php-staging
depends_on:
- redis-staging
- edge-broker
command: ["php-fpm"]
env_file:
- .env.staging
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/staging:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs-staging:/var/log/php
php-cron:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php-cron
depends_on:
- redis
- edge-broker
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
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/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
portainer:
image: portainer/portainer-ce:2.21.4
container_name: portainer
profiles:
- dev
ports:
- "9443:9443"
- "9000:9000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer_data:/data
jaeger:
image: jaegertracing/all-in-one:1.53
container_name: jaeger
profiles:
- dev
environment:
- COLLECTOR_ZIPKIN_HTTP_PORT=9411
ports:
- "16686:16686"
n8n:
image: n8nio/n8n:latest
container_name: n8n
restart: always
environment:
- N8N_HOST=n8n.truckwash.io
- N8N_PORT=5678
- N8N_PROTOCOL=https
- NODE_ENV=production
- WEBHOOK_URL=https://n8n.truckwash.io/
- GENERIC_TIMEZONE=${CONFIG_TIMEZONE:-Europe/Copenhagen}
volumes:
- n8n_data:/home/node/.n8n
labels:
- "traefik.enable=true"
- "traefik.http.routers.n8n.rule=Host(`n8n.truckwash.io`)"
- "traefik.http.routers.n8n.entrypoints=websecure"
- "traefik.http.routers.n8n.tls=true"
- "traefik.http.routers.n8n.tls.certresolver=le_io"
- "traefik.http.routers.n8n.service=n8n"
- "traefik.http.routers.n8n-http.rule=Host(`n8n.truckwash.io`)"
- "traefik.http.routers.n8n-http.entrypoints=web"
- "traefik.http.routers.n8n-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.n8n-http.service=n8n"
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
volumes:
db_data:
db_debug_data:
nnks_redis:
nnks_redis_staging:
es_data:
portainer_data:
fleet-server-data:
elastic-agent-data:
n8n_data:
+51 -8
View File
@@ -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
@@ -86,9 +86,51 @@ services:
dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker
environment:
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
ports:
- "4300:4300"
labels:
- "traefik.enable=true"
- "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api.tls=true"
- "traefik.http.routers.edge-broker-api.tls.certresolver=le"
- "traefik.http.routers.edge-broker-api.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api.priority=200"
- "traefik.http.routers.edge-broker-api.service=edge-broker"
- "traefik.http.routers.edge-broker-api-io.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-io.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api-io.tls=true"
- "traefik.http.routers.edge-broker-api-io.tls.certresolver=le_io"
- "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-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"
- "traefik.http.routers.edge-broker-api-staging.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-staging.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-staging.priority=200"
- "traefik.http.routers.edge-broker-api-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`)"
- "traefik.http.routers.edge-broker-local-secure.entrypoints=websecure"
- "traefik.http.routers.edge-broker-local-secure.tls=true"
- "traefik.http.routers.edge-broker-local-secure.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local-secure.priority=200"
- "traefik.http.routers.edge-broker-local-secure.service=edge-broker"
- "traefik.http.routers.edge-broker-local-staging.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-local-staging.tls=true"
- "traefik.http.routers.edge-broker-local-staging.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local-staging.priority=200"
- "traefik.http.routers.edge-broker-local-staging.service=edge-broker"
- "traefik.http.middlewares.edge-broker-strip.stripPrefix.prefixes=/edge-broker"
- "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker"
- "traefik.http.services.edge-broker.loadbalancer.server.port=4300"
caddy:
image: caddy:2.7.6-alpine
@@ -101,7 +143,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"
@@ -158,9 +200,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/caddy:/etc/caddy:ro
- ./services/caddy/logs-staging:/var/log/caddy
labels:
- "traefik.enable=true"
+677
View File
@@ -0,0 +1,677 @@
import assert from "node:assert/strict";
import { execFile as execFileCallback, spawn as spawnCallback } from "node:child_process";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs";
const execFile = promisify(execFileCallback);
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"];
function composeArgs(projectName, args) {
return ["compose", "-p", projectName, ...args];
}
async function resolveRootDir(scriptPath) {
const cwd = process.cwd();
try {
await fs.access(path.join(cwd, "docker-compose.yml"));
return cwd;
} catch {
return path.resolve(path.dirname(scriptPath), "..");
}
}
async function runCommand(command, args, { cwd, allowFailure = false, stdio = "pipe" } = {}) {
if (stdio === "inherit") {
await new Promise((resolve, reject) => {
const child = spawnCallback(command, args, {
cwd,
stdio: "inherit",
windowsHide: true,
});
child.on("exit", (code) => {
if (code === 0 || allowFailure) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code}`));
});
child.on("error", reject);
});
return { stdout: "", stderr: "", code: 0 };
}
try {
const result = await execFile(command, args, {
cwd,
windowsHide: true,
encoding: "utf8",
});
return { stdout: result.stdout, stderr: result.stderr, code: 0 };
} catch (error) {
if (!allowFailure) {
throw error;
}
return {
stdout: error.stdout || "",
stderr: error.stderr || "",
code: typeof error.code === "number" ? error.code : 1,
};
}
}
function normalizeBaseUrl(url) {
return String(url || "").replace(/\/+$/, "");
}
async function waitForCondition(predicate, { timeoutMs = 30_000, intervalMs = 500, message = "Timed out" } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(message);
}
async function ensureComposeServices(rootDir, composeProject) {
await runCommand("docker", composeArgs(composeProject, ["up", "-d", ...COMPOSE_SERVICES]), {
cwd: rootDir,
stdio: "inherit",
});
}
async function waitForApiReady(baseUrl, attempts = 60) {
const root = normalizeBaseUrl(baseUrl);
let lastError = "API never responded";
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const response = await fetch(`${root}/ping`);
if (response.ok) {
return;
}
lastError = `Unexpected ping status ${response.status}`;
} catch (error) {
lastError = 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}`);
}
function parseLastJsonLine(output) {
const lines = String(output || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
for (let index = lines.length - 1; index >= 0; index -= 1) {
try {
return JSON.parse(lines[index]);
} catch {
// Continue scanning backwards for the JSON payload.
}
}
throw new Error(`Unable to parse JSON from command output:\n${output}`);
}
async function runPhpFixture(rootDir, composeProject, action, payload = null) {
const encodedPayload = payload === null
? ""
: ` ${Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")}`;
const command = `cd /var/www/html && CONFIG_DB_TARGET=debug php tests/Support/EdgeGatewayE2eFixture.php ${action}${encodedPayload}`;
const result = await runCommand("docker", composeArgs(composeProject, [
"exec",
"-T",
"php1",
"sh",
"-lc",
command,
]), {
cwd: rootDir,
});
return parseLastJsonLine(result.stdout);
}
async function apiRequest(baseUrl, method, endpoint, { token = null, body = null, headers = {} } = {}) {
const response = await fetch(`${normalizeBaseUrl(baseUrl)}${endpoint}`, {
method,
headers: {
...(body === null ? {} : { "content-type": "application/json" }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
},
body: body === null ? undefined : JSON.stringify(body),
});
const rawBody = await response.text();
let json = null;
if (rawBody !== "") {
try {
json = JSON.parse(rawBody);
} catch {
json = null;
}
}
if (!response.ok) {
const message =
json?.data?.message ||
json?.error ||
rawBody ||
`HTTP ${response.status}`;
throw new Error(`${method} ${endpoint} failed: ${message}`);
}
return json;
}
async function loadWebSocketImplementation() {
if (typeof WebSocket !== "undefined") {
return WebSocket;
}
const module = await import("ws");
return module.default;
}
function onSocket(socket, eventName, handler) {
if (typeof socket.addEventListener === "function") {
socket.addEventListener(eventName, (event) => {
if (eventName === "message") {
handler(event.data);
return;
}
handler(event);
});
return;
}
socket.on(eventName, handler);
}
function collectSocketMessages(socket) {
const messages = [];
onSocket(socket, "message", (payload) => {
const text = typeof payload === "string"
? payload
: Buffer.isBuffer(payload)
? payload.toString("utf8")
: typeof payload?.toString === "function"
? payload.toString()
: "";
if (text === "") {
return;
}
try {
messages.push(JSON.parse(text));
} catch {
// Ignore non-JSON frames.
}
});
return messages;
}
async function waitForSocketOpen(socket) {
if (socket.readyState === 1) {
return;
}
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Timed out waiting for websocket open.")), 10_000);
const socketUrl = typeof socket.url === "string" && socket.url !== "" ? ` (${socket.url})` : "";
const onOpen = () => {
clearTimeout(timeout);
resolve();
};
const onError = (error) => {
clearTimeout(timeout);
if (error instanceof Error) {
reject(error);
return;
}
const readyState = typeof socket.readyState === "number" ? socket.readyState : "unknown";
reject(new Error(`Websocket failed to open${socketUrl}; readyState=${readyState}.`));
};
const onClose = (event) => {
clearTimeout(timeout);
const code = event && typeof event === "object" && "code" in event ? event.code : "unknown";
const reason = event && typeof event === "object" && "reason" in event ? event.reason : "";
reject(new Error(`Websocket closed before open${socketUrl}; code=${code} reason=${reason || "none"}.`));
};
onSocket(socket, "open", onOpen);
onSocket(socket, "error", onError);
onSocket(socket, "close", onClose);
});
}
async function waitForSocketMessage(messages, predicate, options) {
await waitForCondition(() => messages.some(predicate), options);
}
function buildSocketUrl(wsUrl, token) {
const url = new URL(String(wsUrl));
url.searchParams.set("token", token);
return url.toString();
}
function closeSocket(socket) {
if (!socket || typeof socket.close !== "function") {
return;
}
const readyState = typeof socket.readyState === "number" ? socket.readyState : null;
if (readyState !== null && readyState >= 2) {
return;
}
socket.close();
}
function collectMessages(rows) {
return Array.isArray(rows)
? rows
.map((row) => (row && typeof row === "object" ? row.message : null))
.filter((value) => typeof value === "string")
: [];
}
function summarizeStreamMessages(messages, limit = 12) {
return messages
.slice(-limit)
.map((message) => {
if (!message || typeof message !== "object") {
return null;
}
const summary = {
type: message.type || "unknown",
};
if (message.operationId !== undefined) {
summary.operationId = Number(message.operationId || 0);
}
if (message.operation && typeof message.operation === "object") {
summary.operationStatus = message.operation.status || null;
}
if (message.gateway && typeof message.gateway === "object") {
summary.gatewayStatus = message.gateway.status || null;
}
return summary;
})
.filter(Boolean);
}
async function main() {
const scriptPath = fileURLToPath(import.meta.url);
const rootDir = await resolveRootDir(scriptPath);
const runId = randomUUID().slice(0, 8);
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;
const composeProject =
process.env.EDGE_GATEWAY_E2E_COMPOSE_PROJECT
|| path.basename(rootDir);
let fixture = null;
let gatewayId = null;
let streamSocket = null;
let shellSocket = null;
try {
await ensureComposeServices(rootDir, composeProject);
await waitForApiReady(baseUrl);
fixture = await runPhpFixture(rootDir, composeProject, "create");
const authToken = String(fixture.auth_token || "");
const departmentId = Number(fixture.department_id || 0);
assert.ok(authToken !== "", "Fixture helper did not return an auth token.");
assert.ok(departmentId > 0, "Fixture helper did not return a department id.");
const installTokenResponse = await apiRequest(baseUrl, "POST", "/edge-gateways/install-token", {
token: authToken,
body: {
department_id: departmentId,
label: `Edge Gateway E2E ${runId}`,
},
});
const installToken = String(installTokenResponse?.data?.token || "");
assert.ok(installToken !== "", "Install token creation did not return a token.");
await runCommand(process.execPath, [
"scripts/test-gateway.mjs",
"start",
"--install-token",
installToken,
"--container-name",
containerName,
"--config-dir",
configDir,
"--heartbeat-seconds",
"3",
"--skip-compose-up",
], {
cwd: rootDir,
stdio: "inherit",
});
await waitForCondition(
async () => {
try {
await fs.access(configFilePath);
return true;
} catch {
return false;
}
},
{ message: `Gateway config file was not created at ${configFilePath}` }
);
const config = JSON.parse(await fs.readFile(configFilePath, "utf8"));
gatewayId = Number(config.gatewayId || 0);
assert.ok(gatewayId > 0, "Gateway config did not include a gateway id.");
await waitForCondition(
async () => {
const result = await runCommand("docker", [
"exec",
containerName,
"test",
"-f",
"/opt/truckwash-edge-agent/runtime/last-heartbeat-ok.txt",
], {
allowFailure: true,
});
return result.code === 0;
},
{
timeoutMs: 60_000,
message: "Gateway never wrote the successful heartbeat marker.",
}
);
await waitForCondition(
async () => {
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
token: authToken,
});
return detail?.data?.status === "ONLINE"
&& Object.keys(detail?.data?.metadata?.system_metrics || {}).length > 0;
},
{
timeoutMs: 60_000,
message: "Gateway detail never transitioned to ONLINE with fresh system metrics after install.",
}
);
await waitForCondition(
async () => {
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
token: authToken,
});
return Boolean(
detail?.data?.channel_status?.broker?.connected
|| detail?.data?.metadata?.broker_connected
);
},
{
timeoutMs: 90_000,
message: "Gateway never established a live broker connection after install.",
}
);
const WebSocketImpl = await loadWebSocketImplementation();
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
token: authToken,
body: {
scopes: ["overview", "tasks", "logs", "statistics"],
},
});
const streamWsUrl = buildSocketUrl(String(streamSession?.data?.ws_url || ""), String(streamSession?.data?.token || ""));
streamSocket = new WebSocketImpl(streamWsUrl);
const streamMessages = collectSocketMessages(streamSocket);
await waitForSocketOpen(streamSocket);
await waitForSocketMessage(
streamMessages,
(message) => message?.type === "gateway.stream.ready",
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
);
const readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready");
assert.equal(
Boolean(readyMessage?.connected),
true,
"Gateway stream became ready before the broker reported the gateway as connected."
);
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
body: {
type: "DISCOVERY",
request: {
inventory: [{
device_id: `edge-e2e-${runId}`,
local_ip: "10.70.80.90",
model: "TruckWash Edge E2E",
channel_count: 1,
online: true,
capabilities: {
gateway_management_v2: true,
},
metadata: {
hostname: `edge-e2e-${runId}`,
},
}],
},
},
});
const operationId = Number(operationResponse?.data?.operation?.id || 0);
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
try {
await waitForSocketMessage(
streamMessages,
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId,
{ timeoutMs: 180_000, message: "Live gateway stream never emitted task.updated for the queued operation." }
);
} catch (error) {
let operationSnapshot = null;
try {
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
});
operationSnapshot = Array.isArray(operations?.data)
? operations.data.find((item) => Number(item?.id || 0) === operationId) || null
: null;
} catch {
operationSnapshot = null;
}
const diagnostic = [
error instanceof Error ? error.message : String(error),
`Recent stream messages: ${JSON.stringify(summarizeStreamMessages(streamMessages))}`,
`Operation snapshot: ${JSON.stringify(operationSnapshot)}`,
].join("\n");
throw new Error(diagnostic);
}
await waitForCondition(
async () => {
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
});
const operation = Array.isArray(operations?.data)
? operations.data.find((item) => Number(item?.id || 0) === operationId)
: null;
return operation?.status === "COMPLETED";
},
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
);
await waitForSocketMessage(
streamMessages,
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
{ timeoutMs: 15_000, message: "Live gateway stream never emitted telemetry or statistics updates." }
);
await waitForCondition(
async () => {
const logs = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const timeline = Array.isArray(logs?.data?.entries)
? logs.data.entries
: (Array.isArray(logs?.data?.timeline) ? logs.data.timeline : []);
return timeline.some((entry) => {
const nestedEntry = entry?.entry && typeof entry.entry === "object" ? entry.entry : null;
const directOperationId = Number(nestedEntry?.operation_id || 0);
const contextualOperationId = Number(nestedEntry?.context?.operation_id || 0);
return directOperationId === operationId || contextualOperationId === operationId;
});
},
{ timeoutMs: 30_000, message: "Gateway logs page never reflected the live operation timeline." }
);
const statistics = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/statistics`, {
token: authToken,
});
assert.ok(
Object.keys(statistics?.data?.system_metrics || {}).length > 0,
"Gateway statistics page did not expose system metrics after live telemetry."
);
const shellSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/shell-sessions`, {
token: authToken,
body: {
reason: "Edge gateway E2E shell validation",
cwd: "/opt/truckwash-edge-agent",
cols: 120,
rows: 40,
},
});
const shellWsUrl = buildSocketUrl(String(shellSession?.data?.ws_url || ""), String(shellSession?.data?.token || ""));
shellSocket = new WebSocketImpl(shellWsUrl);
const shellMessages = collectSocketMessages(shellSocket);
await waitForSocketOpen(shellSocket);
await waitForSocketMessage(
shellMessages,
(message) => message?.type === "opened",
{ timeoutMs: 20_000, message: "Browser shell never opened against the live gateway." }
);
shellSocket.send(JSON.stringify({
type: "input",
data: "printf 'edge-e2e-shell\\n'; exit\n",
}));
await waitForSocketMessage(
shellMessages,
(message) => message?.type === "output" && String(message?.data || "").includes("edge-e2e-shell"),
{ timeoutMs: 20_000, message: "Browser shell never returned the expected command output." }
);
await waitForSocketMessage(
shellMessages,
(message) => message?.type === "closed",
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
);
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: [];
assert.ok(
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
"Gateway logs page did not persist the shell transcript."
);
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
"Gateway logs page did not include the shell close audit event."
);
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
} finally {
closeSocket(shellSocket);
closeSocket(streamSocket);
await runCommand(process.execPath, [
"scripts/test-gateway.mjs",
"stop",
"--container-name",
containerName,
], {
cwd: rootDir,
allowFailure: true,
}).catch(() => {});
if (gatewayId !== null && fixture?.auth_token) {
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
token: String(fixture.auth_token),
}).catch(() => {});
}
if (fixture !== null) {
await runPhpFixture(rootDir, composeProject, "cleanup", fixture).catch(() => {});
}
await fs.rm(configDir, { recursive: true, force: true }).catch(() => {});
}
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
+22 -3
View File
@@ -21,6 +21,21 @@ export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15;
export const DEFAULT_INSTALLED_VERSION = "php-agent-v1";
const DEFAULT_COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "caddy"];
function composeArgs(projectName, args) {
return ["compose", "-p", projectName, ...args];
}
async function resolveRootDir(scriptPath) {
const cwd = process.cwd();
try {
await fs.access(path.join(cwd, "docker-compose.yml"));
return cwd;
} catch {
return path.resolve(path.dirname(scriptPath), "..");
}
}
function printUsage() {
process.stdout.write(`Usage:
node scripts/test-gateway.mjs start [--install-token <token>] [--container-name <name>] [--hostname <hostname>]
@@ -245,7 +260,7 @@ async function ensureComposeServices(rootDir, skipComposeUp) {
return;
}
await runCommand("docker", ["compose", "up", "-d", ...DEFAULT_COMPOSE_SERVICES], {
await runCommand("docker", composeArgs(resolveComposeProjectName(rootDir), ["up", "-d", ...DEFAULT_COMPOSE_SERVICES]), {
cwd: rootDir,
stdio: "inherit",
});
@@ -417,7 +432,7 @@ async function main() {
}
const scriptPath = fileURLToPath(import.meta.url);
const rootDir = path.resolve(path.dirname(scriptPath), "..");
const rootDir = await resolveRootDir(scriptPath);
const configDir = resolveConfigDirectory(rootDir, options.configDir);
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
@@ -485,8 +500,12 @@ Gateway ID: ${config.gatewayId ?? "unclaimed"}
}
const currentFilePath = fileURLToPath(import.meta.url);
const currentRealPath = await fs.realpath(currentFilePath).catch(() => currentFilePath);
const invokedScript = process.argv[1] ? path.resolve(process.argv[1]) : "";
if (invokedScript === path.resolve(currentFilePath)) {
const invokedRealPath = invokedScript !== ""
? await fs.realpath(invokedScript).catch(() => invokedScript)
: "";
if (invokedRealPath === currentRealPath) {
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
+1 -5
View File
@@ -4,11 +4,7 @@ WORKDIR /opt/truckwash-edge-agent
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
libcurl4-openssl-dev \
libsqlite3-dev \
ca-certificates; \
docker-php-ext-install curl sqlite3; \
apt-get install -y --no-install-recommends ca-certificates; \
rm -rf /var/lib/apt/lists/*
COPY services/nginx/app/resources/edge-gateway-agent/ ./
+34 -9
View File
@@ -137,6 +137,7 @@ 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,
@@ -1764,19 +1765,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 +1841,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 +1877,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 {
+199 -154
View File
@@ -1,5 +1,6 @@
import http from "node:http";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { WebSocketServer } from "ws";
function parseJsonBody(req) {
@@ -515,179 +516,183 @@ export function createBrokerServer(options = {}) {
return;
}
if (ws.gatewayId) {
if (message.type === "COMMAND_RESULT") {
const pending = pendingCommands.get(message.commandId);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
pendingCommands.delete(message.commandId);
pending.resolve({
ok: Boolean(message.ok),
payload: message.payload,
error: message.error,
});
return;
}
if (message.type === "TELEMETRY") {
const payload = message.payload || {};
const ingested = await ingestTelemetry(String(ws.gatewayId), payload);
broadcastGatewayEvent(String(ws.gatewayId), {
type: "gateway.telemetry",
gatewayId: String(ws.gatewayId),
telemetry: payload,
gateway: ingested?.gateway || ingested || null,
});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "stats.updated",
gatewayId: String(ws.gatewayId),
statistics: ingested?.statistics || ingested || null,
});
return;
}
if (message.type === "TASK_EVENT") {
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
if (!Number.isFinite(operationId) || operationId <= 0) {
try {
if (ws.gatewayId) {
if (message.type === "COMMAND_RESULT") {
const pending = pendingCommands.get(message.commandId);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
pendingCommands.delete(message.commandId);
pending.resolve({
ok: Boolean(message.ok),
payload: message.payload,
error: message.error,
});
return;
}
const operation = await ingestTaskEvent(String(ws.gatewayId), operationId, message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "task.updated",
gatewayId: String(ws.gatewayId),
operationId,
operation,
});
return;
}
if (message.type === "TASK_RESULT") {
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
if (!Number.isFinite(operationId) || operationId <= 0) {
if (message.type === "TELEMETRY") {
const payload = message.payload || {};
const ingested = await ingestTelemetry(String(ws.gatewayId), payload);
broadcastGatewayEvent(String(ws.gatewayId), {
type: "gateway.telemetry",
gatewayId: String(ws.gatewayId),
telemetry: payload,
gateway: ingested?.gateway || ingested || null,
});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "stats.updated",
gatewayId: String(ws.gatewayId),
statistics: ingested?.statistics || ingested || null,
});
return;
}
const operation = await ingestTaskResult(String(ws.gatewayId), operationId, message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "task.updated",
gatewayId: String(ws.gatewayId),
operationId,
operation,
});
await syncGatewayBacklog(String(ws.gatewayId), ws).catch(() => {});
if (message.type === "TASK_EVENT") {
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
if (!Number.isFinite(operationId) || operationId <= 0) {
return;
}
const operation = await ingestTaskEvent(String(ws.gatewayId), operationId, message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "task.updated",
gatewayId: String(ws.gatewayId),
operationId,
operation,
});
return;
}
if (message.type === "TASK_RESULT") {
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
if (!Number.isFinite(operationId) || operationId <= 0) {
return;
}
const operation = await ingestTaskResult(String(ws.gatewayId), operationId, message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "task.updated",
gatewayId: String(ws.gatewayId),
operationId,
operation,
});
await syncGatewayBacklog(String(ws.gatewayId), ws).catch(() => {});
return;
}
if (message.type === "LOG_FRAME") {
const logEntry = await ingestLogEntry(String(ws.gatewayId), message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "log.append",
gatewayId: String(ws.gatewayId),
entry: logEntry,
});
return;
}
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
const sessionRecord = browserShellSessions.get(String(message.sessionId));
if (!sessionRecord) {
return;
}
if (message.type === "SHELL_OUTPUT") {
sessionRecord.transcript += String(message.data || "");
sendJson(sessionRecord.ws, { type: "output", data: String(message.data || "") });
return;
}
if (message.type === "SHELL_OPENED") {
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");
browserShellSessions.delete(String(message.sessionId));
if (sessionRecord.ws.readyState < 2) {
sessionRecord.ws.close();
}
}
}
return;
}
if (message.type === "LOG_FRAME") {
const logEntry = await ingestLogEntry(String(ws.gatewayId), message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "log.append",
gatewayId: String(ws.gatewayId),
entry: logEntry,
});
if (ws.sessionInfo) {
const sessionId = String(ws.sessionInfo.id);
const agent = agents.get(String(ws.sessionInfo.gateway_id));
if (!agent || agent.readyState !== 1) {
return;
}
if (message.type === "input") {
sendJson(agent, {
type: "SHELL_INPUT",
payload: {
sessionId,
data: String(message.data || ""),
},
});
return;
}
if (message.type === "resize") {
sendJson(agent, {
type: "RESIZE_ROOT_SHELL",
payload: {
sessionId,
cols: Number(message.cols || 0),
rows: Number(message.rows || 0),
},
});
return;
}
if (message.type === "close") {
sendJson(agent, {
type: "CLOSE_ROOT_SHELL",
payload: { sessionId },
});
}
return;
}
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
const sessionRecord = browserShellSessions.get(String(message.sessionId));
if (ws.streamSessionInfo) {
const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id));
if (!sessionRecord) {
return;
}
if (message.type === "SHELL_OUTPUT") {
sessionRecord.transcript += String(message.data || "");
sendJson(sessionRecord.ws, { type: "output", data: String(message.data || "") });
return;
}
if (message.type === "SHELL_OPENED") {
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");
browserShellSessions.delete(String(message.sessionId));
if (sessionRecord.ws.readyState < 2) {
sessionRecord.ws.close();
if (message.type === "SUBSCRIBE") {
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sessionRecord.subscriptions.add(scope);
}
sendJson(sessionRecord.ws, {
type: "subscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "UNSUBSCRIBE") {
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sessionRecord.subscriptions.delete(scope);
}
sendJson(sessionRecord.ws, {
type: "unsubscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "PING") {
sendJson(sessionRecord.ws, { type: "PONG" });
}
}
return;
}
if (ws.sessionInfo) {
const sessionId = String(ws.sessionInfo.id);
const agent = agents.get(String(ws.sessionInfo.gateway_id));
if (!agent || agent.readyState !== 1) {
return;
}
if (message.type === "input") {
sendJson(agent, {
type: "SHELL_INPUT",
payload: {
sessionId,
data: String(message.data || ""),
},
});
return;
}
if (message.type === "resize") {
sendJson(agent, {
type: "RESIZE_ROOT_SHELL",
payload: {
sessionId,
cols: Number(message.cols || 0),
rows: Number(message.rows || 0),
},
});
return;
}
if (message.type === "close") {
sendJson(agent, {
type: "CLOSE_ROOT_SHELL",
payload: { sessionId },
});
}
return;
}
if (ws.streamSessionInfo) {
const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id));
if (!sessionRecord) {
return;
}
if (message.type === "SUBSCRIBE") {
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sessionRecord.subscriptions.add(scope);
}
sendJson(sessionRecord.ws, {
type: "subscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "UNSUBSCRIBE") {
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sessionRecord.subscriptions.delete(scope);
}
sendJson(sessionRecord.ws, {
type: "unsubscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "PING") {
sendJson(sessionRecord.ws, { type: "PONG" });
}
} catch {
// Ignore stale gateway/session delivery errors without killing the broker process.
}
});
@@ -788,3 +793,43 @@ export function createBrokerServer(options = {}) {
},
};
}
async function runBrokerFromCli() {
const broker = createBrokerServer();
let shuttingDown = false;
const shutdown = async (signal) => {
if (shuttingDown) {
return;
}
shuttingDown = true;
try {
await broker.close();
process.exit(0);
} catch (error) {
console.error(`Failed to shut down broker after ${signal}:`, error);
process.exit(1);
}
};
process.on("SIGINT", () => {
void shutdown("SIGINT");
});
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});
const requestedPort = Number(process.env.PORT || 4300);
const address = await broker.listen(Number.isFinite(requestedPort) ? requestedPort : 4300);
const normalizedPort =
typeof address === "object" && address !== null && "port" in address
? address.port
: requestedPort;
console.log(`TruckWash edge broker listening on ${normalizedPort}`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
runBrokerFromCli().catch((error) => {
console.error("TruckWash edge broker failed to start:", error);
process.exit(1);
});
}
+30
View File
@@ -350,3 +350,33 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
agent.terminate();
await broker.close();
});
test("broker survives telemetry ingestion failures for stale gateways", async () => {
const broker = createBrokerServer({
authMode: "stub",
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
ingestTelemetry: async () => {
throw new Error("Edge gateway not found");
},
});
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`);
await new Promise((resolve) => agent.once("open", resolve));
agent.send(
JSON.stringify({
type: "TELEMETRY",
payload: {
status: "ONLINE",
},
})
);
await new Promise((resolve) => setTimeout(resolve, 100));
assert.equal(broker.server.listening, true);
agent.terminate();
await broker.close();
});
+32 -6
View File
@@ -15,6 +15,7 @@ function readRequiredSource(...pathSegments) {
const baseComposeSource = readRequiredSource("docker-compose.yml");
const exampleComposeSource = readRequiredSource("docker-compose.example.yml");
const standaloneProdComposeSource = readRequiredSource("docker-compose.prod.standalone.yml");
const traefikSource = [
readRequiredSource("services", "traefik", "traefik.yml"),
readRequiredSource("services", "traefik", "traefik.prod.yml"),
@@ -35,14 +36,39 @@ test("traefik does not expose a dedicated public edge broker port", () => {
assert.doesNotMatch(traefikSource, /edge-broker:\s*\n\s*address:\s*":4300"/);
});
test("base docker compose exposes the edge broker service on port 4300", () => {
assert.match(baseComposeSource, /\bedge-broker:\b/);
assert.match(baseComposeSource, /edge-broker:\s*\n[\s\S]*?\n\s+ports:\s*\n\s+- "4300:4300"/);
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_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-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/);
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
});
test("example docker compose exposes the edge broker service on port 4300", () => {
assert.match(exampleComposeSource, /\bedge-broker:\b/);
assert.match(exampleComposeSource, /edge-broker:\s*\n[\s\S]*?\n\s+ports:\s*\n\s+- "4300:4300"/);
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_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`\)/);
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
});
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_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-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
});
test("php services receive broker websocket environment defaults", () => {
@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
const brokerEntryPath = path.resolve(testDirectory, "../server.mjs");
test("server entrypoint starts the broker and stays alive until terminated", async () => {
const child = spawn(process.execPath, [brokerEntryPath], {
env: {
...process.env,
PORT: "0",
EDGE_AUTH_MODE: "stub",
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Broker entrypoint did not report readiness.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
}, 10_000);
child.once("exit", (code, signal) => {
clearTimeout(timeout);
reject(new Error(`Broker entrypoint exited early with code=${code} signal=${signal}.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
});
const poll = () => {
if (/TruckWash edge broker listening on \d+/.test(stdout)) {
clearTimeout(timeout);
resolve();
return;
}
setTimeout(poll, 25);
};
poll();
});
assert.equal(child.exitCode, null, `Broker exited unexpectedly.\nstdout:\n${stdout}\nstderr:\n${stderr}`);
const exitResult = await new Promise((resolve, reject) => {
child.once("exit", (code, signal) => resolve({ code, signal }));
child.kill("SIGTERM");
setTimeout(() => reject(new Error("Broker did not exit after SIGTERM.")), 10_000);
});
const exitedCleanly = exitResult.code === 0 || exitResult.signal === "SIGTERM";
assert.equal(exitedCleanly, true, `Broker exited unsuccessfully.\nstdout:\n${stdout}\nstderr:\n${stderr}`);
});
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,497 @@
<?php
namespace classes;
use objects\logs_o;
use objects\users_o;
class customer_mass_import_service
{
/**
* Import or create a company customer from one normalized spreadsheet row.
*
* @throws \RuntimeException
*/
public function import(array $payload): array
{
$normalized = $this->normalizePayload($payload);
$this->assertValidNormalizedPayload($normalized);
$customerNumber = (int)$normalized['customer_number'];
$cvr = (string)$normalized['cvr'];
$warnings = [];
$economicCustomers = $this->searchEconomicCustomersByCvr($cvr);
$localUserExistsBefore = $this->localCustomerNumberExists($customerNumber);
$localUser = $localUserExistsBefore ? $this->loadLocalCustomerByNumber($customerNumber) : null;
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economicCustomers, $customerNumber);
if ($matchingEconomicCustomer !== null) {
$customer = $this->resolveLocalCustomer($customerNumber, $localUserExistsBefore, $localUser);
$this->syncLocalCustomer($customer, $normalized, $warnings);
[$action, $message] = $this->resolveExistingCustomerOutcome($localUserExistsBefore, $this->hasLocalAccount($customer));
return $this->buildSuccessResult(
$normalized,
$customer,
$action,
$message,
$localUserExistsBefore,
true,
false,
$warnings
);
}
if (count($economicCustomers) > 0) {
$existingEconomicCustomerNumber = $this->extractEconomicCustomerNumber($economicCustomers[0]);
$this->logIssue('CUSTOMER_MASS_IMPORT_CONFLICT', [
'phase' => 'search',
'cvr' => $cvr,
'requestedCustomerNumber' => $customerNumber,
'existingCustomerNumber' => $existingEconomicCustomerNumber,
]);
throw new \RuntimeException(
'CVR already registered under customer number '
. $existingEconomicCustomerNumber
. '. The submitted phone number must match the customer id. Manual cleanup or reassignment is required before retrying.',
409
);
}
$normalized['name'] = $this->resolveCreateName($normalized);
$normalized['email'] = $this->resolveCreateEmail($normalized, $warnings);
$createResponse = $this->createEconomicCustomer($normalized);
$createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse);
if ($createdCustomerNumber !== $customerNumber) {
$this->logIssue('CUSTOMER_MASS_IMPORT_CONFLICT', [
'phase' => 'create',
'cvr' => $cvr,
'requestedCustomerNumber' => $customerNumber,
'createdCustomerNumber' => $createdCustomerNumber,
'response' => $createResponse,
]);
throw new \RuntimeException(
'E-conomic created the customer under customer number '
. $createdCustomerNumber
. ' instead of the submitted phone number '
. $customerNumber
. '. Manual cleanup or reassignment is required before retrying.',
409
);
}
$customer = $this->resolveLocalCustomer($customerNumber, $localUserExistsBefore, $localUser);
$this->syncLocalCustomer($customer, $normalized, $warnings);
[$action, $message] = $this->resolveCreatedCustomerOutcome($localUserExistsBefore, $this->hasLocalAccount($customer));
return $this->buildSuccessResult(
$normalized,
$customer,
$action,
$message,
$localUserExistsBefore,
false,
true,
$warnings
);
}
protected function normalizePayload(array $payload): array
{
return [
'customer_number' => $this->normalizePositiveInt($payload['customer_number'] ?? $payload['phone'] ?? null),
'phone' => $this->normalizePositiveInt($payload['phone'] ?? $payload['customer_number'] ?? null),
'cvr' => $this->normalizeDigitString($payload['cvr'] ?? null),
'name' => $this->normalizeText($payload['name'] ?? $payload['company_name'] ?? null),
'email' => $this->normalizeEmail($payload['email'] ?? null),
'ean' => $this->normalizeDigitString($payload['ean'] ?? null),
];
}
protected function assertValidNormalizedPayload(array $normalized): void
{
$customerNumber = $normalized['customer_number'];
$cvr = $normalized['cvr'];
if ($customerNumber === null) {
throw new \RuntimeException('Phone number is required.', 400);
}
$customerNumberLength = strlen((string)$customerNumber);
if ($customerNumberLength < 8 || $customerNumberLength > 10) {
throw new \RuntimeException('Phone number must be between 8 and 10 digits.', 400);
}
if ($cvr === null) {
throw new \RuntimeException('CVR is required.', 400);
}
$cvrLength = strlen($cvr);
if ($cvrLength < 8 || $cvrLength > 20) {
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
}
}
protected function normalizePositiveInt(mixed $value): ?int
{
$digits = $this->normalizeDigitString($value);
if ($digits === null) {
return null;
}
$normalized = (int)$digits;
return $normalized > 0 ? $normalized : null;
}
protected function normalizeDigitString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', (string)$value);
if (!is_string($digits)) {
return null;
}
$digits = trim($digits);
return $digits !== '' ? $digits : null;
}
protected function normalizeText(mixed $value): ?string
{
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
protected function normalizeEmail(mixed $value): ?string
{
$email = $this->normalizeText($value);
if ($email === null) {
return null;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \RuntimeException('Invalid email address.', 400);
}
return $email;
}
protected function resolveCreateName(array $normalized): string
{
if ($normalized['name'] !== null) {
return $normalized['name'];
}
$name = trim($this->fetchCompanyNameByCvr((string)$normalized['cvr']));
if ($name === '') {
throw new \RuntimeException('Customer name is required to create a new company.', 400);
}
return $name;
}
protected function resolveCreateEmail(array $normalized, array &$warnings): string
{
if ($normalized['email'] !== null) {
return $normalized['email'];
}
$warnings[] = 'No email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
return 'jb@truckwash.dk';
}
protected function searchEconomicCustomersByCvr(string $cvr): array
{
$response = (new economic())->customers->customers->search([
'corporateIdentificationNumber' => $cvr,
], [
'skipPages' => 0,
'pageSize' => 1000,
])->collection ?? [];
return is_array($response) ? $response : [];
}
protected function createEconomicCustomer(array $normalized): object
{
$payload = [
'customerNumber' => (int)$normalized['customer_number'],
'corporateIdentificationNumber' => (string)$normalized['cvr'],
'customerGroup' => [
'customerGroupNumber' => 1,
],
'paymentTerms' => [
'paymentTermsNumber' => 12,
],
'name' => (string)$normalized['name'],
'email' => (string)$normalized['email'],
'phone' => (int)$normalized['phone'],
'telephoneAndFaxNumber' => (string)$normalized['phone'],
'mobilePhone' => (string)$normalized['phone'],
'currency' => 'DKK',
'vatZone' => [
'vatZoneNumber' => 1,
],
];
if ($normalized['ean'] !== null) {
$payload['ean'] = (string)$normalized['ean'];
}
return (new economic())->customers->customers->create($payload);
}
protected function localCustomerNumberExists(int $customerNumber): bool
{
$rows = (new users_o())->getFieldsWhere([
'customer_number' => (string)$customerNumber,
], ['id']);
return count($rows) > 0;
}
protected function loadLocalCustomerByNumber(int $customerNumber): ?object
{
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
return $this->localUserExists($customer) ? $customer : null;
}
protected function resolveLocalCustomer(int $customerNumber, bool $localUserExistsBefore, ?object $localUser): object
{
if ($localUserExistsBefore && $this->localUserExists($localUser)) {
return $localUser;
}
return $this->bootstrapLocalCustomerOrFail($customerNumber);
}
protected function bootstrapLocalCustomerOrFail(int $customerNumber): object
{
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
if ($this->localUserExists($customer)) {
return $customer;
}
$this->logIssue('CUSTOMER_MASS_IMPORT_LOCAL_BOOTSTRAP_FAILED', [
'customerNumber' => $customerNumber,
]);
throw new \RuntimeException('Customer was created in e-conomic but could not be imported locally.', 500);
}
protected function fetchCompanyNameByCvr(string $cvr): string
{
return (string)((new virkdata())->getCompanyInformation($cvr, '', [])->name ?? '');
}
protected function logIssue(string $action, array $context): void
{
$message = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($message === false) {
$message = 'Unable to encode customer mass import context';
}
(new logs_o())->add('customers', 'global', 0, 0, $action, $message);
}
protected function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
{
foreach ($customers as $customer) {
if (!is_object($customer)) {
continue;
}
if ($this->extractEconomicCustomerNumber($customer) === $customerNumber) {
return $customer;
}
}
return null;
}
protected function extractEconomicCustomerNumber(object $customer): int
{
if (!isset($customer->customerNumber) || !is_numeric($customer->customerNumber)) {
return 0;
}
return (int)$customer->customerNumber;
}
protected function resolveExistingCustomerOutcome(bool $localUserExistsBefore, bool $hasAccount): array
{
if ($localUserExistsBefore && $hasAccount) {
return [
'account_already_exists',
'Customer already exists locally and already has a login account.',
];
}
if ($localUserExistsBefore) {
return [
'customer_already_exists',
'Customer already exists locally but does not have a login password yet.',
];
}
return [
'imported_existing_customer',
'Imported an existing e-conomic customer into the local customer database.',
];
}
protected function resolveCreatedCustomerOutcome(bool $localUserExistsBefore, bool $hasAccount): array
{
if ($localUserExistsBefore && $hasAccount) {
return [
'economic_customer_created_for_existing_account',
'Created the e-conomic customer for an existing local login account.',
];
}
if ($localUserExistsBefore) {
return [
'economic_customer_created_for_existing_customer',
'Created the e-conomic customer for an existing local customer record.',
];
}
return [
'created_customer',
'Created the customer in e-conomic and imported it locally.',
];
}
protected function buildSuccessResult(
array $normalized,
object $customer,
string $action,
string $message,
bool $existingLocalCustomer,
bool $existingEconomicCustomer,
bool $createdEconomicCustomer,
array $warnings
): array {
$customerName = $this->extractLocalUserDisplayName($customer) ?? $normalized['name'];
return [
'customer_number' => (int)$normalized['customer_number'],
'cvr' => (string)$normalized['cvr'],
'name' => $customerName,
'email' => $normalized['email'],
'ean' => $normalized['ean'],
'action' => $action,
'message' => $message,
'user_id' => $this->extractLocalUserId($customer),
'has_account' => $this->hasLocalAccount($customer),
'existing_local_customer' => $existingLocalCustomer,
'existing_economic_customer' => $existingEconomicCustomer,
'created_economic_customer' => $createdEconomicCustomer,
'warnings' => array_values(array_filter($warnings, static fn(mixed $warning): bool => is_string($warning) && trim($warning) !== '')),
];
}
protected function syncLocalCustomer(object $customer, array $normalized, array &$warnings): void
{
if (!$customer instanceof users_o || !$customer->exists()) {
return;
}
$name = $normalized['name'] ?? null;
$email = $normalized['email'] ?? null;
$phone = $normalized['phone'] ?? null;
$displayName = trim((string)($customer->display_name->value() ?? ''));
if ($name !== null && ($displayName === '' || strtolower($displayName) === 'unnamed')) {
$customer->display_name->set($name);
}
if ($email !== null && trim((string)($customer->email->value() ?? '')) === '') {
try {
$customer->setEmail($email);
} catch (\Throwable $throwable) {
$warnings[] = 'Unable to update local email: ' . $throwable->getMessage();
}
}
if ($phone !== null && empty($customer->phone->value())) {
try {
$customer->setPhoneNumber((int)$phone);
} catch (\Throwable $throwable) {
$warnings[] = 'Unable to update local phone number: ' . $throwable->getMessage();
}
}
}
protected function localUserExists(?object $user): bool
{
if (!is_object($user)) {
return false;
}
if (method_exists($user, 'exists')) {
try {
return (bool)$user->exists();
} catch (\Throwable) {
return false;
}
}
return isset($user->id) && is_numeric($user->id) && (int)$user->id > 0;
}
protected function hasLocalAccount(?object $user): bool
{
if (!$this->localUserExists($user)) {
return false;
}
if (method_exists($user, 'hasPassword')) {
try {
return (bool)$user->hasPassword();
} catch (\Throwable) {
return false;
}
}
return (bool)($user->has_password ?? false);
}
protected function extractLocalUserId(?object $user): ?int
{
if (!is_object($user) || !isset($user->id) || !is_numeric($user->id)) {
return null;
}
$userId = (int)$user->id;
return $userId > 0 ? $userId : null;
}
protected function extractLocalUserDisplayName(?object $user): ?string
{
if (!is_object($user)) {
return null;
}
if ($user instanceof users_o) {
$name = trim((string)($user->display_name->value() ?? ''));
return $name !== '' ? $name : null;
}
$name = trim((string)($user->display_name ?? $user->name ?? ''));
return $name !== '' ? $name : null;
}
}
@@ -9,15 +9,19 @@ class department_gate_config
public string $type;
public ?string $phone_number = null;
public ?int $call_duration_threshold = null;
public ?string $relay_id = null;
public ?int $pulse_seconds = null;
/**
* @param array $config
*/
public function __construct(array $config = [])
{
$this->type = (string)($config['type'] ?? '');
$this->type = strtoupper(trim((string)($config['type'] ?? '')));
$this->phone_number = isset($config['phone_number']) ? (string)$config['phone_number'] : null;
$this->call_duration_threshold = isset($config['call_duration_threshold']) ? (int)$config['call_duration_threshold'] : null;
$this->relay_id = isset($config['relay_id']) ? trim((string)$config['relay_id']) : null;
$this->pulse_seconds = isset($config['pulse_seconds']) ? (int)$config['pulse_seconds'] : null;
}
/**
@@ -37,6 +41,14 @@ class department_gate_config
$array['call_duration_threshold'] = $this->call_duration_threshold;
}
if ($this->relay_id !== null) {
$array['relay_id'] = $this->relay_id;
}
if ($this->pulse_seconds !== null) {
$array['pulse_seconds'] = $this->pulse_seconds;
}
return $array;
}
@@ -58,6 +70,19 @@ class department_gate_config
if ($this->call_duration_threshold === null) {
throw new Exception('Call duration threshold is required for PHONE_CALL gate type');
}
return;
}
if ($this->type === 'RELAY') {
if ($this->relay_id === null || $this->relay_id === '') {
throw new Exception('relay_id is required for RELAY gate type');
}
if ($this->pulse_seconds !== null && $this->pulse_seconds < 0) {
throw new Exception('pulse_seconds must be a positive integer for RELAY gate type');
}
return;
}
throw new Exception('Unsupported gate config type: ' . $this->type);
}
}
+50 -3
View File
@@ -54,7 +54,7 @@ class shelly implements shelly_i
self::requireValidSecretKey();
// Send the request
$response = match ($method) {
//'GET' => self::sendGetRequest($endpoint, $data),
'GET' => self::sendGetRequest($endpoint, $data),
'POST' => self::sendPostRequest($endpoint, $data),
//'PUT' => self::sendPutRequest($endpoint, $data),
//'DELETE' => self::sendDeleteRequest($endpoint, $data),
@@ -186,9 +186,56 @@ class shelly implements shelly_i
return json_decode($response);
}
function appendAuthKeyToQuery(string $url): string
/**
* @inheritDoc
* @throws Exception
*/
function sendGetRequest(string $endpoint, array $data): array|object|null
{
return $url . '?auth_key=' . $this->config->secret_key->getVariableValue();
self::requireModuleEnabled();
self::requireValidSecretKey();
self::requireValidServerURL();
$this->waitForShellyRateLimitWindow();
$ch = curl_init();
curl_setopt(
$ch,
CURLOPT_URL,
self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint, $data)
);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPGET, true);
$response = curl_exec($ch);
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
self::exception(
[
'method' => 'GET',
'endpoint' => $endpoint,
'data' => $data,
'response' => $response,
],
$status_code
);
}
curl_close($ch);
if ($response === false) {
return null;
}
return json_decode($response);
}
function appendAuthKeyToQuery(string $url, array $query = []): string
{
$query['auth_key'] = $this->config->secret_key->getVariableValue();
$separator = str_contains($url, '?') ? '&' : '?';
return $url . $separator . http_build_query($query);
}
/**
@@ -0,0 +1,436 @@
<?php
namespace classes;
use Exception;
class shelly_relay_inventory
{
private ?shelly $client;
/** @var callable|null */
private $inventory_fetcher = null;
/** @var callable|null */
private $device_list_fetcher = null;
public function __construct(?shelly $client = null)
{
$this->client = $client;
}
public function setInventoryFetcher(callable $fetcher): self
{
$this->inventory_fetcher = $fetcher;
return $this;
}
public function setDeviceListFetcher(callable $fetcher): self
{
$this->device_list_fetcher = $fetcher;
return $this;
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listRelayOptions(): array
{
$devices_status = $this->fetchOwnedDevicesStatus();
$device_catalog = $this->fetchOwnedDeviceCatalog();
$options_by_id = [];
foreach ($devices_status as $device) {
$normalized_device = $this->normalizeToArray($device);
$device_id = $this->extractFirstString([
$normalized_device['_dev_info']['id'] ?? null,
$normalized_device['id'] ?? null,
]);
$catalog_entry = $device_id !== '' ? ($device_catalog[$device_id] ?? null) : null;
$option = $this->buildRelayOption(
$normalized_device,
is_array($catalog_entry) ? $catalog_entry : null
);
if ($option === null) {
continue;
}
$options_by_id[$option['id']] = $option;
}
$options = array_values($options_by_id);
usort($options, static function (array $left, array $right): int {
$status_compare = self::statusSortWeight((string)($left['status_color'] ?? ''))
<=> self::statusSortWeight((string)($right['status_color'] ?? ''));
if ($status_compare !== 0) {
return $status_compare;
}
$name_compare = strcasecmp((string)($left['name'] ?? ''), (string)($right['name'] ?? ''));
if ($name_compare !== 0) {
return $name_compare;
}
return strcmp((string)($left['id'] ?? ''), (string)($right['id'] ?? ''));
});
return $options;
}
/**
* @return array<string,array<string,mixed>>
*/
private function fetchOwnedDeviceCatalog(): array
{
try {
$payload = is_callable($this->device_list_fetcher)
? ($this->device_list_fetcher)()
: $this->getClient()->sendGetRequest('/interface/device/list', [
'no_shared' => 'true',
]);
} catch (\Throwable) {
return [];
}
$normalized = $this->normalizeToArray($payload);
if (($normalized['isok'] ?? true) === false) {
return [];
}
$devices = $normalized['data']['devices'] ?? null;
if (!is_array($devices)) {
return [];
}
$catalog_by_id = [];
foreach ($devices as $device_key => $device) {
$normalized_device = $this->normalizeToArray($device);
$device_id = $this->extractFirstString([
$normalized_device['id'] ?? null,
is_string($device_key) ? $device_key : null,
]);
if ($device_id === '') {
continue;
}
$catalog_by_id[$device_id] = $normalized_device;
}
return $catalog_by_id;
}
/**
* @return array<string,mixed>
* @throws Exception
*/
private function fetchOwnedDevicesStatus(): array
{
$payload = is_callable($this->inventory_fetcher)
? ($this->inventory_fetcher)()
: $this->getClient()->sendGetRequest('/device/all_status', [
'show_info' => 'true',
'no_shared' => 'true',
]);
$normalized = $this->normalizeToArray($payload);
$is_ok = $normalized['isok'] ?? null;
if ($is_ok === false) {
throw new Exception('Shelly relay inventory request failed');
}
$devices_status = $normalized['data']['devices_status'] ?? null;
if (!is_array($devices_status)) {
throw new Exception('Shelly relay inventory response was missing devices_status');
}
return $devices_status;
}
private function getClient(): shelly
{
if ($this->client instanceof shelly) {
return $this->client;
}
$this->client = new shelly();
return $this->client;
}
/**
* @param array<string,mixed> $device
* @return array<string,mixed>|null
*/
private function buildRelayOption(array $device, ?array $catalog_entry = null): ?array
{
if ($device === [] || !$this->isRelayCapableDevice($device)) {
return null;
}
$device_id = $this->extractFirstString([
$device['_dev_info']['id'] ?? null,
$device['id'] ?? null,
]);
if ($device_id === '') {
return null;
}
$cloud_name = $this->extractFirstString([
$catalog_entry['name'] ?? null,
]);
$local_device_name = $this->extractFirstString([
$device['name'] ?? null,
$device['_dev_info']['name'] ?? null,
$device['settings']['name'] ?? null,
$device['settings']['device']['name'] ?? null,
$device['status']['name'] ?? null,
$device['status']['sys']['device']['name'] ?? null,
]);
$device_name = $cloud_name !== '' ? $cloud_name : $local_device_name;
$device_code = $this->extractFirstString([
$device['_dev_info']['code'] ?? null,
$device['code'] ?? null,
]);
$device_type = $this->extractDeviceType($device, $device_code, $catalog_entry);
$control_type = $this->extractControlType($device);
$control_name = $this->extractControlName($device);
$online = $this->extractOnlineState($device);
if ($online === null) {
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
}
$status_color = $this->extractStatusColor($online);
return [
'id' => $device_id,
'name' => $this->buildRelayLabel(
$device_type,
$cloud_name,
$local_device_name,
$control_name,
$device_id
),
'device_id' => $device_id,
'device_name' => $device_name !== '' ? $device_name : null,
'cloud_name' => $cloud_name !== '' ? $cloud_name : null,
'device_type' => $device_type,
'code' => $device_code !== '' ? $device_code : null,
'control_type' => $control_type,
'control_name' => $control_name !== '' ? $control_name : null,
'status_color' => $status_color,
'online' => $online,
];
}
/**
* @param array<string,mixed> $device
*/
private function isRelayCapableDevice(array $device): bool
{
if ($this->payloadContainsRelayState($device)) {
return true;
}
if ($this->payloadContainsRelayState($device['status'] ?? null)) {
return true;
}
return $this->payloadContainsRelayState($device['settings'] ?? null);
}
private function payloadContainsRelayState(array|object|null $payload): bool
{
$normalized = $this->normalizeToArray($payload);
if ($normalized === []) {
return false;
}
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
if (array_key_exists($switch_key, $normalized)) {
return true;
}
}
foreach (['relays', 'switches'] as $collection_key) {
if (isset($normalized[$collection_key]) && is_array($normalized[$collection_key]) && $normalized[$collection_key] !== []) {
return true;
}
}
return false;
}
/**
* @param array<string,mixed> $device
*/
private function extractOnlineState(array $device): ?bool
{
$online = $device['_dev_info']['online'] ?? $device['online'] ?? null;
if (is_bool($online)) {
return $online;
}
if (is_numeric($online)) {
return (int)$online === 1;
}
return null;
}
private function extractStatusColor(?bool $online): string
{
if ($online === true) {
return 'Green';
}
if ($online === false) {
return 'Red';
}
return 'Yellow';
}
/**
* @param array<string,mixed> $device
*/
private function extractDeviceType(array $device, string $device_code, ?array $catalog_entry = null): ?string
{
$device_type = $this->extractFirstString([
$device['_dev_info']['model'] ?? null,
$device['_dev_info']['type'] ?? null,
$device['model'] ?? null,
$device['type'] ?? null,
$device['settings']['device']['type'] ?? null,
$catalog_entry['type'] ?? null,
$device_code,
]);
return $device_type !== '' ? $device_type : null;
}
/**
* @param array<string,mixed> $device
*/
private function extractControlType(array $device): string
{
$status = $this->normalizeToArray($device['status'] ?? null);
$settings = $this->normalizeToArray($device['settings'] ?? null);
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
if (array_key_exists($switch_key, $device) || array_key_exists($switch_key, $status)) {
return 'Switch';
}
}
if ((isset($device['switches']) && is_array($device['switches']) && $device['switches'] !== [])
|| (isset($settings['switches']) && is_array($settings['switches']) && $settings['switches'] !== [])) {
return 'Switch';
}
if ((isset($device['relays']) && is_array($device['relays']) && $device['relays'] !== [])
|| (isset($settings['relays']) && is_array($settings['relays']) && $settings['relays'] !== [])) {
return 'Relay';
}
return 'Device';
}
/**
* @param array<string,mixed> $device
*/
private function extractControlName(array $device): string
{
$status = $this->normalizeToArray($device['status'] ?? null);
$settings = $this->normalizeToArray($device['settings'] ?? null);
return $this->extractFirstString([
$status['switch:0']['name'] ?? null,
$status['switch_0']['name'] ?? null,
$status['switch0']['name'] ?? null,
$device['switches'][0]['name'] ?? null,
$settings['switches'][0]['name'] ?? null,
$device['relays'][0]['name'] ?? null,
$settings['relays'][0]['name'] ?? null,
]);
}
private function buildRelayLabel(
?string $device_type,
string $cloud_name,
string $local_device_name,
string $control_name,
string $device_id
): string {
if ($cloud_name !== '') {
$display_name = $cloud_name;
} elseif ($local_device_name !== '' && $control_name !== '' && strcasecmp($local_device_name, $control_name) !== 0) {
$display_name = $local_device_name . ' / ' . $control_name;
} else {
$display_name = $control_name !== '' ? $control_name : ($local_device_name !== '' ? $local_device_name : $device_id);
}
if ($device_type !== null && $device_type !== '') {
return $display_name . ' (' . $device_type . ')';
}
return $display_name;
}
/**
* @param array<int,mixed> $values
*/
private function extractFirstString(array $values): string
{
foreach ($values as $value) {
$normalized = trim((string)($value ?? ''));
if ($normalized !== '') {
return $normalized;
}
}
return '';
}
private function normalizeBoolean(mixed $value): ?bool
{
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int)$value === 1;
}
return null;
}
private static function statusSortWeight(string $status_color): int
{
return match (strtolower($status_color)) {
'green' => 0,
'yellow' => 1,
'red' => 2,
default => 3,
};
}
/**
* @return array<string,mixed>
*/
private function normalizeToArray(array|object|null $payload): array
{
if (is_array($payload)) {
return $payload;
}
if (is_object($payload)) {
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE);
if (!is_string($encoded)) {
return [];
}
$decoded = json_decode($encoded, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
}
+8
View File
@@ -7,6 +7,14 @@
"Composer\\Config::disableProcessTimeout",
"@php -r \"putenv('RUN_API_TESTS=1'); passthru('vendor/bin/pest --testsuite=Api --colors=always', $exitCode); exit($exitCode);\""
],
"test:api:edge": [
"Composer\\Config::disableProcessTimeout",
"@php -r \"putenv('RUN_API_TESTS=1'); putenv('API_TEST_BOOTSTRAP_SCHEMA=1'); putenv('CONFIG_DB_TARGET=debug'); putenv('API_TEST_REQUEST_TIMEOUT=180'); putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=0'); putenv('EDGE_BROKER_URL'); passthru('vendor/bin/pest tests/Api/EdgeGateway*ApiTest.php --colors=always', $exitCode); exit($exitCode);\""
],
"test:integration:edge": [
"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: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);\""
+9 -1
View File
@@ -37,6 +37,14 @@ interface shelly_i
*/
function requireValidServerUrl(): void;
/**
* Send a GET request to Shelly.
* @param string $endpoint The endpoint to send the request to
* @param array $data Query parameters to append to the request
* @return array|object|null The response from Shelly
*/
function sendGetRequest(string $endpoint, array $data): array|object|null;
/**
* Send a POST request to the shelly
* @param string $endpoint The endpoint to send the request to (e.g. "latest")
@@ -44,4 +52,4 @@ interface shelly_i
* @return array|object|null The response from the shelly
*/
function sendPostRequest(string $endpoint, array $data): array|object|null;
}
}
@@ -0,0 +1,900 @@
<?php
namespace classes;
use Exception;
use objects\department_gates_o;
use objects\department_lanes_o;
use objects\department_relays_o;
use objects\departments_o;
use objects\plate_scanners_o;
use objects\plate_scans_o;
class edge_gateway_department_workspace_service
{
public function __construct(private readonly ?edge_gateway_manager $manager = null)
{
edge_gateway_schema_bootstrap::ensureTables();
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listDepartmentSummaries(): array
{
$departments = (new departments_o())->list(true);
usort($departments, static function (array $left, array $right): int {
$leftPriority = (int)($left['order_priority'] ?? PHP_INT_MAX);
$rightPriority = (int)($right['order_priority'] ?? PHP_INT_MAX);
if ($leftPriority !== $rightPriority) {
return $leftPriority <=> $rightPriority;
}
return (int)($left['id'] ?? 0) <=> (int)($right['id'] ?? 0);
});
$summaries = [];
foreach ($departments as $departmentRow) {
$departmentId = (int)($departmentRow['id'] ?? 0);
if ($departmentId <= 0) {
continue;
}
$workspace = $this->buildDepartmentWorkspace($departmentId, false, $departmentRow);
$summaries[] = $workspace['summary'];
}
return $summaries;
}
/**
* @return array<string,mixed>
* @throws Exception
*/
public function getDepartmentWorkspace(int $departmentId): array
{
return $this->buildDepartmentWorkspace($departmentId, true);
}
/**
* @param array<string,mixed>|null $departmentRow
* @return array<string,mixed>
* @throws Exception
*/
private function buildDepartmentWorkspace(int $departmentId, bool $includeGateways, ?array $departmentRow = null): array
{
$department = (new departments_o())->select($departmentId);
if (!$department->exists()) {
throw new Exception('Department not found');
}
$departmentPayload = [
'id' => $departmentId,
'name' => (string)$department->name->value(),
'description' => (string)$department->description->value(),
'order_priority' => (int)$department->order_priority->value(),
];
$transportMode = $this->manager()->getDepartmentTransportMode($departmentId);
$gateways = $this->manager()->listGateways($departmentId, true);
$bindingsByRelayId = $this->indexBindingsByRelayId($gateways);
$relayCatalog = $this->indexRelayCatalog($departmentId);
$consumersByRelayId = [];
$lanes = $this->buildLanePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId);
$gates = $this->buildGatePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId);
$relays = $this->buildRelayPayloads($relayCatalog, $bindingsByRelayId, $consumersByRelayId);
$gateways = $this->applyBindingConsumerContexts($gateways, $consumersByRelayId);
$selfServe = $this->buildSelfServePayload($department, $lanes);
$scanners = $this->buildScannerPayloads($departmentId, $lanes);
$issues = $this->buildIssues($transportMode, $gateways, $lanes, $gates, $scanners, $selfServe);
$actions = $this->buildActions($departmentId, $gateways, $lanes, $gates, $scanners, $selfServe);
$summary = $this->buildSummary(
$departmentPayload,
$departmentRow,
$transportMode,
$gateways,
$lanes,
$gates,
$scanners,
$selfServe,
$issues
);
return [
'department' => $departmentPayload,
'summary' => $summary,
'gateways' => $includeGateways ? $gateways : [],
'lanes' => $lanes,
'self_serve' => $selfServe,
'gates' => $gates,
'relays' => $relays,
'scanners' => $scanners,
'issues' => $issues,
'actions' => $actions,
];
}
/**
* @param array<int,array<string,mixed>> $gateways
* @return array<string,array<int,array<string,mixed>>>
*/
private function indexBindingsByRelayId(array $gateways): array
{
$bindingsByRelayId = [];
foreach ($gateways as $gateway) {
$bindings = isset($gateway['bindings']) && is_array($gateway['bindings'])
? (array)$gateway['bindings']
: [];
foreach ($bindings as $binding) {
if (!is_array($binding)) {
continue;
}
$relayId = trim((string)($binding['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$binding['gateway_label'] = (string)($gateway['label'] ?? ('Gateway ' . ($gateway['id'] ?? '')));
$binding['gateway_status'] = (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE);
$binding['is_primary_gateway'] = (bool)($gateway['is_primary'] ?? false);
$bindingsByRelayId[$relayId][] = $binding;
}
}
foreach ($bindingsByRelayId as $relayId => $bindings) {
usort($bindings, static function (array $left, array $right): int {
return ((int)($right['is_primary_gateway'] ?? 0) <=> (int)($left['is_primary_gateway'] ?? 0))
?: ((int)($left['gateway_id'] ?? 0) <=> (int)($right['gateway_id'] ?? 0));
});
$bindingsByRelayId[$relayId] = $bindings;
}
return $bindingsByRelayId;
}
/**
* @return array<string,array<string,mixed>>
* @throws Exception
*/
private function indexRelayCatalog(int $departmentId): array
{
$catalog = [];
foreach ((new department_relays_o())->getDepartmentRelays($departmentId) as $relay) {
if (!$relay->exists()) {
continue;
}
$relayId = trim((string)$relay->relay_id->value());
if ($relayId === '') {
continue;
}
$catalog[$relayId] = $relay->asArray();
}
return $catalog;
}
/**
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
* @param array<string,array<string,mixed>> $relayCatalog
* @param array<string,array<int,array<string,mixed>>> $consumersByRelayId
* @return array<int,array<string,mixed>>
* @throws Exception
*/
private function buildLanePayloads(
int $departmentId,
array $bindingsByRelayId,
array $relayCatalog,
array &$consumersByRelayId
): array {
$lanes = [];
$slotMap = [
'relay_in_id' => 'ENTRY',
'relay_out_id' => 'EXIT',
'relay_machine_id' => 'MACHINE',
'relay_machine_program_picker_id' => 'PROGRAM_PICKER',
'relay_machine_cleaner_id' => 'CLEANER',
];
foreach ((new department_lanes_o())->getDepartmentLanes($departmentId) as $lane) {
if (!$lane->exists()) {
continue;
}
$relaySlots = [];
$boundRelayCount = 0;
foreach ($slotMap as $property => $slotName) {
$relayId = trim((string)$lane->{$property}->value());
if ($relayId === '') {
continue;
}
$consumersByRelayId[$relayId][] = [
'type' => 'lane',
'id' => (int)$lane->id,
'slot' => $slotName,
'label' => (string)$lane->name->value(),
];
$coverage = $this->buildRelayCoverage($relayId, $bindingsByRelayId);
if ((bool)($coverage['covered'] ?? false)) {
$boundRelayCount += 1;
}
$relaySlots[] = [
'slot' => $slotName,
'relay_id' => $relayId,
'catalog' => $relayCatalog[$relayId] ?? null,
'coverage' => $coverage,
];
}
$requiredRelayCount = count($relaySlots);
$laneStatus = 'UNKNOWN';
try {
$laneStatus = (string)$lane->getLaneStatus()->name;
} catch (\Throwable) {
}
$lanes[] = [
'id' => (int)$lane->id,
'department' => (int)$lane->department->value(),
'name' => (string)$lane->name->value(),
'relay_in_id' => $lane->relay_in_id->value() === null ? null : (string)$lane->relay_in_id->value(),
'relay_out_id' => $lane->relay_out_id->value() === null ? null : (string)$lane->relay_out_id->value(),
'relay_machine_id' => $lane->relay_machine_id->value() === null ? null : (string)$lane->relay_machine_id->value(),
'relay_machine_program_picker_id' => $lane->relay_machine_program_picker_id->value() === null ? null : (string)$lane->relay_machine_program_picker_id->value(),
'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(),
'status' => $laneStatus,
'self_serve_products' => $lane->getSelfServeLaneProducts(),
'relay_slots' => $relaySlots,
'binding_coverage' => [
'required' => $requiredRelayCount,
'bound' => $boundRelayCount,
'missing' => max(0, $requiredRelayCount - $boundRelayCount),
'state' => $requiredRelayCount === 0
? 'NOT_REQUIRED'
: ($boundRelayCount === $requiredRelayCount ? 'READY' : 'MISSING'),
],
'links' => [
'legacy' => '/superuser/department/lanes/' . (int)$lane->id,
'self_serve_studio' => '/admin/' . $departmentId . '/modules/self-serve/studio',
],
];
}
return $lanes;
}
/**
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
* @param array<string,array<string,mixed>> $relayCatalog
* @param array<string,array<int,array<string,mixed>>> $consumersByRelayId
* @return array<int,array<string,mixed>>
* @throws Exception
*/
private function buildGatePayloads(
int $departmentId,
array $bindingsByRelayId,
array $relayCatalog,
array &$consumersByRelayId
): array {
$gates = [];
foreach ((new department_gates_o())->getDepartmentGates($departmentId) as $gate) {
if (!$gate->exists()) {
continue;
}
$config = (array)$gate->config->value();
$gateType = strtoupper(trim((string)($config['type'] ?? 'UNKNOWN')));
$relayId = trim((string)($config['relay_id'] ?? ''));
if ($gateType === 'RELAY' && $relayId !== '') {
$consumersByRelayId[$relayId][] = [
'type' => 'gate',
'id' => (int)$gate->id,
'slot' => ((bool)$gate->is_entrance->value() ? 'ENTRANCE' : ((bool)$gate->is_exit->value() ? 'EXIT' : 'GENERAL')),
'label' => (string)$gate->name->value(),
];
}
$coverage = $gateType === 'RELAY' && $relayId !== ''
? $this->buildRelayCoverage($relayId, $bindingsByRelayId)
: null;
$gates[] = [
'id' => (int)$gate->id,
'department' => (int)$gate->department->value(),
'name' => (string)$gate->name->value(),
'is_entrance' => (bool)$gate->is_entrance->value(),
'is_exit' => (bool)$gate->is_exit->value(),
'config' => $config,
'transport_type' => $gateType,
'config_complete' => $this->isGateConfigComplete($config),
'relay' => $relayId !== '' ? ($relayCatalog[$relayId] ?? ['relay_id' => $relayId]) : null,
'coverage' => $coverage,
];
}
return $gates;
}
/**
* @param array<string,array<string,mixed>> $relayCatalog
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
* @param array<string,array<int,array<string,mixed>>> $consumersByRelayId
* @return array<int,array<string,mixed>>
*/
private function buildRelayPayloads(
array $relayCatalog,
array $bindingsByRelayId,
array $consumersByRelayId
): array {
$relays = [];
foreach ($relayCatalog as $relayId => $relay) {
$config = isset($relay['config']) && is_array($relay['config'])
? (array)$relay['config']
: [];
$relays[] = [
'id' => isset($relay['id']) ? (int)$relay['id'] : 0,
'department' => isset($relay['department']) ? (int)$relay['department'] : 0,
'relay_id' => (string)($relay['relay_id'] ?? $relayId),
'name' => (string)($relay['name'] ?? $relayId),
'type' => (string)($relay['type'] ?? ''),
'config' => $config,
'coverage' => $this->buildRelayCoverage((string)$relayId, $bindingsByRelayId),
'consumer_contexts' => $consumersByRelayId[(string)$relayId] ?? [],
];
}
return $relays;
}
/**
* @param array<int,array<string,mixed>> $lanes
* @return array<string,mixed>
* @throws Exception
*/
private function buildSelfServePayload(departments_o $department, array $lanes): array
{
$enabled = false;
try {
$enabled = $department->getSelfServeEnabled();
} catch (\Throwable) {
}
$readyLanes = array_values(array_filter($lanes, static function (array $lane): bool {
return (string)($lane['binding_coverage']['state'] ?? 'UNKNOWN') === 'READY';
}));
$taskRows = (new \objects\department_selfserve_tasks_o())->getFieldsWhere([
'department' => (int)$department->id,
'deleted_at' => null,
], ['id', 'lane', 'product']);
$productIds = [];
foreach ($taskRows as $taskRow) {
if (isset($taskRow['product'])) {
$productIds[(int)$taskRow['product']] = true;
}
}
return [
'enabled' => $enabled,
'lane_count' => count($lanes),
'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')),
'links' => [
'studio' => '/admin/' . (int)$department->id . '/modules/self-serve/studio',
'legacy' => '/superuser/selfserve',
],
];
}
/**
* @param array<int,array<string,mixed>> $lanes
* @return array<int,array<string,mixed>>
* @throws Exception
*/
private function buildScannerPayloads(int $departmentId, array $lanes): array
{
$laneIndex = [];
foreach ($lanes as $lane) {
$laneIndex[(int)$lane['id']] = $lane;
}
$recentScansByScannerId = $this->groupRecentScansByScannerId($departmentId);
$scanners = [];
foreach ((new plate_scanners_o())->getDepartmentScanners($departmentId) as $scanner) {
if (!$scanner->exists()) {
continue;
}
$scannerPayload = $scanner->asArray();
$laneId = isset($scannerPayload['lane_id']) ? (int)($scannerPayload['lane_id'] ?? 0) : 0;
$assignedLane = $laneId > 0 ? ($laneIndex[$laneId] ?? null) : null;
$recentScans = $recentScansByScannerId[(int)$scanner->id] ?? [];
$recentScanAt = $recentScans !== [] ? ($recentScans[0]['created_at'] ?? null) : null;
$assignmentState = $laneId <= 0
? 'UNASSIGNED'
: ($assignedLane === null
? 'INVALID'
: (((int)($assignedLane['binding_coverage']['missing'] ?? 0) === 0) ? 'READY' : 'PARTIAL'));
$scanners[] = [
...$scannerPayload,
'assigned_lane' => $assignedLane,
'assignment_state' => $assignmentState,
'recent_scan_at' => $recentScanAt,
'recent_scans' => $recentScans,
'recent_scan_count' => count($recentScans),
];
}
return $scanners;
}
/**
* @return array<string,array<int,array<string,mixed>>>
*/
private function groupRecentScansByScannerId(int $departmentId): array
{
$rows = (new plate_scans_o())->getFieldsWhere([
'department_id' => $departmentId,
], ['id', 'plate_scanner_id', 'plate', 'bay_id', 'created_at']);
usort($rows, static function (array $left, array $right): int {
$rightTimestamp = strtotime((string)($right['created_at'] ?? '')) ?: 0;
$leftTimestamp = strtotime((string)($left['created_at'] ?? '')) ?: 0;
return $rightTimestamp <=> $leftTimestamp ?: ((int)($right['id'] ?? 0) <=> (int)($left['id'] ?? 0));
});
$grouped = [];
foreach ($rows as $row) {
$scannerId = (int)($row['plate_scanner_id'] ?? 0);
if ($scannerId <= 0) {
continue;
}
if (!isset($grouped[$scannerId])) {
$grouped[$scannerId] = [];
}
if (count($grouped[$scannerId]) >= 5) {
continue;
}
$grouped[$scannerId][] = [
'id' => (int)($row['id'] ?? 0),
'plate' => (string)($row['plate'] ?? ''),
'bay_id' => isset($row['bay_id']) ? (string)$row['bay_id'] : null,
'created_at' => isset($row['created_at']) ? (string)$row['created_at'] : null,
];
}
return $grouped;
}
/**
* @param array<int,array<string,mixed>> $gateways
* @param array<int,array<string,mixed>> $lanes
* @param array<int,array<string,mixed>> $gates
* @param array<int,array<string,mixed>> $scanners
* @return array<int,array<string,mixed>>
*/
private function buildIssues(
string $transportMode,
array $gateways,
array $lanes,
array $gates,
array $scanners,
array $selfServe
): array {
$issues = [];
if ($gateways === []) {
$issues[] = [
'severity' => 'danger',
'code' => 'NO_GATEWAY',
'message' => 'No edge gateway has been claimed for this department.',
];
}
$onlineGateways = array_values(array_filter($gateways, static function (array $gateway): bool {
return strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE;
}));
if ($transportMode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY && $onlineGateways === []) {
$issues[] = [
'severity' => 'danger',
'code' => 'NO_ONLINE_GATEWAY',
'message' => 'Gateway transport mode is enabled, but no department gateway is currently online.',
];
}
foreach ($lanes as $lane) {
if ((int)($lane['binding_coverage']['missing'] ?? 0) > 0) {
$issues[] = [
'severity' => 'warning',
'code' => 'LANE_BINDING_GAP',
'message' => 'Lane ' . (string)$lane['name'] . ' is missing relay bindings.',
'target_type' => 'lane',
'target_id' => (int)$lane['id'],
];
}
}
foreach ($gates as $gate) {
if (!($gate['config_complete'] ?? false)) {
$issues[] = [
'severity' => 'warning',
'code' => 'GATE_CONFIG_INCOMPLETE',
'message' => 'Gate ' . (string)$gate['name'] . ' has incomplete transport configuration.',
'target_type' => 'gate',
'target_id' => (int)$gate['id'],
];
continue;
}
if (($gate['transport_type'] ?? '') === 'RELAY' && !($gate['coverage']['covered'] ?? false)) {
$issues[] = [
'severity' => 'warning',
'code' => 'GATE_BINDING_MISSING',
'message' => 'Gate ' . (string)$gate['name'] . ' is assigned to an unbound relay.',
'target_type' => 'gate',
'target_id' => (int)$gate['id'],
];
}
}
foreach ($scanners as $scanner) {
if (($scanner['assignment_state'] ?? 'UNASSIGNED') === 'UNASSIGNED') {
$issues[] = [
'severity' => 'warning',
'code' => 'SCANNER_UNASSIGNED',
'message' => 'Scanner ' . (string)$scanner['name'] . ' is not assigned to a default lane.',
'target_type' => 'scanner',
'target_id' => (int)$scanner['id'],
];
} elseif (($scanner['assignment_state'] ?? '') === 'PARTIAL') {
$issues[] = [
'severity' => 'info',
'code' => 'SCANNER_LANE_PARTIAL',
'message' => 'Scanner ' . (string)$scanner['name'] . ' is assigned to a lane with missing relay coverage.',
'target_type' => 'scanner',
'target_id' => (int)$scanner['id'],
];
}
}
if (($selfServe['enabled'] ?? false) && (int)($selfServe['ready_lanes'] ?? 0) < (int)($selfServe['lane_count'] ?? 0)) {
$issues[] = [
'severity' => 'warning',
'code' => 'SELFSERVE_PARTIAL_READY',
'message' => 'Self-serve is enabled, but one or more lanes are missing required relay coverage.',
];
}
return $issues;
}
/**
* @param array<int,array<string,mixed>> $gateways
* @param array<int,array<string,mixed>> $lanes
* @param array<int,array<string,mixed>> $gates
* @param array<int,array<string,mixed>> $scanners
* @return array<int,array<string,mixed>>
*/
private function buildActions(
int $departmentId,
array $gateways,
array $lanes,
array $gates,
array $scanners,
array $selfServe
): array {
$actions = [
[
'code' => 'OPEN_GATEWAY_TAB',
'label' => 'Open gateway controls',
'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=gateways',
],
];
if ($gateways === []) {
$actions[] = [
'code' => 'INSTALL_GATEWAY',
'label' => 'Install first edge gateway',
'path' => '/superuser/configuration/edgegateway',
];
}
foreach ($lanes as $lane) {
if ((int)($lane['binding_coverage']['missing'] ?? 0) > 0) {
$actions[] = [
'code' => 'REVIEW_LANE_BINDINGS',
'label' => 'Resolve lane bindings',
'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=lanes',
];
break;
}
}
foreach ($gates as $gate) {
if (($gate['transport_type'] ?? '') === 'RELAY' && !($gate['coverage']['covered'] ?? false)) {
$actions[] = [
'code' => 'REVIEW_GATE_BINDINGS',
'label' => 'Resolve gate relay bindings',
'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=gates',
];
break;
}
}
foreach ($scanners as $scanner) {
if (($scanner['assignment_state'] ?? 'UNASSIGNED') === 'UNASSIGNED') {
$actions[] = [
'code' => 'ASSIGN_SCANNERS',
'label' => 'Assign scanners to lanes',
'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=scanners',
];
break;
}
}
if (($selfServe['enabled'] ?? false) && (int)($selfServe['lane_count'] ?? 0) > 0) {
$actions[] = [
'code' => 'OPEN_SELFSERVE_STUDIO',
'label' => 'Open self-serve studio',
'path' => '/admin/' . $departmentId . '/modules/self-serve/studio',
];
}
return $actions;
}
/**
* @param array<string,mixed> $departmentPayload
* @param array<string,mixed>|null $departmentRow
* @param array<int,array<string,mixed>> $gateways
* @param array<int,array<string,mixed>> $lanes
* @param array<int,array<string,mixed>> $gates
* @param array<int,array<string,mixed>> $scanners
* @param array<int,array<string,mixed>> $issues
* @return array<string,mixed>
*/
private function buildSummary(
array $departmentPayload,
?array $departmentRow,
string $transportMode,
array $gateways,
array $lanes,
array $gates,
array $scanners,
array $selfServe,
array $issues
): array {
$onlineGatewayCount = count(array_filter($gateways, static function (array $gateway): bool {
return strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE;
}));
$primaryGateway = null;
foreach ($gateways as $gateway) {
if (!empty($gateway['is_primary'])) {
$primaryGateway = [
'id' => (int)$gateway['id'],
'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])),
'status' => (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE),
];
break;
}
}
if ($primaryGateway === null && $gateways !== []) {
$gateway = $gateways[0];
$primaryGateway = [
'id' => (int)$gateway['id'],
'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])),
'status' => (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE),
];
}
$requiredRelayIds = [];
$coveredRelayIds = [];
foreach ($lanes as $lane) {
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
$relayId = trim((string)($slot['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$requiredRelayIds[$relayId] = true;
if (!empty($slot['coverage']['covered'])) {
$coveredRelayIds[$relayId] = true;
}
}
}
foreach ($gates as $gate) {
if (($gate['transport_type'] ?? '') !== 'RELAY') {
continue;
}
$relayId = trim((string)($gate['relay']['relay_id'] ?? $gate['config']['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$requiredRelayIds[$relayId] = true;
if (!empty($gate['coverage']['covered'])) {
$coveredRelayIds[$relayId] = true;
}
}
$assignedScannerCount = count(array_filter($scanners, static function (array $scanner): bool {
return (int)($scanner['lane_id'] ?? 0) > 0;
}));
$recentScanAt = null;
foreach ($scanners as $scanner) {
$candidate = isset($scanner['recent_scan_at']) ? (string)$scanner['recent_scan_at'] : null;
if ($candidate === null || trim($candidate) === '') {
continue;
}
if ($recentScanAt === null || strtotime($candidate) > strtotime($recentScanAt)) {
$recentScanAt = $candidate;
}
}
$relayGateCount = count(array_filter($gates, static function (array $gate): bool {
return ($gate['transport_type'] ?? '') === 'RELAY';
}));
$phoneGateCount = count(array_filter($gates, static function (array $gate): bool {
return ($gate['transport_type'] ?? '') === 'PHONE_CALL';
}));
return [
'department_id' => (int)$departmentPayload['id'],
'department_name' => (string)$departmentPayload['name'],
'order_priority' => (int)($departmentRow['order_priority'] ?? $departmentPayload['order_priority'] ?? PHP_INT_MAX),
'transport_mode' => $transportMode,
'gateway_count' => count($gateways),
'online_gateway_count' => $onlineGatewayCount,
'primary_gateway' => $primaryGateway,
'lane_count' => count($lanes),
'self_serve_enabled' => (bool)($selfServe['enabled'] ?? false),
'self_serve_ready_lanes' => (int)($selfServe['ready_lanes'] ?? 0),
'required_relay_count' => count($requiredRelayIds),
'bound_relay_count' => count($coveredRelayIds),
'missing_binding_count' => max(0, count($requiredRelayIds) - count($coveredRelayIds)),
'gate_count' => count($gates),
'gate_transport_mix' => [
'relay' => $relayGateCount,
'phone_call' => $phoneGateCount,
],
'scanner_count' => count($scanners),
'assigned_scanner_count' => $assignedScannerCount,
'recent_scan_at' => $recentScanAt,
'issue_count' => count($issues),
'health' => $this->deriveHealthState($transportMode, $gateways, $issues),
];
}
/**
* @param array<int,array<string,mixed>> $gateways
* @param array<int,array<string,mixed>> $issues
*/
private function deriveHealthState(string $transportMode, array $gateways, array $issues): string
{
foreach ($issues as $issue) {
if (($issue['severity'] ?? '') === 'danger') {
return 'AT_RISK';
}
}
if ($transportMode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY) {
foreach ($gateways as $gateway) {
if (strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE) {
return $issues === [] ? 'READY' : 'PARTIAL';
}
}
return 'AT_RISK';
}
return $issues === [] ? 'READY' : 'PARTIAL';
}
/**
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
* @return array<string,mixed>
*/
private function buildRelayCoverage(string $relayId, array $bindingsByRelayId): array
{
$bindings = $bindingsByRelayId[$relayId] ?? [];
$primaryBinding = $bindings[0] ?? null;
return [
'relay_id' => $relayId,
'covered' => $bindings !== [],
'status' => $bindings !== [] ? 'BOUND' : 'MISSING',
'binding_count' => count($bindings),
'primary_binding' => $primaryBinding,
'bindings' => $bindings,
];
}
/**
* @param array<int,array<string,mixed>> $gateways
* @param array<string,array<int,array<string,mixed>>> $consumersByRelayId
* @return array<int,array<string,mixed>>
*/
private function applyBindingConsumerContexts(array $gateways, array $consumersByRelayId): array
{
foreach ($gateways as $gatewayIndex => $gateway) {
$bindings = isset($gateway['bindings']) && is_array($gateway['bindings'])
? (array)$gateway['bindings']
: [];
foreach ($bindings as $bindingIndex => $binding) {
if (!is_array($binding)) {
continue;
}
$relayId = trim((string)($binding['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$metadata = isset($binding['metadata']) && is_array($binding['metadata'])
? (array)$binding['metadata']
: [];
$consumerContexts = $consumersByRelayId[$relayId] ?? [];
$metadata['consumer_contexts'] = $consumerContexts;
$metadata['consumers'] = $consumerContexts;
$bindings[$bindingIndex]['metadata'] = $metadata;
$bindings[$bindingIndex]['consumer_contexts'] = $consumerContexts;
}
$gateways[$gatewayIndex]['bindings'] = $bindings;
}
return $gateways;
}
/**
* @param array<string,mixed> $config
*/
private function isGateConfigComplete(array $config): bool
{
$type = strtoupper(trim((string)($config['type'] ?? '')));
if ($type === 'PHONE_CALL') {
return trim((string)($config['phone_number'] ?? '')) !== ''
&& isset($config['call_duration_threshold']);
}
if ($type === 'RELAY') {
return trim((string)($config['relay_id'] ?? '')) !== '';
}
return false;
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
}
@@ -124,7 +124,7 @@ class edge_gateway_manager
'status' => self::INSTALL_SESSION_STATUS_PENDING,
'step' => self::INSTALL_SESSION_STATUS_PENDING,
'message' => 'Installer command generated. Run it on the gateway host.',
], strtotime($expiresAt) - self::INSTALL_TOKEN_TTL_SECONDS),
], (self::parseApplicationDateTime($expiresAt) ?? time()) - self::INSTALL_TOKEN_TTL_SECONDS),
],
]);
@@ -1735,8 +1735,10 @@ BASH;
throw new Exception('Shell session is closed');
}
$expiresAt = $session->expires_at->value() === null ? null : strtotime((string)$session->expires_at->value());
if ($expiresAt !== null && $expiresAt !== false && $expiresAt <= time()) {
$expiresAt = self::parseApplicationDateTime(
$session->expires_at->value() === null ? null : (string)$session->expires_at->value()
);
if ($expiresAt !== null && $expiresAt <= time()) {
$session->status->set('EXPIRED');
$session->closed_at->set($this->now());
throw new Exception('Shell session expired');
@@ -2071,7 +2073,11 @@ BASH;
'events' => self::sanitizeInstallSessionEvents($session['events'] ?? []),
];
$status = (string)$normalized['status'];
if (!self::installSessionStatusIsTerminal($status) && $expiresAt !== null && strtotime($expiresAt) < ($now ?? time())) {
if (
!self::installSessionStatusIsTerminal($status)
&& $expiresAt !== null
&& (self::parseApplicationDateTime($expiresAt) ?? PHP_INT_MAX) < ($now ?? time())
) {
$status = self::INSTALL_SESSION_STATUS_EXPIRED;
$normalized['status'] = $status;
$normalized['message'] = $normalized['message'] ?: 'Installer token expired before the gateway claimed successfully.';
@@ -2257,7 +2263,16 @@ BASH;
$host .= ':' . $port;
}
return $scheme . '://' . $host;
$forwardedPrefix = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null);
if ($forwardedPrefix !== null) {
$normalizedForwardedPrefix = '/' . trim($forwardedPrefix, '/');
$basePath = $normalizedForwardedPrefix === '/' ? '' : $normalizedForwardedPrefix;
} else {
$requestPath = parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
$basePath = (is_string($requestPath) && preg_match('#^/api(?:/|$)#', $requestPath) === 1) ? '/api' : '';
}
return $scheme . '://' . $host . $basePath;
}
private function detectForwardedScheme(): ?string
@@ -2383,7 +2398,7 @@ BASH;
if (!$claimToken->exists()) {
throw new Exception('Invalid install token');
}
if (strtotime((string)$claimToken->expires_at->value()) < time()) {
if ((self::parseApplicationDateTime((string)$claimToken->expires_at->value()) ?? 0) < time()) {
throw new Exception('Install token has expired');
}
@@ -2597,7 +2612,7 @@ BASH;
try {
$statement = $pdo->prepare(
'SELECT id
'SELECT id, delivery_json
FROM edge_gateway_command_jobs
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
@@ -2626,16 +2641,25 @@ BASH;
return null;
}
$delivery = isset($row['delivery_json']) && is_string($row['delivery_json'])
? json_decode($row['delivery_json'], true)
: [];
if (!is_array($delivery)) {
$delivery = [];
}
$delivery['delivery_channel'] = self::DELIVERY_CHANNEL_API;
$delivery['attempt_count'] = ((int)($delivery['attempt_count'] ?? 0)) + 1;
$delivery['last_dispatch_error'] = null;
$encodedDelivery = json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encodedDelivery)) {
$encodedDelivery = '{}';
}
$update = $pdo->prepare(
'UPDATE edge_gateway_command_jobs
SET status = :status,
response_json = :response_json,
delivery_json = JSON_SET(
COALESCE(delivery_json, JSON_OBJECT()),
\'$.delivery_channel\', :delivery_channel,
\'$.attempt_count\', COALESCE(JSON_EXTRACT(COALESCE(delivery_json, JSON_OBJECT()), \'$.attempt_count\'), 0) + 1,
\'$.last_dispatch_error\', CAST(NULL AS JSON)
),
delivery_json = :delivery_json,
error_message = NULL,
completed_at = NULL
WHERE id = :id'
@@ -2643,7 +2667,7 @@ BASH;
$update->execute([
':status' => 'DISPATCHING',
':response_json' => json_encode([], JSON_UNESCAPED_UNICODE),
':delivery_channel' => json_encode(self::DELIVERY_CHANNEL_API, JSON_UNESCAPED_UNICODE),
':delivery_json' => $encodedDelivery,
':id' => (int)$row['id'],
]);
@@ -2655,6 +2679,7 @@ BASH;
throw $throwable;
}
$this->clearObjectPropertyCache('edge_gateway_command_jobs', (int)$row['id']);
return (new edge_gateway_command_jobs_o())->select((int)$row['id']);
}
@@ -2999,24 +3024,11 @@ BASH;
}
$apiBaseUrl = $this->getApiBaseUrl();
$parsed = parse_url($apiBaseUrl);
$host = $parsed['host'] ?? null;
if (!is_string($host) || trim($host) === '') {
if (trim($apiBaseUrl) === '') {
return null;
}
$scheme = strtolower((string)($parsed['scheme'] ?? 'http')) === 'https' ? 'https' : 'http';
$port = (int)(getenv('EDGE_PUBLIC_BROKER_PORT') ?: 4300);
if ($port <= 0) {
$port = 4300;
}
$hostWithPort = $host;
if (!(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) {
$hostWithPort .= ':' . $port;
}
return $scheme . '://' . $hostWithPort;
return rtrim($apiBaseUrl, '/') . '/edge-broker';
}
private function buildBrokerPublicWebSocketUrl(string $path = ''): ?string
@@ -3034,9 +3046,11 @@ BASH;
}
$port = isset($parsed['port']) ? ':' . (int)$parsed['port'] : '';
$basePath = rtrim((string)($parsed['path'] ?? ''), '/');
$normalizedPath = '/' . ltrim($path, '/');
$fullPath = $basePath . ($normalizedPath === '/' ? '' : $normalizedPath);
return $scheme . '://' . $host . $port . ($normalizedPath === '/' ? '' : $normalizedPath);
return $scheme . '://' . $host . $port . ($fullPath === '' ? '' : $fullPath);
}
private function buildBrokerInternalUrl(): ?string
@@ -3133,6 +3147,29 @@ BASH;
}
$metadata['fallback_mode'] = $fallbackMode;
$consumerContexts = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? [];
if (!is_array($consumerContexts)) {
$consumerContexts = [];
}
$consumerContexts = array_values(array_filter(array_map(static function (mixed $consumer): ?array {
if (!is_array($consumer)) {
return null;
}
$consumerType = trim((string)($consumer['type'] ?? ''));
if ($consumerType === '') {
return null;
}
return [
'type' => $consumerType,
'id' => isset($consumer['id']) ? (int)$consumer['id'] : null,
'slot' => isset($consumer['slot']) ? (string)$consumer['slot'] : null,
'label' => isset($consumer['label']) ? (string)$consumer['label'] : null,
];
}, $consumerContexts)));
$metadata['consumer_contexts'] = $consumerContexts;
$metadata['consumers'] = $consumerContexts;
if (isset($binding['last_resolution']) && is_array($binding['last_resolution'])) {
$metadata['last_resolution'] = (array)$binding['last_resolution'];
@@ -3947,11 +3984,19 @@ BASH;
$channelStatus = self::deriveChannelStatus($gateway, $effectiveStatus, $now);
$relayHealth = self::buildRelayHealth($gateway, $effectiveStatus, $now);
$fallbackSummary = self::buildFallbackSummary($relayHealth);
$gateway['outbox_status'] = self::buildOutboxStatusSummary($gateway, $now);
$lastSyncAt = self::resolveLastSyncAt($gateway);
$gateway['channel_status'] = $channelStatus;
$gateway['relay_health'] = $relayHealth;
$gateway['fallback_summary'] = $fallbackSummary;
$gateway['transport_health'] = self::deriveTransportHealth($effectiveStatus, $channelStatus, $fallbackSummary);
$gateway['transport_health'] = self::deriveTransportHealth(
$gateway,
$effectiveStatus,
$channelStatus,
$fallbackSummary,
$lastSyncAt
);
$gateway['last_successful_command_at'] = $gateway['operational_snapshot']['last_successful_command_at']
?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null);
$gateway['last_successful_discovery_at'] = $gateway['operational_snapshot']['last_successful_discovery_at']
@@ -3964,8 +4009,7 @@ BASH;
$gateway['version_drift'] = self::buildVersionDriftSummary($gateway);
$gateway['credential_freshness'] = self::buildCredentialFreshnessSummary($gateway, $now);
$gateway['container_health'] = self::buildContainerHealthSummary($gateway, $effectiveStatus);
$gateway['outbox_status'] = self::buildOutboxStatusSummary($gateway, $now);
$gateway['last_sync_at'] = self::resolveLastSyncAt($gateway);
$gateway['last_sync_at'] = $lastSyncAt;
$gateway['update_window'] = self::buildUpdateWindowSummary($gateway);
$gateway['staged_version'] = self::buildStagedVersionSummary($gateway);
$gateway['rollback_status'] = self::buildRollbackStatusSummary($gateway);
@@ -4138,8 +4182,18 @@ BASH;
return $summary;
}
private static function deriveTransportHealth(string $effectiveStatus, array $channelStatus, array $fallbackSummary): array
private static function deriveTransportHealth(
array $gateway,
string $effectiveStatus,
array $channelStatus,
array $fallbackSummary,
?string $lastSyncAt
): array
{
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
$controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status'])
? (array)$metadata['control_plane_status']
: [];
$brokerState = (string)($channelStatus['broker']['state'] ?? self::STATUS_OFFLINE);
$affectedRelayCount = count((array)($fallbackSummary['affected_relays'] ?? []));
$transportState = $effectiveStatus;
@@ -4157,6 +4211,13 @@ BASH;
? 'Broker fast path er aktiv med API polling som fallback'
: 'API polling er aktiv som primær kontrolkanal'),
'recommended_action' => $fallbackSummary['recommended_action'] ?? ($channelStatus['broker']['last_error'] ?? null),
'last_successful_sync_at' => $lastSyncAt,
'last_transport_failure_at' => isset($controlPlaneStatus['last_transport_failure_at'])
? (string)$controlPlaneStatus['last_transport_failure_at']
: null,
'last_transport_error' => isset($controlPlaneStatus['last_transport_error'])
? (string)$controlPlaneStatus['last_transport_error']
: null,
];
}
@@ -4308,17 +4369,23 @@ BASH;
private static function resolveLastSyncAt(array $gateway): ?string
{
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
if (!empty($metadata['last_sync_at'])) {
return (string)$metadata['last_sync_at'];
}
$controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status'])
? (array)$metadata['control_plane_status']
: [];
$outbox = isset($gateway['outbox_status']) && is_array($gateway['outbox_status'])
? (array)$gateway['outbox_status']
: [];
$outboxMetadata = isset($metadata['outbox_status']) && is_array($metadata['outbox_status'])
? (array)$metadata['outbox_status']
: [];
return isset($outbox['last_replayed_at']) && $outbox['last_replayed_at'] !== null
? (string)$outbox['last_replayed_at']
: null;
return self::latestTimestamp([
$controlPlaneStatus['last_successful_sync_at'] ?? null,
$metadata['last_sync_at'] ?? null,
$outbox['last_replayed_at'] ?? null,
$outboxMetadata['last_replayed_at'] ?? null,
$gateway['last_heartbeat_at'] ?? null,
]);
}
private static function buildUpdateWindowSummary(array $gateway): array
@@ -4399,8 +4466,8 @@ BASH;
? (array)$gateway['active_operation']
: null;
if ($activeOperation !== null && !empty($activeOperation['started_at'])) {
$startedAt = strtotime((string)$activeOperation['started_at']);
if ($startedAt !== false && (($now ?? time()) - $startedAt) >= edge_gateway_operation_service::OPERATION_TIMEOUT_SECONDS) {
$startedAt = self::parseApplicationDateTime((string)$activeOperation['started_at']);
if ($startedAt !== null && (($now ?? time()) - $startedAt) >= edge_gateway_operation_service::OPERATION_TIMEOUT_SECONDS) {
$diagnostics[] = [
'code' => edge_gateway_operation_service::ERROR_OPERATION_TIMEOUT,
'severity' => 'warning',
@@ -4539,6 +4606,33 @@ BASH;
return null;
}
/**
* @param array<int,mixed> $timestamps
*/
private static function latestTimestamp(array $timestamps): ?string
{
$latestValue = null;
$latestEpoch = 0;
foreach ($timestamps as $timestamp) {
if (!is_string($timestamp) || trim($timestamp) === '') {
continue;
}
$epoch = self::parseApplicationDateTime($timestamp);
if ($epoch === null) {
continue;
}
if ($latestValue === null || $epoch >= $latestEpoch) {
$latestValue = $timestamp;
$latestEpoch = $epoch;
}
}
return $latestValue;
}
private static function normalizeFallbackMode(?string $fallbackMode): string
{
$normalized = strtoupper(trim((string)$fallbackMode));
@@ -4553,6 +4647,16 @@ BASH;
return self::RELAY_FALLBACK_PREFER_LOCAL;
}
private function clearObjectPropertyCache(string $table, int $id): void
{
if ($id <= 0 || !defined('redis')) {
return;
}
$normalizedTable = trim($table, " `\t\n\r\0\x0B");
redis->clear_keys('obj_prop:' . $normalizedTable . ':' . $id . ':*');
}
private static function resolveDeviceFreshnessState(?array $device, ?int $ageSeconds): string
{
if ($device === null) {
@@ -4623,8 +4727,8 @@ BASH;
return null;
}
$heartbeatTimestamp = strtotime($lastHeartbeatAt);
if ($heartbeatTimestamp === false) {
$heartbeatTimestamp = self::parseApplicationDateTime($lastHeartbeatAt);
if ($heartbeatTimestamp === null) {
return null;
}
@@ -4637,8 +4741,7 @@ BASH;
return 0;
}
$heartbeatTimestamp = strtotime($lastHeartbeatAt);
return $heartbeatTimestamp === false ? 0 : $heartbeatTimestamp;
return self::parseApplicationDateTime($lastHeartbeatAt) ?? 0;
}
private static function statusPriority(string $status): int
@@ -4753,14 +4856,55 @@ BASH;
return hash('sha256', $plainToken);
}
public static function parseApplicationDateTime(?string $value): ?int
{
$normalized = trim((string)$value);
if ($normalized === '') {
return null;
}
$timezone = self::applicationTimeZone();
$dateTime = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $normalized, $timezone);
if ($dateTime instanceof \DateTimeImmutable) {
return $dateTime->getTimestamp();
}
try {
return (new \DateTimeImmutable($normalized, $timezone))->getTimestamp();
} catch (\Throwable) {
return null;
}
}
public static function formatApplicationDateTime(int $timestamp): string
{
return (new \DateTimeImmutable('@' . $timestamp))
->setTimezone(self::applicationTimeZone())
->format('Y-m-d H:i:s');
}
private static function applicationTimeZone(): \DateTimeZone
{
$timezone = trim((string)($_ENV['CONFIG_TIMEZONE'] ?? getenv('CONFIG_TIMEZONE') ?: 'Europe/Copenhagen'));
if ($timezone === '') {
$timezone = 'Europe/Copenhagen';
}
try {
return new \DateTimeZone($timezone);
} catch (\Throwable) {
return new \DateTimeZone('Europe/Copenhagen');
}
}
private function now(): string
{
return date('Y-m-d H:i:s');
return self::formatApplicationDateTime(time());
}
private function formatDateTime(int $timestamp): string
{
return date('Y-m-d H:i:s', $timestamp);
return self::formatApplicationDateTime($timestamp);
}
private function remoteIp(): ?string
@@ -3,6 +3,7 @@
namespace classes;
use Exception;
use RuntimeException;
use objects\edge_gateway_operation_events_o;
use objects\edge_gateway_operations_o;
use objects\edge_gateways_o;
@@ -413,7 +414,7 @@ class edge_gateway_operation_service
self::STATUS_CANCEL_REQUESTED,
self::STATUS_CANCELLED,
], true)) {
return $this->serializeOperation($operation, true);
return $this->serializePersistedOperation($operationId, true);
}
$level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO)));
@@ -446,9 +447,10 @@ class edge_gateway_operation_service
$this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context);
}
$this->refreshGatewayViewCache($gatewayId);
$this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
$this->clearGatewayViewCache($gatewayId);
return $this->serializeOperation($operation, true);
return $this->serializePersistedOperation($operationId, true);
}
/**
@@ -477,7 +479,7 @@ class edge_gateway_operation_service
self::STATUS_FAILED,
self::STATUS_CANCELLED,
], true)) {
return $this->serializeOperation($operation, true);
return $this->serializePersistedOperation($operationId, true);
}
$ok = (bool)($payload['ok'] ?? false);
@@ -549,10 +551,11 @@ class edge_gateway_operation_service
$errorMessage
);
}
$this->refreshGatewayViewCache($gatewayId);
$this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
$this->clearGatewayViewCache($gatewayId);
$this->manager()->notifyBrokerGatewaySync($gatewayId);
return $this->serializeOperation($operation, true);
return $this->serializePersistedOperation($operationId, true);
}
/**
@@ -632,7 +635,7 @@ class edge_gateway_operation_service
try {
$statement = $pdo->prepare(
"SELECT id
"SELECT id, type, attempt_count, summary_json
FROM edge_gateway_operations
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
@@ -649,36 +652,69 @@ class edge_gateway_operation_service
return null;
}
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
$operation->status->set(self::STATUS_IN_PROGRESS);
$operation->started_at->set($this->now());
$operation->agent_instance_id->set($agentInstanceId);
$operation->last_progress_at->set($this->now());
$operation->lease_expires_at->set($this->leaseExpiry());
$operation->attempt_count->set(((int)($operation->attempt_count->value() ?? 0)) + 1);
$summary = (array)($operation->summary_json->value() ?? []);
$operationId = (int)$row['id'];
$startedAt = $this->now();
$leaseExpiresAt = $this->leaseExpiry();
$attemptCount = ((int)($row['attempt_count'] ?? 0)) + 1;
$summary = isset($row['summary_json']) && is_string($row['summary_json'])
? json_decode($row['summary_json'], true)
: [];
if (!is_array($summary)) {
$summary = [];
}
$summary['label'] = 'Gateway is processing the operation';
$summary['progress'] = max(5, (int)($summary['progress'] ?? 0));
$summary['claimed_by'] = $agentInstanceId;
$operation->summary_json->set($summary);
$encodedSummary = json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encodedSummary)) {
$encodedSummary = '[]';
}
$update = $pdo->prepare(
"UPDATE edge_gateway_operations
SET status = :status,
started_at = :started_at,
agent_instance_id = :agent_instance_id,
last_progress_at = :last_progress_at,
lease_expires_at = :lease_expires_at,
attempt_count = :attempt_count,
summary_json = :summary_json
WHERE id = :id"
);
$update->execute([
':status' => self::STATUS_IN_PROGRESS,
':started_at' => $startedAt,
':agent_instance_id' => $agentInstanceId,
':last_progress_at' => $startedAt,
':lease_expires_at' => $leaseExpiresAt,
':attempt_count' => $attemptCount,
':summary_json' => $encodedSummary,
':id' => $operationId,
]);
$pdo->commit();
$this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
$operation = $this->fetchOperationRecord($operationId, $pdo);
if ($operation === null) {
throw new RuntimeException('Claimed edge gateway operation could not be reloaded');
}
$this->appendEventRecord(
$gatewayId,
(int)$operation->id,
$operationId,
self::LEVEL_INFO,
'OPERATION_STARTED',
'Gateway started processing the operation',
[
'type' => (string)$operation->type->value(),
'type' => (string)($row['type'] ?? $operation['type'] ?? ''),
'agent_instance_id' => $agentInstanceId,
'attempt_count' => (int)($operation->attempt_count->value() ?? 1),
'attempt_count' => $attemptCount,
'stage' => self::STATUS_IN_PROGRESS,
]
);
$this->refreshGatewayViewCache($gatewayId);
$this->clearGatewayViewCache($gatewayId, $pdo);
return $this->serializeOperation($operation, true);
return $this->serializeOperationRecord($operation, true);
} catch (\Throwable $throwable) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
@@ -706,13 +742,15 @@ class edge_gateway_operation_service
foreach ($rows as $row) {
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
$status = (string)$operation->status->value();
$startedAt = $operation->started_at->value() === null ? null : strtotime((string)$operation->started_at->value());
$leaseExpiresAt = $operation->lease_expires_at->value() === null ? null : strtotime((string)$operation->lease_expires_at->value());
$timedOut = $startedAt !== false
&& $startedAt !== null
$startedAt = edge_gateway_manager::parseApplicationDateTime(
$operation->started_at->value() === null ? null : (string)$operation->started_at->value()
);
$leaseExpiresAt = edge_gateway_manager::parseApplicationDateTime(
$operation->lease_expires_at->value() === null ? null : (string)$operation->lease_expires_at->value()
);
$timedOut = $startedAt !== null
&& ($now - $startedAt) >= self::OPERATION_TIMEOUT_SECONDS;
$leaseExpired = $leaseExpiresAt !== false
&& $leaseExpiresAt !== null
$leaseExpired = $leaseExpiresAt !== null
&& $leaseExpiresAt <= $now;
if (!$timedOut && !$leaseExpired) {
@@ -924,9 +962,19 @@ class edge_gateway_operation_service
string $message,
array $context
): array {
$stage = trim((string)($context['stage'] ?? ''));
if ($stage === '') {
$operationRecord = $this->fetchOperationRecord($operationId);
$stage = trim((string)($operationRecord['status'] ?? ''));
}
if ($stage === '') {
$stage = 'RECORDED';
}
$eventId = (new edge_gateway_operation_events_o())->add_object([
'operation_id' => $operationId,
'gateway_id' => $gatewayId,
'stage' => $stage,
'level' => $level,
'code' => $code,
'message' => $message,
@@ -947,6 +995,136 @@ class edge_gateway_operation_service
edge_gateway_view_cache::syncGateway($this->manager()->getGateway($gatewayId));
}
private function clearGatewayViewCache(int $gatewayId, ?\PDO $pdo = null): void
{
$statement = ($pdo ?? db::getPDO())->prepare(
"SELECT department_id
FROM edge_gateways
WHERE id = :id
AND deleted_at IS NULL
LIMIT 1"
);
$statement->execute([':id' => $gatewayId]);
$row = $statement->fetch();
$departmentId = is_array($row) && isset($row['department_id']) ? (int)$row['department_id'] : null;
edge_gateway_view_cache::clearGateway($gatewayId, $departmentId);
}
private function clearObjectPropertyCache(string $table, int $id): void
{
if ($id <= 0 || !defined('redis')) {
return;
}
$normalizedTable = trim($table, " `\t\n\r\0\x0B");
redis->clear_keys('obj_prop:' . $normalizedTable . ':' . $id . ':*');
}
private function fetchOperationRecord(int $operationId, ?\PDO $pdo = null): ?array
{
$statement = ($pdo ?? db::getPDO())->prepare(
"SELECT id,
gateway_id,
type,
operation_type,
status,
request_json,
summary_json,
result_json,
error_code,
error_message,
correlation_id,
agent_instance_id,
lease_expires_at,
last_progress_at,
attempt_count,
requested_by,
requested_at,
started_at,
completed_at,
created_at,
updated_at
FROM edge_gateway_operations
WHERE id = :id
AND deleted_at IS NULL
LIMIT 1"
);
$statement->execute([':id' => $operationId]);
$row = $statement->fetch();
return is_array($row) ? $row : null;
}
/**
* @return array<string,mixed>
*/
private function serializePersistedOperation(int $operationId, bool $includeEvents = true): array
{
$operation = $this->fetchOperationRecord($operationId);
if ($operation === null) {
throw new RuntimeException('Edge gateway operation could not be reloaded');
}
return $this->serializeOperationRecord($operation, $includeEvents);
}
/**
* @param array<string,mixed> $operation
* @return array<string,mixed>
*/
private function serializeOperationRecord(array $operation, bool $includeEvents = true): array
{
$operationId = (int)($operation['id'] ?? 0);
$gatewayId = (int)($operation['gateway_id'] ?? 0);
$payload = [
'id' => $operationId,
'gateway_id' => $gatewayId,
'type' => (string)($operation['type'] ?? $operation['operation_type'] ?? ''),
'status' => (string)($operation['status'] ?? self::STATUS_PENDING),
'request' => $this->decodeJsonRecord($operation['request_json'] ?? []),
'summary' => $this->decodeJsonRecord($operation['summary_json'] ?? []),
'result' => $this->decodeJsonRecord($operation['result_json'] ?? []),
'error_code' => isset($operation['error_code']) ? (string)$operation['error_code'] : null,
'error_message' => isset($operation['error_message']) ? (string)$operation['error_message'] : null,
'correlation_id' => (string)($operation['correlation_id'] ?? ''),
'agent_instance_id' => isset($operation['agent_instance_id']) ? (string)$operation['agent_instance_id'] : null,
'lease_expires_at' => isset($operation['lease_expires_at']) ? (string)$operation['lease_expires_at'] : null,
'last_progress_at' => isset($operation['last_progress_at']) ? (string)$operation['last_progress_at'] : null,
'attempt_count' => (int)($operation['attempt_count'] ?? 0),
'requested_by' => isset($operation['requested_by']) ? (int)$operation['requested_by'] : null,
'requested_at' => isset($operation['requested_at']) ? (string)$operation['requested_at'] : '',
'started_at' => isset($operation['started_at']) ? (string)$operation['started_at'] : null,
'completed_at' => isset($operation['completed_at']) ? (string)$operation['completed_at'] : null,
'created_at' => isset($operation['created_at']) ? (string)$operation['created_at'] : '',
'updated_at' => isset($operation['updated_at']) ? (string)$operation['updated_at'] : null,
];
if ($includeEvents && $gatewayId > 0 && $operationId > 0) {
$payload['events'] = $this->listOperationEvents($gatewayId, $operationId, 20);
}
return $payload;
}
/**
* @return array<mixed>
*/
private function decodeJsonRecord(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
/**
* @throws Exception
*/
@@ -986,12 +1164,12 @@ class edge_gateway_operation_service
private function now(): string
{
return date('Y-m-d H:i:s');
return edge_gateway_manager::formatApplicationDateTime(time());
}
private function leaseExpiry(): string
{
return date('Y-m-d H:i:s', time() + self::OPERATION_LEASE_SECONDS);
return edge_gateway_manager::formatApplicationDateTime(time() + self::OPERATION_LEASE_SECONDS);
}
private function normalizeAgentInstanceId(?string $agentInstanceId, int $gatewayId): string
@@ -1065,7 +1243,7 @@ class edge_gateway_operation_service
self::STATUS_CANCEL_REQUESTED,
self::STATUS_CANCELLED,
], true)) {
return $this->serializeOperation($operation, true);
return $this->serializePersistedOperation($operationId, true);
}
$level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO)));
@@ -1098,9 +1276,10 @@ class edge_gateway_operation_service
$this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context);
}
$this->refreshGatewayViewCache($gatewayId);
$this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
$this->clearGatewayViewCache($gatewayId);
return $this->serializeOperation($operation, true);
return $this->serializePersistedOperation($operationId, true);
}
/**
@@ -1114,7 +1293,7 @@ class edge_gateway_operation_service
self::STATUS_FAILED,
self::STATUS_CANCELLED,
], true)) {
return $this->serializeOperation($operation, true);
return $this->serializePersistedOperation($operationId, true);
}
$ok = (bool)($payload['ok'] ?? false);
@@ -1186,9 +1365,10 @@ class edge_gateway_operation_service
$errorMessage
);
}
$this->refreshGatewayViewCache($gatewayId);
$this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
$this->clearGatewayViewCache($gatewayId);
$this->manager()->notifyBrokerGatewaySync($gatewayId);
return $this->serializeOperation($operation, true);
return $this->serializePersistedOperation($operationId, true);
}
}
@@ -157,11 +157,15 @@ class edge_gateway_schema_bootstrap
id INT AUTO_INCREMENT PRIMARY KEY,
operation_id INT NOT NULL,
gateway_id INT NOT NULL,
stage VARCHAR(64) NOT NULL DEFAULT 'RECORDED',
level VARCHAR(16) NOT NULL DEFAULT 'INFO',
code VARCHAR(128) NULL,
message TEXT NOT NULL,
context_json JSON NULL,
counts_json JSON NULL,
payload_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_edge_gateway_operation_events_operation (operation_id),
INDEX idx_edge_gateway_operation_events_gateway (gateway_id),
INDEX idx_edge_gateway_operation_events_level (level)
@@ -254,8 +258,12 @@ class edge_gateway_schema_bootstrap
self::ensureColumn('edge_gateway_operations', 'updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
self::ensureColumn('edge_gateway_operations', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER updated_at');
self::ensureColumn('edge_gateway_operation_events', 'stage', "VARCHAR(64) NOT NULL DEFAULT 'RECORDED' AFTER gateway_id");
self::ensureColumn('edge_gateway_operation_events', 'code', 'VARCHAR(128) NULL AFTER level');
self::ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message');
self::ensureColumn('edge_gateway_operation_events', 'counts_json', 'JSON NULL AFTER context_json');
self::ensureColumn('edge_gateway_operation_events', 'payload_json', 'JSON NULL AFTER counts_json');
self::ensureColumn('edge_gateway_operation_events', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER created_at');
self::ensureColumn('edge_gateway_audit_logs', 'actor_type', "VARCHAR(32) NOT NULL DEFAULT 'USER' AFTER actor_user_id");
self::ensureColumn('edge_gateway_audit_logs', 'severity', "VARCHAR(16) NOT NULL DEFAULT 'INFO' AFTER actor_type");
@@ -4,6 +4,7 @@ namespace routes;
use classes\authentication;
use classes\edge_gateway_manager;
use classes\edge_gateway_department_workspace_service;
use classes\edge_gateway_operation_exception;
use classes\edge_gateway_operation_service;
use classes\edge_gateway_registry_service;
@@ -23,6 +24,12 @@ class moduleEdgeGatewayRoute
$this->get('/modules/edge-gateways', fn() => $this->handleListGateways(), [
'modules_shelly_config' => 'List edge gateway module fleet',
]);
$this->get('/modules/edge-gateways/workspace/departments', fn() => $this->handleDepartmentWorkspaceList(), [
'modules_shelly_config' => 'List department hardware workspaces',
]);
$this->get('/modules/edge-gateways/workspace/departments/{id}', fn() => $this->handleDepartmentWorkspaceDetail(), [
'modules_shelly_config' => 'View department hardware workspace detail',
]);
$this->get('/modules/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [
'modules_shelly_config' => 'View edge gateway module detail',
]);
@@ -102,6 +109,26 @@ class moduleEdgeGatewayRoute
$response->success($gateway);
}
private function handleDepartmentWorkspaceList(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$summaries = $this->workspaces()->listDepartmentSummaries();
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_WORKSPACE_LIST', 'Listed department hardware workspace summaries');
$response->success($summaries);
}
private function handleDepartmentWorkspaceDetail(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$departmentId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($departmentId, 'id');
$this->requireDepartmentAccess((string)$departmentId);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_WORKSPACE_GET', 'Fetched department hardware workspace detail');
$response->success($this->workspaces()->getDepartmentWorkspace($departmentId));
}
private function handleGatewayTasksPage(): void
{
global /** @var response $response */ $response;
@@ -395,4 +422,9 @@ class moduleEdgeGatewayRoute
{
return new edge_gateway_manager();
}
private function workspaces(): edge_gateway_department_workspace_service
{
return new edge_gateway_department_workspace_service();
}
}
@@ -3,6 +3,7 @@
namespace objects;
use classes\department_gate_config;
use classes\edge_gateway_manager;
use classes\bird;
use classes\db;
use classes\object_property;
@@ -262,6 +263,11 @@ class department_gates_o extends db
return new slack();
}
protected function resolveEdgeGatewayManager(): edge_gateway_manager
{
return new edge_gateway_manager();
}
/**
* @return array<int,array<string,mixed>>
*/
@@ -367,9 +373,25 @@ class department_gates_o extends db
$this->requireSelected();
$config = (array)$this->config->value();
if (!$this->matchesPhoneCallGateConfig($config)) {
throw new Exception('Unsupported gate type: ' . (string)($config['type'] ?? ''));
if ($this->matchesPhoneCallGateConfig($config)) {
$this->openPhoneCallGate($config);
return;
}
if (strtoupper(trim((string)($config['type'] ?? ''))) === 'RELAY') {
$this->openRelayBackedGate($config);
return;
}
throw new Exception('Unsupported gate type: ' . (string)($config['type'] ?? ''));
}
/**
* @param array<string,mixed> $config
* @throws Exception
*/
protected function openPhoneCallGate(array $config): void
{
if (!isset($config['phone_number'])) {
throw new Exception('Phone number is required for PHONE_CALL gate type');
}
@@ -391,4 +413,30 @@ class department_gates_o extends db
throw new Exception('Failed to open gate relay via phone call', 0, $e);
}
}
/**
* @param array<string,mixed> $config
* @throws Exception
*/
protected function openRelayBackedGate(array $config): void
{
$relayId = trim((string)($config['relay_id'] ?? ''));
if ($relayId === '') {
throw new Exception('relay_id is required for RELAY gate type');
}
$departmentId = (int)$this->department->value();
if ($departmentId <= 0) {
throw new Exception('Gate department is invalid');
}
$pulseSeconds = isset($config['pulse_seconds']) ? max(0, (int)$config['pulse_seconds']) : 1;
$manager = $this->resolveEdgeGatewayManager();
$manager->dispatchRelaySwitch($departmentId, $relayId, true);
if ($pulseSeconds > 0) {
usleep($pulseSeconds * 1000000);
$manager->dispatchRelaySwitch($departmentId, $relayId, false);
}
}
}
@@ -13,6 +13,7 @@ class edge_gateway_operation_events_o extends db
public object_property $operation_id;
public object_property $gateway_id;
public object_property $stage;
public object_property $level;
public object_property $code;
public object_property $message;
@@ -29,6 +30,7 @@ class edge_gateway_operation_events_o extends db
{
$this->operation_id = new object_property($this->table, $this->id, 'operation_id', 'int', false);
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->stage = new object_property($this->table, $this->id, 'stage', 'string', false);
$this->level = new object_property($this->table, $this->id, 'level', 'string', false);
$this->code = new object_property($this->table, $this->id, 'code', 'string', false);
$this->message = new object_property($this->table, $this->id, 'message', 'text', false);
@@ -48,6 +50,7 @@ class edge_gateway_operation_events_o extends db
'id' => (int)$this->id,
'operation_id' => (int)$this->operation_id->value(),
'gateway_id' => (int)$this->gateway_id->value(),
'stage' => (string)$this->stage->value(),
'level' => (string)$this->level->value(),
'code' => $this->code->value() === null ? null : (string)$this->code->value(),
'message' => (string)$this->message->value(),
+141 -15
View File
@@ -4,6 +4,7 @@ namespace objects;
use classes\db;
use classes\object_property;
use Exception;
use traits\db_object_t;
class plate_scanners_o extends db
@@ -11,12 +12,16 @@ class plate_scanners_o extends db
use db_object_t;
public object_property $department_id;
public object_property $lane_id;
public object_property $name;
public object_property $notes;
public object_property $api_key;
private static bool $schemaInitialized = false;
public function structure(): void
{
self::ensureSchema();
$this->setTable('plate_scanners');
}
@@ -41,22 +46,25 @@ class plate_scanners_o extends db
public function getObjectProperties(): void
{
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int');
$this->lane_id = new object_property($this->table, $this->id, 'lane_id', 'int');
$this->name = new object_property($this->table, $this->id, 'name', 'string');
$this->notes = new object_property($this->table, $this->id, 'notes', 'string');
$this->api_key = new object_property($this->table, $this->id, 'api_key', 'string');
}
public function add(int $department_id, string $name, string $notes): void
public function add(int $department_id, string $name, string $notes, ?int $lane_id = null): void
{
global $db, $response;
try {
// Generate an API key
$api_key = bin2hex(random_bytes(32));
$lane_id = $this->normalizeLaneId($department_id, $lane_id);
// Avoid SQL injection
$name = $db->escape_string($name);
$notes = $db->escape_string($notes);
$laneValue = $lane_id === null ? 'NULL' : (string)$lane_id;
// Create a new record in the database
$sql = "INSERT INTO $this->table (department_id, name, notes, api_key) VALUES ($department_id, '$name', '$notes', '$api_key')";
$sql = "INSERT INTO $this->table (department_id, lane_id, name, notes, api_key) VALUES ($department_id, $laneValue, '$name', '$notes', '$api_key')";
$db->query($sql);
// Get the id of the new record
@@ -69,20 +77,26 @@ class plate_scanners_o extends db
}
}
public function edit(int $id, int $department_id, string $name, string $notes): void
public function edit(
int $id,
int $department_id,
string $name,
string $notes,
?int $lane_id = null,
bool $laneIdProvided = false
): void
{
global $db, $response;
$this->id = $id;
global $response;
try {
// Avoid SQL injection
$name = $db->escape_string($name);
$notes = $db->escape_string($notes);
// Update the record in the database
$sql = "UPDATE $this->table SET department_id = $department_id, name = '$name', notes = '$notes' WHERE id = $id";
$db->query($sql);
// Set the values of the object properties
$this->getObjectProperties();
$this->select($id);
// Use object_property setters so cached field values are invalidated before we serialize the scanner.
$this->department_id->set($department_id);
$this->name->set($name);
$this->notes->set($notes);
if ($laneIdProvided) {
$lane_id = $this->normalizeLaneId($department_id, $lane_id);
$this->lane_id->set($lane_id);
}
} catch (\Exception $e) {
$response->error($e->getMessage());
}
@@ -102,4 +116,116 @@ class plate_scanners_o extends db
}
return $this;
}
}
/**
* @return array{id:int,department_id:int,lane_id:int|null,name:string,notes:string,api_key:string}
* @throws Exception
*/
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'department_id' => (int)$this->department_id->value(),
'lane_id' => $this->lane_id->value() === null ? null : (int)$this->lane_id->value(),
'name' => (string)$this->name->value(),
'notes' => (string)$this->notes->value(),
'api_key' => (string)$this->api_key->value(),
];
}
/**
* @return array<int,plate_scanners_o>
* @throws Exception
*/
public function getDepartmentScanners(int $departmentId): array
{
$scanners = [];
$rows = self::getFieldsWhere([
'department_id' => $departmentId,
], ['id']);
foreach ($rows as $row) {
$scanner = (new plate_scanners_o())->select((int)$row['id']);
if ($scanner->exists()) {
$scanners[] = $scanner;
}
}
return $scanners;
}
/**
* @throws Exception
*/
public function rotateApiKey(int $id): array
{
$scanner = $this->select($id);
if (!$scanner->exists()) {
throw new Exception('Number plate scanner not found');
}
$newApiKey = bin2hex(random_bytes(32));
$scanner->api_key->set($newApiKey);
return $scanner->asArray();
}
private function normalizeLaneId(int $departmentId, ?int $laneId): ?int
{
if ($laneId === null || $laneId <= 0) {
return null;
}
$lane = (new department_lanes_o())->select($laneId);
if (!$lane->exists()) {
throw new Exception('Department lane not found');
}
if ((int)$lane->department->value() !== $departmentId) {
throw new Exception('The lane does not belong to the number plate scanner department');
}
return (int)$lane->id;
}
private static function ensureSchema(): void
{
if (self::$schemaInitialized) {
return;
}
global $db;
if (!self::tableHasColumn('plate_scanners', 'lane_id')) {
$db->query("ALTER TABLE `plate_scanners` ADD COLUMN `lane_id` INT NULL AFTER `department_id`");
}
self::$schemaInitialized = true;
}
private static function tableHasColumn(string $table, string $column): bool
{
global $db;
$table = $db->escape_string($table);
$column = $db->escape_string($column);
$database = $db->escape_string($db->getDatabase());
$result = $db->query(
"SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND COLUMN_NAME = '$column'"
);
if (!$result) {
return false;
}
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
}
@@ -346,7 +346,7 @@ final class LocalStateStore
final class BrokerWebSocketClient
{
private const CONNECT_TIMEOUT_SECONDS = 5;
private const CONNECT_TIMEOUT_SECONDS = 15;
private const RECONNECT_DELAY_SECONDS = 2;
/** @var resource|null */
@@ -998,6 +998,8 @@ final class TruckwashEdgeAgent
private const DEFAULT_UPDATE_WINDOW = '02:00-04:00';
private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120;
private const BROKER_MESSAGE_PUMP_LIMIT = 12;
private const LOOP_STALE_AFTER_SECONDS = 30;
private const CONTROL_PLANE_SYNC_STALE_AFTER_SECONDS = 90;
private AgentConfig $config;
private HttpJsonClient $http;
@@ -1011,6 +1013,7 @@ final class TruckwashEdgeAgent
private string $statePath;
private string $lastOperationSnapshotPath;
private string $lastHeartbeatMarkerPath;
private string $controlPlaneStatusPath;
private string $stagedUpdatePath;
private int $lastHeartbeatAt = 0;
private string $agentInstanceId;
@@ -1031,12 +1034,14 @@ final class TruckwashEdgeAgent
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
$this->lastOperationSnapshotPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-operation.json';
$this->lastHeartbeatMarkerPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-heartbeat-ok.txt';
$this->controlPlaneStatusPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'control-plane-status.json';
$this->stagedUpdatePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'staged-update.json';
$this->agentInstanceId = $this->ensureAgentInstanceId();
$this->brokerClient = new BrokerWebSocketClient($this->logger);
$this->shellBridge = new AgentShellBridge($this->installDir);
$this->logger->setSink(fn(string $level, string $message): bool => $this->emitLogFrame($level, $message));
$this->configureBrokerClient();
$this->initializeControlPlaneStatus();
}
public function run(): void
@@ -1046,6 +1051,7 @@ final class TruckwashEdgeAgent
while (true) {
try {
$this->touchLoopHeartbeat();
$this->reloadConfigFromDisk();
$this->ensureClaimed();
$this->configureBrokerClient();
@@ -1081,23 +1087,29 @@ final class TruckwashEdgeAgent
}
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v2');
$response = $this->http->post('/edge-agent/claim', [
'token' => (string)$this->config->get('installToken'),
'hostname' => gethostname() ?: 'truckwash-edge',
'installed_version' => $installedVersion,
'metadata' => [
'runtime' => 'compose-php',
'runtime_mode' => 'compose',
'php_version' => PHP_VERSION,
'agent_instance_id' => $this->agentInstanceId,
'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW),
'container_health' => $this->buildContainerHealth(),
'outbox_status' => $this->buildOutboxStatus(),
'last_sync_at' => $this->stateStore->getJson('last_sync_at'),
'rollback_status' => $this->readRollbackStatus(),
'staged_version' => $this->currentStagedUpdate(),
],
]);
try {
$response = $this->http->post('/edge-agent/claim', [
'token' => (string)$this->config->get('installToken'),
'hostname' => gethostname() ?: 'truckwash-edge',
'installed_version' => $installedVersion,
'metadata' => [
'runtime' => 'compose-php',
'runtime_mode' => 'compose',
'php_version' => PHP_VERSION,
'agent_instance_id' => $this->agentInstanceId,
'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW),
'container_health' => $this->buildContainerHealth(),
'outbox_status' => $this->buildOutboxStatus(),
'last_sync_at' => $this->currentLastSuccessfulSyncAt(),
'control_plane_status' => $this->buildControlPlaneStatusPayload(),
'rollback_status' => $this->readRollbackStatus(),
'staged_version' => $this->currentStagedUpdate(),
],
]);
} catch (Throwable $throwable) {
$this->recordTransportFailure('Gateway claim failed', $throwable);
throw $throwable;
}
$payload = $response['data'] ?? [];
$gateway = is_array($payload['gateway'] ?? null) ? (array)$payload['gateway'] : [];
@@ -1110,7 +1122,7 @@ final class TruckwashEdgeAgent
$this->config->set('targetVersion', (string)($gateway['target_version'] ?? $installedVersion));
$this->config->set('agentInstanceId', $this->agentInstanceId);
$this->config->save();
$this->stateStore->setJson('last_sync_at', date('c'));
$this->recordSuccessfulSync();
$this->logger->info('Claimed gateway ' . (string)($gateway['id'] ?? 'unknown') . '.');
}
@@ -1145,7 +1157,7 @@ final class TruckwashEdgeAgent
{
$sent = $this->brokerClient->send($message);
if ($sent) {
$this->stateStore->setJson('last_sync_at', date('c'));
$this->recordSuccessfulSync();
}
return $sent;
}
@@ -1224,6 +1236,7 @@ final class TruckwashEdgeAgent
return;
}
$this->recordHeartbeatAttempt();
$operationState = $this->readOperationState();
$payload = [
'agent_token' => (string)$this->config->get('agentToken'),
@@ -1242,7 +1255,8 @@ final class TruckwashEdgeAgent
'system_metrics' => $this->buildSystemMetrics(),
'container_health' => $this->buildContainerHealth(),
'outbox_status' => $this->buildOutboxStatus(),
'last_sync_at' => $this->stateStore->getJson('last_sync_at'),
'last_sync_at' => $this->currentLastSuccessfulSyncAt(),
'control_plane_status' => $this->buildControlPlaneStatusPayload(),
'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW),
'staged_version' => $this->currentStagedUpdate(),
'rollback_status' => $this->readRollbackStatus(),
@@ -1265,10 +1279,14 @@ final class TruckwashEdgeAgent
return;
}
$heartbeatSucceededAt = date('c');
$this->lastHeartbeatAt = time();
$this->recordSuccessfulSync($heartbeatSucceededAt, [
'last_heartbeat_success_at' => $heartbeatSucceededAt,
]);
file_put_contents($this->lastHeartbeatMarkerPath, json_encode([
'gateway_id' => $gatewayId,
'at' => date('c'),
'at' => $heartbeatSucceededAt,
'agent_instance_id' => $this->agentInstanceId,
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
@@ -1991,6 +2009,143 @@ final class TruckwashEdgeAgent
];
}
private function initializeControlPlaneStatus(): void
{
$this->writeControlPlaneStatus();
}
private function touchLoopHeartbeat(): void
{
$this->writeControlPlaneStatus([
'last_loop_at' => date('c'),
]);
}
private function currentLastSuccessfulSyncAt(): ?string
{
$current = $this->readControlPlaneStatus()['last_successful_sync_at'] ?? $this->stateStore->getJson('last_sync_at');
return $this->normalizeControlPlaneStatusTimestamp($current);
}
private function recordHeartbeatAttempt(): string
{
$attemptedAt = date('c');
$this->writeControlPlaneStatus([
'last_heartbeat_attempt_at' => $attemptedAt,
]);
return $attemptedAt;
}
private function recordSuccessfulSync(?string $at = null, array $statusOverrides = []): void
{
$syncedAt = $this->normalizeControlPlaneStatusTimestamp($at ?? date('c')) ?? date('c');
$this->stateStore->setJson('last_sync_at', $syncedAt);
$statusOverrides['last_successful_sync_at'] = $syncedAt;
$this->writeControlPlaneStatus($statusOverrides);
}
private function recordTransportFailure(string $context, Throwable $throwable): void
{
$message = $this->normalizeControlPlaneStatusString($throwable->getMessage()) ?? $throwable::class;
$this->writeControlPlaneStatus([
'last_transport_failure_at' => date('c'),
'last_transport_error' => $message,
]);
$this->logger->warning($context . ': ' . $message);
}
/**
* @return array<string,mixed>
*/
private function buildControlPlaneStatusPayload(): array
{
return $this->writeControlPlaneStatus();
}
/**
* @return array<string,mixed>
*/
private function readControlPlaneStatus(): array
{
if (!is_file($this->controlPlaneStatusPath)) {
return [];
}
$decoded = json_decode((string)file_get_contents($this->controlPlaneStatusPath), true);
return is_array($decoded) ? $decoded : [];
}
/**
* @param array<string,mixed> $overrides
* @return array<string,mixed>
*/
private function writeControlPlaneStatus(array $overrides = []): array
{
$current = $this->readControlPlaneStatus();
$outboxSummary = $this->stateStore->outboxSummary();
$lastSuccessfulSyncAt = array_key_exists('last_successful_sync_at', $overrides)
? $overrides['last_successful_sync_at']
: ($current['last_successful_sync_at'] ?? $this->stateStore->getJson('last_sync_at'));
$lastTransportError = array_key_exists('last_transport_error', $overrides)
? $overrides['last_transport_error']
: ($current['last_transport_error'] ?? null);
$lastTransportFailureAt = array_key_exists('last_transport_failure_at', $overrides)
? $overrides['last_transport_failure_at']
: ($current['last_transport_failure_at'] ?? null);
$lastHeartbeatAttemptAt = array_key_exists('last_heartbeat_attempt_at', $overrides)
? $overrides['last_heartbeat_attempt_at']
: ($current['last_heartbeat_attempt_at'] ?? null);
$lastHeartbeatSuccessAt = array_key_exists('last_heartbeat_success_at', $overrides)
? $overrides['last_heartbeat_success_at']
: ($current['last_heartbeat_success_at'] ?? null);
$lastLoopAt = array_key_exists('last_loop_at', $overrides)
? $overrides['last_loop_at']
: ($current['last_loop_at'] ?? null);
$status = [
'started_at' => $this->normalizeControlPlaneStatusTimestamp($current['started_at'] ?? null) ?? date('c'),
'agent_instance_id' => $this->agentInstanceId,
'last_loop_at' => $this->normalizeControlPlaneStatusTimestamp($lastLoopAt),
'last_heartbeat_attempt_at' => $this->normalizeControlPlaneStatusTimestamp($lastHeartbeatAttemptAt),
'last_heartbeat_success_at' => $this->normalizeControlPlaneStatusTimestamp($lastHeartbeatSuccessAt),
'last_successful_sync_at' => $this->normalizeControlPlaneStatusTimestamp($lastSuccessfulSyncAt),
'outbox_queued' => array_key_exists('outbox_queued', $overrides)
? max(0, (int)$overrides['outbox_queued'])
: max(0, (int)($outboxSummary['queued'] ?? 0)),
'broker_connected' => array_key_exists('broker_connected', $overrides)
? (bool)$overrides['broker_connected']
: $this->isBrokerConnected(),
'last_transport_error' => $this->normalizeControlPlaneStatusString($lastTransportError),
'last_transport_failure_at' => $this->normalizeControlPlaneStatusTimestamp($lastTransportFailureAt),
];
file_put_contents(
$this->controlPlaneStatusPath,
json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
return $status;
}
private function normalizeControlPlaneStatusTimestamp(mixed $value): ?string
{
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
private function normalizeControlPlaneStatusString(mixed $value): ?string
{
if (is_string($value)) {
$normalized = trim($value);
return $normalized !== '' ? $normalized : null;
}
if (is_scalar($value)) {
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
return null;
}
private function flushOutbox(): void
{
$items = $this->stateStore->queuedItems(25);
@@ -2006,9 +2161,12 @@ final class TruckwashEdgeAgent
$this->http->post($endpoint, $payload, $timeoutSeconds);
}
$this->stateStore->removeOutboxItem((int)$item['id']);
$this->stateStore->setJson('last_sync_at', date('c'));
$this->recordSuccessfulSync();
} catch (Throwable $throwable) {
$this->logger->warning('Outbox replay blocked on ' . (string)$item['type'] . ': ' . $throwable->getMessage());
$this->recordTransportFailure(
'Outbox replay blocked on ' . (string)$item['type'] . ' for ' . (string)$item['endpoint'],
$throwable
);
break;
}
}
@@ -2106,17 +2264,19 @@ final class TruckwashEdgeAgent
{
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
if ($brokerDispatch === true) {
$this->stateStore->setJson('last_sync_at', date('c'));
return true;
}
try {
$this->http->post($endpoint, $payload, 20);
$this->stateStore->setJson('last_sync_at', date('c'));
$this->recordSuccessfulSync();
return true;
} catch (Throwable $throwable) {
$this->stateStore->enqueue($type, $endpoint, $payload);
$this->logger->warning('Queued ' . $type . ' to local outbox after transport failure: ' . $throwable->getMessage());
$this->recordTransportFailure(
'Queued ' . $type . ' to local outbox after transport failure on ' . $endpoint,
$throwable
);
return false;
}
}
@@ -2125,17 +2285,19 @@ final class TruckwashEdgeAgent
{
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
if ($brokerDispatch === true) {
$this->stateStore->setJson('last_sync_at', date('c'));
return ['data' => ['status' => 'ACKNOWLEDGED']];
}
try {
$response = $this->http->post($endpoint, $payload, $timeoutSeconds);
$this->stateStore->setJson('last_sync_at', date('c'));
$this->recordSuccessfulSync();
return is_array($response) ? $response : null;
} catch (Throwable $throwable) {
$this->stateStore->enqueue($type, $endpoint, $payload);
$this->logger->warning('Queued ' . $type . ' to local outbox after transport failure: ' . $throwable->getMessage());
$this->recordTransportFailure(
'Queued ' . $type . ' to local outbox after transport failure on ' . $endpoint,
$throwable
);
return null;
}
}
@@ -2356,22 +2518,27 @@ final class TruckwashEdgeAgent
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
if ($brokerDispatch === true) {
$this->stateStore->setJson('last_sync_at', date('c'));
return true;
}
try {
$this->http->post($endpoint, $payload, self::OPERATION_COMPLETE_TIMEOUT_SECONDS);
$this->stateStore->setJson('last_sync_at', date('c'));
$this->recordSuccessfulSync();
return true;
} catch (Throwable $throwable) {
if ($queueOnFailure) {
$this->stateStore->enqueue('operation_complete', $endpoint, $payload);
$this->logger->warning('Queued operation_complete to local outbox after completion acknowledgement failure: ' . $throwable->getMessage());
$this->recordTransportFailure(
'Queued operation_complete to local outbox after completion acknowledgement failure on ' . $endpoint,
$throwable
);
return false;
}
$this->logger->warning('Retrying backend completion acknowledgement later: ' . $throwable->getMessage());
$this->recordTransportFailure(
'Retrying backend completion acknowledgement later for ' . $endpoint,
$throwable
);
return false;
}
}
@@ -89,7 +89,11 @@ services:
- ./config.json:/config/config.json
- ./runtime:/opt/truckwash-edge-agent/runtime
healthcheck:
test: ["CMD-SHELL", "kill -0 1"]
test:
[
"CMD-SHELL",
"php -r '$$path=\"/opt/truckwash-edge-agent/runtime/control-plane-status.json\"; if (!is_file($$path)) { exit(1); } $$data=json_decode((string)file_get_contents($$path), true); if (!is_array($$data)) { exit(1); } $$loopAt=strtotime((string)($$data[\"last_loop_at\"] ?? \"\")); $$syncAt=strtotime((string)($$data[\"last_successful_sync_at\"] ?? $$data[\"started_at\"] ?? \"\")); if ($$loopAt === false || $$syncAt === false) { exit(1); } $$now=time(); exit((($$now - $$loopAt) <= 30 && ($$now - $$syncAt) <= 90) ? 0 : 1);'",
]
interval: 30s
timeout: 5s
retries: 3
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\customer_mass_import_service;
use customers\economicCustomers;
use objects\logs_o;
use objects\users_o;
@@ -143,6 +144,59 @@ class customerSearchRoute
'search_customers' => 'Search for customers, and list all customers if no search is provided'
]
);
$this->post('/customers/import', function () {
global $response;
$this->requirePermission('add_user');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('customers', 'global', 1, 0, 'IMPORT_CUSTOMER', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
$data = [];
}
try {
$result = (new customer_mass_import_service())->import($data);
} catch (\RuntimeException $throwable) {
$statusCode = (int)$throwable->getCode();
if ($statusCode < 400 || $statusCode > 599) {
$statusCode = 400;
}
(new logs_o())->add(
'customers',
'global',
1,
$user->id,
'IMPORT_CUSTOMER_FAILED',
$throwable->getMessage()
);
$response->error([
'message' => $throwable->getMessage(),
], $statusCode);
}
(new logs_o())->add(
'customers',
'global',
1,
$user->id,
'IMPORT_CUSTOMER',
'Successfully imported or created customer ' . ($result['customer_number'] ?? 'unknown')
);
$response->success($result);
},
[
'add_user' => 'Add a user'
]
);
}
private static function parseFunction($collection, \Closure $param): array
@@ -4,6 +4,7 @@ namespace routes;
use classes\authentication;
use classes\response;
use classes\shelly_relay_inventory;
use dynamicimages\images\machine_1;
use objects\categories_o;
use objects\department_lanes_o;
@@ -81,6 +82,52 @@ class departmentLanesRoute
]
);
$this->get('/department/lanes/relay-options', function () {
global $response;
$this->requirePermission('list_department_lanes');
$user = (new authentication())->get_user();
if ($user) {
try {
$options = (new shelly_relay_inventory())->listRelayOptions();
(new logs_o())->add(
'department_lanes',
'global',
1,
$user->id,
'LIST_DEPARTMENT_LANE_RELAY_OPTIONS',
'User listed available Shelly relay options'
);
$response->success($options);
} catch (\Throwable $e) {
(new logs_o())->add(
'department_lanes',
'global',
0,
$user->id,
'LIST_DEPARTMENT_LANE_RELAY_OPTIONS',
'Failed to list Shelly relay options: ' . $e->getMessage()
);
$response->error('Failed to fetch Shelly relay options: ' . $e->getMessage(), 400);
}
} else {
(new logs_o())->add(
'department_lanes',
'global',
1,
0,
'LIST_DEPARTMENT_LANE_RELAY_OPTIONS',
'User tried to list Shelly relay options without being logged in'
);
$response->error('Invalid session', 400);
}
},
[
'list_department_lanes' => 'List available Shelly relay options for department lanes'
]
);
/**
* Generate dynamic image for a department lane (machine UI)
*
@@ -255,11 +302,11 @@ class departmentLanesRoute
// Get the request data
$name = $response->getRequestParameter('name') ?? null;
$department = $response->getRequestParameter('department') ?? null;
$relay_in_id = $response->getRequestParameter('relay_in_id') ?? null;
$relay_out_id = $response->getRequestParameter('relay_out_id') ?? null;
$relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null;
$relay_machine_program_picker_id = $response->getRequestParameter('relay_machine_program_picker_id') ?? null;
$relay_machine_cleaner_id = $response->getRequestParameter('relay_machine_cleaner_id') ?? null;
$relay_in_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_in_id') ?? null);
$relay_out_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_out_id') ?? null);
$relay_machine_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_id') ?? null);
$relay_machine_program_picker_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_program_picker_id') ?? null);
$relay_machine_cleaner_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_cleaner_id') ?? null);
$dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null;
$machine_type_id = $response->getRequestParameter('machine_type_id') ?? null;
@@ -276,7 +323,6 @@ class departmentLanesRoute
} else {
$machine_type_id = null;
}
// Remove spaces from the relay_in_id and relay_out_id
// Check if the required fields are set
if ($name && $department) {
// Add the department lane
@@ -315,11 +361,11 @@ class departmentLanesRoute
$id = $response->getRequestParameter('id') ?? null;
$name = $response->getRequestParameter('name') ?? null;
$department = $response->getRequestParameter('department') ?? null;
$relay_in_id = $response->getRequestParameter('relay_in_id') ?? null;
$relay_out_id = $response->getRequestParameter('relay_out_id') ?? null;
$relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null;
$relay_machine_program_picker_id = $response->getRequestParameter('relay_machine_program_picker_id') ?? null;
$relay_machine_cleaner_id = $response->getRequestParameter('relay_machine_cleaner_id') ?? null;
$relay_in_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_in_id') ?? null);
$relay_out_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_out_id') ?? null);
$relay_machine_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_id') ?? null);
$relay_machine_program_picker_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_program_picker_id') ?? null);
$relay_machine_cleaner_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_cleaner_id') ?? null);
$dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null;
$machine_type_id = $response->getRequestParameter('machine_type_id') ?? null;
@@ -340,19 +386,19 @@ class departmentLanesRoute
$department_lane->department->set((int)$department);
}
if (self::isParametersSet(['relay_in_id'])) {
$department_lane->relay_in_id->set((string)$relay_in_id);
self::syncDepartmentLaneRelayValue($department_lane->relay_in_id, $relay_in_id);
}
if (self::isParametersSet(['relay_out_id'])) {
$department_lane->relay_out_id->set((string)$relay_out_id);
self::syncDepartmentLaneRelayValue($department_lane->relay_out_id, $relay_out_id);
}
if (self::isParametersSet(['relay_machine_id'])) {
$department_lane->relay_machine_id->set((string)$relay_machine_id);
self::syncDepartmentLaneRelayValue($department_lane->relay_machine_id, $relay_machine_id);
}
if (self::isParametersSet(['relay_machine_program_picker_id'])) {
$department_lane->relay_machine_program_picker_id->set((string)$relay_machine_program_picker_id);
self::syncDepartmentLaneRelayValue($department_lane->relay_machine_program_picker_id, $relay_machine_program_picker_id);
}
if (self::isParametersSet(['relay_machine_cleaner_id'])) {
$department_lane->relay_machine_cleaner_id->set((string)$relay_machine_cleaner_id);
self::syncDepartmentLaneRelayValue($department_lane->relay_machine_cleaner_id, $relay_machine_cleaner_id);
}
if (self::isParametersSet(['dynamic_image_id'])) {
$param = $response->getRequestParameter('dynamic_image_id');
@@ -390,4 +436,25 @@ class departmentLanesRoute
]
);
}
private static function normalizeRelayRequestParameter(mixed $value): ?string
{
$normalized = trim((string)($value ?? ''));
if ($normalized === '' || strtolower($normalized) === 'null') {
return null;
}
return $normalized;
}
private static function syncDepartmentLaneRelayValue(mixed $field, mixed $value): void
{
$normalized = self::normalizeRelayRequestParameter($value);
if ($normalized === null) {
$field->nullify();
return;
}
$field->set($normalized);
}
}
@@ -69,6 +69,17 @@ class machineButtonPressRoute
return (int)$lane->id;
}
if ($plateScanner->lane_id->value() !== null) {
$lane = (new department_lanes_o())->select((int)$plateScanner->lane_id->value());
if (!$lane->exists()) {
$response->error('The default lane configured for the plate scanner no longer exists', 404);
}
if ((int)$lane->department->value() !== (int)$plateScanner->department_id->value()) {
$response->error('The default lane does not belong to the plate scanner department', 403);
}
return (int)$lane->id;
}
$lanes = (new department_lanes_o())->getDepartmentLanes((int)$plateScanner->department_id->value());
if (count($lanes) === 1) {
return (int)$lanes[0]->id;
@@ -34,7 +34,11 @@ class plateScannersRoute
'name',
'notes'
])
->listObjectsWithPaginationIfSet()
->listObjectsWithPaginationIfSet(
static function (array $scanner): array {
return (new plate_scanners_o())->select((int)$scanner['id'])->asArray();
}
)
);
} else {
// Log the incident
@@ -68,12 +72,19 @@ class plateScannersRoute
if (!isset($data['notes'])) {
$response->error('Notes is required', 400);
}
$laneId = array_key_exists('lane_id', $data) && $data['lane_id'] !== null
? (int)$data['lane_id']
: null;
// Add the number plate scanner
(new plate_scanners_o())->add($data['department_id'], $data['name'], $data['notes']);
$scanner = new plate_scanners_o();
$scanner->add((int)$data['department_id'], (string)$data['name'], (string)$data['notes'], $laneId);
// Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'ADD_NUMBER_PLATE_SCANNER', 'Successfully added a number plate scanner');
// Return a success message
$response->success(['message' => 'Number plate scanner added']);
$response->success([
'message' => 'Number plate scanner added',
'scanner' => $scanner->asArray(),
]);
} else {
// Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, 0, 'ADD_NUMBER_PLATE_SCANNER', 'No user found, or invalid session');
@@ -109,12 +120,25 @@ class plateScannersRoute
if (!isset($data['notes'])) {
$response->error('Notes is required', 400);
}
$laneIdProvided = array_key_exists('lane_id', $data);
$laneId = $laneIdProvided && $data['lane_id'] !== null ? (int)$data['lane_id'] : null;
// Edit the number plate scanner
(new plate_scanners_o())->edit($data['id'], $data['department_id'], $data['name'], $data['notes']);
$scanner = new plate_scanners_o();
$scanner->edit(
(int)$data['id'],
(int)$data['department_id'],
(string)$data['name'],
(string)$data['notes'],
$laneId,
$laneIdProvided
);
// Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'EDIT_NUMBER_PLATE_SCANNER', 'Successfully edited a number plate scanner');
// Return a success message
$response->success(['message' => 'Number plate scanner edited']);
$response->success([
'message' => 'Number plate scanner edited',
'scanner' => $scanner->asArray(),
]);
} else {
// Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, 0, 'EDIT_NUMBER_PLATE_SCANNER', 'No user found, or invalid session');
@@ -127,6 +151,35 @@ class plateScannersRoute
]
);
$this->post('/numberplatescanners/{id}/rotate-key', function () {
global $response;
$this->requirePermission('edit_number_plate_scanner');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('numberplatescanners', 'global', 1, 0, 'ROTATE_NUMBER_PLATE_SCANNER_KEY', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$scannerId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($scannerId, 'id');
$scanner = (new plate_scanners_o())->select($scannerId);
if (!$scanner->exists()) {
$response->error('Number plate scanner not found', 404);
}
self::requireDepartmentAccess((int)$scanner->department_id->value());
$rotatedScanner = (new plate_scanners_o())->rotateApiKey($scannerId);
(new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'ROTATE_NUMBER_PLATE_SCANNER_KEY', 'Successfully rotated a number plate scanner API key');
$response->success([
'message' => 'Number plate scanner API key rotated',
'scanner' => $rotatedScanner,
'api_key' => (string)$rotatedScanner['api_key'],
]);
}, [
'edit_number_plate_scanner' => 'Rotate a number plate scanner API key',
]);
self::get('/department/numberplatescanners', function () {
// Require the user to be logged in
global $response;
@@ -152,12 +205,14 @@ class plateScannersRoute
'department_id' => (int)self::getParameter('id')
], [
'id',
'lane_id',
'name',
'notes'
]);
// Parse the result
foreach ( $result as $key => $value ) {
$result[$key]['id'] = (int)$value['id'];
$result[$key]['lane_id'] = $value['lane_id'] === null ? null : (int)$value['lane_id'];
}
// Return the list of plate scanners
$response->success(
@@ -176,4 +231,4 @@ class plateScannersRoute
]
);
}
}
}
@@ -0,0 +1,354 @@
<?php
declare(strict_types=1);
use classes\edge_gateway_manager;
usesApiSuite();
it('serves installer artifacts and recovers gateway runtime status after fresh heartbeats', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Agent Department',
]);
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
$installTokenResponse = api_client()->post('/edge-gateways/install-token', [
'department_id' => (int)$department['id'],
'label' => 'Edge Agent Install',
], $session['headers']);
$installTokenResponse
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$installToken = $installTokenResponse->data();
$installScript = api_client()->get('/edge-agent/install.sh?token=' . urlencode((string)$installToken['token']));
expect($installScript->status)->toBe(200)
->and($installScript->body)
->toContain('/edge-agent/install-token/status')
->toContain('agent.php')
->toContain((string)$installToken['token']);
$artifact = api_client()->get('/edge-agent/artifacts/agent.php');
expect($artifact->status)->toBe(200)
->and($artifact->body)
->toContain('<?php');
$claimResponse = api_client()->post('/edge-agent/claim', [
'token' => (string)$installToken['token'],
'hostname' => 'edge-agent-api',
'installed_version' => 'php-agent-v1',
]);
$claimResponse
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$gatewayId = (int)($claimResponse->data()['gateway']['id'] ?? 0);
$agentToken = (string)($claimResponse->data()['agent_token'] ?? '');
expect($gatewayId)->toBeGreaterThan(0)
->and($agentToken)->not->toBe('');
edge_agent_test_set_heartbeat_age($gatewayId, edge_gateway_manager::HEARTBEAT_DEGRADED_AFTER_SECONDS + 1);
$degradedDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']);
$degradedDetail
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($degradedDetail->data())
->toHaveKey('status', 'DEGRADED');
edge_agent_test_set_heartbeat_age($gatewayId, edge_gateway_manager::HEARTBEAT_OFFLINE_AFTER_SECONDS + 1);
$offlineDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']);
$offlineDetail
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($offlineDetail->data())
->toHaveKey('status', 'OFFLINE');
$heartbeatResponse = api_client()->post('/edge-agent/gateways/' . $gatewayId . '/heartbeat', [
'agent_token' => $agentToken,
'status' => 'ONLINE',
'hostname' => 'edge-agent-api',
'metadata' => [
'system_metrics' => [
'cpu_percent' => 21,
'memory_mb' => 128,
],
],
'inventory' => edge_agent_test_inventory('heartbeat'),
]);
$heartbeatResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($heartbeatResponse->data())
->toHaveKey('status', 'ONLINE');
$recoveredDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']);
$recoveredDetail
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($recoveredDetail->data())
->toHaveKey('status', 'ONLINE')
->and($recoveredDetail->data()['metadata']['system_metrics']['cpu_percent'] ?? null)
->toBe(21);
});
it('polls operations and commands, submits results, and records broker presence for task pages', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Agent Operations Department',
]);
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
$gateway = api_fixtures()->createClaimedEdgeGateway([
'department_id' => (int)$department['id'],
'label' => 'Edge Agent Runtime',
]);
$operationResponse = api_client()->post('/edge-gateways/' . (int)$gateway['id'] . '/operations', [
'type' => 'DISCOVERY',
'request' => [
'inventory' => edge_agent_test_inventory('operation'),
],
], $session['headers']);
$operationResponse
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$operationId = (int)($operationResponse->data()['operation']['id'] ?? 0);
expect($operationId)->toBeGreaterThan(0);
$operationLease = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/next', [
'agent_token' => (string)$gateway['agent_token'],
'wait_seconds' => 0,
'agent_instance_id' => 'edge-agent-api-test',
]);
$operationLease
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($operationLease->data())
->toHaveKey('id', $operationId)
->toHaveKey('status', 'IN_PROGRESS');
$eventResponse = api_client()->post(
'/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events',
[
'agent_token' => (string)$gateway['agent_token'],
'level' => 'INFO',
'code' => 'DISCOVERY_RUNNING',
'message' => 'Discovery is running through the agent API.',
'context' => [
'progress' => 55,
'label' => 'Discovery running',
],
]
);
$eventResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$completeResponse = api_client()->post(
'/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/complete',
[
'agent_token' => (string)$gateway['agent_token'],
'ok' => true,
'result' => [
'inventory' => edge_agent_test_inventory('completed'),
],
]
);
$completeResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($completeResponse->data())
->toHaveKey('status', 'COMPLETED');
$job = api_fixtures()->createEdgeCommandJob([
'gateway_id' => (int)$gateway['id'],
'command_type' => 'GET_RELAY_STATUS',
'request' => [
'relayId' => 'relay-main',
'localIp' => '10.0.0.18',
],
]);
$commandPoll = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/commands/poll', [
'agent_token' => (string)$gateway['agent_token'],
'wait_seconds' => 0,
]);
$commandPoll
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($commandPoll->data())
->toHaveKey('id', (int)$job['id'])
->toHaveKey('command_type', 'GET_RELAY_STATUS');
$commandResult = api_client()->post(
'/edge-agent/gateways/' . (int)$gateway['id'] . '/commands/' . (int)$job['id'] . '/result',
[
'agent_token' => (string)$gateway['agent_token'],
'ok' => true,
'result' => [
'relayId' => 'relay-main',
'online' => true,
],
]
);
$commandResult
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($commandResult->data())
->toHaveKey('acknowledged', true)
->and($commandResult->data()['job']['status'] ?? null)
->toBe('COMPLETED');
$presenceResponse = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/presence', [
'agent_token' => (string)$gateway['agent_token'],
'status' => 'connected',
'connection_id' => 'broker-connection-1',
'metadata' => [
'transport' => 'ws',
],
]);
$presenceResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($presenceResponse->data())
->toHaveKey('connected', true);
$tasksPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/tasks', $session['headers']);
$tasksPage
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($tasksPage->data()['operations'] ?? [])
->toBeArray()
->not->toBeEmpty()
->and($tasksPage->data()['recent_commands'] ?? [])
->toBeArray()
->not->toBeEmpty();
expect($tasksPage->data()['recent_operations_summary']['completed'] ?? 0)
->toBeGreaterThanOrEqual(1);
});
it('rejects missing and invalid edge agent tokens', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Agent Auth Department',
]);
$gateway = api_fixtures()->createClaimedEdgeGateway([
'department_id' => (int)$department['id'],
]);
$missingToken = api_client()->get('/edge-agent/install-token/verify');
$missingToken
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Missing token');
$missingAgentToken = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/heartbeat', [
'status' => 'ONLINE',
]);
$missingAgentToken
->assertStatus(401)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Missing edge gateway agent token');
$invalidAgentToken = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/next', [
'agent_token' => 'invalid-token',
'wait_seconds' => 0,
]);
$invalidAgentToken
->assertStatus(401)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Invalid edge gateway token');
});
function edge_agent_test_set_heartbeat_age(int $gatewayId, int $secondsAgo): void
{
$db = api_test_runtime()->db();
$row = api_fixtures()->fetchRowById('edge_gateways', $gatewayId) ?? [];
$referenceTimestamp = strtotime((string)($row['last_heartbeat_at'] ?? $row['updated_at'] ?? $row['created_at'] ?? ''));
if ($referenceTimestamp === false || $referenceTimestamp <= 0) {
$referenceTimestamp = time();
}
$timestamp = date('Y-m-d H:i:s', $referenceTimestamp - max(0, $secondsAgo));
$escapedTimestamp = $db->real_escape_string($timestamp);
$db->query("UPDATE edge_gateways SET last_heartbeat_at = '{$escapedTimestamp}', status = 'ONLINE' WHERE id = " . (int)$gatewayId);
$redis = api_test_runtime()->redis();
if ($redis !== null) {
foreach ([
'obj_prop:*:' . $gatewayId . ':status',
'obj_prop:*:' . $gatewayId . ':last_heartbeat_at',
'obj_prop:*:' . $gatewayId . ':updated_at',
] as $pattern) {
$keys = $redis->keys($pattern);
if (is_array($keys) && $keys !== []) {
$redis->del($keys);
}
}
}
api_fixtures()->clearEdgeGatewayViewCache();
}
function edge_agent_test_inventory(string $suffix): array
{
return [[
'device_id' => 'agent-' . $suffix,
'local_ip' => '10.30.40.50',
'model' => 'TruckWash Edge Agent',
'channel_count' => 1,
'online' => true,
'capabilities' => [
'gateway_management_v2' => true,
],
'metadata' => [
'hostname' => 'edge-' . $suffix,
],
]];
}
@@ -0,0 +1,389 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('validates broker sessions and ingests presence, telemetry, logs, and shell lifecycle data', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Broker Department',
]);
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
$gateway = api_fixtures()->createClaimedEdgeGateway([
'department_id' => (int)$department['id'],
'label' => 'Broker Gateway',
]);
$validateGateway = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/validate',
['token' => (string)$gateway['agent_token']],
edge_test_broker_headers()
);
$validateGateway
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($validateGateway->data())
->toHaveKey('gateway_id', (int)$gateway['id'])
->toHaveKey('department_id', (int)$department['id']);
$presence = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence',
[
'status' => 'connected',
'connection_id' => 'broker-presence-1',
'metadata' => [
'transport' => 'ws',
],
],
edge_test_broker_headers()
);
$presence
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($presence->data())
->toHaveKey('connected', true)
->toHaveKey('connection_id', 'broker-presence-1');
$telemetry = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/telemetry',
[
'status' => 'ONLINE',
'metadata' => [
'system_metrics' => [
'cpu_load' => 0.42,
'memory_mb' => 512,
],
],
'inventory' => edge_broker_test_inventory('telemetry'),
],
edge_test_broker_headers()
);
$telemetry
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($telemetry->data()['metadata']['system_metrics']['cpu_load'] ?? null)
->toBe(0.42);
$logEntry = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/logs',
[
'level' => 'INFO',
'stream' => 'agent',
'source' => 'BROKER',
'message' => 'Broker forwarded a live gateway log.',
'context' => [
'source' => 'broker-test',
],
],
edge_test_broker_headers()
);
$logEntry
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($logEntry->data())
->toHaveKey('message', 'Broker forwarded a live gateway log.');
$streamSession = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/stream-session',
['scopes' => ['logs', 'statistics', 'tasks']],
$session['headers']
);
$streamSession
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$streamValidation = api_client()->post(
'/edge-agent/internal/browser-streams/validate',
['token' => (string)$streamSession->data()['token']],
edge_test_broker_headers()
);
$streamValidation
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($streamValidation->data())
->toHaveKey('session_type', 'gateway-stream')
->toHaveKey('gateway_id', (int)$gateway['id']);
$shellSession = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions',
['reason' => 'Broker shell validation'],
$session['headers']
);
$shellSession
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$shellToken = (string)$shellSession->data()['token'];
$validateShell = api_client()->post(
'/edge-agent/internal/shell-sessions/validate',
['token' => $shellToken],
edge_test_broker_headers()
);
$validateShell
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($validateShell->data())
->toHaveKey('status', 'PENDING');
$openedShell = api_client()->post(
'/edge-agent/internal/shell-sessions/opened',
[
'token' => $shellToken,
'connection_id' => 'shell-connection-1',
],
edge_test_broker_headers()
);
$openedShell
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($openedShell->data())
->toHaveKey('status', 'OPEN')
->toHaveKey('connection_id', 'shell-connection-1');
$closedShell = api_client()->post(
'/edge-agent/internal/shell-sessions/close',
[
'token' => $shellToken,
'transcript' => "edge-broker-shell\n",
'reason' => 'agent_exit',
],
edge_test_broker_headers()
);
$closedShell
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($closedShell->data())
->toHaveKey('status', 'COMPLETED')
->and($closedShell->data()['transcript'] ?? null)
->toBe("edge-broker-shell\n");
$logsPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/logs', $session['headers']);
$logsPage
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(collect_gateway_messages($logsPage->data()['log_entries'] ?? []))
->toContain('Broker forwarded a live gateway log.');
expect(collect_gateway_messages($logsPage->data()['timeline'] ?? []))
->toContain('GATEWAY_SHELL_SESSION_OPENED')
->toContain('GATEWAY_SHELL_SESSION_CLOSED');
expect($logsPage->data()['shell_sessions'][0]['transcript'] ?? null)
->toBe("edge-broker-shell\n");
$statisticsPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/statistics', $session['headers']);
$statisticsPage
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($statisticsPage->data())
->toHaveKey('system_metrics')
->and($statisticsPage->data()['system_metrics']['cpu_load'] ?? null)
->toBe(0.42);
});
it('builds broker backlog and completes gateway operations through broker endpoints', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Broker Backlog Department',
]);
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
$gateway = api_fixtures()->createClaimedEdgeGateway([
'department_id' => (int)$department['id'],
'label' => 'Broker Backlog Gateway',
]);
$queuedOperation = api_client()->post('/edge-gateways/' . (int)$gateway['id'] . '/operations', [
'type' => 'DISCOVERY',
'request' => [
'inventory' => edge_broker_test_inventory('backlog'),
],
], $session['headers']);
$queuedOperation
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$operationId = (int)($queuedOperation->data()['operation']['id'] ?? 0);
expect($operationId)->toBeGreaterThan(0);
$backlog = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/backlog',
['agent_instance_id' => 'broker-agent-1'],
edge_test_broker_headers()
);
$backlog
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($backlog->data()['dispatch'] ?? [])
->toBeArray()
->not->toBeEmpty()
->and($backlog->data()['dispatch'][0]['type'] ?? null)
->toBe('TASK_DISPATCH')
->and((int)($backlog->data()['dispatch'][0]['operation']['id'] ?? 0))
->toBe($operationId);
$event = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events',
[
'level' => 'INFO',
'code' => 'BROKER_EXECUTING',
'message' => 'Broker is executing the operation.',
'context' => [
'progress' => 50,
],
],
edge_test_broker_headers()
);
$event
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$complete = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/complete',
[
'ok' => true,
'result' => [
'inventory' => edge_broker_test_inventory('completed'),
],
],
edge_test_broker_headers()
);
$complete
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($complete->data())
->toHaveKey('status', 'COMPLETED');
$operations = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/operations', $session['headers']);
$operations
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(($operations->data()[0]['status'] ?? null))
->toBe('COMPLETED');
$events = api_client()->get(
'/edge-gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events',
$session['headers']
);
$events
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(collect_gateway_messages($events->data()))
->toContain('Broker is executing the operation.')
->toContain('Operation completed successfully');
$tasksPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/tasks', $session['headers']);
$tasksPage
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($tasksPage->data()['recent_operations_summary']['completed'] ?? 0)
->toBeGreaterThanOrEqual(1);
});
it('rejects invalid edge broker shared secrets', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Broker Forbidden Department',
]);
$gateway = api_fixtures()->createClaimedEdgeGateway([
'department_id' => (int)$department['id'],
]);
$response = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence',
['status' => 'connected'],
['X-Edge-Broker-Secret' => 'wrong-secret']
);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Invalid edge broker secret');
});
function edge_broker_test_inventory(string $suffix): array
{
return [[
'device_id' => 'broker-' . $suffix,
'local_ip' => '10.40.50.60',
'model' => 'TruckWash Edge Broker',
'channel_count' => 1,
'online' => true,
'capabilities' => [
'relay_commands' => true,
],
'metadata' => [
'hostname' => 'broker-' . $suffix,
],
]];
}
/**
* @param mixed $items
* @return array<int, string>
*/
function collect_gateway_messages(mixed $items): array
{
if (!is_array($items)) {
return [];
}
$messages = [];
foreach ($items as $item) {
if (is_array($item) && isset($item['message']) && is_string($item['message'])) {
$messages[] = $item['message'];
}
}
return $messages;
}
@@ -0,0 +1,418 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('creates install tokens, tracks installer status, and exposes claimed gateway detail to authorized operators', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Operator Department',
]);
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
$createResponse = api_client()->post('/edge-gateways/install-token', [
'department_id' => (int)$department['id'],
'label' => 'Dock 7 Gateway',
], $session['headers']);
$createResponse
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$installToken = $createResponse->data();
expect($installToken)
->toBeArray()
->toHaveKeys(['claim_token_id', 'token', 'install_command']);
$statusResponse = api_client()->get(
'/edge-gateways/install-token/' . (int)$installToken['claim_token_id'] . '/status',
$session['headers']
);
$statusResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($statusResponse->data())
->toHaveKey('status', 'PENDING')
->toHaveKey('gateway_id', null);
$verifyResponse = api_client()->get('/edge-agent/install-token/verify?token=' . urlencode((string)$installToken['token']));
$verifyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($verifyResponse->data())
->toHaveKey('valid', true)
->toHaveKey('claim_token_id', (int)$installToken['claim_token_id']);
$runningStatus = api_client()->post('/edge-agent/install-token/status', [
'token' => (string)$installToken['token'],
'status' => 'RUNNING',
'step' => 'BOOTSTRAP',
'message' => 'Installer is downloading gateway artifacts.',
'diagnostics' => [
'download-1',
'download-2',
'download-3',
'download-4',
'download-5',
'download-6',
'download-7',
],
]);
$runningStatus
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$runningStatusDetail = api_client()->get(
'/edge-gateways/install-token/' . (int)$installToken['claim_token_id'] . '/status',
$session['headers']
);
$runningStatusDetail
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($runningStatusDetail->data())
->toHaveKey('status', 'RUNNING')
->toHaveKey('step', 'BOOTSTRAP')
->and($runningStatusDetail->data()['diagnostics'] ?? [])
->toBeArray()
->toHaveCount(6);
$claimResponse = api_client()->post('/edge-agent/claim', [
'token' => (string)$installToken['token'],
'hostname' => 'edge-operator-api',
'installed_version' => 'php-agent-v1',
'metadata' => [
'agent_runtime' => 'compose-php',
],
]);
$claimResponse
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$claimedPayload = $claimResponse->data();
$gatewayId = (int)($claimedPayload['gateway']['id'] ?? 0);
expect($claimedPayload)
->toHaveKey('agent_token')
->and($gatewayId)
->toBeGreaterThan(0)
->and($claimedPayload['gateway']['status'] ?? null)
->toBe('ONLINE');
$claimedStatus = api_client()->get(
'/edge-gateways/install-token/' . (int)$installToken['claim_token_id'] . '/status',
$session['headers']
);
$claimedStatus
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($claimedStatus->data())
->toHaveKey('status', 'CLAIMED')
->toHaveKey('gateway_id', $gatewayId)
->toHaveKey('last_error', null);
$listResponse = api_client()->get('/edge-gateways?department_id=' . (int)$department['id'], $session['headers']);
$listResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(collect_gateway_ids_from_api_response($listResponse->data()))
->toContain($gatewayId);
$detailResponse = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']);
$detailResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($detailResponse->data())
->toHaveKey('id', $gatewayId)
->toHaveKey('department_id', (int)$department['id'])
->toHaveKey('status', 'ONLINE');
});
it('manages edge gateway metadata, bindings, operations, sessions, rotation, cutover, and deletion', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Operator Control Department',
]);
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
$gateway = api_fixtures()->createClaimedEdgeGateway([
'department_id' => (int)$department['id'],
'label' => 'Original Gateway Label',
]);
api_fixtures()->createClaimedEdgeGateway([
'department_id' => (int)$department['id'],
'label' => 'Alternate Gateway Label',
'is_primary' => 0,
]);
$updateResponse = api_client()->put('/edge-gateways/' . (int)$gateway['id'], [
'label' => 'Renamed Gateway',
'is_primary' => false,
], $session['headers']);
$updateResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($updateResponse->data())
->toHaveKey('label', 'Renamed Gateway')
->toHaveKey('is_primary', false);
$bindingsResponse = api_client()->put('/edge-gateways/' . (int)$gateway['id'] . '/bindings', [
'bindings' => [[
'relay_id' => 'relay-main',
'device_id' => 'device-main',
'local_ip' => '10.0.0.18',
'channel' => 0,
'binding_source' => 'MANUAL',
]],
], $session['headers']);
$bindingsResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($bindingsResponse->data()['bindings'] ?? [])
->toBeArray()
->toHaveCount(1)
->and(($bindingsResponse->data()['bindings'][0]['relay_id'] ?? null))
->toBe('relay-main');
$operationResponse = api_client()->post('/edge-gateways/' . (int)$gateway['id'] . '/operations', [
'type' => 'DISCOVERY',
'request' => [
'inventory' => edge_operator_test_inventory('operator'),
],
], $session['headers']);
$operationResponse
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$operationId = (int)($operationResponse->data()['operation']['id'] ?? 0);
expect($operationId)->toBeGreaterThan(0);
$operationsList = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/operations', $session['headers']);
$operationsList
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(collect_gateway_ids_from_api_response($operationsList->data(), 'id'))
->toContain($operationId);
$eventsResponse = api_client()->get(
'/edge-gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events',
$session['headers']
);
$eventsResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($eventsResponse->data())
->toBeArray()
->not->toBeEmpty()
->and($eventsResponse->data()[0]['code'] ?? null)
->toBe('OPERATION_QUEUED');
$cancelResponse = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/cancel',
[],
$session['headers']
);
$cancelResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($cancelResponse->data()['operation']['status'] ?? null)
->toBe('CANCELLED');
$streamSession = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/stream-session',
['scopes' => ['logs', 'tasks']],
$session['headers']
);
$streamSession
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
expect($streamSession->data())
->toHaveKey('token')
->toHaveKey('ws_url');
$shellSession = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions',
[
'reason' => 'Operator smoke session',
'cwd' => '/opt/truckwash-edge-agent',
'cols' => 120,
'rows' => 40,
],
$session['headers']
);
$shellSession
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
expect($shellSession->data())
->toHaveKey('token')
->toHaveKey('session')
->and($shellSession->data()['session']['reason'] ?? null)
->toBe('Operator smoke session');
$rotateResponse = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/rotate-credentials',
[],
$session['headers']
);
$rotateResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($rotateResponse->data())
->toHaveKey('gateway_id', (int)$gateway['id'])
->toHaveKey('agent_token')
->toHaveKey('config_json');
$cutoverResponse = api_client()->post(
'/departments/' . (int)$department['id'] . '/gateway-cutover',
['transport_mode' => 'gateway'],
$session['headers']
);
$cutoverResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($cutoverResponse->data())
->toHaveKey('department_id', (int)$department['id'])
->toHaveKey('transport_mode', 'gateway');
$deleteResponse = api_client()->delete('/edge-gateways/' . (int)$gateway['id'], null, $session['headers']);
$deleteResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($deleteResponse->data())
->toHaveKey('deleted', true)
->toHaveKey('gateway_id', (int)$gateway['id']);
});
it('rejects operator edge routes when module permission or department access is missing', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Operator Access Department',
]);
$allowed = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
$token = api_client()->post('/edge-gateways/install-token', [
'department_id' => (int)$department['id'],
'label' => 'Restricted Gateway',
], $allowed['headers']);
$token
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$missingModule = api_fixtures()->createUserSession([
'department_access_' . (int)$department['id'],
]);
$missingModuleResponse = api_client()->get(
'/edge-gateways?department_id=' . (int)$department['id'],
$missingModule['headers']
);
$missingModuleResponse
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['modules_shelly_config']);
$missingDepartment = api_fixtures()->createUserSession(['modules_shelly_config']);
$missingDepartmentResponse = api_client()->get(
'/edge-gateways/install-token/' . (int)$token->data()['claim_token_id'] . '/status',
$missingDepartment['headers']
);
$missingDepartmentResponse
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$department['id']]);
});
function edge_operator_test_inventory(string $suffix): array
{
return [[
'device_id' => 'gateway-' . $suffix,
'local_ip' => '10.20.30.40',
'model' => 'TruckWash Edge Test',
'channel_count' => 2,
'online' => true,
'capabilities' => [
'local_discovery' => true,
'relay_commands' => true,
],
'metadata' => [
'hostname' => 'edge-' . $suffix,
],
]];
}
/**
* @param mixed $items
* @return array<int, int>
*/
function collect_gateway_ids_from_api_response(mixed $items, string $key = 'id'): array
{
if (!is_array($items)) {
return [];
}
$ids = [];
foreach ($items as $item) {
if (is_array($item) && isset($item[$key]) && is_numeric($item[$key])) {
$ids[] = (int)$item[$key];
}
}
return $ids;
}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('returns the updated lane id after editing a scanner whose null lane was already cached', function (): void {
api_test_covers('PUT /numberplatescanners', 'happy');
$department = api_fixtures()->createDepartment([
'name' => 'Scanner Department ' . uniqid('', false),
]);
$session = api_fixtures()->createUserSession([
'add_department_lane',
'add_number_plate_scanner',
'edit_number_plate_scanner',
]);
$laneName = 'Lane ' . uniqid('', false);
$laneResponse = api_client()->post('/department/lanes', [
'department' => $department['id'],
'name' => $laneName,
], $session['headers']);
$laneResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$lane = api_test_runtime()->queryOne(
"SELECT id FROM department_lanes WHERE department = " . (int)$department['id'] .
" AND name = '" . api_test_runtime()->db()->real_escape_string($laneName) . "'" .
' ORDER BY id DESC LIMIT 1'
);
expect($lane)->not->toBeNull();
$laneId = (int)($lane['id'] ?? 0);
expect($laneId)->toBeGreaterThan(0);
api_fixtures()->cleanupDeleteById('department_lanes', $laneId);
$scannerName = 'Scanner ' . uniqid('', false);
$createResponse = api_client()->post('/numberplatescanners', [
'department_id' => $department['id'],
'name' => $scannerName,
'notes' => 'Original notes',
], $session['headers']);
$createResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Number plate scanner added');
$createdScanner = api_test_runtime()->queryOne(
"SELECT id FROM plate_scanners WHERE department_id = " . (int)$department['id'] .
" AND name = '" . api_test_runtime()->db()->real_escape_string($scannerName) . "'" .
' ORDER BY id DESC LIMIT 1'
);
expect($createdScanner)->not->toBeNull();
$scannerId = (int)($createdScanner['id'] ?? 0);
expect($scannerId)->toBeGreaterThan(0);
api_fixtures()->cleanupDeleteById('plate_scanners', $scannerId);
$redis = api_test_runtime()->redis();
expect($redis)->not->toBeNull();
$cacheKeys = [
"obj_prop:plate_scanners:{$scannerId}:lane_id" => '___NULL___',
"obj_prop:plate_scanners:{$scannerId}:name" => $scannerName,
"obj_prop:plate_scanners:{$scannerId}:notes" => 'Original notes',
];
foreach ($cacheKeys as $cacheKey => $value) {
$redis->set($cacheKey, $value);
}
$updateResponse = api_client()->put('/numberplatescanners', [
'id' => $scannerId,
'department_id' => $department['id'],
'lane_id' => $laneId,
'name' => 'Updated scanner',
'notes' => 'Updated notes',
], $session['headers']);
$updateResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Number plate scanner edited');
$redis->del(array_keys($cacheKeys));
$updatedScanner = $updateResponse->data()['scanner'] ?? null;
expect($updatedScanner)->toBeArray();
expect($updatedScanner['id'] ?? null)->toBe($scannerId);
expect($updatedScanner['department_id'] ?? null)->toBe($department['id']);
expect($updatedScanner['lane_id'] ?? null)->toBe($laneId);
expect($updatedScanner['name'] ?? null)->toBe('Updated scanner');
expect($updatedScanner['notes'] ?? null)->toBe('Updated notes');
$row = api_fixtures()->fetchRowById('plate_scanners', $scannerId);
expect($row)->not->toBeNull();
expect((int)($row['lane_id'] ?? 0))->toBe($laneId);
expect($row['name'] ?? null)->toBe('Updated scanner');
expect($row['notes'] ?? null)->toBe('Updated notes');
});
it('rejects plate scanner edits when the permission is missing', function (): void {
api_test_covers('PUT /numberplatescanners', 'auth');
$session = api_fixtures()->createUserSession([]);
$response = api_client()->put('/numberplatescanners', [
'id' => 1,
'department_id' => 1,
'name' => 'No permission',
'notes' => 'Should fail',
], $session['headers']);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['edit_number_plate_scanner']);
});
@@ -14,6 +14,7 @@ return [
'GET /orders',
'POST /orders',
'PUT /orders',
'PUT /numberplatescanners',
'DELETE /orders',
'POST /bird/voice/calls/webhook/inbound',
],
@@ -0,0 +1,447 @@
<?php
declare(strict_types=1);
use classes\db;
use classes\edge_gateway_manager;
use classes\edge_gateway_operation_service;
use Predis\Client as PredisClient;
use Tests\Support\Api\ApiCleanup;
use Tests\Support\Api\ApiFixtures;
use Tests\Support\Api\ApiSchemaBootstrap;
require_once dirname(__DIR__, 2) . '/Support/Api/ApiCleanup.php';
require_once dirname(__DIR__, 2) . '/Support/Api/ApiFixtures.php';
require_once dirname(__DIR__, 2) . '/Support/Api/ApiSchemaBootstrap.php';
app_require('classes/db.php');
app_require('classes/edge_gateway_manager.php');
app_require('classes/edge_gateway_operation_service.php');
it('persists install-session updates and derives gateway runtime status from heartbeats', function (): void {
$context = edge_gateway_integration_context();
try {
$department = $context['fixtures']->createDepartment([
'name' => 'Edge Integration Install Department',
]);
$token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Install Token');
$claimTokenId = (int)($token['claim_token_id'] ?? 0);
$context['manager']->reportInstallTokenStatus((string)$token['token'], [
'status' => 'FAILED',
'step' => 'DOWNLOAD_FAILED',
'message' => 'The installer could not download the runtime bundle.',
'diagnostics' => [
'diag-1',
'diag-2',
'diag-3',
'diag-4',
'diag-5',
'diag-6',
'diag-7',
'diag-8',
],
]);
$failedStatus = $context['manager']->getInstallTokenStatus($claimTokenId);
expect($failedStatus)
->toHaveKey('status', 'FAILED')
->toHaveKey('step', 'DOWNLOAD_FAILED')
->toHaveKey('last_error', 'The installer could not download the runtime bundle.')
->and($failedStatus['diagnostics'] ?? [])
->toBeArray()
->toHaveCount(6);
$claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-integration-host', 'php-agent-v1', [
'source' => 'integration-test',
]);
$gatewayId = (int)($claimed['gateway']['id'] ?? 0);
$agentToken = (string)($claimed['agent_token'] ?? '');
expect($gatewayId)->toBeGreaterThan(0)
->and($agentToken)->not->toBe('');
$claimedStatus = $context['manager']->getInstallTokenStatus($claimTokenId);
expect($claimedStatus)
->toHaveKey('status', 'CLAIMED')
->toHaveKey('gateway_id', $gatewayId)
->toHaveKey('last_error', null);
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_DEGRADED_AFTER_SECONDS + 1);
$degraded = $context['manager']->getGateway($gatewayId);
expect($degraded)->toHaveKey('status', 'DEGRADED');
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_OFFLINE_AFTER_SECONDS + 1);
$offline = $context['manager']->getGateway($gatewayId);
expect($offline)->toHaveKey('status', 'OFFLINE');
$context['manager']->recordHeartbeat($gatewayId, $agentToken, [
'status' => 'ONLINE',
'hostname' => 'edge-integration-host',
'metadata' => [
'system_metrics' => [
'cpu_percent' => 44,
],
],
]);
$online = $context['manager']->getGateway($gatewayId);
expect($online)
->toHaveKey('status', 'ONLINE')
->and($online['metadata']['system_metrics']['cpu_percent'] ?? null)
->toBe(44);
} finally {
$context['cleanup']->run();
}
});
it('assembles tasks, logs, statistics, operations, commands, and shell lifecycle state from persisted records', function (): void {
$context = edge_gateway_integration_context();
try {
$department = $context['fixtures']->createDepartment([
'name' => 'Edge Integration Runtime Department',
]);
$user = $context['fixtures']->createUser([
'display_name' => 'Edge Integration User',
]);
$token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Runtime Token', (int)$user['id']);
$claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-runtime-host', 'php-agent-v1');
$gatewayId = (int)($claimed['gateway']['id'] ?? 0);
$agentToken = (string)($claimed['agent_token'] ?? '');
$operation = $context['operations']->queueOperation(
$gatewayId,
edge_gateway_operation_service::TYPE_DISCOVERY,
['inventory' => edge_gateway_integration_inventory('runtime')],
(int)$user['id']
);
$operationId = (int)($operation['id'] ?? 0);
expect($operationId)->toBeGreaterThan(0);
$claimedOperation = $context['operations']->claimNextOperation($gatewayId, $agentToken, 0, 'integration-agent-1');
expect($claimedOperation)
->toBeArray()
->toHaveKey('status', 'IN_PROGRESS');
$context['operations']->appendAgentOperationEvent($gatewayId, $operationId, $agentToken, [
'level' => 'INFO',
'code' => 'DISCOVERY_RUNNING',
'message' => 'Integration discovery is executing.',
'context' => [
'progress' => 70,
],
]);
$context['operations']->completeAgentOperation($gatewayId, $operationId, $agentToken, [
'ok' => true,
'result' => [
'inventory' => edge_gateway_integration_inventory('completed'),
],
]);
$job = $context['fixtures']->createEdgeCommandJob([
'gateway_id' => $gatewayId,
'command_type' => 'GET_RELAY_STATUS',
'request' => [
'relayId' => 'relay-main',
],
'requested_by' => (int)$user['id'],
]);
$jobId = (int)($job['id'] ?? 0);
$polledCommand = $context['manager']->pollCommand($gatewayId, $agentToken, 0);
expect($polledCommand)
->toBeArray()
->toHaveKey('id', $jobId)
->toHaveKey('command_type', 'GET_RELAY_STATUS');
$commandResult = $context['manager']->submitCommandResult($gatewayId, $jobId, $agentToken, true, [
'relayId' => 'relay-main',
'online' => true,
]);
expect($commandResult)
->toHaveKey('acknowledged', true)
->and($commandResult['job']['status'] ?? null)
->toBe('COMPLETED');
$shellSession = $context['manager']->createShellSession(
$gatewayId,
(int)$user['id'],
'Integration shell session',
120,
40,
'/opt/truckwash-edge-agent'
);
$shellToken = (string)($shellSession['token'] ?? '');
expect($shellToken)->not->toBe('');
$validatedShell = $context['manager']->validateShellSessionToken($shellToken);
expect($validatedShell)->toHaveKey('status', 'PENDING');
$openedShell = $context['manager']->markShellSessionOpened($shellToken, 'shell-connection-1');
expect($openedShell)->toHaveKey('status', 'OPEN');
$closedShell = $context['manager']->closeShellSessionByToken(
$shellToken,
"edge-shell-output\n",
'agent_exit'
);
expect($closedShell)
->toHaveKey('status', 'COMPLETED')
->and($closedShell['transcript'] ?? null)
->toBe("edge-shell-output\n");
$context['manager']->appendGatewayLogEntry(
$gatewayId,
'Integration log line',
'INFO',
'agent',
'BROKER',
['source' => 'integration']
);
$context['manager']->recordBrokerPresence(
$gatewayId,
'connected',
'broker-connection-1',
null,
['transport' => 'ws']
);
$context['manager']->recordTelemetryFromBroker($gatewayId, [
'status' => 'ONLINE',
'metadata' => [
'system_metrics' => [
'cpu_percent' => 17,
'memory_mb' => 256,
],
],
'inventory' => edge_gateway_integration_inventory('telemetry'),
]);
$tasks = $context['manager']->buildGatewayTasksPage($gatewayId);
$logs = $context['manager']->buildGatewayLogsPage($gatewayId);
$statistics = $context['manager']->buildGatewayStatisticsPage($gatewayId);
expect($tasks['operations'] ?? [])
->toBeArray()
->not->toBeEmpty()
->and(($tasks['operations'][0]['status'] ?? null))
->toBe('COMPLETED');
expect($tasks['recent_commands'] ?? [])
->toBeArray()
->not->toBeEmpty()
->and(($tasks['recent_commands'][0]['status'] ?? null))
->toBe('COMPLETED');
expect(edge_gateway_integration_messages($logs['log_entries'] ?? []))
->toContain('Integration log line');
expect(edge_gateway_integration_messages($logs['timeline'] ?? []))
->toContain('Integration discovery is executing.');
expect($logs['shell_sessions'] ?? [])
->toBeArray()
->not->toBeEmpty()
->and(($logs['shell_sessions'][0]['transcript'] ?? null))
->toBe("edge-shell-output\n");
expect($statistics['system_metrics']['cpu_percent'] ?? null)
->toBe(17);
expect($statistics['gateway']['inventory'] ?? [])
->toBeArray()
->not->toBeEmpty();
} finally {
$context['cleanup']->run();
}
});
/**
* @return array{cleanup:ApiCleanup,fixtures:ApiFixtures,manager:edge_gateway_manager,operations:edge_gateway_operation_service,mysqli:mysqli,redis:?PredisClient}
*/
function edge_gateway_integration_context(): array
{
if (!integration_enabled()) {
test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run edge gateway integration tests.');
}
static $bootstrapped = null;
if ($bootstrapped === null) {
$dbConfig = edge_gateway_integration_db_config();
$GLOBALS['CONFIG_DB'] = $dbConfig;
$GLOBALS['response'] = new class {
public function internal_server_error(string $message): void
{
throw new RuntimeException($message);
}
};
$db = new db($dbConfig);
$db->connect();
$GLOBALS['db'] = $db;
$mysqli = $db->conn();
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$mysqli->set_charset('utf8mb4');
(new ApiSchemaBootstrap($mysqli))->ensureSchema();
$bootstrapped = [
'mysqli' => $mysqli,
'redis' => edge_gateway_integration_redis_client(),
];
}
$cleanup = new ApiCleanup();
return [
'cleanup' => $cleanup,
'fixtures' => new ApiFixtures($bootstrapped['mysqli'], $bootstrapped['redis'], $cleanup),
'manager' => new edge_gateway_manager(),
'operations' => new edge_gateway_operation_service(),
'mysqli' => $bootstrapped['mysqli'],
'redis' => $bootstrapped['redis'],
];
}
/**
* @return array{host:string,user:string,password:string,database:string,port:int}
*/
function edge_gateway_integration_db_config(): array
{
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
if ($target !== 'debug') {
$target = 'live';
}
$host = edge_gateway_integration_config_value('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target);
$user = edge_gateway_integration_config_value('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target);
$password = edge_gateway_integration_config_value('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target);
$database = edge_gateway_integration_config_value('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
$port = (int)(edge_gateway_integration_config_value('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
if ($host === '' || $user === '' || $database === '') {
test()->markTestSkipped('Edge gateway integration tests require configured database environment variables.');
}
return [
'host' => $host,
'user' => $user,
'password' => $password,
'database' => $database,
'port' => $port > 0 ? $port : 3306,
];
}
function edge_gateway_integration_redis_client(): ?PredisClient
{
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
if ($target !== 'debug') {
$target = 'live';
}
$host = edge_gateway_integration_config_value('REDIS_CONFIG_HOST', 'REDIS_CONFIG_DEBUG_HOST', $target);
if ($host === '') {
return null;
}
$parameters = [
'scheme' => 'tcp',
'host' => $host,
'port' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_PORT', $target) ?: '6379'),
'database' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_DEBUG_DATABASE', $target) ?: '0'),
'password' => edge_gateway_integration_config_value('REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DEBUG_PASSWORD', $target),
];
$user = edge_gateway_integration_config_value('REDIS_CONFIG_USER', 'REDIS_CONFIG_DEBUG_USER', $target);
if ($user !== '') {
$parameters['username'] = $user;
}
return new PredisClient($parameters);
}
function edge_gateway_integration_config_value(string $liveKey, string $debugKey, string $target): string
{
$liveValue = trim((string)(getenv($liveKey) ?: ''));
$debugValue = trim((string)(getenv($debugKey) ?: ''));
if ($target === 'debug' && $debugValue !== '') {
return $debugValue;
}
return $liveValue;
}
function edge_gateway_integration_set_heartbeat_age(mysqli $mysqli, ?PredisClient $redis, int $gatewayId, int $secondsAgo): void
{
$result = $mysqli->query("SELECT last_heartbeat_at, updated_at, created_at FROM edge_gateways WHERE id = " . (int)$gatewayId . " LIMIT 1");
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if ($result instanceof mysqli_result) {
$result->free();
}
$referenceTimestamp = strtotime((string)($row['last_heartbeat_at'] ?? $row['updated_at'] ?? $row['created_at'] ?? ''));
if ($referenceTimestamp === false || $referenceTimestamp <= 0) {
$referenceTimestamp = time();
}
$timestamp = date('Y-m-d H:i:s', $referenceTimestamp - max(0, $secondsAgo));
$escaped = $mysqli->real_escape_string($timestamp);
$mysqli->query("UPDATE edge_gateways SET last_heartbeat_at = '{$escaped}', status = 'ONLINE' WHERE id = " . (int)$gatewayId);
if ($redis !== null) {
foreach ([
'obj_prop:*:' . $gatewayId . ':status',
'obj_prop:*:' . $gatewayId . ':last_heartbeat_at',
'obj_prop:*:' . $gatewayId . ':updated_at',
'edge_gateway:view:v1:*',
] as $pattern) {
$keys = $redis->keys($pattern);
if (is_array($keys) && $keys !== []) {
$redis->del($keys);
}
}
}
}
function edge_gateway_integration_inventory(string $suffix): array
{
return [[
'device_id' => 'integration-' . $suffix,
'local_ip' => '10.50.60.70',
'model' => 'TruckWash Integration Gateway',
'channel_count' => 1,
'online' => true,
'capabilities' => [
'gateway_management_v2' => true,
],
'metadata' => [
'hostname' => 'integration-' . $suffix,
],
]];
}
/**
* @param mixed $rows
* @return array<int, string>
*/
function edge_gateway_integration_messages(mixed $rows): array
{
if (!is_array($rows)) {
return [];
}
$messages = [];
foreach ($rows as $row) {
if (is_array($row) && isset($row['message']) && is_string($row['message'])) {
$messages[] = $row['message'];
}
}
return $messages;
}
@@ -445,6 +445,25 @@ final class ApiFixtures
];
}
/**
* @param array<int, string> $permissions
* @param array<string, mixed> $userAttributes
* @return array{user:array<string,mixed>,token:string,headers:array<string,string>}
*/
public function createEdgeOperatorSession(int $departmentId, array $permissions = [], array $userAttributes = []): array
{
if ($departmentId <= 0) {
throw new RuntimeException('Edge operator sessions require a positive department id.');
}
$permissions = array_values(array_unique(array_merge(
['modules_shelly_config', 'department_access_' . $departmentId],
$permissions
)));
return $this->createUserSession($permissions, $userAttributes);
}
/**
* @param array<int, string> $permissions
* @return array{user:array<string,mixed>,subuser:array<string,mixed>,token:string,headers:array<string,string>}
@@ -593,6 +612,15 @@ final class ApiFixtures
], $extraHeaders);
}
public function clearEdgeGatewayViewCache(): void
{
$this->deleteRedisPattern('edge_gateway:view:v1:*');
if (class_exists(\classes\edge_gateway_view_cache::class)) {
\classes\edge_gateway_view_cache::clearAll();
}
}
public function fetchRowById(string $table, int $id): ?array
{
$table = $this->sanitizeIdentifier($table);
@@ -600,6 +628,395 @@ final class ApiFixtures
return $this->queryOneBySql("SELECT * FROM `{$table}` WHERE id = {$id} LIMIT 1");
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeInstallToken(array $attributes): array
{
$departmentId = (int)($attributes['department_id'] ?? 0);
if ($departmentId <= 0) {
throw new RuntimeException('Edge install tokens require department_id.');
}
$token = (string)($attributes['token'] ?? (bin2hex(random_bytes(18)) . $this->uniqueSuffix()));
$createdAt = (string)($attributes['created_at'] ?? $this->now());
$expiresAt = (string)($attributes['expires_at'] ?? date('Y-m-d H:i:s', strtotime($createdAt) + 1800));
$installSession = [
'status' => (string)($attributes['status'] ?? 'PENDING'),
'step' => (string)($attributes['step'] ?? 'PENDING'),
'message' => (string)($attributes['message'] ?? 'Installer command generated. Run it on the gateway host.'),
'started_at' => $createdAt,
'updated_at' => $createdAt,
'terminal' => false,
'gateway_id' => $attributes['gateway_id'] ?? null,
'last_error' => $attributes['last_error'] ?? null,
'diagnostics' => isset($attributes['diagnostics']) && is_array($attributes['diagnostics'])
? (array)$attributes['diagnostics']
: [],
'events' => isset($attributes['events']) && is_array($attributes['events'])
? (array)$attributes['events']
: [
[
'status' => (string)($attributes['status'] ?? 'PENDING'),
'step' => (string)($attributes['step'] ?? 'PENDING'),
'message' => (string)($attributes['message'] ?? 'Installer command generated. Run it on the gateway host.'),
'at' => $createdAt,
],
],
];
$claimTokenId = $this->insertRow('edge_gateway_claim_tokens', [
'department_id' => $departmentId,
'label' => $attributes['label'] ?? ('Edge Install ' . $this->uniqueSuffix()),
'token_hash' => hash('sha256', $token),
'created_by' => $attributes['created_by'] ?? null,
'expires_at' => $expiresAt,
'used_at' => $attributes['used_at'] ?? null,
'metadata_json' => array_merge(
isset($attributes['metadata']) && is_array($attributes['metadata']) ? (array)$attributes['metadata'] : [],
['install_session' => $installSession]
),
'created_at' => $createdAt,
'updated_at' => $attributes['updated_at'] ?? $createdAt,
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_claim_tokens', $claimTokenId));
return [
'claim_token_id' => $claimTokenId,
'department_id' => $departmentId,
'label' => $attributes['label'] ?? null,
'token' => $token,
'expires_at' => $expiresAt,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createClaimedEdgeGateway(array $attributes): array
{
$departmentId = (int)($attributes['department_id'] ?? 0);
if ($departmentId <= 0) {
throw new RuntimeException('Claimed edge gateways require department_id.');
}
$agentToken = (string)($attributes['agent_token'] ?? (bin2hex(random_bytes(24)) . $this->uniqueSuffix()));
$createdAt = (string)($attributes['created_at'] ?? $this->now());
$metadata = array_merge([
'credentials_rotated_at' => $createdAt,
'agent_runtime' => 'compose-php',
'runtime_mode' => 'compose',
'update_window' => '02:00-04:00',
'container_health' => [
'overall_status' => 'PENDING',
'services' => [],
],
'outbox_status' => [
'depth' => 0,
'oldest_age_seconds' => 0,
'last_flushed_at' => null,
'pending_types' => [],
],
'rollback_status' => [
'state' => 'NONE',
'reason' => null,
'at' => null,
],
'last_sync_at' => null,
], isset($attributes['metadata']) && is_array($attributes['metadata']) ? (array)$attributes['metadata'] : []);
$gatewayId = $this->insertRow('edge_gateways', [
'department_id' => $departmentId,
'label' => (string)($attributes['label'] ?? ('Edge Gateway ' . $this->uniqueSuffix())),
'hostname' => $attributes['hostname'] ?? ('edge-' . $this->uniqueSuffix()),
'agent_token_hash' => hash('sha256', $agentToken),
'status' => (string)($attributes['status'] ?? 'ONLINE'),
'transport_mode' => (string)($attributes['transport_mode'] ?? 'gateway'),
'release_channel' => (string)($attributes['release_channel'] ?? 'stable'),
'installed_version' => $attributes['installed_version'] ?? 'php-agent-v1',
'target_version' => $attributes['target_version'] ?? ($attributes['installed_version'] ?? 'php-agent-v1'),
'last_heartbeat_at' => $attributes['last_heartbeat_at'] ?? $createdAt,
'last_seen_ip' => $attributes['last_seen_ip'] ?? '127.0.0.1',
'discovery_status' => (string)($attributes['discovery_status'] ?? 'PENDING'),
'is_primary' => $attributes['is_primary'] ?? 1,
'metadata_json' => $metadata,
'created_at' => $createdAt,
'updated_at' => $attributes['updated_at'] ?? $createdAt,
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(function () use ($gatewayId): void {
$this->deleteWhere('edge_gateway_operation_events', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_operations', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_command_jobs', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_device_inventory', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_relay_bindings', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_log_entries', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_shell_sessions', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_audit_logs', ['gateway_id' => $gatewayId]);
$this->deleteById('edge_gateways', $gatewayId);
$this->clearEdgeGatewayViewCache();
});
return [
'id' => $gatewayId,
'department_id' => $departmentId,
'label' => (string)($attributes['label'] ?? ''),
'agent_token' => $agentToken,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeCommandJob(array $attributes): array
{
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
if ($gatewayId <= 0) {
throw new RuntimeException('Edge command jobs require gateway_id.');
}
$jobId = $this->insertRow('edge_gateway_command_jobs', [
'gateway_id' => $gatewayId,
'command_type' => (string)($attributes['command_type'] ?? 'DISCOVER_SHELLY'),
'status' => (string)($attributes['status'] ?? 'PENDING'),
'request_json' => isset($attributes['request']) && is_array($attributes['request']) ? (array)$attributes['request'] : [],
'response_json' => isset($attributes['response']) && is_array($attributes['response']) ? (array)$attributes['response'] : [],
'delivery_json' => isset($attributes['delivery']) && is_array($attributes['delivery']) ? (array)$attributes['delivery'] : [],
'correlation_id' => (string)($attributes['correlation_id'] ?? ('edge-command-' . $this->uniqueSuffix())),
'requested_by' => $attributes['requested_by'] ?? null,
'requested_at' => $attributes['requested_at'] ?? $this->now(),
'completed_at' => $attributes['completed_at'] ?? null,
'error_message' => $attributes['error_message'] ?? null,
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_command_jobs', $jobId));
return [
'id' => $jobId,
'gateway_id' => $gatewayId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeOperation(array $attributes): array
{
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
if ($gatewayId <= 0) {
throw new RuntimeException('Edge operations require gateway_id.');
}
$operationId = $this->insertRow('edge_gateway_operations', [
'gateway_id' => $gatewayId,
'type' => (string)($attributes['type'] ?? 'DISCOVERY'),
'operation_type' => (string)($attributes['operation_type'] ?? ($attributes['type'] ?? 'DISCOVERY')),
'status' => (string)($attributes['status'] ?? 'PENDING'),
'request_json' => isset($attributes['request']) && is_array($attributes['request']) ? (array)$attributes['request'] : [],
'summary_json' => isset($attributes['summary']) && is_array($attributes['summary']) ? (array)$attributes['summary'] : [
'label' => 'Queued',
'progress' => 0,
'retryable' => true,
],
'result_json' => isset($attributes['result']) && is_array($attributes['result']) ? (array)$attributes['result'] : [],
'error_code' => $attributes['error_code'] ?? null,
'error_message' => $attributes['error_message'] ?? null,
'correlation_id' => (string)($attributes['correlation_id'] ?? ('edge-operation-' . $this->uniqueSuffix())),
'agent_instance_id' => $attributes['agent_instance_id'] ?? null,
'lease_expires_at' => $attributes['lease_expires_at'] ?? null,
'last_progress_at' => $attributes['last_progress_at'] ?? null,
'attempt_count' => $attributes['attempt_count'] ?? 0,
'requested_by' => $attributes['requested_by'] ?? null,
'requested_at' => $attributes['requested_at'] ?? $this->now(),
'started_at' => $attributes['started_at'] ?? null,
'completed_at' => $attributes['completed_at'] ?? null,
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(function () use ($gatewayId, $operationId): void {
$this->deleteWhere('edge_gateway_operation_events', [
'gateway_id' => $gatewayId,
'operation_id' => $operationId,
]);
$this->deleteById('edge_gateway_operations', $operationId);
});
return [
'id' => $operationId,
'gateway_id' => $gatewayId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeOperationEvent(array $attributes): array
{
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
$operationId = (int)($attributes['operation_id'] ?? 0);
if ($gatewayId <= 0 || $operationId <= 0) {
throw new RuntimeException('Edge operation events require gateway_id and operation_id.');
}
$eventId = $this->insertRow('edge_gateway_operation_events', [
'gateway_id' => $gatewayId,
'operation_id' => $operationId,
'stage' => (string)($attributes['stage'] ?? 'RECORDED'),
'level' => (string)($attributes['level'] ?? 'INFO'),
'code' => $attributes['code'] ?? null,
'message' => (string)($attributes['message'] ?? 'Edge operation event'),
'context_json' => isset($attributes['context']) && is_array($attributes['context']) ? (array)$attributes['context'] : [],
'counts_json' => isset($attributes['counts']) && is_array($attributes['counts']) ? (array)$attributes['counts'] : [],
'payload_json' => isset($attributes['payload']) && is_array($attributes['payload']) ? (array)$attributes['payload'] : [],
'created_at' => $attributes['created_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_operation_events', $eventId));
return [
'id' => $eventId,
'gateway_id' => $gatewayId,
'operation_id' => $operationId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeAuditLog(array $attributes): array
{
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
$departmentId = (int)($attributes['department_id'] ?? 0);
if ($gatewayId <= 0 || $departmentId <= 0) {
throw new RuntimeException('Edge audit logs require gateway_id and department_id.');
}
$auditLogId = $this->insertRow('edge_gateway_audit_logs', [
'gateway_id' => $gatewayId,
'department_id' => $departmentId,
'action' => (string)($attributes['action'] ?? 'EDGE_AUDIT'),
'actor_user_id' => $attributes['actor_user_id'] ?? null,
'actor_type' => (string)($attributes['actor_type'] ?? 'USER'),
'severity' => (string)($attributes['severity'] ?? 'INFO'),
'context_json' => isset($attributes['context']) && is_array($attributes['context']) ? (array)$attributes['context'] : [],
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
]);
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_audit_logs', $auditLogId));
return [
'id' => $auditLogId,
'gateway_id' => $gatewayId,
'department_id' => $departmentId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeLogEntry(array $attributes): array
{
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
if ($gatewayId <= 0) {
throw new RuntimeException('Edge log entries require gateway_id.');
}
$gatewayRow = $this->fetchRowById('edge_gateways', $gatewayId);
$departmentId = (int)($attributes['department_id'] ?? ($gatewayRow['department_id'] ?? 0));
$logEntryId = $this->insertRow('edge_gateway_log_entries', [
'gateway_id' => $gatewayId,
'department_id' => $departmentId > 0 ? $departmentId : null,
'level' => (string)($attributes['level'] ?? 'INFO'),
'stream' => (string)($attributes['stream'] ?? 'agent'),
'source' => (string)($attributes['source'] ?? 'BROKER'),
'message' => (string)($attributes['message'] ?? 'Edge gateway log entry'),
'context_json' => isset($attributes['context']) && is_array($attributes['context']) ? (array)$attributes['context'] : [],
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
]);
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_log_entries', $logEntryId));
return [
'id' => $logEntryId,
'gateway_id' => $gatewayId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeShellSession(array $attributes): array
{
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
if ($gatewayId <= 0) {
throw new RuntimeException('Edge shell sessions require gateway_id.');
}
$gatewayRow = $this->fetchRowById('edge_gateways', $gatewayId);
$departmentId = (int)($attributes['department_id'] ?? ($gatewayRow['department_id'] ?? 0));
if ($departmentId <= 0) {
throw new RuntimeException('Edge shell sessions require department_id or a valid gateway row.');
}
$sessionToken = (string)($attributes['token'] ?? bin2hex(random_bytes(24)));
$createdAt = (string)($attributes['created_at'] ?? $this->now());
$expiresAt = (string)($attributes['expires_at'] ?? date('Y-m-d H:i:s', strtotime($createdAt) + 900));
$sessionId = $this->insertRow('edge_gateway_shell_sessions', [
'gateway_id' => $gatewayId,
'department_id' => $departmentId,
'actor_user_id' => $attributes['actor_user_id'] ?? null,
'session_token_hash' => hash('sha256', $sessionToken),
'status' => (string)($attributes['status'] ?? 'PENDING'),
'reason' => (string)($attributes['reason'] ?? 'Diagnostic shell session'),
'connection_id' => $attributes['connection_id'] ?? null,
'cwd' => $attributes['cwd'] ?? '/opt/truckwash-edge-agent',
'shell_command' => $attributes['shell_command'] ?? null,
'shell_args_json' => isset($attributes['shell_args']) && is_array($attributes['shell_args']) ? (array)$attributes['shell_args'] : [],
'cols' => $attributes['cols'] ?? 120,
'terminal_rows' => $attributes['rows'] ?? 32,
'transcript' => $attributes['transcript'] ?? null,
'metadata_json' => isset($attributes['metadata']) && is_array($attributes['metadata']) ? (array)$attributes['metadata'] : [],
'expires_at' => $expiresAt,
'approved_at' => $attributes['approved_at'] ?? $createdAt,
'opened_at' => $attributes['opened_at'] ?? null,
'closed_at' => $attributes['closed_at'] ?? null,
'created_at' => $createdAt,
'updated_at' => $attributes['updated_at'] ?? $createdAt,
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_shell_sessions', $sessionId));
return [
'id' => $sessionId,
'gateway_id' => $gatewayId,
'department_id' => $departmentId,
'token' => $sessionToken,
'expires_at' => $expiresAt,
];
}
public function cleanupDeleteById(string $table, int $id): void
{
$this->cleanup->add(fn() => $this->deleteById($table, $id));
@@ -49,3 +49,21 @@ function assert_api_envelope(ApiResponse $response): ApiResponse
{
return $response->assertEnvelope();
}
function edge_test_broker_secret(): string
{
$secret = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
return $secret !== '' ? $secret : 'truckwash-edge-test-secret';
}
/**
* @param array<string, string> $extraHeaders
* @return array<string, string>
*/
function edge_test_broker_headers(array $extraHeaders = []): array
{
return array_merge([
'X-Edge-Broker-Secret' => edge_test_broker_secret(),
], $extraHeaders);
}
@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
use classes\db;
use Predis\Client as PredisClient;
use Tests\Support\Api\ApiCleanup;
use Tests\Support\Api\ApiFixtures;
use Tests\Support\Api\ApiSchemaBootstrap;
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/Api/ApiCleanup.php';
require_once __DIR__ . '/Api/ApiFixtures.php';
require_once __DIR__ . '/Api/ApiSchemaBootstrap.php';
app_require('classes/db.php');
app_require('classes/edge_gateway_manager.php');
$action = strtolower(trim((string)($argv[1] ?? '')));
if ($action === '') {
edge_gateway_e2e_fixture_fail('Missing action. Expected create or cleanup.');
}
try {
if ($action === 'create') {
$context = edge_gateway_e2e_fixture_context();
$fixtures = $context['fixtures'];
$department = $fixtures->createDepartment([
'name' => 'Edge Gateway E2E Department',
]);
$session = $fixtures->createEdgeOperatorSession((int)$department['id'], [], [
'display_name' => 'Edge Gateway E2E Operator',
]);
edge_gateway_e2e_fixture_output([
'department_id' => (int)$department['id'],
'user_id' => (int)$session['user']['id'],
'group_id' => (int)$session['user']['group_id'],
'customer_number' => (int)$session['user']['customer_number'],
'auth_token' => (string)$session['token'],
'broker_secret' => edge_test_broker_secret(),
]);
}
if ($action === 'cleanup') {
$encoded = trim((string)($argv[2] ?? ''));
if ($encoded === '') {
edge_gateway_e2e_fixture_fail('Cleanup requires a base64url payload argument.');
}
$payload = json_decode(base64_decode(strtr($encoded, '-_', '+/')) ?: '', true);
if (!is_array($payload)) {
edge_gateway_e2e_fixture_fail('Invalid cleanup payload.');
}
$context = edge_gateway_e2e_fixture_context();
$db = $context['mysqli'];
$departmentId = (int)($payload['department_id'] ?? 0);
$userId = (int)($payload['user_id'] ?? 0);
$groupId = (int)($payload['group_id'] ?? 0);
if ($departmentId > 0) {
$gatewayIds = edge_gateway_e2e_gateway_ids($db, $departmentId);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_operation_events', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_operations', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_command_jobs', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_log_entries', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_shell_sessions', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_device_inventory', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_relay_bindings', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_audit_logs', $gatewayIds);
$db->query('DELETE FROM edge_gateway_claim_tokens WHERE department_id = ' . $departmentId);
$db->query('DELETE FROM edge_gateways WHERE department_id = ' . $departmentId);
$db->query('DELETE FROM department_variables WHERE department_id = ' . $departmentId);
}
if ($userId > 0) {
$db->query('DELETE FROM tokens WHERE user_id = ' . $userId);
$db->query('DELETE FROM users WHERE id = ' . $userId);
}
if ($groupId > 0) {
$db->query('DELETE FROM groups_permissions WHERE group_id = ' . $groupId);
$db->query('DELETE FROM groups WHERE id = ' . $groupId);
}
if ($departmentId > 0) {
$db->query('DELETE FROM departments WHERE id = ' . $departmentId);
}
edge_gateway_e2e_fixture_output(['ok' => true]);
}
edge_gateway_e2e_fixture_fail('Unsupported action: ' . $action);
} catch (Throwable $throwable) {
edge_gateway_e2e_fixture_fail($throwable->getMessage());
}
/**
* @return array{mysqli:mysqli,fixtures:ApiFixtures}
*/
function edge_gateway_e2e_fixture_context(): array
{
static $context = null;
if ($context !== null) {
return $context;
}
$dbConfig = edge_gateway_e2e_db_config();
$GLOBALS['CONFIG_DB'] = $dbConfig;
$GLOBALS['response'] = new class {
public function internal_server_error(string $message): void
{
throw new RuntimeException($message);
}
};
$db = new db($dbConfig);
$db->connect();
$GLOBALS['db'] = $db;
$mysqli = $db->conn();
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$mysqli->set_charset('utf8mb4');
(new ApiSchemaBootstrap($mysqli))->ensureSchema();
$context = [
'mysqli' => $mysqli,
'fixtures' => new ApiFixtures($mysqli, edge_gateway_e2e_redis_client(), new ApiCleanup()),
];
return $context;
}
/**
* @return array{host:string,user:string,password:string,database:string,port:int}
*/
function edge_gateway_e2e_db_config(): array
{
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
if ($target !== 'debug') {
$target = 'live';
}
$host = edge_gateway_e2e_config_value('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target);
$user = edge_gateway_e2e_config_value('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target);
$password = edge_gateway_e2e_config_value('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target);
$database = edge_gateway_e2e_config_value('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
$port = (int)(edge_gateway_e2e_config_value('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
if ($host === '' || $user === '' || $database === '') {
throw new RuntimeException('Missing database configuration for edge gateway E2E fixtures.');
}
return [
'host' => $host,
'user' => $user,
'password' => $password,
'database' => $database,
'port' => $port > 0 ? $port : 3306,
];
}
function edge_gateway_e2e_config_value(string $liveKey, string $debugKey, string $target): string
{
$liveValue = trim((string)(getenv($liveKey) ?: ''));
$debugValue = trim((string)(getenv($debugKey) ?: ''));
if ($target === 'debug' && $debugValue !== '') {
return $debugValue;
}
return $liveValue;
}
function edge_gateway_e2e_redis_client(): ?PredisClient
{
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
if ($target !== 'debug') {
$target = 'live';
}
$host = edge_gateway_e2e_config_value('REDIS_CONFIG_HOST', 'REDIS_CONFIG_DEBUG_HOST', $target);
if ($host === '') {
return null;
}
$parameters = [
'scheme' => 'tcp',
'host' => $host,
'port' => (int)(edge_gateway_e2e_config_value('REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_PORT', $target) ?: '6379'),
'database' => (int)(edge_gateway_e2e_config_value('REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_DEBUG_DATABASE', $target) ?: '0'),
'password' => edge_gateway_e2e_config_value('REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DEBUG_PASSWORD', $target),
];
$user = edge_gateway_e2e_config_value('REDIS_CONFIG_USER', 'REDIS_CONFIG_DEBUG_USER', $target);
if ($user !== '') {
$parameters['username'] = $user;
}
return new PredisClient($parameters);
}
/**
* @return array<int, int>
*/
function edge_gateway_e2e_gateway_ids(mysqli $db, int $departmentId): array
{
$ids = [];
$result = $db->query('SELECT id FROM edge_gateways WHERE department_id = ' . $departmentId);
if ($result === false) {
return [];
}
while ($row = $result->fetch_assoc()) {
if (isset($row['id']) && is_numeric($row['id'])) {
$ids[] = (int)$row['id'];
}
}
$result->free();
return $ids;
}
/**
* @param array<int, int> $gatewayIds
*/
function edge_gateway_e2e_delete_by_gateway_ids(mysqli $db, string $table, array $gatewayIds): void
{
$gatewayIds = array_values(array_unique(array_filter(array_map('intval', $gatewayIds), static fn(int $id): bool => $id > 0)));
if ($gatewayIds === []) {
return;
}
$db->query('DELETE FROM `' . $table . '` WHERE gateway_id IN (' . implode(', ', $gatewayIds) . ')');
}
/**
* @param array<string, mixed> $payload
*/
function edge_gateway_e2e_fixture_output(array $payload): void
{
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
exit(0);
}
function edge_gateway_e2e_fixture_fail(string $message): never
{
fwrite(STDERR, $message . PHP_EOL);
exit(1);
}
+11 -1
View File
@@ -95,6 +95,14 @@ function integration_enabled(): bool
return getenv('RUN_INTEGRATION_TESTS') === '1';
}
$edgeBrokerSharedSecret = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
if ($edgeBrokerSharedSecret === '') {
$edgeBrokerSharedSecret = 'truckwash-edge-test-secret';
putenv('EDGE_BROKER_SHARED_SECRET=' . $edgeBrokerSharedSecret);
$_ENV['EDGE_BROKER_SHARED_SECRET'] = $edgeBrokerSharedSecret;
$_SERVER['EDGE_BROKER_SHARED_SECRET'] = $edgeBrokerSharedSecret;
}
function run_legacy_script(string $relativeScriptPath): array
{
$script = app_path($relativeScriptPath);
@@ -112,4 +120,6 @@ function run_legacy_script(string $relativeScriptPath): array
}
require_once __DIR__ . '/ApiTestSupport.php';
require_once __DIR__ . '/Api/ApiTestCase.php';
if (class_exists(\PHPUnit\Framework\TestCase::class)) {
require_once __DIR__ . '/Api/ApiTestCase.php';
}
@@ -0,0 +1,49 @@
#!/bin/sh
set -eu
tmp_dir="$(mktemp -d)"
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT HUP INT TERM
cat > "$tmp_dir/composer.json" <<'JSON'
{
"name": "truckwash/composer-entrypoint-fixture",
"autoload": {
"classmap": [
"src/"
]
}
}
JSON
mkdir -p "$tmp_dir/src"
cat > "$tmp_dir/src/FixtureClass.php" <<'PHP'
<?php
class FixtureClass
{
}
PHP
COMPOSER_ALLOW_SUPERUSER=1 composer install \
--no-dev \
--prefer-dist \
--optimize-autoloader \
--no-interaction \
-d "$tmp_dir" >/dev/null 2>&1
printf '%s\n' '<?php' > "$tmp_dir/vendor/composer/autoload_real.php"
AUTO_COMPOSER_INSTALL=true \
APP_DIR="$tmp_dir" \
MODULE_DIR="$tmp_dir/no-module" \
LOG_FILE="$tmp_dir/composer-install.log" \
/usr/local/bin/docker-entrypoint.sh \
php -r "require \$argv[1]; echo class_exists('FixtureClass') ? 'autoload-ok' . PHP_EOL : 'autoload-missing' . PHP_EOL;" \
"$tmp_dir/vendor/autoload.php" >/dev/null
php -d display_errors=1 -r "require \$argv[1]; exit(class_exists('FixtureClass') ? 0 : 1);" "$tmp_dir/vendor/autoload.php"
echo "composer-entrypoint-autoload-recovery-ok"
@@ -0,0 +1,79 @@
<?php
app_require('classes/edge_gateway_manager.php');
app_require('classes/object_property.php');
app_require('objects/department_gates_o.php');
use classes\edge_gateway_manager;
use classes\object_property;
use objects\department_gates_o;
final class DepartmentGatesRelayManagerFake extends edge_gateway_manager
{
public array $switchCalls = [];
public function __construct()
{
}
public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array
{
$this->switchCalls[] = [
'department_id' => $departmentId,
'relay_id' => $logicalRelayId,
'on' => $on,
];
return [
'relay_id' => $logicalRelayId,
'online' => true,
'on' => $on,
];
}
}
final class DepartmentGatesRelayOpenHarness extends department_gates_o
{
public function __construct(
array $config,
int $departmentId,
private readonly DepartmentGatesRelayManagerFake $manager,
) {
$this->id = 1001;
$departmentProperty = new object_property('department_gates', -1, 'department', 'int');
$departmentProperty->set($departmentId);
$this->department = $departmentProperty;
$configProperty = new object_property('department_gates', -1, 'config', 'json');
$configProperty->set($config);
$this->config = $configProperty;
}
public function requireSelected(): void
{
}
protected function resolveEdgeGatewayManager(): edge_gateway_manager
{
return $this->manager;
}
}
it('dispatches relay-backed gates through the edge gateway relay manager', function (): void {
$manager = new DepartmentGatesRelayManagerFake();
$gate = new DepartmentGatesRelayOpenHarness([
'type' => 'RELAY',
'relay_id' => 'ENTRY-GATE-1',
'pulse_seconds' => 0,
], 17, $manager);
$gate->openGate();
expect($manager->switchCalls)->toBe([
[
'department_id' => 17,
'relay_id' => 'ENTRY-GATE-1',
'on' => true,
],
]);
});
@@ -0,0 +1,229 @@
<?php
use classes\customer_mass_import_service;
if (!function_exists('fakeCustomerMassImportUser')) {
function fakeCustomerMassImportUser(int $id, bool $hasPassword, string $displayName = 'Demo Company'): object
{
return new class($id, $hasPassword, $displayName) {
public int $id;
public bool $has_password;
public string $display_name;
public function __construct(int $id, bool $hasPassword, string $displayName)
{
$this->id = $id;
$this->has_password = $hasPassword;
$this->display_name = $displayName;
}
public function exists(): bool
{
return true;
}
public function hasPassword(): bool
{
return $this->has_password;
}
};
}
}
if (!class_exists('CustomerMassImportServiceProbe')) {
class CustomerMassImportServiceProbe extends customer_mass_import_service
{
public array $economicSearchResults = [];
public array $createCalls = [];
public array $bootstrapCalls = [];
public array $syncCalls = [];
public array $logEntries = [];
public bool $localExists = false;
public ?object $localUser = null;
public ?object $bootstrapUser = null;
public ?object $createResponse = null;
public string $companyName = 'Probe Company';
protected function searchEconomicCustomersByCvr(string $cvr): array
{
return $this->economicSearchResults;
}
protected function createEconomicCustomer(array $normalized): object
{
$this->createCalls[] = $normalized;
return $this->createResponse ?? (object)[
'customerNumber' => (int)$normalized['customer_number'],
];
}
protected function localCustomerNumberExists(int $customerNumber): bool
{
return $this->localExists;
}
protected function loadLocalCustomerByNumber(int $customerNumber): ?object
{
return $this->localUser;
}
protected function bootstrapLocalCustomerOrFail(int $customerNumber): object
{
$this->bootstrapCalls[] = $customerNumber;
if ($this->bootstrapUser === null) {
throw new RuntimeException('Customer was created in e-conomic but could not be imported locally.', 500);
}
return $this->bootstrapUser;
}
protected function fetchCompanyNameByCvr(string $cvr): string
{
return $this->companyName;
}
protected function logIssue(string $action, array $context): void
{
$this->logEntries[] = [
'action' => $action,
'context' => $context,
];
}
protected function syncLocalCustomer(object $customer, array $normalized, array &$warnings): void
{
$this->syncCalls[] = [
'customer' => $customer,
'normalized' => $normalized,
];
}
}
}
it('imports a matching e-conomic customer into the local system when no local record exists', function (): void {
$service = new CustomerMassImportServiceProbe();
$service->economicSearchResults = [
(object)[
'customerNumber' => 76964600,
'name' => 'SPF-DANMARK A/S',
],
];
$service->bootstrapUser = fakeCustomerMassImportUser(41, false, 'SPF-DANMARK A/S');
$result = $service->import([
'cvr' => '31744520',
'name' => 'SPF-DANMARK A/S',
'email' => 'spf@example.com',
'ean' => '5790000000001',
'phone' => '76964600',
]);
expect($service->createCalls)->toBe([]);
expect($service->bootstrapCalls)->toBe([76964600]);
expect($result['action'])->toBe('imported_existing_customer');
expect($result['existing_economic_customer'])->toBeTrue();
expect($result['created_economic_customer'])->toBeFalse();
expect($result['has_account'])->toBeFalse();
expect($result['user_id'])->toBe(41);
});
it('reports when the local customer already has a login account', function (): void {
$service = new CustomerMassImportServiceProbe();
$service->localExists = true;
$service->localUser = fakeCustomerMassImportUser(77, true, 'STEA A/S');
$service->economicSearchResults = [
(object)[
'customerNumber' => 75773355,
'name' => 'STEA A/S',
],
];
$result = $service->import([
'cvr' => '26761751',
'name' => 'STEA A/S',
'phone' => '75773355',
]);
expect($service->bootstrapCalls)->toBe([]);
expect($result['action'])->toBe('account_already_exists');
expect($result['existing_local_customer'])->toBeTrue();
expect($result['has_account'])->toBeTrue();
});
it('creates a new e-conomic customer and returns a created result for new rows', function (): void {
$service = new CustomerMassImportServiceProbe();
$service->bootstrapUser = fakeCustomerMassImportUser(105, false, 'TGP TRANSPORT APS');
$service->createResponse = (object)[
'customerNumber' => 22725567,
];
$result = $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'tgp@example.com',
'ean' => '5790001234567',
'phone' => '22725567',
]);
expect($service->createCalls)->toHaveCount(1);
expect($service->createCalls[0]['customer_number'])->toBe(22725567);
expect($service->createCalls[0]['ean'])->toBe('5790001234567');
expect($service->bootstrapCalls)->toBe([22725567]);
expect($result['action'])->toBe('created_customer');
expect($result['created_economic_customer'])->toBeTrue();
expect($result['existing_economic_customer'])->toBeFalse();
expect($result['has_account'])->toBeFalse();
});
it('creates the economic record for an existing local account when no matching upstream customer exists', function (): void {
$service = new CustomerMassImportServiceProbe();
$service->localExists = true;
$service->localUser = fakeCustomerMassImportUser(222, true, 'Existing Account');
$service->createResponse = (object)[
'customerNumber' => 97120896,
];
$result = $service->import([
'cvr' => '49422113',
'name' => 'VESTERBRO PRODUKTHANDEL',
'phone' => '97120896',
]);
expect($service->createCalls)->toHaveCount(1);
expect($service->bootstrapCalls)->toBe([]);
expect($result['action'])->toBe('economic_customer_created_for_existing_account');
expect($result['existing_local_customer'])->toBeTrue();
expect($result['created_economic_customer'])->toBeTrue();
expect($result['has_account'])->toBeTrue();
});
it('rejects CVR conflicts when the upstream customer number does not match the submitted phone number', function (): void {
$service = new CustomerMassImportServiceProbe();
$service->economicSearchResults = [
(object)[
'customerNumber' => 87654321,
'name' => 'Conflict Company',
],
];
$call = static fn() => $service->import([
'cvr' => '33333333',
'name' => 'Conflict Company',
'phone' => '22725567',
]);
expect($call)->toThrow(RuntimeException::class, 'CVR already registered under customer number 87654321.');
expect($service->createCalls)->toBe([]);
expect($service->logEntries[0]['action'] ?? null)->toBe('CUSTOMER_MASS_IMPORT_CONFLICT');
});
it('registers the customer import route and wires it through the mass import service', function (): void {
$routeFile = app_path('routes/customerSearchRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain("\$this->post('/customers/import'");
expect($content)->toContain('new customer_mass_import_service()');
expect($content)->toContain("\$this->requirePermission('add_user');");
});
@@ -0,0 +1,30 @@
<?php
app_require('classes/department_gate_config.php');
use classes\department_gate_config;
it('validates relay gate configs and keeps relay-specific fields in the payload', function (): void {
$config = new department_gate_config([
'type' => 'RELAY',
'relay_id' => 'ENTRY-GATE-1',
'pulse_seconds' => 0,
]);
$config->validate();
expect($config->toArray())->toMatchArray([
'type' => 'RELAY',
'relay_id' => 'ENTRY-GATE-1',
'pulse_seconds' => 0,
]);
});
it('rejects relay gate configs without a logical relay id', function (): void {
$config = new department_gate_config([
'type' => 'RELAY',
]);
expect(fn() => $config->validate())
->toThrow(Exception::class, 'relay_id is required for RELAY gate type');
});
@@ -0,0 +1,17 @@
<?php
it('nullifies department lane relay fields when blank select values are submitted', function (): void {
$route = file_get_contents(app_path('routes/departmentLanesRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain('private static function normalizeRelayRequestParameter');
expect($route)->toContain('private static function syncDepartmentLaneRelayValue');
expect($route)->toContain('$field->nullify();');
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_in_id, $relay_in_id);');
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_out_id, $relay_out_id);');
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_machine_id, $relay_machine_id);');
expect($route)->toContain(
'self::syncDepartmentLaneRelayValue($department_lane->relay_machine_program_picker_id, $relay_machine_program_picker_id);'
);
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_machine_cleaner_id, $relay_machine_cleaner_id);');
});
@@ -0,0 +1,10 @@
<?php
it('registers a department lane relay options endpoint backed by Shelly inventory', function (): void {
$route = file_get_contents(app_path('routes/departmentLanesRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain("'/department/lanes/relay-options'");
expect($route)->toContain('new shelly_relay_inventory()');
expect($route)->toContain('LIST_DEPARTMENT_LANE_RELAY_OPTIONS');
});
@@ -0,0 +1,19 @@
<?php
it('defines the department hardware workspace service payload surface', function (): void {
$service = file_get_contents(app_path('modules/edgegateway/classes/edge_gateway_department_workspace_service.php'));
expect($service)->not->toBeFalse();
expect($service)->toContain('class edge_gateway_department_workspace_service');
expect($service)->toContain("'summary' => \$summary");
expect($service)->toContain("'gateways' => \$includeGateways ? \$gateways : []");
expect($service)->toContain("'lanes' => \$lanes");
expect($service)->toContain("'self_serve' => \$selfServe");
expect($service)->toContain("'gates' => \$gates");
expect($service)->toContain("'relays' => \$relays");
expect($service)->toContain("'scanners' => \$scanners");
expect($service)->toContain("'issues' => \$issues");
expect($service)->toContain("'actions' => \$actions");
expect($service)->toContain("'consumer_contexts'");
expect($service)->toContain("'coverage'");
});
@@ -74,11 +74,17 @@ it('derives relay fallback and transport health details without shell or update
'last_heartbeat_at' => '2026-04-08 10:04:30',
'department_transport_mode' => edge_gateway_manager::TRANSPORT_MODE_GATEWAY,
'metadata' => [
'last_sync_at' => '2026-04-08 10:04:20',
'broker_presence' => [
'connected' => false,
'last_seen_at' => '2026-04-08 10:03:00',
'last_error' => 'broker timeout',
],
'control_plane_status' => [
'last_successful_sync_at' => '2026-04-08 10:04:10',
'last_transport_failure_at' => '2026-04-08 10:04:12',
'last_transport_error' => 'POST https://api.truckwash.io/edge-agent/gateways/17/heartbeat returned HTTP 502',
],
],
'operational_snapshot' => [
'command_backlog' => 2,
@@ -132,6 +138,10 @@ it('derives relay fallback and transport health details without shell or update
expect($gateway['fallback_summary']['local_only_relays'])->toBe(1);
expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED);
expect($gateway['transport_health']['recommended_action'])->toBe('retry_discovery');
expect($gateway['transport_health']['last_successful_sync_at'])->toBe('2026-04-08 10:04:30');
expect($gateway['transport_health']['last_transport_failure_at'])->toBe('2026-04-08 10:04:12');
expect($gateway['transport_health']['last_transport_error'])->toContain('returned HTTP 502');
expect($gateway['last_sync_at'])->toBe('2026-04-08 10:04:30');
expect($gateway['last_successful_discovery_at'])->toBe('2026-04-08 10:02:00');
expect($gateway['diagnostics'])->not->toBeEmpty();
expect($gateway['error_state']['code'])->toBe('EDGE_GATEWAY_OPERATION_TIMEOUT');
@@ -30,6 +30,13 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
}
}
function invoke_edge_gateway_private(object $instance, string $method, mixed ...$arguments): mixed
{
$reflection = new ReflectionMethod($instance, $method);
$reflection->setAccessible(true);
return $reflection->invokeArgs($instance, $arguments);
}
it('builds install script urls with the compose edge gateway artifacts and forwarded https scheme', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'api.truckwash.io:4433',
@@ -64,7 +71,7 @@ it('builds install script urls with the compose edge gateway artifacts and forwa
expect($script)->toContain('"composeFileName":"docker-compose.gateway.yml"');
expect($script)->toContain('"stateDatabasePath":"/opt/truckwash-edge-agent/runtime/gateway-state.sqlite"');
expect($script)->toContain('"operationPollTimeoutSeconds":20');
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4433/edge-broker"');
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
});
});
@@ -85,6 +92,7 @@ it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost',
'HTTP_X_FORWARDED_PROTO' => 'http',
'HTTP_X_FORWARDED_PREFIX' => '/api',
], function (): void {
putenv('EDGE_PUBLIC_API_URL=https://edge.example.test/api');
@@ -94,6 +102,54 @@ it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
expect($manager->getApiBaseUrl())->toBe('https://edge.example.test/api');
expect($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1');
expect($script)->toContain('"apiUrl":"https://edge.example.test/api"');
expect($script)->toContain('"brokerUrl":"https://edge.example.test:4300"');
expect($script)->toContain('"brokerUrl":"https://edge.example.test/api/edge-broker"');
});
});
it('builds websocket broker urls on the traefik broker path', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'api.truckwash.io:4433',
'HTTP_X_FORWARDED_PROTO' => 'https',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicUrl'))
->toBe('https://api.truckwash.io:4433/edge-broker');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicWebSocketUrl', '/ws/browser-shell'))
->toBe('wss://api.truckwash.io:4433/edge-broker/ws/browser-shell');
});
});
it('builds localhost websocket broker urls on the local traefik api prefix', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost',
'HTTP_X_FORWARDED_PROTO' => 'http',
'HTTP_X_FORWARDED_PREFIX' => '/api',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
expect($manager->getApiBaseUrl())
->toBe('http://localhost/api');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicUrl'))
->toBe('http://localhost/api/edge-broker');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicWebSocketUrl', '/ws/browser-gateway-stream'))
->toBe('ws://localhost/api/edge-broker/ws/browser-gateway-stream');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicWebSocketUrl', '/ws/browser-shell'))
->toBe('ws://localhost/api/edge-broker/ws/browser-shell');
});
});
it('keeps root-host api urls unprefixed when the request is not under the local api alias', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost',
'HTTP_X_FORWARDED_PROTO' => 'http',
'REQUEST_URI' => '/edge-gateways/84/stream-session',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
expect($manager->getApiBaseUrl())
->toBe('http://localhost');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicUrl'))
->toBe('http://localhost/edge-broker');
});
});
@@ -5,6 +5,8 @@ it('registers module-scoped edge gateway operator routes', function (): void {
expect($route)->not->toBeFalse();
expect($route)->toContain("'/modules/edge-gateways'");
expect($route)->toContain("'/modules/edge-gateways/workspace/departments'");
expect($route)->toContain("'/modules/edge-gateways/workspace/departments/{id}'");
expect($route)->toContain("'/modules/edge-gateways/{id}'");
expect($route)->toContain("'/modules/edge-gateways/install-token'");
expect($route)->toContain("'/modules/edge-gateways/install-token/{id}/status'");
@@ -6,6 +6,7 @@ it('builds the installer around the compose stack artifacts and management polli
$stackServiceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-gateway-stack.service'));
$launcherSource = file_get_contents(app_path('resources/edge-gateway-agent/gateway-launcher.sh'));
$composeSource = file_get_contents(app_path('resources/edge-gateway-agent/docker-compose.gateway.yml'));
$agentSource = file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
$edgeDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.edge-agent'));
$workerDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.lan-worker'));
$autoUpdaterSource = file_get_contents(app_path('resources/edge-gateway-agent/auto-updater.php'));
@@ -14,6 +15,7 @@ it('builds the installer around the compose stack artifacts and management polli
expect($managerSource)->not->toBeFalse();
expect($launcherSource)->not->toBeFalse();
expect($composeSource)->not->toBeFalse();
expect($agentSource)->not->toBeFalse();
expect($edgeDockerfileSource)->not->toBeFalse();
expect($workerDockerfileSource)->not->toBeFalse();
expect($autoUpdaterSource)->not->toBeFalse();
@@ -66,7 +68,11 @@ it('builds the installer around the compose stack artifacts and management polli
expect($composeSource)->toContain('condition: service_healthy');
expect($composeSource)->toContain("minio:\n condition: service_started");
expect($composeSource)->toContain("mariadb:\n condition: service_started");
expect($composeSource)->toContain("test: [\"CMD-SHELL\", \"kill -0 1\"]");
expect($composeSource)->toContain('/opt/truckwash-edge-agent/runtime/control-plane-status.json');
expect($composeSource)->toContain('$$data[\\"last_loop_at\\"]');
expect($composeSource)->toContain('$$data[\\"last_successful_sync_at\\"]');
expect($composeSource)->toContain('<= 30');
expect($composeSource)->toContain('<= 90');
expect($composeSource)->toContain("http://127.0.0.1:8090/health");
expect($composeSource)->toContain('$$json=@file_get_contents(\'http://127.0.0.1:8090/health\');');
expect($composeSource)->toContain('$$path=\"/opt/truckwash-edge-agent/runtime/auto-updater-heartbeat.json\"');
@@ -76,6 +82,15 @@ it('builds the installer around the compose stack artifacts and management polli
expect($composeSource)->toContain('container_name: truckwash-mariadb');
expect($composeSource)->toContain('container_name: truckwash-minio');
expect($composeSource)->toContain('container_name: truckwash-auto-updater');
expect($agentSource)->toContain('private string $controlPlaneStatusPath;');
expect($agentSource)->toContain('control-plane-status.json');
expect($agentSource)->toContain("'control_plane_status' => \$this->buildControlPlaneStatusPayload()");
expect($agentSource)->toContain("'last_heartbeat_attempt_at'");
expect($agentSource)->toContain("'last_heartbeat_success_at'");
expect($agentSource)->toContain("'last_successful_sync_at'");
expect($agentSource)->toContain("'last_transport_failure_at'");
expect($agentSource)->toContain("'last_transport_error'");
expect($agentSource)->toContain('private function recordTransportFailure(string $context, Throwable $throwable): void');
expect($edgeDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
expect($edgeDockerfileSource)->toContain('COPY agent.php /opt/truckwash-edge-agent/agent.php');
expect($edgeDockerfileSource)->not->toContain('docker-php-ext-install');
@@ -0,0 +1,23 @@
<?php
it('adds lane-aware scanner management and a dedicated rotate-key action', function (): void {
$route = file_get_contents(app_path('routes/plateScannersRoute.php'));
$scannerObject = file_get_contents(app_path('objects/plate_scanners_o.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain("'/numberplatescanners/{id}/rotate-key'");
expect($route)->toContain("'lane_id'");
expect($scannerObject)->not->toBeFalse();
expect($scannerObject)->toContain('public object_property $lane_id;');
expect($scannerObject)->toContain('public function rotateApiKey(int $id): array');
expect($scannerObject)->toContain('ADD COLUMN `lane_id` INT NULL AFTER `department_id`');
});
it('uses the scanner default lane before requiring an explicit lane_id in machine button webhooks', function (): void {
$route = file_get_contents(app_path('routes/machineButtonPressRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain('$plateScanner->lane_id->value()');
expect($route)->toContain('default lane configured for the plate scanner');
});
@@ -0,0 +1,193 @@
<?php
app_require('classes/shelly_relay_inventory.php');
use classes\shelly_relay_inventory;
it('normalizes owned Shelly devices into relay select options', function (): void {
$inventory = (new shelly_relay_inventory())
->setInventoryFetcher(static function (): array {
return [
'isok' => true,
'data' => [
'devices_status' => [
'device-key-1' => [
'_dev_info' => [
'id' => 'shelly-plus-01',
'code' => 'SPSW-001PE16EU',
'model' => 'Shelly Plus 1PM',
'online' => 1,
],
'name' => 'Entry Gate',
'status' => [
'switch:0' => [
'output' => false,
'name' => 'Entrance Relay',
],
],
],
'device-key-2' => [
'_dev_info' => [
'id' => 'shelly-pro-03',
'code' => 'SPSW-201XE16EU',
'model' => 'Shelly Pro 2PM',
],
'name' => 'Machine Cabinet',
'relays' => [
['ison' => false, 'name' => 'Program Picker'],
],
],
'device-key-3' => [
'_dev_info' => [
'id' => 'shelly-legacy-02',
'code' => 'SHSW-1',
'model' => 'Shelly 1',
'online' => 0,
],
'relays' => [
['ison' => false],
],
],
'device-key-4' => [
'_dev_info' => [
'id' => 'shelly-sensor-01',
'code' => 'SHHT-1',
'model' => 'Shelly H&T',
'online' => 1,
],
'sensor' => [
'temperature' => 21.5,
],
],
],
],
];
})
->setDeviceListFetcher(static function (): array {
return [
'isok' => true,
'data' => [
'devices' => [
'shelly-plus-01' => [
'id' => 'shelly-plus-01',
'name' => 'Gate From Shelly Cloud',
'type' => 'SPSW-001PE16EU',
'cloud_online' => true,
],
'shelly-pro-03' => [
'id' => 'shelly-pro-03',
'name' => 'Program Picker From Shelly Cloud',
'type' => 'SPSW-201XE16EU',
'cloud_online' => false,
],
],
],
];
});
expect($inventory->listRelayOptions())->toBe([
[
'id' => 'shelly-plus-01',
'name' => 'Gate From Shelly Cloud (Shelly Plus 1PM)',
'device_id' => 'shelly-plus-01',
'device_name' => 'Gate From Shelly Cloud',
'cloud_name' => 'Gate From Shelly Cloud',
'device_type' => 'Shelly Plus 1PM',
'code' => 'SPSW-001PE16EU',
'control_type' => 'Switch',
'control_name' => 'Entrance Relay',
'status_color' => 'Green',
'online' => true,
],
[
'id' => 'shelly-pro-03',
'name' => 'Program Picker From Shelly Cloud (Shelly Pro 2PM)',
'device_id' => 'shelly-pro-03',
'device_name' => 'Program Picker From Shelly Cloud',
'cloud_name' => 'Program Picker From Shelly Cloud',
'device_type' => 'Shelly Pro 2PM',
'code' => 'SPSW-201XE16EU',
'control_type' => 'Relay',
'control_name' => 'Program Picker',
'status_color' => 'Red',
'online' => false,
],
[
'id' => 'shelly-legacy-02',
'name' => 'shelly-legacy-02 (Shelly 1)',
'device_id' => 'shelly-legacy-02',
'device_name' => null,
'cloud_name' => null,
'device_type' => 'Shelly 1',
'code' => 'SHSW-1',
'control_type' => 'Relay',
'control_name' => null,
'status_color' => 'Red',
'online' => false,
],
]);
});
it('falls back to local device and control names when Shelly cloud list metadata is unavailable', function (): void {
$inventory = (new shelly_relay_inventory())
->setInventoryFetcher(static function (): array {
return [
'isok' => true,
'data' => [
'devices_status' => [
'device-key-1' => [
'_dev_info' => [
'id' => 'shelly-plus-01',
'code' => 'SPSW-001PE16EU',
'model' => 'Shelly Plus 1PM',
'online' => 1,
],
'name' => 'Entry Gate',
'status' => [
'switch:0' => [
'output' => false,
'name' => 'Entrance Relay',
],
],
],
],
],
];
})
->setDeviceListFetcher(static function (): array {
return [
'isok' => false,
'errors' => [
'404' => 'Requested method was not found',
],
];
});
expect($inventory->listRelayOptions())->toBe([
[
'id' => 'shelly-plus-01',
'name' => 'Entry Gate / Entrance Relay (Shelly Plus 1PM)',
'device_id' => 'shelly-plus-01',
'device_name' => 'Entry Gate',
'cloud_name' => null,
'device_type' => 'Shelly Plus 1PM',
'code' => 'SPSW-001PE16EU',
'control_type' => 'Switch',
'control_name' => 'Entrance Relay',
'status_color' => 'Green',
'online' => true,
],
]);
});
it('fails fast when Shelly inventory does not include owned devices status', function (): void {
$inventory = (new shelly_relay_inventory())->setInventoryFetcher(static function (): array {
return [
'isok' => true,
'data' => [],
];
});
expect(fn() => $inventory->listRelayOptions())
->toThrow(Exception::class, 'Shelly relay inventory response was missing devices_status');
});
+14 -3
View File
@@ -2,9 +2,9 @@
set -e
# Config
APP_DIR="/var/www/html"
MODULE_DIR="$APP_DIR/modules/washcertificates"
LOG_FILE="/var/log/php/composer-install.log"
APP_DIR="${APP_DIR:-/var/www/html}"
MODULE_DIR="${MODULE_DIR:-$APP_DIR/modules/washcertificates}"
LOG_FILE="${LOG_FILE:-/var/log/php/composer-install.log}"
# Gate auto-install (set to "true" only on one PHP container, e.g. php1)
AUTO_COMPOSER_INSTALL="${AUTO_COMPOSER_INSTALL:-true}"
@@ -14,17 +14,28 @@ log() { printf "[entrypoint] %s\n" "$*"; }
vendor_sanity_ok() {
dir="$1"
autoload_file="$dir/vendor/autoload.php"
autoload_real_file="$dir/vendor/composer/autoload_real.php"
aws_s3_api_file="$dir/vendor/aws/aws-sdk-php/src/data/s3/2006-03-01/api-2.json.php"
if [ ! -f "$autoload_file" ]; then
return 1
fi
if [ ! -f "$autoload_real_file" ]; then
log "Vendor sanity check failed: missing $autoload_real_file"
return 1
fi
if [ -f "$dir/composer.lock" ] && [ "$dir/composer.lock" -nt "$autoload_file" ]; then
log "composer.lock is newer than vendor/autoload.php in $dir"
return 1
fi
if ! php -d display_errors=1 -r 'require $argv[1];' "$autoload_file" >/dev/null 2>&1; then
log "Vendor sanity check failed for $autoload_file"
return 1
fi
if [ -f "$aws_s3_api_file" ] && ! php -l "$aws_s3_api_file" >/dev/null 2>&1; then
log "Vendor sanity check failed for $aws_s3_api_file"
return 1