Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2258a609e6 | ||
|
|
b98cef0caa | ||
|
|
0813bfc0f0 | ||
|
|
4420d76cd2 | ||
|
|
b9ddc585db | ||
|
|
c38a4379bd | ||
|
|
e09e23025d | ||
|
|
4c32eae49a | ||
|
|
0d3c2d70bf | ||
|
|
fd830deda9 | ||
|
|
f4952f16e6 | ||
|
|
300942f1c8 | ||
|
|
6925b47a5b |
@@ -120,6 +120,52 @@ jobs:
|
||||
working-directory: services/edge-broker
|
||||
run: npm test
|
||||
|
||||
edge-gateway-backend:
|
||||
name: Edge Gateway Backend (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
|
||||
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: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
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]
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
.env
|
||||
/services/caddy/logs*
|
||||
/.tmp/
|
||||
/.env.staging
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -80,6 +80,58 @@ services:
|
||||
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
|
||||
@@ -196,11 +248,14 @@ services:
|
||||
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/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -213,11 +268,14 @@ services:
|
||||
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/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -230,11 +288,14 @@ services:
|
||||
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/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -247,11 +308,14 @@ services:
|
||||
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/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -264,11 +328,14 @@ services:
|
||||
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/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -281,11 +348,14 @@ services:
|
||||
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/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -298,11 +368,14 @@ services:
|
||||
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/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -3,6 +3,13 @@ import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
|
||||
export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [
|
||||
"/edge-agent/install-token/status",
|
||||
"report_install_status",
|
||||
'begin_install_phase "VERIFY_TOKEN"',
|
||||
'begin_install_phase "WAIT_FOR_CLAIM"',
|
||||
'report_install_status "FAILED"',
|
||||
];
|
||||
|
||||
export function normalizeBaseUrl(url) {
|
||||
return String(url || "").trim().replace(/\/+$/, "");
|
||||
@@ -63,6 +70,17 @@ export function buildChecks(baseUrl, installToken) {
|
||||
];
|
||||
}
|
||||
|
||||
export function validateInstallerScriptBody(body) {
|
||||
const source = String(body || "");
|
||||
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet));
|
||||
|
||||
if (missingSnippets.length) {
|
||||
throw new Error(`Installer script is missing required status wiring: ${missingSnippets.join(", ")}`);
|
||||
}
|
||||
|
||||
return INSTALLER_SCRIPT_REQUIRED_SNIPPETS;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
process.stdout.write(`Usage:
|
||||
node scripts/staging-edge-gateway-smoke.mjs --install-token <token> [--base-url <url>]
|
||||
@@ -108,6 +126,10 @@ export async function runSmoke({ baseUrl, installToken }) {
|
||||
`Body preview: ${result.bodyPreview || "<empty>"}`
|
||||
);
|
||||
}
|
||||
|
||||
if (check.name === "Installer script") {
|
||||
result.verifiedSnippets = validateInstallerScriptBody(body);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -3,9 +3,11 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_STAGING_BASE_URL,
|
||||
INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
|
||||
buildChecks,
|
||||
normalizeBaseUrl,
|
||||
parseArgs,
|
||||
validateInstallerScriptBody,
|
||||
} from "./staging-edge-gateway-smoke.mjs";
|
||||
|
||||
test("normalizeBaseUrl strips trailing slashes", () => {
|
||||
@@ -35,3 +37,21 @@ test("buildChecks targets the public staging endpoints", () => {
|
||||
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123",
|
||||
]);
|
||||
});
|
||||
|
||||
test("validateInstallerScriptBody requires install-session reporting wiring", () => {
|
||||
const script = `
|
||||
INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"
|
||||
report_install_status "FAILED"
|
||||
begin_install_phase "VERIFY_TOKEN" "Verifying install token"
|
||||
begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"
|
||||
`;
|
||||
|
||||
assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS);
|
||||
});
|
||||
|
||||
test("validateInstallerScriptBody rejects missing installer status hooks", () => {
|
||||
assert.throws(
|
||||
() => validateInstallerScriptBody("echo hello"),
|
||||
/Installer script is missing required status wiring/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/ ./
|
||||
|
||||
+441
-41
@@ -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) {
|
||||
@@ -55,27 +56,81 @@ function resolveAuthMode(options = {}, managerUrl = "") {
|
||||
return managerUrl ? "manager" : "stub";
|
||||
}
|
||||
|
||||
function parseScopes(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.map((scope) => String(scope || "").trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function eventScopes(message) {
|
||||
switch (message?.type) {
|
||||
case "gateway.telemetry":
|
||||
case "presence.changed":
|
||||
return ["overview", "statistics"];
|
||||
case "task.updated":
|
||||
return ["tasks", "overview"];
|
||||
case "log.append":
|
||||
return ["logs"];
|
||||
case "stats.updated":
|
||||
return ["statistics"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function sessionAllowsScopes(sessionRecord, scopes) {
|
||||
const subscriptions = sessionRecord.subscriptions || new Set();
|
||||
if (subscriptions.has("*")) {
|
||||
return true;
|
||||
}
|
||||
if (!scopes || scopes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return scopes.some((scope) => subscriptions.has(scope));
|
||||
}
|
||||
|
||||
function sendJson(ws, payload) {
|
||||
if (!ws || ws.readyState !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify(payload));
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createBrokerServer(options = {}) {
|
||||
const sharedSecret = options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "";
|
||||
const managerUrl = resolveManagerUrl(options);
|
||||
const authMode = resolveAuthMode(options, managerUrl);
|
||||
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
|
||||
|
||||
const agents = new Map();
|
||||
const pendingCommands = new Map();
|
||||
const browserSessions = new Map();
|
||||
const browserShellSessions = new Map();
|
||||
const browserStreamSessions = new Map();
|
||||
const gatewayStreamSessions = new Map();
|
||||
const inflightGatewaySyncs = new Map();
|
||||
|
||||
const managerRequest = async (path, body = {}) => {
|
||||
const managerRequest = async (path, body = {}, method = "POST") => {
|
||||
if (!managerUrl) {
|
||||
throw new Error("Edge manager URL is not configured");
|
||||
}
|
||||
|
||||
const response = await fetch(`${managerUrl}${path}`, {
|
||||
method: "POST",
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(sharedSecret ? { "x-edge-broker-secret": sharedSecret } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
body: method === "GET" ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const json = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
@@ -88,14 +143,23 @@ export function createBrokerServer(options = {}) {
|
||||
const validateAgent =
|
||||
options.validateAgent ||
|
||||
(authMode === "stub"
|
||||
? async ({ gatewayId }) => ({ id: gatewayId, gateway_id: gatewayId })
|
||||
? async ({ gatewayId }) => ({ id: gatewayId, gateway_id: gatewayId, label: `Gateway ${gatewayId}` })
|
||||
: async ({ gatewayId, token }) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/validate`, { token }));
|
||||
const validateShellSession =
|
||||
options.validateShellSession ||
|
||||
(authMode === "stub"
|
||||
? async ({ token }) => ({ id: token, gateway_id: 1, reason: "stub" })
|
||||
? async ({ token }) => ({ id: token, gateway_id: 1, reason: "stub", cols: 120, rows: 32 })
|
||||
: async ({ token }) => managerRequest("/edge-agent/internal/shell-sessions/validate", { token }));
|
||||
const markShellSessionOpened =
|
||||
options.markShellSessionOpened ||
|
||||
(authMode === "stub"
|
||||
? async () => ({})
|
||||
: async (token, connectionId) =>
|
||||
managerRequest("/edge-agent/internal/shell-sessions/opened", {
|
||||
token,
|
||||
connection_id: connectionId,
|
||||
}));
|
||||
const closeShellSession =
|
||||
options.closeShellSession ||
|
||||
(authMode === "stub"
|
||||
@@ -106,6 +170,15 @@ export function createBrokerServer(options = {}) {
|
||||
transcript,
|
||||
reason,
|
||||
}));
|
||||
const validateBrowserStream =
|
||||
options.validateBrowserStream ||
|
||||
(authMode === "stub"
|
||||
? async ({ token }) => ({
|
||||
id: token,
|
||||
gateway_id: 1,
|
||||
scopes: ["overview", "tasks", "logs", "statistics"],
|
||||
})
|
||||
: async ({ token }) => managerRequest("/edge-agent/internal/browser-streams/validate", { token }));
|
||||
const reportGatewayPresence =
|
||||
options.reportGatewayPresence ||
|
||||
(authMode === "stub"
|
||||
@@ -117,8 +190,58 @@ export function createBrokerServer(options = {}) {
|
||||
reason,
|
||||
metadata,
|
||||
}));
|
||||
const requestGatewayBacklog =
|
||||
options.requestGatewayBacklog ||
|
||||
(authMode === "stub"
|
||||
? async () => ({ gateway: {}, dispatch: [] })
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/backlog`, payload));
|
||||
const ingestTelemetry =
|
||||
options.ingestTelemetry ||
|
||||
(authMode === "stub"
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/telemetry`, payload));
|
||||
const ingestTaskEvent =
|
||||
options.ingestTaskEvent ||
|
||||
(authMode === "stub"
|
||||
? async (_gatewayId, _operationId, payload = {}) => payload
|
||||
: async (gatewayId, operationId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/operations/${operationId}/events`, payload));
|
||||
const ingestTaskResult =
|
||||
options.ingestTaskResult ||
|
||||
(authMode === "stub"
|
||||
? async (_gatewayId, _operationId, payload = {}) => payload
|
||||
: async (gatewayId, operationId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/operations/${operationId}/complete`, payload));
|
||||
const ingestLogEntry =
|
||||
options.ingestLogEntry ||
|
||||
(authMode === "stub"
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload));
|
||||
|
||||
const closeBrowserSession = async (sessionRecord, reason) => {
|
||||
const broadcastGatewayEvent = (gatewayId, message) => {
|
||||
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
|
||||
if (!sessionIds || sessionIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedScopes = eventScopes(message);
|
||||
for (const sessionId of sessionIds.values()) {
|
||||
const sessionRecord = browserStreamSessions.get(String(sessionId));
|
||||
if (!sessionRecord) {
|
||||
continue;
|
||||
}
|
||||
if (!sessionAllowsScopes(sessionRecord, allowedScopes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sendJson(sessionRecord.ws, message);
|
||||
}
|
||||
};
|
||||
|
||||
const closeBrowserShellSession = async (sessionRecord, reason) => {
|
||||
try {
|
||||
await closeShellSession(
|
||||
sessionRecord.session.id,
|
||||
@@ -131,8 +254,8 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const markBrowserSessionsClosed = (gatewayId, reason) => {
|
||||
for (const sessionRecord of browserSessions.values()) {
|
||||
const markGatewayShellSessionsClosed = (gatewayId, reason) => {
|
||||
for (const sessionRecord of browserShellSessions.values()) {
|
||||
if (String(sessionRecord.session.gateway_id) !== String(gatewayId)) {
|
||||
continue;
|
||||
}
|
||||
@@ -144,6 +267,59 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const registerGatewayStreamSession = (sessionRecord) => {
|
||||
const gatewayId = String(sessionRecord.session.gateway_id);
|
||||
if (!gatewayStreamSessions.has(gatewayId)) {
|
||||
gatewayStreamSessions.set(gatewayId, new Set());
|
||||
}
|
||||
gatewayStreamSessions.get(gatewayId).add(String(sessionRecord.session.id));
|
||||
browserStreamSessions.set(String(sessionRecord.session.id), sessionRecord);
|
||||
};
|
||||
|
||||
const removeGatewayStreamSession = (sessionRecord) => {
|
||||
browserStreamSessions.delete(String(sessionRecord.session.id));
|
||||
const gatewayId = String(sessionRecord.session.gateway_id);
|
||||
const sessionIds = gatewayStreamSessions.get(gatewayId);
|
||||
if (!sessionIds) {
|
||||
return;
|
||||
}
|
||||
sessionIds.delete(String(sessionRecord.session.id));
|
||||
if (sessionIds.size === 0) {
|
||||
gatewayStreamSessions.delete(gatewayId);
|
||||
}
|
||||
};
|
||||
|
||||
const syncGatewayBacklog = async (gatewayId, explicitAgent = null) => {
|
||||
const normalizedGatewayId = String(gatewayId);
|
||||
const agent = explicitAgent || agents.get(normalizedGatewayId);
|
||||
if (!agent || agent.readyState !== 1) {
|
||||
return { queued: false };
|
||||
}
|
||||
|
||||
if (inflightGatewaySyncs.has(normalizedGatewayId)) {
|
||||
return inflightGatewaySyncs.get(normalizedGatewayId);
|
||||
}
|
||||
|
||||
const syncPromise = (async () => {
|
||||
const backlog = await requestGatewayBacklog(normalizedGatewayId, {
|
||||
agent_instance_id: agent.agentInstanceId || null,
|
||||
});
|
||||
const dispatch = Array.isArray(backlog?.dispatch) ? backlog.dispatch : [];
|
||||
for (const instruction of dispatch) {
|
||||
sendJson(agent, instruction);
|
||||
}
|
||||
return {
|
||||
queued: dispatch.length > 0,
|
||||
dispatch,
|
||||
};
|
||||
})().finally(() => {
|
||||
inflightGatewaySyncs.delete(normalizedGatewayId);
|
||||
});
|
||||
|
||||
inflightGatewaySyncs.set(normalizedGatewayId, syncPromise);
|
||||
return syncPromise;
|
||||
};
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
@@ -174,13 +350,13 @@ export function createBrokerServer(options = {}) {
|
||||
});
|
||||
});
|
||||
|
||||
agent.send(JSON.stringify({
|
||||
sendJson(agent, {
|
||||
type: "COMMAND",
|
||||
commandId,
|
||||
commandType: body.commandType,
|
||||
payload: body.payload || {},
|
||||
jobId: body.jobId ?? null,
|
||||
}));
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await promise;
|
||||
@@ -191,6 +367,18 @@ export function createBrokerServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) {
|
||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, { error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const gatewayId = url.pathname.split("/")[3];
|
||||
const result = await syncGatewayBacklog(gatewayId);
|
||||
jsonResponse(res, 200, { ok: true, ...result });
|
||||
return;
|
||||
}
|
||||
|
||||
jsonResponse(res, 404, { error: "Not found" });
|
||||
} catch (error) {
|
||||
jsonResponse(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
||||
@@ -204,6 +392,7 @@ export function createBrokerServer(options = {}) {
|
||||
if (url.pathname === "/ws/agent") {
|
||||
const gatewayId = String(url.searchParams.get("gatewayId") || "");
|
||||
const token = String(url.searchParams.get("token") || "");
|
||||
const agentInstanceId = String(url.searchParams.get("agentInstanceId") || "");
|
||||
if (gatewayId === "" || token === "") {
|
||||
socket.destroy();
|
||||
return;
|
||||
@@ -218,6 +407,7 @@ export function createBrokerServer(options = {}) {
|
||||
|
||||
ws.gatewayId = gatewayId;
|
||||
ws.gatewayInfo = gatewayInfo;
|
||||
ws.agentInstanceId = agentInstanceId || null;
|
||||
ws.connectionId = randomUUID();
|
||||
agents.set(gatewayId, ws);
|
||||
reportGatewayPresence(gatewayId, {
|
||||
@@ -225,8 +415,16 @@ export function createBrokerServer(options = {}) {
|
||||
connectionId: ws.connectionId,
|
||||
metadata: {
|
||||
remote_address: req.socket.remoteAddress || null,
|
||||
agent_instance_id: ws.agentInstanceId,
|
||||
},
|
||||
}).catch(() => {});
|
||||
broadcastGatewayEvent(gatewayId, {
|
||||
type: "presence.changed",
|
||||
gatewayId,
|
||||
status: "connected",
|
||||
connectionId: ws.connectionId,
|
||||
});
|
||||
syncGatewayBacklog(gatewayId, ws).catch(() => {});
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
return;
|
||||
@@ -243,35 +441,64 @@ export function createBrokerServer(options = {}) {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.sessionToken = token;
|
||||
ws.sessionInfo = session;
|
||||
browserSessions.set(String(session.id), {
|
||||
const sessionRecord = {
|
||||
ws,
|
||||
session,
|
||||
transcript: "",
|
||||
closedReason: null,
|
||||
});
|
||||
};
|
||||
browserShellSessions.set(String(session.id), sessionRecord);
|
||||
|
||||
const agent = agents.get(String(session.gateway_id));
|
||||
if (agent && agent.readyState === 1) {
|
||||
agent.send(JSON.stringify({
|
||||
sendJson(agent, {
|
||||
type: "OPEN_ROOT_SHELL",
|
||||
payload: {
|
||||
sessionId: String(session.id),
|
||||
reason: session.reason,
|
||||
cols: session.metadata?.cols ?? null,
|
||||
rows: session.metadata?.rows ?? null,
|
||||
cols: session.cols ?? session.metadata?.cols ?? null,
|
||||
rows: session.rows ?? session.metadata?.rows ?? null,
|
||||
cwd: session.cwd ?? session.metadata?.cwd ?? null,
|
||||
shellCommand: session.shell_command ?? session.metadata?.shell_command ?? null,
|
||||
shellArgs: session.shell_args ?? session.metadata?.shell_args ?? [],
|
||||
},
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
const sessionRecord = browserSessions.get(String(session.id));
|
||||
if (sessionRecord) {
|
||||
sessionRecord.closedReason = "agent_offline";
|
||||
}
|
||||
ws.close();
|
||||
}
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/ws/browser-gateway-stream") {
|
||||
const token = String(url.searchParams.get("token") || "");
|
||||
if (token === "") {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await validateBrowserStream({ token, headers: req.headers });
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.sessionToken = token;
|
||||
ws.streamSessionInfo = session;
|
||||
const sessionRecord = {
|
||||
ws,
|
||||
session,
|
||||
subscriptions: new Set(parseScopes(session.scopes || ["overview", "tasks", "logs", "statistics"])),
|
||||
};
|
||||
registerGatewayStreamSession(sessionRecord);
|
||||
sendJson(ws, {
|
||||
type: "gateway.stream.ready",
|
||||
gatewayId: String(session.gateway_id),
|
||||
subscriptions: Array.from(sessionRecord.subscriptions.values()),
|
||||
connected: Boolean(agents.get(String(session.gateway_id))?.readyState === 1),
|
||||
});
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
socket.destroy();
|
||||
return;
|
||||
@@ -282,8 +509,14 @@ export function createBrokerServer(options = {}) {
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
ws.on("message", async (raw) => {
|
||||
const message = JSON.parse(raw.toString());
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (ws.gatewayId) {
|
||||
if (message.type === "COMMAND_RESULT") {
|
||||
const pending = pendingCommands.get(message.commandId);
|
||||
@@ -300,22 +533,88 @@ export function createBrokerServer(options = {}) {
|
||||
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) {
|
||||
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 = browserSessions.get(String(message.sessionId));
|
||||
const sessionRecord = browserShellSessions.get(String(message.sessionId));
|
||||
if (!sessionRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "SHELL_OUTPUT") {
|
||||
sessionRecord.transcript += String(message.data || "");
|
||||
sessionRecord.ws.send(JSON.stringify({ type: "output", data: String(message.data || "") }));
|
||||
sendJson(sessionRecord.ws, { type: "output", data: String(message.data || "") });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "SHELL_OPENED") {
|
||||
sessionRecord.ws.send(JSON.stringify({ type: "opened" }));
|
||||
await markShellSessionOpened(sessionRecord.ws.sessionToken, ws.connectionId || null).catch(() => {});
|
||||
sendJson(sessionRecord.ws, { type: "opened" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "SHELL_EXIT") {
|
||||
sessionRecord.ws.send(JSON.stringify({ type: "closed", code: message.code ?? 0 }));
|
||||
await closeBrowserSession(sessionRecord, "agent_exit");
|
||||
browserSessions.delete(String(message.sessionId));
|
||||
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();
|
||||
}
|
||||
@@ -331,30 +630,69 @@ export function createBrokerServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "input") {
|
||||
agent.send(JSON.stringify({
|
||||
sendJson(agent, {
|
||||
type: "SHELL_INPUT",
|
||||
payload: {
|
||||
sessionId,
|
||||
data: String(message.data || ""),
|
||||
},
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === "resize") {
|
||||
agent.send(JSON.stringify({
|
||||
sendJson(agent, {
|
||||
type: "RESIZE_ROOT_SHELL",
|
||||
payload: {
|
||||
sessionId,
|
||||
cols: Number(message.cols || 0),
|
||||
rows: Number(message.rows || 0),
|
||||
},
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === "close") {
|
||||
agent.send(JSON.stringify({
|
||||
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.
|
||||
}
|
||||
});
|
||||
|
||||
@@ -365,11 +703,20 @@ export function createBrokerServer(options = {}) {
|
||||
if (agents.get(String(ws.gatewayId)) === ws) {
|
||||
agents.delete(String(ws.gatewayId));
|
||||
}
|
||||
markBrowserSessionsClosed(String(ws.gatewayId), "agent_disconnected");
|
||||
markGatewayShellSessionsClosed(String(ws.gatewayId), "agent_disconnected");
|
||||
broadcastGatewayEvent(String(ws.gatewayId), {
|
||||
type: "presence.changed",
|
||||
gatewayId: String(ws.gatewayId),
|
||||
status: "disconnected",
|
||||
reason: closeReason || "agent_disconnected",
|
||||
});
|
||||
reportGatewayPresence(String(ws.gatewayId), {
|
||||
status: "disconnected",
|
||||
connectionId: ws.connectionId || null,
|
||||
reason: closeReason || "agent_disconnected",
|
||||
metadata: {
|
||||
agent_instance_id: ws.agentInstanceId || null,
|
||||
},
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
@@ -378,15 +725,23 @@ export function createBrokerServer(options = {}) {
|
||||
const sessionId = String(ws.sessionInfo.id);
|
||||
const agent = agents.get(String(ws.sessionInfo.gateway_id));
|
||||
if (agent && agent.readyState === 1) {
|
||||
agent.send(JSON.stringify({
|
||||
sendJson(agent, {
|
||||
type: "CLOSE_ROOT_SHELL",
|
||||
payload: { sessionId },
|
||||
}));
|
||||
});
|
||||
}
|
||||
const sessionRecord = browserSessions.get(sessionId);
|
||||
const sessionRecord = browserShellSessions.get(sessionId);
|
||||
if (sessionRecord) {
|
||||
await closeBrowserSession(sessionRecord, sessionRecord.closedReason || "browser_closed");
|
||||
browserSessions.delete(sessionId);
|
||||
await closeBrowserShellSession(sessionRecord, sessionRecord.closedReason || "browser_closed");
|
||||
browserShellSessions.delete(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws.streamSessionInfo) {
|
||||
const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id));
|
||||
if (sessionRecord) {
|
||||
removeGatewayStreamSession(sessionRecord);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -404,7 +759,10 @@ export function createBrokerServer(options = {}) {
|
||||
for (const agent of agents.values()) {
|
||||
agent.terminate();
|
||||
}
|
||||
for (const session of browserSessions.values()) {
|
||||
for (const session of browserShellSessions.values()) {
|
||||
session.ws.terminate();
|
||||
}
|
||||
for (const session of browserStreamSessions.values()) {
|
||||
session.ws.terminate();
|
||||
}
|
||||
for (const pending of pendingCommands.values()) {
|
||||
@@ -426,10 +784,52 @@ export function createBrokerServer(options = {}) {
|
||||
},
|
||||
state: {
|
||||
agents,
|
||||
browserSessions,
|
||||
browserShellSessions,
|
||||
browserStreamSessions,
|
||||
gatewayStreamSessions,
|
||||
pendingCommands,
|
||||
managerUrl,
|
||||
authMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -215,3 +215,168 @@ test("broker closes browser shell sessions when the agent disconnects before she
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker syncs queued gateway backlog on agent connect and manual sync", async () => {
|
||||
const backlogRequests = [];
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
sharedSecret: "secret",
|
||||
requestGatewayBacklog: async (gatewayId, payload) => {
|
||||
backlogRequests.push({ gatewayId, payload });
|
||||
return {
|
||||
dispatch: [
|
||||
{
|
||||
type: "TASK_DISPATCH",
|
||||
taskType: "OPERATION",
|
||||
operation: {
|
||||
id: 91,
|
||||
type: "DISCOVERY",
|
||||
request: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const agent = new WebSocket(
|
||||
`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token&agentInstanceId=instance-1`
|
||||
);
|
||||
const messages = collectMessages(agent);
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
|
||||
await waitFor(
|
||||
() => messages.some((message) => message.type === "TASK_DISPATCH" && message.operation?.id === 91),
|
||||
{ description: "initial backlog dispatch" }
|
||||
);
|
||||
assert.equal(backlogRequests.length, 1);
|
||||
assert.equal(backlogRequests[0].gatewayId, "701");
|
||||
assert.equal(backlogRequests[0].payload.agent_instance_id, "instance-1");
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/gateways/701/sync`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-edge-broker-secret": "secret",
|
||||
},
|
||||
body: JSON.stringify({ gatewayId: 701 }),
|
||||
});
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(json.ok, true);
|
||||
await waitFor(() => backlogRequests.length >= 2, { description: "manual sync backlog request" });
|
||||
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker fans out telemetry, task, log, and presence updates to browser gateway streams", async () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
|
||||
validateBrowserStream: async () => ({
|
||||
id: "stream-1",
|
||||
gateway_id: "701",
|
||||
scopes: ["overview", "tasks", "logs", "statistics"],
|
||||
}),
|
||||
ingestTelemetry: async (_gatewayId, payload) => ({ gateway: { id: 701, metadata: payload.metadata || {} } }),
|
||||
ingestTaskEvent: async (_gatewayId, operationId, payload) => ({
|
||||
id: operationId,
|
||||
status: "IN_PROGRESS",
|
||||
latest_event: payload,
|
||||
}),
|
||||
ingestLogEntry: async (_gatewayId, payload) => ({
|
||||
id: 5001,
|
||||
...payload,
|
||||
}),
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-gateway-stream?token=stream-token`);
|
||||
const browserMessages = collectMessages(browser);
|
||||
await new Promise((resolve) => browser.once("open", resolve));
|
||||
|
||||
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
|
||||
agent.send(
|
||||
JSON.stringify({
|
||||
type: "TELEMETRY",
|
||||
payload: {
|
||||
status: "ONLINE",
|
||||
metadata: {
|
||||
broker_connected: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
agent.send(
|
||||
JSON.stringify({
|
||||
type: "TASK_EVENT",
|
||||
operationId: 41,
|
||||
payload: {
|
||||
level: "INFO",
|
||||
code: "DISCOVERY_RUNNING",
|
||||
message: "Discovery is running",
|
||||
},
|
||||
})
|
||||
);
|
||||
agent.send(
|
||||
JSON.stringify({
|
||||
type: "LOG_FRAME",
|
||||
payload: {
|
||||
level: "INFO",
|
||||
stream: "agent",
|
||||
source: "EDGE_AGENT",
|
||||
message: "Gateway heartbeat acknowledged",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => browserMessages.some((message) => message.type === "presence.changed" && message.status === "connected"),
|
||||
{ description: "presence update" }
|
||||
);
|
||||
assert.ok(browserMessages.some((message) => message.type === "gateway.telemetry"));
|
||||
assert.ok(browserMessages.some((message) => message.type === "stats.updated"));
|
||||
assert.ok(browserMessages.some((message) => message.type === "task.updated" && message.operationId === 41));
|
||||
assert.ok(browserMessages.some((message) => message.type === "log.append" && /heartbeat/.test(message.entry?.message)));
|
||||
|
||||
browser.terminate();
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -15,23 +15,60 @@ 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"),
|
||||
].join("\n");
|
||||
|
||||
function readComposeServiceBlock(composeSource, serviceName) {
|
||||
const escapedServiceName = serviceName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const servicePattern = new RegExp(
|
||||
`^\\s{2}${escapedServiceName}:\\n([\\s\\S]*?)(?=^\\s{2}[A-Za-z0-9_-]+:|^volumes:|^networks:|\\Z)`,
|
||||
"m"
|
||||
);
|
||||
const match = composeSource.match(servicePattern);
|
||||
assert.ok(match, `Expected docker compose service block for ${serviceName}`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
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", () => {
|
||||
@@ -40,3 +77,12 @@ test("php services receive broker websocket environment defaults", () => {
|
||||
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev\}/);
|
||||
}
|
||||
});
|
||||
|
||||
test("base docker compose wires the broker into each php worker", () => {
|
||||
for (const serviceName of ["php1", "php2", "php3", "php4", "php5", "php-staging", "php-cron"]) {
|
||||
const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName);
|
||||
assert.match(serviceBlock, /\n\s+depends_on:\s*\n[\s\S]*?\n\s+- edge-broker/);
|
||||
assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
|
||||
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev\}/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
|
||||
class edge_gateway_view_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 listGateways(?int $departmentId = null, bool $includeDetail = true): array
|
||||
{
|
||||
return $this->manager()->listGateways($departmentId, $includeDetail);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $gateways
|
||||
* @return array<string,mixed>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array
|
||||
{
|
||||
return $this->manager()->buildFleetUsageStatistics($departmentId, $gateways);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getGateway(int $gatewayId): array
|
||||
{
|
||||
return $this->manager()->getGateway($gatewayId);
|
||||
}
|
||||
|
||||
private function manager(): edge_gateway_manager
|
||||
{
|
||||
return $this->manager ?? new edge_gateway_manager();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/interfaces/universal_module_i.php';
|
||||
require_once WD . '/modules/edgegateway/edgegateway_c.php';
|
||||
|
||||
use Exception;
|
||||
use interfaces\universal_module_i;
|
||||
use modules\edgegateway\edgegateway_c;
|
||||
|
||||
class edgegateway implements universal_module_i
|
||||
{
|
||||
public edgegateway_c $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = new edgegateway_c();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function requireModuleEnabled(): void
|
||||
{
|
||||
if (!$this->config->enabled->isTrue()) {
|
||||
throw new Exception('The edge gateway module is not enabled');
|
||||
}
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
try {
|
||||
return $this->config->enabled->isTrue();
|
||||
} catch (Exception $exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function defaultReleaseChannel(): string
|
||||
{
|
||||
$configured = trim((string)$this->config->default_release_channel->getVariableValue());
|
||||
return $configured !== '' ? $configured : 'stable';
|
||||
}
|
||||
|
||||
public function defaultUpdateWindow(): string
|
||||
{
|
||||
$configured = trim((string)$this->config->default_update_window->getVariableValue());
|
||||
return $configured !== '' ? $configured : '02:00-04:00';
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/interfaces/shelly_transport_i.php';
|
||||
require_once WD . '/classes/edge_gateway_manager.php';
|
||||
|
||||
use Exception;
|
||||
use interfaces\shelly_transport_i;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/interfaces/shelly_transport_i.php';
|
||||
require_once WD . '/classes/edge_gateway_manager.php';
|
||||
require_once WD . '/classes/cloud_shelly_transport.php';
|
||||
require_once WD . '/classes/gateway_shelly_transport.php';
|
||||
|
||||
|
||||
@@ -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);\""
|
||||
|
||||
@@ -108,6 +108,9 @@ spl_autoload_register(function (string $class): void {
|
||||
// 1. Core folders: classes, interfaces, traits, objects, statistics
|
||||
$core_folders = ['classes', 'interfaces', 'traits', 'objects', 'statistics'];
|
||||
if (in_array($top, $core_folders)) {
|
||||
if ($top === 'classes' && str_starts_with(strtolower($relative), 'edge_gateway_')) {
|
||||
$candidates[] = $base . 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . $relative;
|
||||
}
|
||||
$candidates[] = $base . $top . DIRECTORY_SEPARATOR . $relative;
|
||||
}
|
||||
// 2. Modules folder: explicitly starting with 'modules'
|
||||
@@ -211,5 +214,47 @@ if ((preg_match('/\.(jpg|jpeg|png)$/', $_SERVER['REQUEST_URI']) || str_contains(
|
||||
exit;
|
||||
}
|
||||
|
||||
// Load enabled module routes before the global route scan.
|
||||
$load_enabled_module_routes = static function (): void {
|
||||
$modules_path = WD . DIRECTORY_SEPARATOR . 'modules';
|
||||
if (!is_dir($modules_path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$module_dirs = array_filter(scandir($modules_path), static function (string $item) use ($modules_path): bool {
|
||||
return $item !== '.' && $item !== '..' && is_dir($modules_path . DIRECTORY_SEPARATOR . $item);
|
||||
});
|
||||
|
||||
foreach ($module_dirs as $module_dir) {
|
||||
$routes_path = $modules_path . DIRECTORY_SEPARATOR . $module_dir . DIRECTORY_SEPARATOR . 'routes';
|
||||
if (!is_dir($routes_path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$module_class = 'classes\\' . $module_dir;
|
||||
if (!class_exists($module_class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$module = new $module_class();
|
||||
if (method_exists($module, 'isEnabled') && !$module->isEnabled()) {
|
||||
continue;
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (scandir($routes_path) as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
require_once $routes_path . DIRECTORY_SEPARATOR . $file;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$load_enabled_module_routes();
|
||||
|
||||
// Autoload all the routes
|
||||
$router->auto_load_routes(WD . '/routes');
|
||||
|
||||
@@ -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")
|
||||
|
||||
+900
@@ -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();
|
||||
}
|
||||
}
|
||||
+2
@@ -9,9 +9,11 @@ class edge_gateway_install_service
|
||||
private const ARTIFACTS = [
|
||||
'agent.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
'lan-worker.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
'auto-updater.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
'docker-compose.gateway.yml' => 'text/yaml; charset=utf-8',
|
||||
'Dockerfile.edge-agent' => 'text/plain; charset=utf-8',
|
||||
'Dockerfile.lan-worker' => 'text/plain; charset=utf-8',
|
||||
'Dockerfile.auto-updater' => 'text/plain; charset=utf-8',
|
||||
'gateway-launcher.sh' => 'text/x-shellscript; charset=utf-8',
|
||||
'truckwash-edge-gateway-stack.service' => 'text/plain; charset=utf-8',
|
||||
'truckwash-edge-agent.service' => 'text/plain; charset=utf-8',
|
||||
+1712
-96
File diff suppressed because it is too large
Load Diff
+633
-45
@@ -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;
|
||||
@@ -15,6 +16,8 @@ class edge_gateway_operation_service
|
||||
|
||||
public const STATUS_PENDING = 'PENDING';
|
||||
public const STATUS_IN_PROGRESS = 'IN_PROGRESS';
|
||||
public const STATUS_CANCEL_REQUESTED = 'CANCEL_REQUESTED';
|
||||
public const STATUS_CANCELLED = 'CANCELLED';
|
||||
public const STATUS_COMPLETED = 'COMPLETED';
|
||||
public const STATUS_FAILED = 'FAILED';
|
||||
|
||||
@@ -29,6 +32,7 @@ class edge_gateway_operation_service
|
||||
public const ERROR_UNSUPPORTED_VERSION = 'EDGE_GATEWAY_UNSUPPORTED_VERSION';
|
||||
public const ERROR_CONFLICT = 'EDGE_GATEWAY_CONFLICT';
|
||||
public const ERROR_VALIDATION = 'EDGE_GATEWAY_VALIDATION_FAILED';
|
||||
public const ERROR_CANCELLED = 'EDGE_GATEWAY_CANCELLED';
|
||||
|
||||
public const POLL_INTERVAL_MICROSECONDS = 250000;
|
||||
public const OPERATION_TIMEOUT_SECONDS = 900;
|
||||
@@ -102,8 +106,8 @@ class edge_gateway_operation_service
|
||||
FROM edge_gateway_operations
|
||||
WHERE gateway_id = :gateway_id
|
||||
AND deleted_at IS NULL
|
||||
AND status IN ('PENDING', 'IN_PROGRESS')
|
||||
ORDER BY FIELD(status, 'IN_PROGRESS', 'PENDING'), id ASC
|
||||
AND status IN ('PENDING', 'IN_PROGRESS', 'CANCEL_REQUESTED')
|
||||
ORDER BY FIELD(status, 'IN_PROGRESS', 'CANCEL_REQUESTED', 'PENDING'), id ASC
|
||||
LIMIT 1"
|
||||
);
|
||||
$statement->execute([':gateway_id' => $gatewayId]);
|
||||
@@ -126,8 +130,11 @@ class edge_gateway_operation_service
|
||||
'total' => count($operations),
|
||||
'pending' => 0,
|
||||
'in_progress' => 0,
|
||||
'cancel_requested' => 0,
|
||||
'cancelled' => 0,
|
||||
'completed' => 0,
|
||||
'failed' => 0,
|
||||
'latest_cancelled_at' => null,
|
||||
'latest_completed_at' => null,
|
||||
'latest_failed_at' => null,
|
||||
'latest_type' => $operations[0]['type'] ?? null,
|
||||
@@ -140,6 +147,11 @@ class edge_gateway_operation_service
|
||||
$summary['pending'] += 1;
|
||||
} elseif ($status === self::STATUS_IN_PROGRESS) {
|
||||
$summary['in_progress'] += 1;
|
||||
} elseif ($status === self::STATUS_CANCEL_REQUESTED) {
|
||||
$summary['cancel_requested'] += 1;
|
||||
} elseif ($status === self::STATUS_CANCELLED) {
|
||||
$summary['cancelled'] += 1;
|
||||
$summary['latest_cancelled_at'] ??= $operation['completed_at'] ?? null;
|
||||
} elseif ($status === self::STATUS_COMPLETED) {
|
||||
$summary['completed'] += 1;
|
||||
$summary['latest_completed_at'] ??= $operation['completed_at'] ?? null;
|
||||
@@ -226,6 +238,9 @@ class edge_gateway_operation_service
|
||||
['operation_id' => $operationId, 'type' => $type]
|
||||
);
|
||||
|
||||
$this->refreshGatewayViewCache($gatewayId);
|
||||
$this->manager()->notifyBrokerGatewaySync($gatewayId);
|
||||
|
||||
return $this->serializeOperation((new edge_gateway_operations_o())->select($operationId), true);
|
||||
}
|
||||
|
||||
@@ -237,6 +252,67 @@ class edge_gateway_operation_service
|
||||
return $this->queueOperation($gatewayId, self::TYPE_DISCOVERY, [], $requestedBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function cancelOperation(int $gatewayId, int $operationId, ?int $requestedBy = null): array
|
||||
{
|
||||
$gateway = $this->requireGateway($gatewayId);
|
||||
$operation = $this->requireOperation($gatewayId, $operationId);
|
||||
$status = (string)$operation->status->value();
|
||||
|
||||
if (in_array($status, [self::STATUS_COMPLETED, self::STATUS_FAILED, self::STATUS_CANCELLED], true)) {
|
||||
return $this->serializeOperation($operation, true);
|
||||
}
|
||||
|
||||
$message = 'Operation cancelled by operator';
|
||||
if ($status === self::STATUS_PENDING) {
|
||||
$this->markOperationCancelled(
|
||||
$gatewayId,
|
||||
$operation,
|
||||
$message,
|
||||
'OPERATION_CANCELLED',
|
||||
['requested_by' => $requestedBy]
|
||||
);
|
||||
$this->manager()->logGatewayAudit(
|
||||
$gatewayId,
|
||||
(int)$gateway->department_id->value(),
|
||||
'GATEWAY_OPERATION_CANCELLED',
|
||||
$requestedBy,
|
||||
['operation_id' => $operationId, 'type' => (string)$operation->type->value()]
|
||||
);
|
||||
} elseif ($status === self::STATUS_IN_PROGRESS) {
|
||||
$operation->status->set(self::STATUS_CANCEL_REQUESTED);
|
||||
$operation->error_code->set(self::ERROR_CANCELLED);
|
||||
$operation->error_message->set('Operation cancellation requested by operator');
|
||||
$operation->last_progress_at->set($this->now());
|
||||
$summary = (array)($operation->summary_json->value() ?? []);
|
||||
$summary['label'] = 'Cancellation requested';
|
||||
$summary['retryable'] = true;
|
||||
$operation->summary_json->set($summary);
|
||||
$this->appendEventRecord(
|
||||
$gatewayId,
|
||||
$operationId,
|
||||
self::LEVEL_WARNING,
|
||||
'OPERATION_CANCEL_REQUESTED',
|
||||
'Operation cancellation requested by operator',
|
||||
['requested_by' => $requestedBy]
|
||||
);
|
||||
$this->manager()->logGatewayAudit(
|
||||
$gatewayId,
|
||||
(int)$gateway->department_id->value(),
|
||||
'GATEWAY_OPERATION_CANCEL_REQUESTED',
|
||||
$requestedBy,
|
||||
['operation_id' => $operationId, 'type' => (string)$operation->type->value()]
|
||||
);
|
||||
}
|
||||
|
||||
$this->refreshGatewayViewCache($gatewayId);
|
||||
$this->manager()->notifyBrokerGatewaySync($gatewayId);
|
||||
|
||||
return $this->serializeOperation($operation, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -278,6 +354,48 @@ class edge_gateway_operation_service
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function claimBrokerOperation(int $gatewayId, ?string $agentInstanceId = null): ?array
|
||||
{
|
||||
$this->requireGateway($gatewayId);
|
||||
$this->failTimedOutOperations($gatewayId);
|
||||
return $this->claimPendingOperation($gatewayId, $this->normalizeAgentInstanceId($agentInstanceId, $gatewayId));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function listBrokerCancellationRequests(int $gatewayId, ?string $agentInstanceId = null): array
|
||||
{
|
||||
$this->requireGateway($gatewayId);
|
||||
$rows = (new edge_gateway_operations_o())->getFieldsWhere([
|
||||
'gateway_id' => $gatewayId,
|
||||
'status' => self::STATUS_CANCEL_REQUESTED,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
|
||||
$requestedInstance = $this->normalizeAgentInstanceId($agentInstanceId, $gatewayId);
|
||||
$operations = [];
|
||||
foreach ($rows as $row) {
|
||||
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
|
||||
if (!$operation->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$claimedBy = trim((string)($operation->agent_instance_id->value() ?? ''));
|
||||
if ($claimedBy !== '' && $requestedInstance !== '' && $claimedBy !== $requestedInstance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$operations[] = $this->serializeOperation($operation, true);
|
||||
}
|
||||
|
||||
return $operations;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -290,8 +408,13 @@ class edge_gateway_operation_service
|
||||
}
|
||||
|
||||
$operation = $this->requireOperation($gatewayId, $operationId);
|
||||
if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
|
||||
return $this->serializeOperation($operation, true);
|
||||
if (in_array((string)$operation->status->value(), [
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_CANCEL_REQUESTED,
|
||||
self::STATUS_CANCELLED,
|
||||
], true)) {
|
||||
return $this->serializePersistedOperation($operationId, true);
|
||||
}
|
||||
|
||||
$level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO)));
|
||||
@@ -320,7 +443,23 @@ class edge_gateway_operation_service
|
||||
$operation->summary_json->set($summary);
|
||||
$this->refreshOperationLease($operation);
|
||||
|
||||
return $this->serializeOperation($operation, true);
|
||||
if ($level === self::LEVEL_ERROR) {
|
||||
$this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context);
|
||||
}
|
||||
|
||||
$this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
|
||||
$this->clearGatewayViewCache($gatewayId);
|
||||
|
||||
return $this->serializePersistedOperation($operationId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function appendBrokerOperationEvent(int $gatewayId, int $operationId, array $payload): array
|
||||
{
|
||||
$this->requireGateway($gatewayId);
|
||||
return $this->appendOperationEventWithoutAuthentication($gatewayId, $operationId, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -335,47 +474,97 @@ class edge_gateway_operation_service
|
||||
}
|
||||
|
||||
$operation = $this->requireOperation($gatewayId, $operationId);
|
||||
if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
|
||||
return $this->serializeOperation($operation, true);
|
||||
if (in_array((string)$operation->status->value(), [
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_CANCELLED,
|
||||
], true)) {
|
||||
return $this->serializePersistedOperation($operationId, true);
|
||||
}
|
||||
|
||||
$ok = (bool)($payload['ok'] ?? false);
|
||||
$result = isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [];
|
||||
$errorMessage = trim((string)($payload['error_message'] ?? $payload['error'] ?? ''));
|
||||
$errorCode = trim((string)($payload['error_code'] ?? ''));
|
||||
$status = (string)$operation->status->value();
|
||||
if ($status === self::STATUS_CANCEL_REQUESTED && !$ok && $errorCode === '') {
|
||||
$errorCode = self::ERROR_CANCELLED;
|
||||
}
|
||||
if (!$ok && $errorCode === '') {
|
||||
$errorCode = $this->classifyCompletionError($gatewayId, $errorMessage);
|
||||
}
|
||||
if (!$ok && $errorMessage === '') {
|
||||
$errorMessage = 'Gateway operation failed';
|
||||
$errorMessage = $errorCode === self::ERROR_CANCELLED
|
||||
? 'Gateway operation cancelled'
|
||||
: 'Gateway operation failed';
|
||||
}
|
||||
|
||||
$operation->status->set($ok ? self::STATUS_COMPLETED : self::STATUS_FAILED);
|
||||
$finalStatus = $ok
|
||||
? self::STATUS_COMPLETED
|
||||
: (($status === self::STATUS_CANCEL_REQUESTED && $errorCode === self::ERROR_CANCELLED)
|
||||
? self::STATUS_CANCELLED
|
||||
: self::STATUS_FAILED);
|
||||
$operation->status->set($finalStatus);
|
||||
$operation->result_json->set($result);
|
||||
$operation->error_code->set($ok ? null : $errorCode);
|
||||
$operation->error_code->set($ok ? null : ($finalStatus === self::STATUS_CANCELLED ? self::ERROR_CANCELLED : $errorCode));
|
||||
$operation->error_message->set($ok ? null : $errorMessage);
|
||||
$operation->completed_at->set($this->now());
|
||||
$operation->lease_expires_at->set(null);
|
||||
$operation->last_progress_at->set($this->now());
|
||||
|
||||
$summary = (array)($operation->summary_json->value() ?? []);
|
||||
$summary['label'] = $ok ? 'Completed' : 'Failed';
|
||||
$summary['progress'] = 100;
|
||||
$summary['retryable'] = !$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION;
|
||||
$summary['label'] = match ($finalStatus) {
|
||||
self::STATUS_COMPLETED => 'Completed',
|
||||
self::STATUS_CANCELLED => 'Cancelled',
|
||||
default => 'Failed',
|
||||
};
|
||||
$summary['progress'] = $finalStatus === self::STATUS_CANCELLED
|
||||
? max(0, min(100, (int)($summary['progress'] ?? 0)))
|
||||
: 100;
|
||||
$summary['retryable'] = $finalStatus === self::STATUS_CANCELLED
|
||||
? true
|
||||
: (!$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION);
|
||||
$operation->summary_json->set($summary);
|
||||
|
||||
$this->appendEventRecord(
|
||||
$gatewayId,
|
||||
$operationId,
|
||||
$ok ? self::LEVEL_INFO : self::LEVEL_ERROR,
|
||||
$ok ? 'OPERATION_COMPLETED' : $errorCode,
|
||||
$ok ? 'Operation completed successfully' : $errorMessage,
|
||||
$finalStatus === self::STATUS_COMPLETED
|
||||
? self::LEVEL_INFO
|
||||
: ($finalStatus === self::STATUS_CANCELLED ? self::LEVEL_WARNING : self::LEVEL_ERROR),
|
||||
$finalStatus === self::STATUS_COMPLETED
|
||||
? 'OPERATION_COMPLETED'
|
||||
: ($finalStatus === self::STATUS_CANCELLED ? 'OPERATION_CANCELLED' : $errorCode),
|
||||
$finalStatus === self::STATUS_COMPLETED
|
||||
? 'Operation completed successfully'
|
||||
: $errorMessage,
|
||||
$result
|
||||
);
|
||||
|
||||
$this->applyCompletionSideEffects($gatewayId, $operation, $ok, $result, $errorCode, $errorMessage);
|
||||
if ($finalStatus !== self::STATUS_CANCELLED) {
|
||||
$this->applyCompletionSideEffects(
|
||||
$gatewayId,
|
||||
$operation,
|
||||
$ok,
|
||||
$result,
|
||||
$errorCode,
|
||||
$errorMessage
|
||||
);
|
||||
}
|
||||
$this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
|
||||
$this->clearGatewayViewCache($gatewayId);
|
||||
$this->manager()->notifyBrokerGatewaySync($gatewayId);
|
||||
|
||||
return $this->serializeOperation($operation, true);
|
||||
return $this->serializePersistedOperation($operationId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function completeBrokerOperation(int $gatewayId, int $operationId, array $payload): array
|
||||
{
|
||||
$this->requireGateway($gatewayId);
|
||||
return $this->completeOperationWithoutAuthentication($gatewayId, $operationId, $payload);
|
||||
}
|
||||
|
||||
private static function normalizeOperationType(string $type): string
|
||||
@@ -446,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
|
||||
@@ -463,34 +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,
|
||||
]
|
||||
);
|
||||
|
||||
return $this->serializeOperation($operation, true);
|
||||
$this->clearGatewayViewCache($gatewayId, $pdo);
|
||||
|
||||
return $this->serializeOperationRecord($operation, true);
|
||||
} catch (\Throwable $throwable) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
@@ -504,28 +728,51 @@ class edge_gateway_operation_service
|
||||
*/
|
||||
private function failTimedOutOperations(int $gatewayId): void
|
||||
{
|
||||
$rows = (new edge_gateway_operations_o())->getFieldsWhere([
|
||||
'gateway_id' => $gatewayId,
|
||||
'status' => self::STATUS_IN_PROGRESS,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
$statement = db::getPDO()->prepare(
|
||||
"SELECT id
|
||||
FROM edge_gateway_operations
|
||||
WHERE gateway_id = :gateway_id
|
||||
AND deleted_at IS NULL
|
||||
AND status IN ('IN_PROGRESS', 'CANCEL_REQUESTED')"
|
||||
);
|
||||
$statement->execute([':gateway_id' => $gatewayId]);
|
||||
$rows = $statement->fetchAll();
|
||||
|
||||
$now = time();
|
||||
foreach ($rows as $row) {
|
||||
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
|
||||
$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
|
||||
$status = (string)$operation->status->value();
|
||||
$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) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status === self::STATUS_CANCEL_REQUESTED) {
|
||||
$this->markOperationCancelled(
|
||||
$gatewayId,
|
||||
$operation,
|
||||
'Gateway did not acknowledge cancellation before the operation lease expired',
|
||||
'OPERATION_CANCELLED',
|
||||
[
|
||||
'agent_instance_id' => $operation->agent_instance_id->value(),
|
||||
'last_progress_at' => $operation->last_progress_at->value(),
|
||||
]
|
||||
);
|
||||
$this->refreshGatewayViewCache($gatewayId);
|
||||
$this->manager()->notifyBrokerGatewaySync($gatewayId);
|
||||
continue;
|
||||
}
|
||||
|
||||
$errorMessage = $leaseExpired
|
||||
? 'Gateway stopped reporting operation progress before the lease expired'
|
||||
: 'Gateway operation timed out';
|
||||
@@ -551,6 +798,9 @@ class edge_gateway_operation_service
|
||||
'last_progress_at' => $operation->last_progress_at->value(),
|
||||
]
|
||||
);
|
||||
|
||||
$this->refreshGatewayViewCache($gatewayId);
|
||||
$this->manager()->notifyBrokerGatewaySync($gatewayId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,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,
|
||||
@@ -730,6 +990,141 @@ class edge_gateway_operation_service
|
||||
$operation->lease_expires_at->set($this->leaseExpiry());
|
||||
}
|
||||
|
||||
private function refreshGatewayViewCache(int $gatewayId): void
|
||||
{
|
||||
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
|
||||
*/
|
||||
@@ -749,6 +1144,9 @@ class edge_gateway_operation_service
|
||||
}
|
||||
|
||||
$normalizedMessage = strtolower(trim($errorMessage));
|
||||
if (str_contains($normalizedMessage, 'cancel')) {
|
||||
return self::ERROR_CANCELLED;
|
||||
}
|
||||
if (str_contains($normalizedMessage, 'version')) {
|
||||
return self::ERROR_UNSUPPORTED_VERSION;
|
||||
}
|
||||
@@ -766,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
|
||||
@@ -783,4 +1181,194 @@ class edge_gateway_operation_service
|
||||
|
||||
return substr($candidate, 0, 128);
|
||||
}
|
||||
|
||||
private function markOperationFailedFromEvent(
|
||||
int $gatewayId,
|
||||
edge_gateway_operations_o $operation,
|
||||
?string $code,
|
||||
string $message,
|
||||
array $context
|
||||
): void {
|
||||
$operation->status->set(self::STATUS_FAILED);
|
||||
$operation->error_code->set($code !== null && trim($code) !== '' ? trim($code) : self::ERROR_VALIDATION);
|
||||
$operation->error_message->set($message);
|
||||
$operation->completed_at->set($this->now());
|
||||
$operation->lease_expires_at->set(null);
|
||||
$summary = (array)($operation->summary_json->value() ?? []);
|
||||
$summary['label'] = 'Failed';
|
||||
$summary['retryable'] = ((string)$operation->error_code->value()) !== self::ERROR_UNSUPPORTED_VERSION;
|
||||
if (isset($context['progress'])) {
|
||||
$summary['progress'] = max(0, min(100, (int)$context['progress']));
|
||||
}
|
||||
$operation->summary_json->set($summary);
|
||||
}
|
||||
|
||||
private function markOperationCancelled(
|
||||
int $gatewayId,
|
||||
edge_gateway_operations_o $operation,
|
||||
string $message,
|
||||
string $eventCode,
|
||||
array $context = []
|
||||
): void {
|
||||
$operation->status->set(self::STATUS_CANCELLED);
|
||||
$operation->error_code->set(self::ERROR_CANCELLED);
|
||||
$operation->error_message->set($message);
|
||||
$operation->completed_at->set($this->now());
|
||||
$operation->lease_expires_at->set(null);
|
||||
$operation->last_progress_at->set($this->now());
|
||||
$summary = (array)($operation->summary_json->value() ?? []);
|
||||
$summary['label'] = 'Cancelled';
|
||||
$summary['retryable'] = true;
|
||||
$summary['progress'] = max(0, min(100, (int)($summary['progress'] ?? 0)));
|
||||
$operation->summary_json->set($summary);
|
||||
$this->appendEventRecord(
|
||||
$gatewayId,
|
||||
(int)$operation->id,
|
||||
self::LEVEL_WARNING,
|
||||
$eventCode,
|
||||
$message,
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function appendOperationEventWithoutAuthentication(int $gatewayId, int $operationId, array $payload): array
|
||||
{
|
||||
$operation = $this->requireOperation($gatewayId, $operationId);
|
||||
if (in_array((string)$operation->status->value(), [
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_CANCEL_REQUESTED,
|
||||
self::STATUS_CANCELLED,
|
||||
], true)) {
|
||||
return $this->serializePersistedOperation($operationId, true);
|
||||
}
|
||||
|
||||
$level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO)));
|
||||
if (!in_array($level, [self::LEVEL_INFO, self::LEVEL_WARNING, self::LEVEL_ERROR], true)) {
|
||||
$level = self::LEVEL_INFO;
|
||||
}
|
||||
|
||||
$message = trim((string)($payload['message'] ?? 'Operation event received'));
|
||||
if ($message === '') {
|
||||
$message = 'Operation event received';
|
||||
}
|
||||
|
||||
$code = isset($payload['code']) ? trim((string)$payload['code']) : null;
|
||||
$context = isset($payload['context']) && is_array($payload['context']) ? (array)$payload['context'] : [];
|
||||
$this->appendEventRecord($gatewayId, $operationId, $level, $code, $message, $context);
|
||||
|
||||
$summary = (array)($operation->summary_json->value() ?? []);
|
||||
$summary['last_event_at'] = $this->now();
|
||||
$summary['last_event_message'] = $message;
|
||||
if (isset($context['progress'])) {
|
||||
$summary['progress'] = max(0, min(100, (int)$context['progress']));
|
||||
}
|
||||
if (isset($context['label']) && trim((string)$context['label']) !== '') {
|
||||
$summary['label'] = trim((string)$context['label']);
|
||||
}
|
||||
$operation->summary_json->set($summary);
|
||||
$this->refreshOperationLease($operation);
|
||||
|
||||
if ($level === self::LEVEL_ERROR) {
|
||||
$this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context);
|
||||
}
|
||||
|
||||
$this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
|
||||
$this->clearGatewayViewCache($gatewayId);
|
||||
|
||||
return $this->serializePersistedOperation($operationId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function completeOperationWithoutAuthentication(int $gatewayId, int $operationId, array $payload): array
|
||||
{
|
||||
$operation = $this->requireOperation($gatewayId, $operationId);
|
||||
if (in_array((string)$operation->status->value(), [
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_CANCELLED,
|
||||
], true)) {
|
||||
return $this->serializePersistedOperation($operationId, true);
|
||||
}
|
||||
|
||||
$ok = (bool)($payload['ok'] ?? false);
|
||||
$result = isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [];
|
||||
$errorMessage = trim((string)($payload['error_message'] ?? $payload['error'] ?? ''));
|
||||
$errorCode = trim((string)($payload['error_code'] ?? ''));
|
||||
$status = (string)$operation->status->value();
|
||||
if ($status === self::STATUS_CANCEL_REQUESTED && !$ok && $errorCode === '') {
|
||||
$errorCode = self::ERROR_CANCELLED;
|
||||
}
|
||||
if (!$ok && $errorCode === '') {
|
||||
$errorCode = $this->classifyCompletionError($gatewayId, $errorMessage);
|
||||
}
|
||||
if (!$ok && $errorMessage === '') {
|
||||
$errorMessage = $errorCode === self::ERROR_CANCELLED
|
||||
? 'Gateway operation cancelled'
|
||||
: 'Gateway operation failed';
|
||||
}
|
||||
|
||||
$finalStatus = $ok
|
||||
? self::STATUS_COMPLETED
|
||||
: (($status === self::STATUS_CANCEL_REQUESTED && $errorCode === self::ERROR_CANCELLED)
|
||||
? self::STATUS_CANCELLED
|
||||
: self::STATUS_FAILED);
|
||||
$operation->status->set($finalStatus);
|
||||
$operation->result_json->set($result);
|
||||
$operation->error_code->set($ok ? null : ($finalStatus === self::STATUS_CANCELLED ? self::ERROR_CANCELLED : $errorCode));
|
||||
$operation->error_message->set($ok ? null : $errorMessage);
|
||||
$operation->completed_at->set($this->now());
|
||||
$operation->lease_expires_at->set(null);
|
||||
$operation->last_progress_at->set($this->now());
|
||||
|
||||
$summary = (array)($operation->summary_json->value() ?? []);
|
||||
$summary['label'] = match ($finalStatus) {
|
||||
self::STATUS_COMPLETED => 'Completed',
|
||||
self::STATUS_CANCELLED => 'Cancelled',
|
||||
default => 'Failed',
|
||||
};
|
||||
$summary['progress'] = $finalStatus === self::STATUS_CANCELLED
|
||||
? max(0, min(100, (int)($summary['progress'] ?? 0)))
|
||||
: 100;
|
||||
$summary['retryable'] = $finalStatus === self::STATUS_CANCELLED
|
||||
? true
|
||||
: (!$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION);
|
||||
$operation->summary_json->set($summary);
|
||||
|
||||
$this->appendEventRecord(
|
||||
$gatewayId,
|
||||
$operationId,
|
||||
$finalStatus === self::STATUS_COMPLETED
|
||||
? self::LEVEL_INFO
|
||||
: ($finalStatus === self::STATUS_CANCELLED ? self::LEVEL_WARNING : self::LEVEL_ERROR),
|
||||
$finalStatus === self::STATUS_COMPLETED
|
||||
? 'OPERATION_COMPLETED'
|
||||
: ($finalStatus === self::STATUS_CANCELLED ? 'OPERATION_CANCELLED' : $errorCode),
|
||||
$finalStatus === self::STATUS_COMPLETED
|
||||
? 'Operation completed successfully'
|
||||
: $errorMessage,
|
||||
$result
|
||||
);
|
||||
|
||||
if ($finalStatus !== self::STATUS_CANCELLED) {
|
||||
$this->applyCompletionSideEffects(
|
||||
$gatewayId,
|
||||
$operation,
|
||||
$ok,
|
||||
$result,
|
||||
$errorCode,
|
||||
$errorMessage
|
||||
);
|
||||
}
|
||||
$this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
|
||||
$this->clearGatewayViewCache($gatewayId);
|
||||
$this->manager()->notifyBrokerGatewaySync($gatewayId);
|
||||
|
||||
return $this->serializePersistedOperation($operationId, true);
|
||||
}
|
||||
}
|
||||
+16
@@ -24,6 +24,22 @@ class edge_gateway_registry_service
|
||||
return $this->manager()->verifyInstallToken($plainToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getInstallTokenStatus(int $claimTokenId): array
|
||||
{
|
||||
return $this->manager()->getInstallTokenStatus($claimTokenId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function reportInstallTokenStatus(string $plainToken, array $payload): array
|
||||
{
|
||||
return $this->manager()->reportInstallTokenStatus($plainToken, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
+109
@@ -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)
|
||||
@@ -181,6 +185,51 @@ class edge_gateway_schema_bootstrap
|
||||
INDEX idx_edge_gateway_audit_department (department_id),
|
||||
INDEX idx_edge_gateway_audit_action (action)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS edge_gateway_log_entries (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
gateway_id INT NOT NULL,
|
||||
department_id INT NULL,
|
||||
level VARCHAR(16) NOT NULL DEFAULT 'INFO',
|
||||
stream VARCHAR(32) NOT NULL DEFAULT 'agent',
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'BROKER',
|
||||
message TEXT NOT NULL,
|
||||
context_json JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_edge_gateway_log_entries_gateway (gateway_id),
|
||||
INDEX idx_edge_gateway_log_entries_department (department_id),
|
||||
INDEX idx_edge_gateway_log_entries_level (level),
|
||||
INDEX idx_edge_gateway_log_entries_stream (stream)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
gateway_id INT NOT NULL,
|
||||
department_id INT NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
session_token_hash CHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||
reason VARCHAR(255) NULL,
|
||||
connection_id VARCHAR(128) NULL,
|
||||
cwd VARCHAR(255) NULL,
|
||||
shell_command VARCHAR(255) NULL,
|
||||
shell_args_json JSON NULL,
|
||||
cols INT NULL,
|
||||
terminal_rows INT NULL,
|
||||
transcript LONGTEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
expires_at DATETIME NULL,
|
||||
approved_at DATETIME NULL,
|
||||
opened_at DATETIME NULL,
|
||||
closed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
UNIQUE KEY uniq_edge_gateway_shell_session_token_hash (session_token_hash),
|
||||
INDEX idx_edge_gateway_shell_sessions_gateway (gateway_id),
|
||||
INDEX idx_edge_gateway_shell_sessions_status (status),
|
||||
INDEX idx_edge_gateway_shell_sessions_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
@@ -209,13 +258,45 @@ 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");
|
||||
self::ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity');
|
||||
|
||||
self::ensureColumn('edge_gateway_log_entries', 'department_id', 'INT NULL AFTER gateway_id');
|
||||
self::ensureColumn('edge_gateway_log_entries', 'level', "VARCHAR(16) NOT NULL DEFAULT 'INFO' AFTER department_id");
|
||||
self::ensureColumn('edge_gateway_log_entries', 'stream', "VARCHAR(32) NOT NULL DEFAULT 'agent' AFTER level");
|
||||
self::ensureColumn('edge_gateway_log_entries', 'source', "VARCHAR(64) NOT NULL DEFAULT 'BROKER' AFTER stream");
|
||||
self::ensureColumn('edge_gateway_log_entries', 'message', 'TEXT NOT NULL AFTER source');
|
||||
self::ensureColumn('edge_gateway_log_entries', 'context_json', 'JSON NULL AFTER message');
|
||||
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'department_id', 'INT NOT NULL AFTER gateway_id');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'actor_user_id', 'INT NULL AFTER department_id');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'session_token_hash', 'CHAR(64) NOT NULL AFTER actor_user_id');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'status', "VARCHAR(32) NOT NULL DEFAULT 'PENDING' AFTER session_token_hash");
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'reason', 'VARCHAR(255) NULL AFTER status');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'connection_id', 'VARCHAR(128) NULL AFTER reason');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'cwd', 'VARCHAR(255) NULL AFTER connection_id');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'shell_command', 'VARCHAR(255) NULL AFTER cwd');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'shell_args_json', 'JSON NULL AFTER shell_command');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'cols', 'INT NULL AFTER shell_args_json');
|
||||
self::renameColumnIfPresent('edge_gateway_shell_sessions', 'rows', 'terminal_rows', 'INT NULL', 'cols');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'terminal_rows', 'INT NULL AFTER cols');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'transcript', 'LONGTEXT NULL AFTER terminal_rows');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'metadata_json', 'JSON NULL AFTER transcript');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'expires_at', 'DATETIME NULL AFTER metadata_json');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'approved_at', 'DATETIME NULL AFTER expires_at');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'opened_at', 'DATETIME NULL AFTER approved_at');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'closed_at', 'DATETIME NULL AFTER opened_at');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER updated_at');
|
||||
|
||||
self::syncOperationTypeColumns();
|
||||
|
||||
self::$initialized = true;
|
||||
@@ -239,6 +320,34 @@ class edge_gateway_schema_bootstrap
|
||||
);
|
||||
}
|
||||
|
||||
private static function renameColumnIfPresent(
|
||||
string $table,
|
||||
string $from,
|
||||
string $to,
|
||||
string $definition,
|
||||
?string $afterColumn = null
|
||||
): void {
|
||||
global $db;
|
||||
|
||||
if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)
|
||||
|| !preg_match('/^[A-Za-z0-9_]+$/', $from)
|
||||
|| !preg_match('/^[A-Za-z0-9_]+$/', $to)
|
||||
|| ($afterColumn !== null && !preg_match('/^[A-Za-z0-9_]+$/', $afterColumn))) {
|
||||
throw new \RuntimeException('Invalid schema bootstrap identifier');
|
||||
}
|
||||
|
||||
$positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`";
|
||||
|
||||
$db->query(
|
||||
"ALTER TABLE `$table`
|
||||
CHANGE COLUMN `$from` `$to` $definition$positionClause"
|
||||
);
|
||||
}
|
||||
|
||||
private static function tableHasColumn(string $table, string $column): bool
|
||||
{
|
||||
global $db;
|
||||
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Throwable;
|
||||
|
||||
class edge_gateway_view_cache
|
||||
{
|
||||
public const PREFIX = 'edge_gateway:view:v1:';
|
||||
|
||||
/**
|
||||
* Optional runtime adapter for tests.
|
||||
*/
|
||||
private static ?object $adapter = null;
|
||||
|
||||
public static function setAdapterForTests(?object $adapter): void
|
||||
{
|
||||
self::$adapter = $adapter;
|
||||
}
|
||||
|
||||
public static function getTtl(): int
|
||||
{
|
||||
$raw = getenv('EDGE_GATEWAY_VIEW_CACHE_TTL');
|
||||
if ($raw === false || trim((string)$raw) === '') {
|
||||
return 15;
|
||||
}
|
||||
|
||||
return max(0, (int)$raw);
|
||||
}
|
||||
|
||||
public static function listKey(?int $departmentId = null, bool $includeDetail = true): string
|
||||
{
|
||||
return self::PREFIX
|
||||
. 'list:department:'
|
||||
. ($departmentId === null ? 'all' : (string)$departmentId)
|
||||
. ':detail:'
|
||||
. ($includeDetail ? '1' : '0');
|
||||
}
|
||||
|
||||
public static function detailKey(int $gatewayId): string
|
||||
{
|
||||
return self::PREFIX . 'detail:' . $gatewayId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{gateways: array<int,array<string,mixed>>, fleet_usage: array<string,mixed>}|null
|
||||
*/
|
||||
public static function getListPayload(?int $departmentId = null, bool $includeDetail = true): ?array
|
||||
{
|
||||
return self::decodeListPayload(self::redisGet(self::listKey($departmentId, $includeDetail)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{gateways: array<int,array<string,mixed>>, fleet_usage: array<string,mixed>} $payload
|
||||
*/
|
||||
public static function storeListPayload(?int $departmentId, bool $includeDetail, array $payload, ?int $ttl = null): void
|
||||
{
|
||||
self::storePayload(self::listKey($departmentId, $includeDetail), $payload, $ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function getDetailPayload(int $gatewayId): ?array
|
||||
{
|
||||
$decoded = self::decodePayload(self::redisGet(self::detailKey($gatewayId)));
|
||||
if (!is_array($decoded) || !isset($decoded['gateway']) || !is_array($decoded['gateway'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded['gateway'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $gateway
|
||||
*/
|
||||
public static function storeDetailPayload(int $gatewayId, array $gateway, ?int $ttl = null): void
|
||||
{
|
||||
self::storePayload(self::detailKey($gatewayId), ['gateway' => $gateway], $ttl);
|
||||
}
|
||||
|
||||
public static function clearAll(): void
|
||||
{
|
||||
self::clearPattern(self::PREFIX . '*');
|
||||
}
|
||||
|
||||
public static function clearGateway(int $gatewayId, ?int $departmentId = null): void
|
||||
{
|
||||
self::clearPattern(self::detailKey($gatewayId));
|
||||
self::clearPattern(self::listKey(null, true));
|
||||
self::clearPattern(self::listKey(null, false));
|
||||
|
||||
if ($departmentId !== null) {
|
||||
self::clearPattern(self::listKey($departmentId, true));
|
||||
self::clearPattern(self::listKey($departmentId, false));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $gateway
|
||||
*/
|
||||
public static function syncGateway(array $gateway): void
|
||||
{
|
||||
$gatewayId = (int)($gateway['id'] ?? 0);
|
||||
$departmentId = isset($gateway['department_id']) ? (int)$gateway['department_id'] : null;
|
||||
|
||||
if ($gatewayId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$detailGateway = edge_gateway_manager::prepareGatewayForListCache($gateway, true);
|
||||
|
||||
self::storeDetailPayload($gatewayId, $detailGateway);
|
||||
self::syncListPayload(null, true, $detailGateway);
|
||||
self::syncListPayload(null, false, $detailGateway);
|
||||
|
||||
if ($departmentId !== null && $departmentId > 0) {
|
||||
self::syncListPayload($departmentId, true, $detailGateway);
|
||||
self::syncListPayload($departmentId, false, $detailGateway);
|
||||
}
|
||||
}
|
||||
|
||||
public static function removeGateway(int $gatewayId, ?int $departmentId = null): void
|
||||
{
|
||||
self::clearPattern(self::detailKey($gatewayId));
|
||||
self::removeGatewayFromListPayload(null, true, $gatewayId);
|
||||
self::removeGatewayFromListPayload(null, false, $gatewayId);
|
||||
|
||||
if ($departmentId !== null) {
|
||||
self::removeGatewayFromListPayload($departmentId, true, $gatewayId);
|
||||
self::removeGatewayFromListPayload($departmentId, false, $gatewayId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $gateway
|
||||
*/
|
||||
private static function syncListPayload(?int $departmentId, bool $includeDetail, array $gateway): void
|
||||
{
|
||||
$payload = self::getListPayload($departmentId, $includeDetail);
|
||||
if ($payload === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = isset($payload['gateways']) && is_array($payload['gateways']) ? array_values($payload['gateways']) : [];
|
||||
$preparedGateway = edge_gateway_manager::prepareGatewayForListCache($gateway, $includeDetail);
|
||||
$gatewayId = (int)($preparedGateway['id'] ?? 0);
|
||||
$matchesDepartment = $departmentId === null
|
||||
|| (int)($preparedGateway['department_id'] ?? 0) === (int)$departmentId;
|
||||
|
||||
if ($gatewayId <= 0 || !$matchesDepartment) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updated = false;
|
||||
foreach ($rows as $index => $row) {
|
||||
if ((int)($row['id'] ?? 0) !== $gatewayId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[$index] = self::mergeGatewayPayload($row, $preparedGateway, $includeDetail);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!$updated) {
|
||||
$rows[] = $preparedGateway;
|
||||
}
|
||||
|
||||
usort($rows, static fn(array $left, array $right): int => ((int)($left['department_id'] ?? 0) <=> (int)($right['department_id'] ?? 0))
|
||||
?: ((int)($left['id'] ?? 0) <=> (int)($right['id'] ?? 0)));
|
||||
|
||||
self::storeListPayload($departmentId, $includeDetail, [
|
||||
'gateways' => $rows,
|
||||
'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows($rows),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function removeGatewayFromListPayload(?int $departmentId, bool $includeDetail, int $gatewayId): void
|
||||
{
|
||||
$payload = self::getListPayload($departmentId, $includeDetail);
|
||||
if ($payload === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = array_values(array_filter(
|
||||
isset($payload['gateways']) && is_array($payload['gateways']) ? $payload['gateways'] : [],
|
||||
static fn(mixed $row): bool => (int)(is_array($row) ? ($row['id'] ?? 0) : 0) !== $gatewayId
|
||||
));
|
||||
|
||||
self::storeListPayload($departmentId, $includeDetail, [
|
||||
'gateways' => $rows,
|
||||
'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows($rows),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $currentGateway
|
||||
* @param array<string,mixed> $nextGateway
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function mergeGatewayPayload(array $currentGateway, array $nextGateway, bool $includeDetail): array
|
||||
{
|
||||
$merged = array_merge($currentGateway, $nextGateway);
|
||||
$merged['metadata'] = array_merge(
|
||||
isset($currentGateway['metadata']) && is_array($currentGateway['metadata']) ? $currentGateway['metadata'] : [],
|
||||
isset($nextGateway['metadata']) && is_array($nextGateway['metadata']) ? $nextGateway['metadata'] : []
|
||||
);
|
||||
|
||||
foreach (['inventory', 'bindings', 'recent_commands', 'audit_logs', 'operations', 'relay_health', 'diagnostics'] as $listKey) {
|
||||
if (array_key_exists($listKey, $nextGateway)) {
|
||||
$merged[$listKey] = $nextGateway[$listKey];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists($listKey, $currentGateway)) {
|
||||
$merged[$listKey] = $currentGateway[$listKey];
|
||||
}
|
||||
}
|
||||
|
||||
return edge_gateway_manager::prepareGatewayForListCache($merged, $includeDetail);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{gateways: array<int,array<string,mixed>>, fleet_usage: array<string,mixed>}|null
|
||||
*/
|
||||
private static function decodeListPayload(?string $raw): ?array
|
||||
{
|
||||
$decoded = self::decodePayload($raw);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isset($decoded['gateways']) || !is_array($decoded['gateways'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isset($decoded['fleet_usage']) || !is_array($decoded['fleet_usage'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'gateways' => array_values($decoded['gateways']),
|
||||
'fleet_usage' => $decoded['fleet_usage'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private static function decodePayload(?string $raw): ?array
|
||||
{
|
||||
if ($raw === null || trim($raw) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private static function storePayload(string $key, array $payload, ?int $ttl = null): void
|
||||
{
|
||||
$cacheTtl = $ttl ?? self::getTtl();
|
||||
if ($cacheTtl <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($encoded)) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::redisSetEx($key, $encoded, $cacheTtl);
|
||||
}
|
||||
|
||||
private static function clearPattern(string $pattern): void
|
||||
{
|
||||
try {
|
||||
$client = self::redisClient();
|
||||
if ($client === null) {
|
||||
return;
|
||||
}
|
||||
$client->clear_keys($pattern);
|
||||
} catch (Throwable) {
|
||||
// Cache invalidation must never break request flow.
|
||||
}
|
||||
}
|
||||
|
||||
private static function redisSetEx(string $key, string $value, int $ttl): void
|
||||
{
|
||||
try {
|
||||
$client = self::redisClient();
|
||||
if ($client === null) {
|
||||
return;
|
||||
}
|
||||
$client->setEx($key, $value, $ttl);
|
||||
} catch (Throwable) {
|
||||
// Best-effort cache write.
|
||||
}
|
||||
}
|
||||
|
||||
private static function redisGet(string $key): ?string
|
||||
{
|
||||
try {
|
||||
$client = self::redisClient();
|
||||
if ($client === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $client->get($key);
|
||||
return is_string($value) ? $value : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function redisClient(): ?object
|
||||
{
|
||||
if (self::$adapter !== null) {
|
||||
return self::$adapter;
|
||||
}
|
||||
|
||||
try {
|
||||
if (defined('redis')) {
|
||||
$instance = constant('redis');
|
||||
if (is_object($instance)) {
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
|
||||
return (new redis())->connect();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
|
||||
class edge_gateway_view_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 listGateways(?int $departmentId = null, bool $includeDetail = true): array
|
||||
{
|
||||
return $this->listGatewaysWithFleetUsage($departmentId, $includeDetail)['gateways'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{gateways: array<int,array<string,mixed>>, fleet_usage: array<string,mixed>}
|
||||
* @throws Exception
|
||||
*/
|
||||
public function listGatewaysWithFleetUsage(?int $departmentId = null, bool $includeDetail = true): array
|
||||
{
|
||||
$cached = edge_gateway_view_cache::getListPayload($departmentId, $includeDetail);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$gateways = $this->manager()->listGateways($departmentId, $includeDetail);
|
||||
$payload = [
|
||||
'gateways' => $gateways,
|
||||
'fleet_usage' => $this->manager()->buildFleetUsageStatistics($departmentId, $gateways),
|
||||
];
|
||||
edge_gateway_view_cache::storeListPayload($departmentId, $includeDetail, $payload);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $gateways
|
||||
* @return array<string,mixed>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array
|
||||
{
|
||||
if ($gateways === []) {
|
||||
return $this->listGatewaysWithFleetUsage($departmentId, false)['fleet_usage'];
|
||||
}
|
||||
|
||||
return $this->manager()->buildFleetUsageStatistics($departmentId, $gateways);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getGateway(int $gatewayId): array
|
||||
{
|
||||
$cached = edge_gateway_view_cache::getDetailPayload($gatewayId);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$gateway = $this->manager()->getGateway($gatewayId);
|
||||
edge_gateway_view_cache::storeDetailPayload($gatewayId, $gateway);
|
||||
|
||||
return $gateway;
|
||||
}
|
||||
|
||||
private function manager(): edge_gateway_manager
|
||||
{
|
||||
return $this->manager ?? new edge_gateway_manager();
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace modules\edgegateway\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class edgegateway_default_release_channel_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'edgegateway',
|
||||
'default_release_channel',
|
||||
'string',
|
||||
true,
|
||||
['stable', 'canary'],
|
||||
'The default release channel assigned to newly claimed edge gateways.',
|
||||
'stable',
|
||||
false,
|
||||
'stable'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace modules\edgegateway\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class edgegateway_default_update_window_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'edgegateway',
|
||||
'default_update_window',
|
||||
'string',
|
||||
true,
|
||||
null,
|
||||
'The default maintenance window applied to newly claimed edge gateways.',
|
||||
'02:00-04:00',
|
||||
false,
|
||||
'02:00-04:00'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace modules\edgegateway\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class edgegateway_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'edgegateway',
|
||||
'enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether the edge gateway module is enabled.',
|
||||
'true',
|
||||
false,
|
||||
'true'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace modules\edgegateway;
|
||||
|
||||
require_once WD . '/modules/edgegateway/config/edgegateway_enabled_c.php';
|
||||
require_once WD . '/modules/edgegateway/config/edgegateway_default_release_channel_c.php';
|
||||
require_once WD . '/modules/edgegateway/config/edgegateway_default_update_window_c.php';
|
||||
|
||||
use modules\edgegateway\config\edgegateway_default_release_channel_c;
|
||||
use modules\edgegateway\config\edgegateway_default_update_window_c;
|
||||
use modules\edgegateway\config\edgegateway_enabled_c;
|
||||
use traits\module_config_t;
|
||||
|
||||
class edgegateway_c
|
||||
{
|
||||
use module_config_t;
|
||||
|
||||
public edgegateway_enabled_c $enabled;
|
||||
public edgegateway_default_release_channel_c $default_release_channel;
|
||||
public edgegateway_default_update_window_c $default_update_window;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfig('edgegateway');
|
||||
$this->allowUpdate([
|
||||
edgegateway_enabled_c::class,
|
||||
edgegateway_default_release_channel_c::class,
|
||||
edgegateway_default_update_window_c::class,
|
||||
]);
|
||||
$this->enabled = new edgegateway_enabled_c();
|
||||
$this->default_release_channel = new edgegateway_default_release_channel_c();
|
||||
$this->default_update_window = new edgegateway_default_update_window_c();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\edgegateway;
|
||||
use classes\response;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
class edgeGatewayConfigRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/edgegateway/config', fn() => $this->handleGetConfig(), [
|
||||
'modules_shelly_config' => 'Get edge gateway config',
|
||||
]);
|
||||
$this->post('/edgegateway/config', fn() => $this->handlePostConfig(), [
|
||||
'modules_shelly_config' => 'Update edge gateway config',
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleGetConfig(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
if (!$user) {
|
||||
(new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully fetched edge gateway config');
|
||||
$response->success((new edgegateway())->config->getConfigRequest());
|
||||
}
|
||||
|
||||
private function handlePostConfig(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
if (!$user) {
|
||||
(new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully updated edge gateway config');
|
||||
$response->success((new edgegateway())->config->postConfigRequest());
|
||||
}
|
||||
}
|
||||
+297
-3
@@ -25,6 +25,21 @@ class edgeGatewaysRoute
|
||||
$this->get('/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [
|
||||
'modules_shelly_config' => 'View department edge gateway detail',
|
||||
]);
|
||||
$this->get('/edge-gateways/{id}/tasks', fn() => $this->handleGatewayTasksPage(), [
|
||||
'modules_shelly_config' => 'View edge gateway task timeline',
|
||||
]);
|
||||
$this->get('/edge-gateways/{id}/logs', fn() => $this->handleGatewayLogsPage(), [
|
||||
'modules_shelly_config' => 'View edge gateway logs',
|
||||
]);
|
||||
$this->get('/edge-gateways/{id}/statistics', fn() => $this->handleGatewayStatisticsPage(), [
|
||||
'modules_shelly_config' => 'View edge gateway statistics',
|
||||
]);
|
||||
$this->post('/edge-gateways/{id}/stream-session', fn() => $this->handleGatewayStreamSessionCreate(), [
|
||||
'modules_shelly_config' => 'Create an edge gateway live stream session',
|
||||
]);
|
||||
$this->post('/edge-gateways/{id}/shell-sessions', fn() => $this->handleGatewayShellSessionCreate(), [
|
||||
'modules_shelly_config' => 'Create an edge gateway shell session',
|
||||
]);
|
||||
$this->put('/edge-gateways/{id}', fn() => $this->handleGatewayUpdate(), [
|
||||
'modules_shelly_config' => 'Update edge gateway metadata and primary assignment',
|
||||
]);
|
||||
@@ -34,6 +49,9 @@ class edgeGatewaysRoute
|
||||
$this->post('/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationCreate(), [
|
||||
'modules_shelly_config' => 'Queue an edge gateway operation',
|
||||
]);
|
||||
$this->post('/edge-gateways/{id}/operations/{operationId}/cancel', fn() => $this->handleGatewayOperationCancel(), [
|
||||
'modules_shelly_config' => 'Cancel an active edge gateway operation',
|
||||
]);
|
||||
$this->get('/edge-gateways/{id}/operations/{operationId}/events', fn() => $this->handleGatewayOperationEvents(), [
|
||||
'modules_shelly_config' => 'List edge gateway operation events',
|
||||
]);
|
||||
@@ -43,6 +61,9 @@ class edgeGatewaysRoute
|
||||
$this->post('/edge-gateways/install-token', fn() => $this->handleInstallTokenCreate(), [
|
||||
'modules_shelly_config' => 'Create a one-time Raspberry Pi edge gateway installer token',
|
||||
]);
|
||||
$this->get('/edge-gateways/install-token/{id}/status', fn() => $this->handleInstallTokenStatus(), [
|
||||
'modules_shelly_config' => 'View edge gateway installer session status',
|
||||
]);
|
||||
$this->post('/edge-gateways/{id}/discovery', fn() => $this->handleGatewayDiscovery(), [
|
||||
'modules_shelly_config' => 'Queue Shelly discovery through the local edge gateway',
|
||||
]);
|
||||
@@ -57,12 +78,15 @@ class edgeGatewaysRoute
|
||||
]);
|
||||
|
||||
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
|
||||
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
|
||||
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
|
||||
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
|
||||
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
|
||||
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
|
||||
$this->get('/edge-agent/artifacts/docker-compose.gateway.yml', fn() => $this->renderArtifact('docker-compose.gateway.yml'));
|
||||
$this->get('/edge-agent/artifacts/Dockerfile.edge-agent', fn() => $this->renderArtifact('Dockerfile.edge-agent'));
|
||||
$this->get('/edge-agent/artifacts/Dockerfile.lan-worker', fn() => $this->renderArtifact('Dockerfile.lan-worker'));
|
||||
$this->get('/edge-agent/artifacts/Dockerfile.auto-updater', fn() => $this->renderArtifact('Dockerfile.auto-updater'));
|
||||
$this->get('/edge-agent/artifacts/gateway-launcher.sh', fn() => $this->renderArtifact('gateway-launcher.sh'));
|
||||
$this->get('/edge-agent/artifacts/truckwash-edge-gateway-stack.service', fn() => $this->renderArtifact('truckwash-edge-gateway-stack.service'));
|
||||
$this->get('/edge-agent/artifacts/truckwash-edge-agent.service', fn() => $this->renderArtifact('truckwash-edge-agent.service'));
|
||||
@@ -74,6 +98,18 @@ class edgeGatewaysRoute
|
||||
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
|
||||
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
|
||||
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
|
||||
|
||||
$this->post('/edge-agent/internal/gateways/{id}/validate', fn() => $this->handleBrokerGatewayValidate());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/presence', fn() => $this->handleBrokerGatewayPresence());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/backlog', fn() => $this->handleBrokerGatewayBacklog());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/telemetry', fn() => $this->handleBrokerGatewayTelemetry());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry());
|
||||
$this->post('/edge-agent/internal/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate());
|
||||
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate());
|
||||
$this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened());
|
||||
$this->post('/edge-agent/internal/shell-sessions/close', fn() => $this->handleBrokerShellSessionClose());
|
||||
}
|
||||
|
||||
private function handleListGateways(): void
|
||||
@@ -87,9 +123,9 @@ class edgeGatewaysRoute
|
||||
$this->requireDepartmentAccess($departmentId);
|
||||
}
|
||||
|
||||
$gateways = $this->views()->listGateways($departmentId, $view !== 'summary');
|
||||
$response->add_meta('fleet_usage', $this->views()->buildFleetUsageStatistics($departmentId, $gateways));
|
||||
$response->success($gateways);
|
||||
$payload = $this->views()->listGatewaysWithFleetUsage($departmentId, $view !== 'summary');
|
||||
$response->add_meta('fleet_usage', $payload['fleet_usage']);
|
||||
$response->success($payload['gateways']);
|
||||
}
|
||||
|
||||
private function handleGatewayDetail(): void
|
||||
@@ -99,6 +135,58 @@ class edgeGatewaysRoute
|
||||
$response->success($this->requireGatewayAccess((int)$this->fromRoute('id')));
|
||||
}
|
||||
|
||||
private function handleGatewayTasksPage(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$response->success($this->manager()->buildGatewayTasksPage($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayLogsPage(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$response->success($this->manager()->buildGatewayLogsPage($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayStatisticsPage(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$response->success($this->manager()->buildGatewayStatisticsPage($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayStreamSessionCreate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$payload = self::getParametersAsArray();
|
||||
$scopes = isset($payload['scopes']) && is_array($payload['scopes']) ? (array)$payload['scopes'] : [];
|
||||
$response->success($this->manager()->createBrowserStreamSession($gatewayId, $this->actorUserId(), $scopes), 201);
|
||||
}
|
||||
|
||||
private function handleGatewayShellSessionCreate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$payload = self::getParametersAsArray();
|
||||
$reason = isset($payload['reason']) ? (string)$payload['reason'] : '';
|
||||
$cwd = isset($payload['cwd']) ? (string)$payload['cwd'] : null;
|
||||
$cols = isset($payload['cols']) ? (int)$payload['cols'] : null;
|
||||
$rows = isset($payload['rows']) ? (int)$payload['rows'] : null;
|
||||
$response->success($this->manager()->createShellSession($gatewayId, $this->actorUserId(), $reason, $cols, $rows, $cwd), 201);
|
||||
}
|
||||
|
||||
private function handleGatewayUpdate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
@@ -166,6 +254,29 @@ class edgeGatewaysRoute
|
||||
$response->success($this->operations()->listOperationEvents($gatewayId, $operationId));
|
||||
}
|
||||
|
||||
private function handleGatewayOperationCancel(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$operationId = (int)$this->fromRoute('operationId');
|
||||
self::requireParameterIntPositive($operationId, 'operationId');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
|
||||
try {
|
||||
$operation = $this->operations()->cancelOperation($gatewayId, $operationId, $this->actorUserId());
|
||||
$response->success([
|
||||
'operation' => $operation,
|
||||
'gateway' => $this->views()->getGateway($gatewayId),
|
||||
]);
|
||||
} catch (edge_gateway_operation_exception $exception) {
|
||||
$response->error([
|
||||
'message' => $exception->getMessage(),
|
||||
'error_code' => $exception->errorCode,
|
||||
], $exception->status);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleGatewayCredentialRotate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
@@ -195,6 +306,20 @@ class edgeGatewaysRoute
|
||||
);
|
||||
}
|
||||
|
||||
private function handleInstallTokenStatus(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
|
||||
$claimTokenId = (int)$this->fromRoute('id');
|
||||
self::requireParameterIntPositive($claimTokenId, 'id');
|
||||
|
||||
$status = $this->registry()->getInstallTokenStatus($claimTokenId);
|
||||
$this->requireDepartmentAccess((int)$status['department_id']);
|
||||
unset($status['department_id']);
|
||||
$response->success($status);
|
||||
}
|
||||
|
||||
private function handleGatewayDiscovery(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
@@ -265,6 +390,25 @@ class edgeGatewaysRoute
|
||||
$response->success($this->install()->verifyInstallToken($token));
|
||||
}
|
||||
|
||||
private function handleAgentInstallTokenStatus(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
self::requireParameters(['token', 'status']);
|
||||
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->registry()->reportInstallTokenStatus(
|
||||
(string)$payload['token'],
|
||||
[
|
||||
'status' => (string)$payload['status'],
|
||||
'step' => isset($payload['step']) ? (string)$payload['step'] : null,
|
||||
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
|
||||
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
|
||||
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
|
||||
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null,
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
private function renderArtifact(string $fileName): void
|
||||
{
|
||||
try {
|
||||
@@ -417,6 +561,147 @@ class edgeGatewaysRoute
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerGatewayValidate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
self::requireParameters(['token']);
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$response->success($this->manager()->validateGatewayAgentForBroker($gatewayId, (string)self::getParameter('token')));
|
||||
}
|
||||
|
||||
private function handleBrokerGatewayPresence(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->recordBrokerPresence(
|
||||
$gatewayId,
|
||||
isset($payload['status']) ? (string)$payload['status'] : 'disconnected',
|
||||
isset($payload['connection_id']) ? (string)$payload['connection_id'] : null,
|
||||
isset($payload['reason']) ? (string)$payload['reason'] : null,
|
||||
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerGatewayBacklog(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->buildBrokerBacklog(
|
||||
$gatewayId,
|
||||
isset($payload['agent_instance_id']) ? (string)$payload['agent_instance_id'] : null
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerGatewayTelemetry(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->recordTelemetryFromBroker($gatewayId, $payload));
|
||||
}
|
||||
|
||||
private function handleBrokerOperationEvent(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$operationId = (int)$this->fromRoute('operationId');
|
||||
self::requireParameterIntPositive($operationId, 'operationId');
|
||||
$payload = self::getParametersAsArray();
|
||||
|
||||
try {
|
||||
$response->success($this->operations()->appendBrokerOperationEvent($gatewayId, $operationId, $payload));
|
||||
} catch (edge_gateway_operation_exception $exception) {
|
||||
$response->error([
|
||||
'message' => $exception->getMessage(),
|
||||
'error_code' => $exception->errorCode,
|
||||
], $exception->status);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleBrokerOperationComplete(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$operationId = (int)$this->fromRoute('operationId');
|
||||
self::requireParameterIntPositive($operationId, 'operationId');
|
||||
$payload = self::getParametersAsArray();
|
||||
|
||||
try {
|
||||
$response->success($this->operations()->completeBrokerOperation($gatewayId, $operationId, $payload));
|
||||
} catch (edge_gateway_operation_exception $exception) {
|
||||
$response->error([
|
||||
'message' => $exception->getMessage(),
|
||||
'error_code' => $exception->errorCode,
|
||||
], $exception->status);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleBrokerGatewayLogEntry(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
self::requireParameters(['message']);
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->appendGatewayLogEntry(
|
||||
$gatewayId,
|
||||
(string)self::getParameter('message'),
|
||||
isset($payload['level']) ? (string)$payload['level'] : 'INFO',
|
||||
isset($payload['stream']) ? (string)$payload['stream'] : 'agent',
|
||||
isset($payload['source']) ? (string)$payload['source'] : 'BROKER',
|
||||
isset($payload['context']) && is_array($payload['context']) ? (array)$payload['context'] : []
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerBrowserStreamValidate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
self::requireParameters(['token']);
|
||||
$response->success($this->manager()->validateBrowserStreamToken((string)self::getParameter('token')));
|
||||
}
|
||||
|
||||
private function handleBrokerShellSessionValidate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
self::requireParameters(['token']);
|
||||
$response->success($this->manager()->validateShellSessionToken((string)self::getParameter('token')));
|
||||
}
|
||||
|
||||
private function handleBrokerShellSessionOpened(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
self::requireParameters(['token']);
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->markShellSessionOpened(
|
||||
(string)self::getParameter('token'),
|
||||
isset($payload['connection_id']) ? (string)$payload['connection_id'] : null
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerShellSessionClose(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
self::requireParameters(['token']);
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->closeShellSessionByToken(
|
||||
(string)self::getParameter('token'),
|
||||
isset($payload['transcript']) ? (string)$payload['transcript'] : '',
|
||||
isset($payload['reason']) ? (string)$payload['reason'] : null
|
||||
));
|
||||
}
|
||||
|
||||
private function requireGatewayAccess(int $gatewayId): array
|
||||
{
|
||||
self::requireParameterIntPositive($gatewayId, 'id');
|
||||
@@ -436,6 +721,15 @@ class edgeGatewaysRoute
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function requireBrokerSecret(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$provided = trim((string)($_SERVER['HTTP_X_EDGE_BROKER_SECRET'] ?? ''));
|
||||
if (!$this->manager()->validateBrokerSharedSecret($provided)) {
|
||||
$response->error('Invalid edge broker secret', 403);
|
||||
}
|
||||
}
|
||||
|
||||
private function actorUserId(): ?int
|
||||
{
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -0,0 +1,430 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use classes\edge_gateway_view_service;
|
||||
use classes\edgegateway;
|
||||
use classes\response;
|
||||
use Exception;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
class moduleEdgeGatewayRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$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',
|
||||
]);
|
||||
$this->get('/modules/edge-gateways/{id}/tasks', fn() => $this->handleGatewayTasksPage(), [
|
||||
'modules_shelly_config' => 'View edge gateway module tasks',
|
||||
]);
|
||||
$this->get('/modules/edge-gateways/{id}/logs', fn() => $this->handleGatewayLogsPage(), [
|
||||
'modules_shelly_config' => 'View edge gateway module logs',
|
||||
]);
|
||||
$this->get('/modules/edge-gateways/{id}/statistics', fn() => $this->handleGatewayStatisticsPage(), [
|
||||
'modules_shelly_config' => 'View edge gateway module statistics',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/{id}/stream-session', fn() => $this->handleGatewayStreamSessionCreate(), [
|
||||
'modules_shelly_config' => 'Create an edge gateway module live stream session',
|
||||
]);
|
||||
$this->put('/modules/edge-gateways/{id}', fn() => $this->handleGatewayUpdate(), [
|
||||
'modules_shelly_config' => 'Update edge gateway module metadata',
|
||||
]);
|
||||
$this->get('/modules/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationsList(), [
|
||||
'modules_shelly_config' => 'List edge gateway module operations',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationCreate(), [
|
||||
'modules_shelly_config' => 'Queue an edge gateway module operation',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/{id}/operations/{operationId}/cancel', fn() => $this->handleGatewayOperationCancel(), [
|
||||
'modules_shelly_config' => 'Cancel an edge gateway module operation',
|
||||
]);
|
||||
$this->get('/modules/edge-gateways/{id}/operations/{operationId}/events', fn() => $this->handleGatewayOperationEvents(), [
|
||||
'modules_shelly_config' => 'List edge gateway module operation events',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/{id}/rotate-credentials', fn() => $this->handleGatewayCredentialRotate(), [
|
||||
'modules_shelly_config' => 'Rotate edge gateway module credentials',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/install-token', fn() => $this->handleInstallTokenCreate(), [
|
||||
'modules_shelly_config' => 'Create an edge gateway module install token',
|
||||
]);
|
||||
$this->get('/modules/edge-gateways/install-token/{id}/status', fn() => $this->handleInstallTokenStatus(), [
|
||||
'modules_shelly_config' => 'View edge gateway module installer status',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/{id}/discovery', fn() => $this->handleGatewayDiscovery(), [
|
||||
'modules_shelly_config' => 'Queue discovery through the edge gateway module',
|
||||
]);
|
||||
$this->put('/modules/edge-gateways/{id}/bindings', fn() => $this->handleBindingsUpdate(), [
|
||||
'modules_shelly_config' => 'Update edge gateway module relay bindings',
|
||||
]);
|
||||
$this->delete('/modules/edge-gateways/{id}', fn() => $this->handleGatewayDelete(), [
|
||||
'modules_shelly_config' => 'Delete an edge gateway module registration',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/departments/{id}/cutover', fn() => $this->handleDepartmentCutover(), [
|
||||
'modules_shelly_config' => 'Update department cutover through the edge gateway module',
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleListGateways(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
|
||||
$departmentId = self::isParametersSet(['department_id']) ? (int)self::getParameter('department_id') : null;
|
||||
$view = trim((string)$this->fromQuery('view'));
|
||||
if ($departmentId !== null && $departmentId > 0) {
|
||||
$this->requireDepartmentAccess((string)$departmentId);
|
||||
}
|
||||
|
||||
$payload = $this->views()->listGatewaysWithFleetUsage($departmentId, $view !== 'summary');
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_LIST', 'Listed edge gateways');
|
||||
$response->add_meta('fleet_usage', $payload['fleet_usage']);
|
||||
$response->success($payload['gateways']);
|
||||
}
|
||||
|
||||
private function handleGatewayDetail(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gateway = $this->requireGatewayAccess((int)$this->fromRoute('id'));
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_GET', 'Fetched edge gateway detail');
|
||||
$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;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_TASKS_GET', 'Fetched edge gateway task timeline');
|
||||
$response->success($this->manager()->buildGatewayTasksPage($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayLogsPage(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_LOGS_GET', 'Fetched edge gateway logs');
|
||||
$response->success($this->manager()->buildGatewayLogsPage($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayStatisticsPage(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_STATISTICS_GET', 'Fetched edge gateway statistics');
|
||||
$response->success($this->manager()->buildGatewayStatisticsPage($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayStreamSessionCreate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$payload = self::getParametersAsArray();
|
||||
$scopes = isset($payload['scopes']) && is_array($payload['scopes']) ? (array)$payload['scopes'] : [];
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_STREAM_SESSION_CREATE', 'Created edge gateway stream session');
|
||||
$response->success($this->manager()->createBrowserStreamSession($gatewayId, (int)$user->id, $scopes), 201);
|
||||
}
|
||||
|
||||
private function handleGatewayUpdate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
self::requireParameters(['label', 'is_primary']);
|
||||
self::requireType(self::getParameter('label'), self::TYPE_STRING());
|
||||
self::requireType(self::getParameter('is_primary'), self::TYPE_BOOL());
|
||||
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$result = $this->registry()->updateGatewayMetadata($gatewayId, [
|
||||
'label' => (string)self::getParameter('label'),
|
||||
'is_primary' => (bool)self::getParameter('is_primary'),
|
||||
], (int)$user->id);
|
||||
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_UPDATE', 'Updated edge gateway metadata');
|
||||
$response->success($result);
|
||||
}
|
||||
|
||||
private function handleGatewayOperationsList(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_OPERATIONS_LIST', 'Listed edge gateway operations');
|
||||
$response->success($this->operations()->listOperations($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayOperationCreate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
self::requireParameters(['type', 'request']);
|
||||
self::requireType(self::getParameter('type'), self::TYPE_STRING());
|
||||
self::requireType(self::getParameter('request'), self::TYPE_ARRAY());
|
||||
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
|
||||
try {
|
||||
$operation = $this->operations()->queueOperation(
|
||||
$gatewayId,
|
||||
(string)self::getParameter('type'),
|
||||
(array)self::getParameter('request'),
|
||||
(int)$user->id
|
||||
);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_OPERATION_QUEUE', 'Queued edge gateway operation');
|
||||
$response->success([
|
||||
'operation' => $operation,
|
||||
'gateway' => $this->views()->getGateway($gatewayId),
|
||||
], 201);
|
||||
} catch (edge_gateway_operation_exception $exception) {
|
||||
$response->error([
|
||||
'message' => $exception->getMessage(),
|
||||
'error_code' => $exception->errorCode,
|
||||
], $exception->status);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleGatewayOperationCancel(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$operationId = (int)$this->fromRoute('operationId');
|
||||
self::requireParameterIntPositive($operationId, 'operationId');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
|
||||
try {
|
||||
$operation = $this->operations()->cancelOperation($gatewayId, $operationId, (int)$user->id);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_OPERATION_CANCEL', 'Cancelled edge gateway operation');
|
||||
$response->success([
|
||||
'operation' => $operation,
|
||||
'gateway' => $this->views()->getGateway($gatewayId),
|
||||
]);
|
||||
} catch (edge_gateway_operation_exception $exception) {
|
||||
$response->error([
|
||||
'message' => $exception->getMessage(),
|
||||
'error_code' => $exception->errorCode,
|
||||
], $exception->status);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleGatewayOperationEvents(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$operationId = (int)$this->fromRoute('operationId');
|
||||
self::requireParameterIntPositive($operationId, 'operationId');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_EVENTS_LIST', 'Listed edge gateway operation events');
|
||||
$response->success($this->operations()->listOperationEvents($gatewayId, $operationId));
|
||||
}
|
||||
|
||||
private function handleGatewayCredentialRotate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_CREDENTIALS_ROTATE', 'Rotated edge gateway credentials');
|
||||
$response->success($this->operations()->rotateCredentials($gatewayId, (int)$user->id));
|
||||
}
|
||||
|
||||
private function handleInstallTokenCreate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
self::requireParameters(['department_id']);
|
||||
|
||||
$departmentId = (int)self::getParameter('department_id');
|
||||
self::requireParameterIntPositive($departmentId, 'department_id');
|
||||
$this->requireDepartmentAccess((string)$departmentId);
|
||||
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_INSTALL_TOKEN_CREATE', 'Created edge gateway install token');
|
||||
$response->success(
|
||||
$this->registry()->createInstallToken(
|
||||
$departmentId,
|
||||
self::isParametersSet(['label']) ? (string)self::getParameter('label') : null,
|
||||
(int)$user->id
|
||||
),
|
||||
201
|
||||
);
|
||||
}
|
||||
|
||||
private function handleInstallTokenStatus(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$claimTokenId = (int)$this->fromRoute('id');
|
||||
self::requireParameterIntPositive($claimTokenId, 'id');
|
||||
|
||||
$status = $this->registry()->getInstallTokenStatus($claimTokenId);
|
||||
$departmentId = (int)($status['department_id'] ?? 0);
|
||||
self::requireParameterIntPositive($departmentId, 'department_id');
|
||||
$this->requireDepartmentAccess((string)$departmentId);
|
||||
|
||||
(new logs_o())->add(
|
||||
'modules_edgegateway',
|
||||
'global',
|
||||
1,
|
||||
$user->id,
|
||||
'MODULES_EDGEGATEWAY_INSTALL_TOKEN_STATUS',
|
||||
'Viewed edge gateway installer status'
|
||||
);
|
||||
|
||||
unset($status['department_id']);
|
||||
$response->success($status);
|
||||
}
|
||||
|
||||
private function handleGatewayDiscovery(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$this->operations()->queueDiscoveryOperation($gatewayId, (int)$user->id);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_DISCOVERY_QUEUE', 'Queued edge gateway discovery');
|
||||
$response->success($this->views()->getGateway($gatewayId));
|
||||
}
|
||||
|
||||
private function handleBindingsUpdate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
self::requireParameters(['bindings']);
|
||||
self::requireType(self::getParameter('bindings'), self::TYPE_ARRAY());
|
||||
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$this->registry()->setRelayBindings($gatewayId, (array)self::getParameter('bindings'), (int)$user->id);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_BINDINGS_UPDATE', 'Updated edge gateway bindings');
|
||||
$response->success($this->views()->getGateway($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayDelete(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_DELETE', 'Deleted edge gateway');
|
||||
$response->success($this->registry()->deleteGateway($gatewayId, (int)$user->id));
|
||||
}
|
||||
|
||||
private function handleDepartmentCutover(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
self::requireParameters(['transport_mode']);
|
||||
$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_DEPARTMENT_CUTOVER', 'Updated department gateway cutover');
|
||||
$response->success($this->registry()->setDepartmentTransportMode($departmentId, (string)self::getParameter('transport_mode'), (int)$user->id));
|
||||
}
|
||||
|
||||
private function requireModuleOperator(): object
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
|
||||
try {
|
||||
(new edgegateway())->requireModuleEnabled();
|
||||
} catch (Exception $exception) {
|
||||
$response->error($exception->getMessage(), 409);
|
||||
}
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function requireGatewayAccess(int $gatewayId): array
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$gateway = $this->views()->getGateway($gatewayId);
|
||||
if (!isset($gateway['id'])) {
|
||||
$response->error('Edge gateway not found', 404);
|
||||
}
|
||||
|
||||
$departmentId = (int)($gateway['department_id'] ?? 0);
|
||||
self::requireParameterIntPositive($departmentId, 'department_id');
|
||||
$this->requireDepartmentAccess((string)$departmentId);
|
||||
return $gateway;
|
||||
}
|
||||
|
||||
private function views(): edge_gateway_view_service
|
||||
{
|
||||
return new edge_gateway_view_service();
|
||||
}
|
||||
|
||||
private function registry(): edge_gateway_registry_service
|
||||
{
|
||||
return new edge_gateway_registry_service();
|
||||
}
|
||||
|
||||
private function operations(): edge_gateway_operation_service
|
||||
{
|
||||
return new edge_gateway_operation_service();
|
||||
}
|
||||
|
||||
private function manager(): edge_gateway_manager
|
||||
{
|
||||
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)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_log_entries_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $gateway_id;
|
||||
public object_property $department_id;
|
||||
public object_property $level;
|
||||
public object_property $stream;
|
||||
public object_property $source;
|
||||
public object_property $message;
|
||||
public object_property $context_json;
|
||||
public object_property $created_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_log_entries');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->level = new object_property($this->table, $this->id, 'level', 'string', false);
|
||||
$this->stream = new object_property($this->table, $this->id, 'stream', 'string', false);
|
||||
$this->source = new object_property($this->table, $this->id, 'source', 'string', false);
|
||||
$this->message = new object_property($this->table, $this->id, 'message', 'text', false);
|
||||
$this->context_json = new object_property($this->table, $this->id, 'context_json', 'json', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'gateway_id' => (int)$this->gateway_id->value(),
|
||||
'department_id' => $this->department_id->value() === null ? null : (int)$this->department_id->value(),
|
||||
'level' => (string)$this->level->value(),
|
||||
'stream' => (string)$this->stream->value(),
|
||||
'source' => (string)$this->source->value(),
|
||||
'message' => (string)$this->message->value(),
|
||||
'context' => (array)($this->context_json->value() ?? []),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_shell_sessions_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $gateway_id;
|
||||
public object_property $department_id;
|
||||
public object_property $actor_user_id;
|
||||
public object_property $session_token_hash;
|
||||
public object_property $status;
|
||||
public object_property $reason;
|
||||
public object_property $connection_id;
|
||||
public object_property $cwd;
|
||||
public object_property $shell_command;
|
||||
public object_property $shell_args_json;
|
||||
public object_property $cols;
|
||||
public object_property $rows;
|
||||
public object_property $transcript;
|
||||
public object_property $metadata_json;
|
||||
public object_property $expires_at;
|
||||
public object_property $approved_at;
|
||||
public object_property $opened_at;
|
||||
public object_property $closed_at;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_shell_sessions');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->actor_user_id = new object_property($this->table, $this->id, 'actor_user_id', 'int', false);
|
||||
$this->session_token_hash = new object_property($this->table, $this->id, 'session_token_hash', 'string', false);
|
||||
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
|
||||
$this->reason = new object_property($this->table, $this->id, 'reason', 'string', false);
|
||||
$this->connection_id = new object_property($this->table, $this->id, 'connection_id', 'string', false);
|
||||
$this->cwd = new object_property($this->table, $this->id, 'cwd', 'string', false);
|
||||
$this->shell_command = new object_property($this->table, $this->id, 'shell_command', 'string', false);
|
||||
$this->shell_args_json = new object_property($this->table, $this->id, 'shell_args_json', 'json', false);
|
||||
$this->cols = new object_property($this->table, $this->id, 'cols', 'int', false);
|
||||
$this->rows = new object_property($this->table, $this->id, 'terminal_rows', 'int', false);
|
||||
$this->transcript = new object_property($this->table, $this->id, 'transcript', 'text', false);
|
||||
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
|
||||
$this->expires_at = new object_property($this->table, $this->id, 'expires_at', 'string', false);
|
||||
$this->approved_at = new object_property($this->table, $this->id, 'approved_at', 'string', false);
|
||||
$this->opened_at = new object_property($this->table, $this->id, 'opened_at', 'string', false);
|
||||
$this->closed_at = new object_property($this->table, $this->id, 'closed_at', 'string', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'gateway_id' => (int)$this->gateway_id->value(),
|
||||
'department_id' => (int)$this->department_id->value(),
|
||||
'actor_user_id' => $this->actor_user_id->value() === null ? null : (int)$this->actor_user_id->value(),
|
||||
'status' => (string)$this->status->value(),
|
||||
'reason' => $this->reason->value() === null ? null : (string)$this->reason->value(),
|
||||
'connection_id' => $this->connection_id->value() === null ? null : (string)$this->connection_id->value(),
|
||||
'cwd' => $this->cwd->value() === null ? null : (string)$this->cwd->value(),
|
||||
'shell_command' => $this->shell_command->value() === null ? null : (string)$this->shell_command->value(),
|
||||
'shell_args' => (array)($this->shell_args_json->value() ?? []),
|
||||
'cols' => $this->cols->value() === null ? null : (int)$this->cols->value(),
|
||||
'rows' => $this->rows->value() === null ? null : (int)$this->rows->value(),
|
||||
'transcript' => $this->transcript->value() === null ? null : (string)$this->transcript->value(),
|
||||
'metadata' => (array)($this->metadata_json->value() ?? []),
|
||||
'expires_at' => $this->expires_at->value() === null ? null : (string)$this->expires_at->value(),
|
||||
'approved_at' => $this->approved_at->value() === null ? null : (string)$this->approved_at->value(),
|
||||
'opened_at' => $this->opened_at->value() === null ? null : (string)$this->opened_at->value(),
|
||||
'closed_at' => $this->closed_at->value() === null ? null : (string)$this->closed_at->value(),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
ARG BASE_IMAGE=php:8.2-cli-bookworm
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY auto-updater.php /usr/local/bin/auto-updater.php
|
||||
|
||||
ENTRYPOINT ["php", "/usr/local/bin/auto-updater.php"]
|
||||
@@ -3,16 +3,6 @@ FROM ${BASE_IMAGE}
|
||||
|
||||
WORKDIR /opt/truckwash-edge-agent
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
libcurl4-openssl-dev \
|
||||
libsqlite3-dev; \
|
||||
docker-php-ext-install curl sqlite3; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY agent.php /opt/truckwash-edge-agent/agent.php
|
||||
|
||||
ENTRYPOINT ["php", "/opt/truckwash-edge-agent/agent.php"]
|
||||
|
||||
@@ -3,15 +3,6 @@ FROM ${BASE_IMAGE}
|
||||
|
||||
WORKDIR /opt/truckwash-edge-agent
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
libcurl4-openssl-dev; \
|
||||
docker-php-ext-install curl; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php
|
||||
|
||||
CMD ["php", "-S", "0.0.0.0:8090", "/opt/truckwash-edge-agent/lan-worker.php"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "This updater must run from the CLI.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$installDir = rtrim((string)(getenv('TRUCKWASH_INSTALL_DIR') ?: '/opt/truckwash-edge-agent'), DIRECTORY_SEPARATOR);
|
||||
$runtimeDir = $installDir . DIRECTORY_SEPARATOR . 'runtime';
|
||||
$launcherPath = $installDir . DIRECTORY_SEPARATOR . 'gateway-launcher.sh';
|
||||
$stagedUpdatePath = $runtimeDir . DIRECTORY_SEPARATOR . 'staged-update.json';
|
||||
$heartbeatPath = $runtimeDir . DIRECTORY_SEPARATOR . 'auto-updater-heartbeat.json';
|
||||
$intervalSeconds = max(15, (int)(getenv('AUTO_UPDATER_INTERVAL_SECONDS') ?: 30));
|
||||
|
||||
if (!is_dir($runtimeDir)) {
|
||||
@mkdir($runtimeDir, 0777, true);
|
||||
}
|
||||
|
||||
$writeHeartbeat = static function (array $payload) use ($heartbeatPath): void {
|
||||
$payload['updated_at'] = date(DATE_ATOM);
|
||||
file_put_contents($heartbeatPath, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
};
|
||||
|
||||
$writeHeartbeat([
|
||||
'status' => 'starting',
|
||||
'interval_seconds' => $intervalSeconds,
|
||||
]);
|
||||
|
||||
while (true) {
|
||||
$stagedUpdatePresent = is_file($stagedUpdatePath);
|
||||
$writeHeartbeat([
|
||||
'status' => $stagedUpdatePresent ? 'waiting_for_window' : 'idle',
|
||||
'interval_seconds' => $intervalSeconds,
|
||||
'staged_update_present' => $stagedUpdatePresent,
|
||||
]);
|
||||
|
||||
if ($stagedUpdatePresent) {
|
||||
$writeHeartbeat([
|
||||
'status' => 'reconciling',
|
||||
'interval_seconds' => $intervalSeconds,
|
||||
'staged_update_present' => true,
|
||||
]);
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec('/bin/bash ' . escapeshellarg($launcherPath) . ' reconcile 2>&1', $output, $exitCode);
|
||||
|
||||
$writeHeartbeat([
|
||||
'status' => $exitCode === 0 ? 'idle' : 'error',
|
||||
'interval_seconds' => $intervalSeconds,
|
||||
'staged_update_present' => is_file($stagedUpdatePath),
|
||||
'last_exit_code' => $exitCode,
|
||||
'last_output' => implode("\n", array_slice($output, -40)),
|
||||
'last_reconciled_at' => date(DATE_ATOM),
|
||||
]);
|
||||
}
|
||||
|
||||
sleep($intervalSeconds);
|
||||
}
|
||||
@@ -1,4 +1,42 @@
|
||||
version: "2.4"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: ${REDIS_BASE_IMAGE:-redis:7-alpine}
|
||||
container_name: truckwash-redis
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- ./runtime/redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
mariadb:
|
||||
image: ${MARIADB_BASE_IMAGE:-mariadb:11}
|
||||
container_name: truckwash-mariadb
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_DATABASE: truckwash_edge
|
||||
MARIADB_USER: truckwash_edge
|
||||
MARIADB_PASSWORD: truckwash_edge
|
||||
MARIADB_ROOT_PASSWORD: truckwash_edge_root
|
||||
volumes:
|
||||
- ./runtime/mariadb:/var/lib/mysql
|
||||
|
||||
minio:
|
||||
image: ${MINIO_BASE_IMAGE:-minio/minio:latest}
|
||||
container_name: truckwash-minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: truckwashminio
|
||||
MINIO_ROOT_PASSWORD: truckwash_edge_storage
|
||||
volumes:
|
||||
- ./runtime/minio:/data
|
||||
|
||||
lan-worker:
|
||||
build:
|
||||
context: .
|
||||
@@ -9,10 +47,23 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8090:8090"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
mariadb:
|
||||
condition: service_started
|
||||
minio:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./runtime:/opt/truckwash-edge-agent/runtime
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8090/health"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"php",
|
||||
"-r",
|
||||
"$$json=@file_get_contents('http://127.0.0.1:8090/health'); if ($$json===false) exit(1); $$data=json_decode($$json,true); exit((($$data['status'] ?? '') === 'healthy') ? 0 : 1);",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -26,13 +77,49 @@ services:
|
||||
container_name: truckwash-edge-agent
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
mariadb:
|
||||
condition: service_started
|
||||
minio:
|
||||
condition: service_started
|
||||
lan-worker:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./config.json:/config/config.json
|
||||
- ./runtime:/opt/truckwash-edge-agent/runtime
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "test -f /opt/truckwash-edge-agent/runtime/last-heartbeat-ok.txt"]
|
||||
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
|
||||
|
||||
auto-updater:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.auto-updater
|
||||
args:
|
||||
BASE_IMAGE: ${AUTO_UPDATER_BASE_IMAGE:-php:8.2-cli-bookworm}
|
||||
container_name: truckwash-auto-updater
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
edge-agent:
|
||||
condition: service_started
|
||||
environment:
|
||||
AUTO_UPDATER_INTERVAL_SECONDS: 30
|
||||
volumes:
|
||||
- .:/opt/truckwash-edge-agent
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"php -r '$$path=\"/opt/truckwash-edge-agent/runtime/auto-updater-heartbeat.json\"; if (!is_file($$path)) { exit(1); } exit((time() - filemtime($$path)) <= 90 ? 0 : 1);'",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -8,6 +8,7 @@ COMPOSE_FILE="$INSTALL_DIR/docker-compose.gateway.yml"
|
||||
RUNTIME_DIR="$INSTALL_DIR/runtime"
|
||||
ROLLBACK_STATUS_PATH="$RUNTIME_DIR/rollback-status.json"
|
||||
STAGED_UPDATE_PATH="$RUNTIME_DIR/staged-update.json"
|
||||
STACK_HEALTHCHECK_TIMEOUT_SECONDS="${STACK_HEALTHCHECK_TIMEOUT_SECONDS:-300}"
|
||||
|
||||
log() {
|
||||
printf '[gateway-launcher] %s\n' "$1"
|
||||
@@ -28,6 +29,17 @@ compose_cmd() {
|
||||
return 1
|
||||
}
|
||||
|
||||
print_compose_diagnostics() {
|
||||
log "docker ps --format '{{.Names}} {{.Status}}'"
|
||||
docker ps --format '{{.Names}} {{.Status}}' || true
|
||||
|
||||
log "compose ps"
|
||||
compose_cmd -f "$COMPOSE_FILE" ps || true
|
||||
|
||||
log "compose logs --tail=80"
|
||||
compose_cmd -f "$COMPOSE_FILE" logs --tail=80 || true
|
||||
}
|
||||
|
||||
config_value() {
|
||||
local key="$1"
|
||||
local fallback="${2:-}"
|
||||
@@ -94,10 +106,26 @@ EOF_JSON
|
||||
apply_stack() {
|
||||
local edge_base_image
|
||||
local worker_base_image
|
||||
local auto_updater_base_image
|
||||
local redis_base_image
|
||||
local mariadb_base_image
|
||||
local minio_base_image
|
||||
local compose_project_name
|
||||
edge_base_image="$(config_value edgeAgentBaseImage 'php:8.2-cli-bookworm')"
|
||||
worker_base_image="$(config_value lanWorkerBaseImage 'php:8.2-cli-bookworm')"
|
||||
auto_updater_base_image="$(config_value autoUpdaterBaseImage 'php:8.2-cli-bookworm')"
|
||||
redis_base_image="$(config_value redisBaseImage 'redis:7-alpine')"
|
||||
mariadb_base_image="$(config_value mariadbBaseImage 'mariadb:11')"
|
||||
minio_base_image="$(config_value minioBaseImage 'minio/minio:latest')"
|
||||
compose_project_name="$(config_value composeProjectName 'truckwash-edge-gateway')"
|
||||
cd "$INSTALL_DIR"
|
||||
EDGE_AGENT_BASE_IMAGE="$edge_base_image" LAN_WORKER_BASE_IMAGE="$worker_base_image" \
|
||||
COMPOSE_PROJECT_NAME="$compose_project_name" \
|
||||
EDGE_AGENT_BASE_IMAGE="$edge_base_image" \
|
||||
LAN_WORKER_BASE_IMAGE="$worker_base_image" \
|
||||
AUTO_UPDATER_BASE_IMAGE="$auto_updater_base_image" \
|
||||
REDIS_BASE_IMAGE="$redis_base_image" \
|
||||
MARIADB_BASE_IMAGE="$mariadb_base_image" \
|
||||
MINIO_BASE_IMAGE="$minio_base_image" \
|
||||
compose_cmd -f "$COMPOSE_FILE" up -d --build
|
||||
}
|
||||
|
||||
@@ -107,9 +135,11 @@ rollback_stack() {
|
||||
for file in \
|
||||
agent.php \
|
||||
lan-worker.php \
|
||||
auto-updater.php \
|
||||
docker-compose.gateway.yml \
|
||||
Dockerfile.edge-agent \
|
||||
Dockerfile.lan-worker \
|
||||
Dockerfile.auto-updater \
|
||||
gateway-launcher.sh \
|
||||
truckwash-edge-gateway-stack.service \
|
||||
truckwash-edge-agent.service; do
|
||||
@@ -117,7 +147,10 @@ rollback_stack() {
|
||||
mv -f "$INSTALL_DIR/$file.bak" "$INSTALL_DIR/$file"
|
||||
fi
|
||||
done
|
||||
apply_stack
|
||||
if ! apply_stack; then
|
||||
write_rollback_status "FAILED" "rollback_apply_failed" "$installed_version"
|
||||
return 1
|
||||
fi
|
||||
write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"
|
||||
if [ -f "$STAGED_UPDATE_PATH" ]; then
|
||||
php -r '
|
||||
@@ -133,18 +166,53 @@ rollback_stack() {
|
||||
fi
|
||||
}
|
||||
|
||||
container_is_healthy() {
|
||||
local container_name="$1"
|
||||
local health
|
||||
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || echo missing)"
|
||||
[ "$health" = "healthy" ] || [ "$health" = "running" ]
|
||||
}
|
||||
|
||||
healthcheck_stack() {
|
||||
curl -fsS http://127.0.0.1:8090/health >/dev/null
|
||||
container_is_healthy truckwash-redis &&
|
||||
container_is_healthy truckwash-mariadb &&
|
||||
container_is_healthy truckwash-minio &&
|
||||
container_is_healthy truckwash-lan-worker &&
|
||||
container_is_healthy truckwash-edge-agent &&
|
||||
container_is_healthy truckwash-auto-updater
|
||||
}
|
||||
|
||||
wait_for_stack_health() {
|
||||
local timeout_seconds="${1:-$STACK_HEALTHCHECK_TIMEOUT_SECONDS}"
|
||||
local elapsed=0
|
||||
|
||||
while [ "$elapsed" -lt "$timeout_seconds" ]; do
|
||||
if healthcheck_stack; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
reconcile_stack() {
|
||||
local installed_version
|
||||
installed_version="$(config_value installedVersion '')"
|
||||
ensure_dirs
|
||||
log "Reconciling compose stack"
|
||||
apply_stack
|
||||
sleep 5
|
||||
if ! apply_stack; then
|
||||
log "Compose rollout failed during build/startup"
|
||||
print_compose_diagnostics
|
||||
write_rollback_status "FAILED" "compose_up_failed" "$installed_version"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! healthcheck_stack; then
|
||||
if ! wait_for_stack_health "$STACK_HEALTHCHECK_TIMEOUT_SECONDS"; then
|
||||
log "Healthcheck failed after compose rollout; reverting to previous artifacts"
|
||||
print_compose_diagnostics
|
||||
rollback_stack
|
||||
return 1
|
||||
fi
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ WorkingDirectory=/opt/truckwash-edge-agent
|
||||
ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up
|
||||
ExecReload=/opt/truckwash-edge-agent/gateway-launcher.sh reconcile
|
||||
ExecStop=/opt/truckwash-edge-agent/gateway-launcher.sh down
|
||||
TimeoutStartSec=300
|
||||
TimeoutStartSec=900
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
+447
@@ -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);
|
||||
}
|
||||
@@ -10,7 +10,19 @@ function app_path(string $relative = ''): string
|
||||
return WD;
|
||||
}
|
||||
|
||||
return WD . DIRECTORY_SEPARATOR . ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative), DIRECTORY_SEPARATOR);
|
||||
$normalized = ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative), DIRECTORY_SEPARATOR);
|
||||
|
||||
if (str_starts_with($normalized, 'classes' . DIRECTORY_SEPARATOR . 'edge_gateway_')) {
|
||||
$normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . basename($normalized);
|
||||
} elseif ($normalized === 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewaysRoute.php') {
|
||||
$normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewaysRoute.php';
|
||||
} elseif ($normalized === 'routes' . DIRECTORY_SEPARATOR . 'moduleEdgeGatewayRoute.php') {
|
||||
$normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'moduleEdgeGatewayRoute.php';
|
||||
} elseif ($normalized === 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewayConfigRoute.php') {
|
||||
$normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewayConfigRoute.php';
|
||||
}
|
||||
|
||||
return WD . DIRECTORY_SEPARATOR . $normalized;
|
||||
}
|
||||
|
||||
function app_require(string $relative): void
|
||||
@@ -36,6 +48,12 @@ spl_autoload_register(function (string $class): void {
|
||||
$candidates = [];
|
||||
|
||||
if (in_array($top, ['classes', 'interfaces', 'traits', 'objects', 'routes', 'statistics'], true)) {
|
||||
if ($top === 'classes' && str_starts_with(strtolower($relative), 'edge_gateway_')) {
|
||||
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . $relative;
|
||||
}
|
||||
if ($top === 'routes' && in_array($relative, ['edgeGatewaysRoute', 'moduleEdgeGatewayRoute', 'edgeGatewayConfigRoute'], true)) {
|
||||
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . $relative;
|
||||
}
|
||||
$candidates[] = $base . DIRECTORY_SEPARATOR . $top . DIRECTORY_SEPARATOR . $relative;
|
||||
} elseif ($top === 'modules') {
|
||||
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . $relative;
|
||||
@@ -77,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);
|
||||
@@ -94,4 +120,6 @@ function run_legacy_script(string $relativeScriptPath): array
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/ApiTestSupport.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');
|
||||
});
|
||||
+17
@@ -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');
|
||||
});
|
||||
+19
@@ -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'");
|
||||
});
|
||||
@@ -89,3 +89,62 @@ it('summarizes fleet usage statistics for the dashboard landing view', function
|
||||
'disk_usage_pct_avg' => 67,
|
||||
]);
|
||||
});
|
||||
|
||||
it('derives fleet usage directly from cached gateway row summaries', function (): void {
|
||||
$summary = edge_gateway_manager::summarizeFleetUsageFromGatewayRows([
|
||||
[
|
||||
'id' => 701,
|
||||
'department_id' => 1,
|
||||
'status' => edge_gateway_manager::STATUS_ONLINE,
|
||||
'version_drift' => ['is_drifted' => true],
|
||||
'channel_status' => ['broker' => ['connected' => true]],
|
||||
'active_operation' => ['id' => 91, 'type' => 'DISCOVERY'],
|
||||
'recent_operations_summary' => ['pending' => 1, 'in_progress' => 1],
|
||||
'backlog_depth' => ['operations' => 2, 'commands' => 3],
|
||||
'metadata' => [
|
||||
'system_metrics' => [
|
||||
'latency_ms' => 184,
|
||||
'cpu_usage_pct' => 27,
|
||||
'memory_usage_pct' => 61,
|
||||
'disk_usage_pct' => 58,
|
||||
],
|
||||
],
|
||||
'inventory_summary' => ['total' => 2, 'online' => 1, 'offline' => 1],
|
||||
'binding_summary' => ['total' => 3, 'fallback_overrides' => 2],
|
||||
'fallback_summary' => ['cloud_only_relays' => 1, 'local_only_relays' => 1],
|
||||
],
|
||||
[
|
||||
'id' => 702,
|
||||
'department_id' => 2,
|
||||
'status' => edge_gateway_manager::STATUS_OFFLINE,
|
||||
'version_drift' => ['is_drifted' => false],
|
||||
'channel_status' => ['broker' => ['connected' => false]],
|
||||
'active_operation' => null,
|
||||
'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0],
|
||||
'backlog_depth' => ['operations' => 0, 'commands' => 1],
|
||||
'metadata' => [
|
||||
'system_metrics' => [
|
||||
'latency_ms' => 412,
|
||||
'cpu_usage_pct' => 9,
|
||||
'memory_usage_pct' => 42,
|
||||
'disk_usage_pct' => 76,
|
||||
],
|
||||
],
|
||||
'inventory_summary' => ['total' => 1, 'online' => 1, 'offline' => 0],
|
||||
'binding_summary' => ['total' => 0, 'fallback_overrides' => 0],
|
||||
'fallback_summary' => ['cloud_only_relays' => 0, 'local_only_relays' => 0],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($summary['inventory'])->toBe([
|
||||
'total' => 3,
|
||||
'online' => 2,
|
||||
'offline' => 1,
|
||||
]);
|
||||
expect($summary['bindings'])->toBe([
|
||||
'total' => 3,
|
||||
'fallback_overrides' => 2,
|
||||
'cloud_only' => 1,
|
||||
'local_only' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/edge_gateway_manager.php');
|
||||
|
||||
use classes\edge_gateway_manager;
|
||||
|
||||
it('caps install-session diagnostics and events while preserving the first start timestamp', function (): void {
|
||||
$session = [];
|
||||
|
||||
for ($index = 1; $index <= 14; $index += 1) {
|
||||
$session = edge_gateway_manager::mergeInstallSessionUpdate($session, [
|
||||
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_RUNNING,
|
||||
'step' => 'STEP_' . $index,
|
||||
'message' => 'Installer phase ' . $index,
|
||||
], strtotime('2026-04-08 10:00:' . str_pad((string)$index, 2, '0', STR_PAD_LEFT)));
|
||||
}
|
||||
|
||||
$failed = edge_gateway_manager::mergeInstallSessionUpdate($session, [
|
||||
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED,
|
||||
'step' => 'START_STACK',
|
||||
'message' => 'Compose rollout failed during startup.',
|
||||
'diagnostics' => array_map(
|
||||
static fn(int $index): array => [
|
||||
'name' => 'Diagnostic ' . $index,
|
||||
'output' => 'Output ' . $index,
|
||||
],
|
||||
range(1, 8)
|
||||
),
|
||||
], strtotime('2026-04-08 10:01:30'));
|
||||
|
||||
expect($failed['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED);
|
||||
expect($failed['step'])->toBe('START_STACK');
|
||||
expect($failed['message'])->toBe('Compose rollout failed during startup.');
|
||||
expect($failed['started_at'])->toBe('2026-04-08 10:00:01');
|
||||
expect($failed['updated_at'])->toBe('2026-04-08 10:01:30');
|
||||
expect($failed['last_error'])->toBe('Compose rollout failed during startup.');
|
||||
expect($failed['diagnostics'])->toHaveCount(6);
|
||||
expect($failed['diagnostics'][0]['name'])->toBe('Diagnostic 3');
|
||||
expect($failed['diagnostics'][5]['name'])->toBe('Diagnostic 8');
|
||||
expect($failed['events'])->toHaveCount(12);
|
||||
expect($failed['events'][11]['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED);
|
||||
expect($failed['events'][11]['step'])->toBe('START_STACK');
|
||||
});
|
||||
|
||||
it('clears terminal failure details after a successful claim update', function (): void {
|
||||
$claimed = edge_gateway_manager::mergeInstallSessionUpdate([
|
||||
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED,
|
||||
'step' => 'START_STACK',
|
||||
'message' => 'Compose rollout failed during startup.',
|
||||
'started_at' => '2026-04-08 10:00:01',
|
||||
'updated_at' => '2026-04-08 10:01:30',
|
||||
'last_error' => 'Compose rollout failed during startup.',
|
||||
'diagnostics' => [
|
||||
['name' => 'systemctl status', 'output' => 'failed'],
|
||||
],
|
||||
'events' => [
|
||||
[
|
||||
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED,
|
||||
'step' => 'START_STACK',
|
||||
'message' => 'Compose rollout failed during startup.',
|
||||
'at' => '2026-04-08 10:01:30',
|
||||
],
|
||||
],
|
||||
], [
|
||||
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED,
|
||||
'step' => edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED,
|
||||
'message' => 'Gateway claimed successfully.',
|
||||
'gateway_id' => 703,
|
||||
], strtotime('2026-04-08 10:02:00'));
|
||||
|
||||
expect($claimed['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED);
|
||||
expect($claimed['gateway_id'])->toBe(703);
|
||||
expect($claimed['last_error'])->toBeNull();
|
||||
expect($claimed['diagnostics'])->toBe([]);
|
||||
expect($claimed['events'])->toHaveCount(2);
|
||||
expect($claimed['events'][1]['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED);
|
||||
});
|
||||
|
||||
it('marks expired non-terminal install sessions as terminal when read back', function (): void {
|
||||
$normalized = edge_gateway_manager::normalizeInstallSessionRecord([
|
||||
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_RUNNING,
|
||||
'step' => 'WAIT_FOR_CLAIM',
|
||||
'message' => 'Installer is waiting for the gateway heartbeat and claim.',
|
||||
'started_at' => '2026-04-08 10:00:01',
|
||||
'updated_at' => '2026-04-08 10:01:30',
|
||||
], '2026-04-08 10:01:00', strtotime('2026-04-08 10:02:00'));
|
||||
|
||||
expect($normalized['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_EXPIRED);
|
||||
expect($normalized['step'])->toBe('WAIT_FOR_CLAIM');
|
||||
expect($normalized['updated_at'])->toBe('2026-04-08 10:01:30');
|
||||
expect($normalized['message'])->toBe('Installer is waiting for the gateway heartbeat and claim.');
|
||||
expect($normalized['last_error'])->toBe('Install token expired.');
|
||||
expect($normalized['terminal'])->toBeTrue();
|
||||
});
|
||||
@@ -16,8 +16,12 @@ it('keeps relay dispatch and discovery queueing on the edge gateway manager', fu
|
||||
expect($managerSource)->toContain('public function buildUpdateOperationRequest');
|
||||
expect($managerSource)->toContain('public function rotateGatewayCredentials');
|
||||
expect($operationServiceSource)->toContain('public function queueOperation');
|
||||
expect($operationServiceSource)->toContain('public function cancelOperation');
|
||||
expect($operationServiceSource)->toContain('public function claimNextOperation');
|
||||
expect($operationServiceSource)->toContain('public function completeAgentOperation');
|
||||
expect($operationServiceSource)->toContain("public const STATUS_CANCEL_REQUESTED = 'CANCEL_REQUESTED';");
|
||||
expect($operationServiceSource)->toContain("public const STATUS_CANCELLED = 'CANCELLED';");
|
||||
expect($operationServiceSource)->toContain("public const ERROR_CANCELLED = 'EDGE_GATEWAY_CANCELLED';");
|
||||
expect($operationServiceSource)->toContain('public const OPERATION_LEASE_SECONDS = 45;');
|
||||
expect($operationServiceSource)->toContain('private function refreshOperationLease');
|
||||
expect($operationServiceSource)->toContain('agent_instance_id');
|
||||
@@ -41,6 +45,7 @@ it('loads relay command helpers on the manager and gateway operations on the ded
|
||||
expect($reflection->getMethod('syncDeviceInventory')->isPrivate())->toBeTrue();
|
||||
expect($reflection->hasMethod('syncGatewayInventory'))->toBeTrue();
|
||||
expect($operationServiceReflection->hasMethod('queueOperation'))->toBeTrue();
|
||||
expect($operationServiceReflection->hasMethod('cancelOperation'))->toBeTrue();
|
||||
expect($operationServiceReflection->hasMethod('listOperations'))->toBeTrue();
|
||||
expect($operationServiceReflection->hasMethod('claimNextOperation'))->toBeTrue();
|
||||
expect($operationServiceReflection->hasMethod('appendAgentOperationEvent'))->toBeTrue();
|
||||
|
||||
@@ -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',
|
||||
@@ -43,21 +50,29 @@ it('builds install script urls with the compose edge gateway artifacts and forwa
|
||||
expect($manager->buildInstallScriptUrl('abc123'))->toBe('https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123');
|
||||
expect($manager->buildInstallCommand('abc123'))->toContain("https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123");
|
||||
expect($script)->toContain('fetch_http "Verify install token" "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123"');
|
||||
expect($script)->toContain('INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"');
|
||||
expect($script)->toContain('fetch_http "Download PHP edge agent" "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php" "$INSTALL_DIR/agent.php"');
|
||||
expect($script)->toContain('fetch_http "Download LAN worker" "https://api.truckwash.io:4433/edge-agent/artifacts/lan-worker.php" "$INSTALL_DIR/lan-worker.php"');
|
||||
expect($script)->toContain('fetch_http "Download compose stack" "https://api.truckwash.io:4433/edge-agent/artifacts/docker-compose.gateway.yml" "$INSTALL_DIR/docker-compose.gateway.yml"');
|
||||
expect($script)->toContain('fetch_http "Download compose stack service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-gateway-stack.service" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
|
||||
expect($script)->toContain('Installer failed during step: ${CURRENT_STEP:-unknown}');
|
||||
expect($script)->toContain('report_install_status() {');
|
||||
expect($script)->toContain('begin_install_phase "VERIFY_TOKEN" "Verifying install token"');
|
||||
expect($script)->toContain('begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"');
|
||||
expect($script)->toContain('report_install_status "FAILED" "FAILED" "$failure_message" "$diagnostics_json" "$gateway_id"');
|
||||
expect($script)->toContain('report_install_status "CLAIMED" "CLAIMED"');
|
||||
expect($script)->toContain('Installer failed during step ${CURRENT_STEP_CODE:-FAILED}: ${CURRENT_STEP:-unknown}');
|
||||
expect($script)->toContain('Last request: ${CURRENT_METHOD} ${CURRENT_URL}');
|
||||
expect($script)->toContain('Response body preview (first 400 bytes):');
|
||||
expect($script)->toContain('wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
||||
expect($script)->toContain('wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
||||
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
|
||||
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
|
||||
expect($script)->toContain('"stackServiceName":"truckwash-edge-gateway-stack.service"');
|
||||
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:4433/edge-broker"');
|
||||
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
|
||||
expect($script)->not->toContain('"brokerUrl"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,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');
|
||||
|
||||
@@ -86,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)->not->toContain('"brokerUrl"');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
it('registers module-scoped edge gateway operator routes', function (): void {
|
||||
$route = file_get_contents(app_path('modules/edgegateway/routes/moduleEdgeGatewayRoute.php'));
|
||||
|
||||
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'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/{id}/discovery'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/{id}/bindings'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/{id}/operations'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/{id}/operations/{operationId}/cancel'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/{id}/operations/{operationId}/events'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/{id}/rotate-credentials'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/departments/{id}/cutover'");
|
||||
expect($route)->toContain('requireModuleEnabled()');
|
||||
expect($route)->not->toContain("'/modules/edge-gateways/{id}/shell-sessions'");
|
||||
});
|
||||
|
||||
it('registers edge gateway config endpoints from the module route directory', function (): void {
|
||||
$route = file_get_contents(app_path('modules/edgegateway/routes/edgeGatewayConfigRoute.php'));
|
||||
$legacyRoute = file_get_contents(app_path('routes/moduleConfigRoute.php'));
|
||||
|
||||
expect($route)->not->toBeFalse();
|
||||
expect($route)->toContain("'/edgegateway/config'");
|
||||
expect($route)->toContain('new edgegateway()');
|
||||
expect($legacyRoute)->not->toContain("'/edgegateway/config'");
|
||||
});
|
||||
|
||||
it('keeps only the module facade in the global classes directory and conditionally loads module routes', function (): void {
|
||||
$classes = glob(app_path('classes/*.php')) ?: [];
|
||||
$edgeGatewayClasses = array_values(array_filter($classes, static function (string $path): bool {
|
||||
$name = basename($path);
|
||||
return str_contains($name, 'edgegateway') || str_contains($name, 'edge_gateway');
|
||||
}));
|
||||
$index = file_get_contents(app_path('index.php'));
|
||||
|
||||
expect($edgeGatewayClasses)->toEqual([app_path('classes/edgegateway.php')]);
|
||||
expect($index)->toContain("\$routes_path = \$modules_path . DIRECTORY_SEPARATOR . \$module_dir . DIRECTORY_SEPARATOR . 'routes'");
|
||||
expect($index)->toContain("method_exists(\$module, 'isEnabled') && !\$module->isEnabled()");
|
||||
});
|
||||
@@ -7,14 +7,21 @@ it('registers the v2 operator-facing edge gateway routes', function (): void {
|
||||
expect($route)->toContain("'/edge-gateways'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}'");
|
||||
expect($route)->toContain("'/edge-gateways/install-token'");
|
||||
expect($route)->toContain("'/edge-gateways/install-token/{id}/status'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/discovery'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/bindings'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/operations'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/cancel'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/events'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/tasks'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/logs'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/statistics'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/stream-session'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/rotate-credentials'");
|
||||
expect($route)->toContain("'/departments/{id}/gateway-cutover'");
|
||||
expect($route)->toContain("add_meta('fleet_usage'");
|
||||
expect($route)->not->toContain("'/edge-gateways/{id}/shell-sessions'");
|
||||
expect($route)->toContain('listGatewaysWithFleetUsage(');
|
||||
expect($route)->not->toContain('private function requirePermission');
|
||||
expect($route)->not->toContain('private function requireDepartmentAccess');
|
||||
});
|
||||
@@ -23,12 +30,15 @@ it('registers PHP edge agent routes for operations and legacy relay command poll
|
||||
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
||||
|
||||
expect($route)->toContain("'/edge-agent/install-token/verify'");
|
||||
expect($route)->toContain("'/edge-agent/install-token/status'");
|
||||
expect($route)->toContain("'/edge-agent/install.sh'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/agent.php'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/lan-worker.php'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/auto-updater.php'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/docker-compose.gateway.yml'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.edge-agent'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.lan-worker'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.auto-updater'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/gateway-launcher.sh'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/truckwash-edge-gateway-stack.service'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/truckwash-edge-agent.service'");
|
||||
@@ -40,6 +50,12 @@ it('registers PHP edge agent routes for operations and legacy relay command poll
|
||||
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/poll'");
|
||||
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/{jobId}/result'");
|
||||
expect($route)->toContain("'/edge-agent/gateways/{id}/presence'");
|
||||
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/validate'");
|
||||
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/backlog'");
|
||||
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/telemetry'");
|
||||
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/logs'");
|
||||
expect($route)->toContain("'/edge-agent/internal/browser-streams/validate'");
|
||||
expect($route)->toContain("'/edge-agent/internal/shell-sessions/opened'");
|
||||
expect($route)->toContain('echo $exception->getMessage()');
|
||||
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
|
||||
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
|
||||
|
||||
@@ -12,7 +12,8 @@ it('defines the v2 edge gateway schema bootstrap tables', function (): void {
|
||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operations');
|
||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operation_events');
|
||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs');
|
||||
expect($bootstrapContent)->not->toContain('edge_gateway_shell_sessions');
|
||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_log_entries');
|
||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions');
|
||||
expect($bootstrapContent)->not->toContain('edge_gateway_shell_action_jobs');
|
||||
expect($bootstrapContent)->not->toContain('edge_gateway_shell_events');
|
||||
});
|
||||
@@ -40,7 +41,10 @@ it('stores operation metadata and event timelines for management workflows', fun
|
||||
expect($bootstrapContent)->toContain('SET type = operation_type');
|
||||
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message')");
|
||||
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity')");
|
||||
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_log_entries', 'context_json', 'JSON NULL AFTER message')");
|
||||
expect($bootstrapContent)->toContain('summary_json JSON NULL');
|
||||
expect($bootstrapContent)->toContain('context_json JSON NULL');
|
||||
expect($bootstrapContent)->not->toContain('session_token_hash CHAR(64) NOT NULL');
|
||||
expect($bootstrapContent)->toContain('session_token_hash CHAR(64) NOT NULL');
|
||||
expect($bootstrapContent)->toContain('terminal_rows INT NULL');
|
||||
expect($bootstrapContent)->toContain("renameColumnIfPresent('edge_gateway_shell_sessions', 'rows', 'terminal_rows', 'INT NULL', 'cols')");
|
||||
});
|
||||
|
||||
@@ -4,44 +4,146 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
||||
$serviceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-agent.service'));
|
||||
$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'));
|
||||
$autoUpdaterDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.auto-updater'));
|
||||
|
||||
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();
|
||||
expect($autoUpdaterDockerfileSource)->not->toBeFalse();
|
||||
expect($managerSource)->toContain("'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS");
|
||||
expect($managerSource)->toContain('fetch_http "Verify install token" "__VERIFY_URL__"');
|
||||
expect($managerSource)->toContain('fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"');
|
||||
expect($managerSource)->toContain('fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"');
|
||||
expect($managerSource)->toContain('fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.php"');
|
||||
expect($managerSource)->toContain('fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml"');
|
||||
expect($managerSource)->toContain('fetch_http "Download auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.auto-updater"');
|
||||
expect($managerSource)->toContain('fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
|
||||
expect($managerSource)->toContain('log_error "Request: GET ${url}"');
|
||||
expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"');
|
||||
expect($managerSource)->toContain('apt-get install -y curl ca-certificates docker.io docker-compose-plugin php-cli php-curl php-mbstring php-sqlite3');
|
||||
expect($managerSource)->toContain('run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3');
|
||||
expect($managerSource)->toContain('run_step "Installing Docker Compose runtime" install_compose_runtime');
|
||||
expect($managerSource)->toContain('apt-get install -y docker-compose-plugin');
|
||||
expect($managerSource)->toContain('apt-get install -y docker-compose');
|
||||
expect($managerSource)->toContain('Unable to install Docker Compose using docker-compose-plugin or docker-compose.');
|
||||
expect($managerSource)->toContain('Existing claimed gateway detected; reinstall will reuse saved gateway credentials.');
|
||||
expect($managerSource)->toContain('report_install_status() {');
|
||||
expect($managerSource)->toContain('begin_install_phase "START_STACK" "Starting edge gateway stack"');
|
||||
expect($managerSource)->toContain('report_install_status "RUNNING" "$CURRENT_STEP_CODE" "$CURRENT_STEP"');
|
||||
expect($managerSource)->toContain('report_install_status "FAILED" "FAILED" "$failure_message" "$diagnostics_json" "$gateway_id"');
|
||||
expect($managerSource)->toContain('run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"');
|
||||
expect($managerSource)->toContain('chmod 0755 "$INSTALL_DIR/agent.php" "$INSTALL_DIR/lan-worker.php" "$INSTALL_DIR/auto-updater.php" "$INSTALL_DIR/gateway-launcher.sh"');
|
||||
expect($managerSource)->toContain('systemctl enable truckwash-edge-gateway-stack.service');
|
||||
expect($managerSource)->toContain('systemctl restart truckwash-edge-gateway-stack.service');
|
||||
expect($managerSource)->toContain('systemctl is-active --quiet truckwash-edge-gateway-stack.service');
|
||||
expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
|
||||
expect($managerSource)->toContain('run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
|
||||
expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
||||
expect($managerSource)->toContain('run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
||||
expect($managerSource)->toContain('journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager || true');
|
||||
expect($managerSource)->not->toContain('agent.mjs');
|
||||
expect($managerSource)->not->toContain('"brokerUrl"');
|
||||
expect($managerSource)->toContain("'brokerUrl' => \$this->buildBrokerPublicUrl()");
|
||||
expect($serviceSource)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json');
|
||||
expect($stackServiceSource)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up');
|
||||
expect($stackServiceSource)->toContain('TimeoutStartSec=900');
|
||||
expect($launcherSource)->toContain('STACK_HEALTHCHECK_TIMEOUT_SECONDS="${STACK_HEALTHCHECK_TIMEOUT_SECONDS:-300}"');
|
||||
expect($launcherSource)->toContain('wait_for_stack_health');
|
||||
expect($launcherSource)->toContain('container_is_healthy truckwash-auto-updater');
|
||||
expect($launcherSource)->toContain('compose_project_name="$(config_value composeProjectName \'truckwash-edge-gateway\')"');
|
||||
expect($launcherSource)->toContain('COMPOSE_PROJECT_NAME="$compose_project_name"');
|
||||
expect($launcherSource)->toContain('AUTO_UPDATER_BASE_IMAGE="$auto_updater_base_image"');
|
||||
expect($launcherSource)->toContain('compose_cmd -f "$COMPOSE_FILE" logs --tail=80 || true');
|
||||
expect($launcherSource)->toContain('log "Compose rollout failed during build/startup"');
|
||||
expect($launcherSource)->toContain('write_rollback_status "FAILED" "compose_up_failed" "$installed_version"');
|
||||
expect($launcherSource)->toContain('write_rollback_status "FAILED" "rollback_apply_failed" "$installed_version"');
|
||||
expect($launcherSource)->toContain('write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"');
|
||||
expect($composeSource)->toContain('version: "2.4"');
|
||||
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('/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\"');
|
||||
expect($composeSource)->not->toContain("curl\", \"-fsS\", \"http://127.0.0.1:9000/minio/health/live");
|
||||
expect($composeSource)->not->toContain('mysqladmin ping -h 127.0.0.1 -uroot -ptruckwash_edge_root --silent');
|
||||
expect($composeSource)->toContain('container_name: truckwash-redis');
|
||||
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');
|
||||
expect($workerDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
|
||||
expect($workerDockerfileSource)->toContain('COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php');
|
||||
expect($workerDockerfileSource)->not->toContain('docker-php-ext-install');
|
||||
expect($autoUpdaterSource)->toContain("'/bin/bash ' . escapeshellarg(\$launcherPath) . ' reconcile 2>&1'");
|
||||
expect($autoUpdaterDockerfileSource)->toContain('COPY auto-updater.php /usr/local/bin/auto-updater.php');
|
||||
expect($autoUpdaterDockerfileSource)->toContain('apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose;');
|
||||
expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs');
|
||||
});
|
||||
|
||||
it('exposes update payload, credential rotation, and operation endpoints without shell transport wiring', function (): void {
|
||||
it('exposes update payload, credential rotation, cancel endpoints, and operation endpoints without shell transport wiring', function (): void {
|
||||
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
||||
$routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
||||
$agentSource = file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
|
||||
|
||||
expect($managerSource)->toContain('public function buildUpdateOperationRequest');
|
||||
expect($managerSource)->toContain('public function rotateGatewayCredentials');
|
||||
expect($managerSource)->toContain("'autoUpdaterArtifactUrl' => \$this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT)");
|
||||
expect($managerSource)->toContain("'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE");
|
||||
expect($managerSource)->toContain("'brokerUrl' => \$this->buildBrokerPublicUrl()");
|
||||
expect($routeSource)->toContain("'/edge-gateways/{id}/rotate-credentials'");
|
||||
expect($routeSource)->toContain("'/edge-gateways/{id}/operations/{operationId}/cancel'");
|
||||
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/operations/next'");
|
||||
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions'");
|
||||
expect($agentSource)->toContain("/operations/next");
|
||||
expect($agentSource)->toContain("/operations/' . \$operationId . '/complete");
|
||||
expect($agentSource)->toContain('final class OperationAbortException extends RuntimeException');
|
||||
expect($agentSource)->toContain('private BrokerWebSocketClient $brokerClient;');
|
||||
expect($agentSource)->toContain('private AgentShellBridge $shellBridge;');
|
||||
expect($agentSource)->toContain('private function dispatchBrokerControlPlaneEvent(string $endpoint, array $payload): ?bool');
|
||||
expect($agentSource)->toContain("'type' => 'TELEMETRY'");
|
||||
expect($agentSource)->toContain("'type' => 'TASK_EVENT'");
|
||||
expect($agentSource)->toContain("'type' => 'TASK_RESULT'");
|
||||
expect($agentSource)->toContain("'type' => 'LOG_FRAME'");
|
||||
expect($agentSource)->toContain('private function requestControlPlaneEvent(string $endpoint, array $payload, string $type, int $timeoutSeconds = 20): ?array');
|
||||
expect($agentSource)->toContain('throw new OperationAbortException(\'Operation cancelled by operator\', true);');
|
||||
expect($agentSource)->toContain('private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120;');
|
||||
expect($agentSource)->toContain('if ($this->resumePendingOperationCompletion()) {');
|
||||
expect($agentSource)->toContain('$this->finalizeOperationCompletion(');
|
||||
expect($agentSource)->toContain('private function finalizeOperationCompletion(');
|
||||
expect($agentSource)->toContain('private function resumePendingOperationCompletion(): bool');
|
||||
expect($agentSource)->toContain('private function dispatchOperationCompletion(array $completion, bool $queueOnFailure): bool');
|
||||
expect($agentSource)->toContain("\$state['status'] = 'COMPLETION_PENDING';");
|
||||
expect($agentSource)->toContain("\$state['stage'] = 'awaiting_completion_ack';");
|
||||
expect($agentSource)->toContain("? self::OPERATION_COMPLETE_TIMEOUT_SECONDS");
|
||||
expect($agentSource)->toContain("unset(\$state['completion']);");
|
||||
expect($agentSource)->toContain('$services[] = $this->probeTcpService(\'redis\', \'redis\', 6379);');
|
||||
expect($agentSource)->toContain('final class HttpRequestTimeoutException extends RuntimeException');
|
||||
expect($agentSource)->toContain('], $this->pollRequestTimeoutSeconds($waitSeconds));');
|
||||
expect($agentSource)->toContain('} catch (HttpRequestTimeoutException) {');
|
||||
expect($agentSource)->toContain('private function pollRequestTimeoutSeconds(int $waitSeconds): int');
|
||||
expect($agentSource)->toContain('last-heartbeat-ok.txt');
|
||||
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions'");
|
||||
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/edge_gateway_manager.php');
|
||||
app_require('classes/edge_gateway_view_cache.php');
|
||||
|
||||
use classes\edge_gateway_manager;
|
||||
use classes\edge_gateway_view_cache;
|
||||
|
||||
if (!class_exists('EdgeGatewayViewCacheRedisFake')) {
|
||||
class EdgeGatewayViewCacheRedisFake
|
||||
{
|
||||
/** @var array<string,string> */
|
||||
public array $store = [];
|
||||
|
||||
public function get(string $key): ?string
|
||||
{
|
||||
return $this->store[$key] ?? null;
|
||||
}
|
||||
|
||||
public function setEx(string $key, string $value, int $ttl): void
|
||||
{
|
||||
$this->store[$key] = $value;
|
||||
}
|
||||
|
||||
public function clear_keys(string $pattern): void
|
||||
{
|
||||
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
|
||||
foreach (array_keys($this->store) as $key) {
|
||||
if (preg_match($regex, $key) === 1) {
|
||||
unset($this->store[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->oldTtl = getenv('EDGE_GATEWAY_VIEW_CACHE_TTL');
|
||||
$this->redis = new EdgeGatewayViewCacheRedisFake();
|
||||
edge_gateway_view_cache::setAdapterForTests($this->redis);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
edge_gateway_view_cache::setAdapterForTests(null);
|
||||
|
||||
if ($this->oldTtl === false) {
|
||||
putenv('EDGE_GATEWAY_VIEW_CACHE_TTL');
|
||||
return;
|
||||
}
|
||||
|
||||
putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=' . $this->oldTtl);
|
||||
});
|
||||
|
||||
function edgeGatewayCacheGatewayRow(
|
||||
int $gatewayId,
|
||||
int $departmentId,
|
||||
string $status,
|
||||
array $inventorySummary = ['total' => 0, 'online' => 0, 'offline' => 0],
|
||||
array $bindingSummary = ['total' => 0, 'fallback_overrides' => 0],
|
||||
array $fallbackSummary = ['cloud_only_relays' => 0, 'local_only_relays' => 0]
|
||||
): array {
|
||||
return [
|
||||
'id' => $gatewayId,
|
||||
'department_id' => $departmentId,
|
||||
'label' => 'Gateway ' . $gatewayId,
|
||||
'status' => $status,
|
||||
'version_drift' => ['is_drifted' => false],
|
||||
'channel_status' => ['broker' => ['connected' => $status === edge_gateway_manager::STATUS_ONLINE]],
|
||||
'active_operation' => null,
|
||||
'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0],
|
||||
'backlog_depth' => ['operations' => 0, 'commands' => 0],
|
||||
'metadata' => [
|
||||
'system_metrics' => [
|
||||
'latency_ms' => 100,
|
||||
'cpu_usage_pct' => 10,
|
||||
'memory_usage_pct' => 20,
|
||||
'disk_usage_pct' => 30,
|
||||
],
|
||||
],
|
||||
'inventory_summary' => $inventorySummary,
|
||||
'binding_summary' => $bindingSummary,
|
||||
'fallback_summary' => $fallbackSummary,
|
||||
'inventory' => [],
|
||||
'bindings' => [],
|
||||
'recent_commands' => [],
|
||||
'audit_logs' => [],
|
||||
'operations' => [],
|
||||
];
|
||||
}
|
||||
|
||||
it('uses sane ttl defaults and deterministic cache keys', function (): void {
|
||||
putenv('EDGE_GATEWAY_VIEW_CACHE_TTL');
|
||||
expect(edge_gateway_view_cache::getTtl())->toBe(15);
|
||||
|
||||
putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=25');
|
||||
expect(edge_gateway_view_cache::getTtl())->toBe(25);
|
||||
|
||||
putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=-5');
|
||||
expect(edge_gateway_view_cache::getTtl())->toBe(0);
|
||||
|
||||
expect(edge_gateway_view_cache::listKey(null, false))->toBe('edge_gateway:view:v1:list:department:all:detail:0');
|
||||
expect(edge_gateway_view_cache::detailKey(701))->toBe('edge_gateway:view:v1:detail:701');
|
||||
});
|
||||
|
||||
it('stores and retrieves cached list and detail payloads', function (): void {
|
||||
$gateway = edgeGatewayCacheGatewayRow(701, 1, edge_gateway_manager::STATUS_ONLINE, ['total' => 2, 'online' => 1, 'offline' => 1]);
|
||||
$payload = [
|
||||
'gateways' => [$gateway],
|
||||
'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$gateway]),
|
||||
];
|
||||
|
||||
edge_gateway_view_cache::storeListPayload(null, false, $payload, 30);
|
||||
edge_gateway_view_cache::storeDetailPayload(701, $gateway, 30);
|
||||
|
||||
expect(edge_gateway_view_cache::getListPayload(null, false))->toBe($payload);
|
||||
expect(edge_gateway_view_cache::getDetailPayload(701))->toBe($gateway);
|
||||
});
|
||||
|
||||
it('syncs gateway snapshots into cached list payloads and refreshes fleet usage', function (): void {
|
||||
$staleGateway = edgeGatewayCacheGatewayRow(
|
||||
701,
|
||||
1,
|
||||
edge_gateway_manager::STATUS_OFFLINE,
|
||||
['total' => 1, 'online' => 0, 'offline' => 1],
|
||||
['total' => 1, 'fallback_overrides' => 0],
|
||||
['cloud_only_relays' => 0, 'local_only_relays' => 0]
|
||||
);
|
||||
$otherGateway = edgeGatewayCacheGatewayRow(
|
||||
702,
|
||||
2,
|
||||
edge_gateway_manager::STATUS_ONLINE,
|
||||
['total' => 1, 'online' => 1, 'offline' => 0],
|
||||
['total' => 1, 'fallback_overrides' => 1],
|
||||
['cloud_only_relays' => 1, 'local_only_relays' => 0]
|
||||
);
|
||||
|
||||
edge_gateway_view_cache::storeListPayload(null, false, [
|
||||
'gateways' => [$staleGateway, $otherGateway],
|
||||
'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$staleGateway, $otherGateway]),
|
||||
]);
|
||||
edge_gateway_view_cache::storeListPayload(1, false, [
|
||||
'gateways' => [$staleGateway],
|
||||
'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$staleGateway]),
|
||||
]);
|
||||
|
||||
$freshGateway = [
|
||||
'id' => 701,
|
||||
'department_id' => 1,
|
||||
'label' => 'Gateway 701',
|
||||
'status' => edge_gateway_manager::STATUS_ONLINE,
|
||||
'version_drift' => ['is_drifted' => false],
|
||||
'channel_status' => ['broker' => ['connected' => true]],
|
||||
'active_operation' => null,
|
||||
'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0],
|
||||
'backlog_depth' => ['operations' => 0, 'commands' => 0],
|
||||
'metadata' => [
|
||||
'system_metrics' => [
|
||||
'latency_ms' => 150,
|
||||
'cpu_usage_pct' => 15,
|
||||
'memory_usage_pct' => 25,
|
||||
'disk_usage_pct' => 35,
|
||||
],
|
||||
],
|
||||
'inventory' => [
|
||||
['id' => 1, 'online' => true],
|
||||
['id' => 2, 'online' => true],
|
||||
],
|
||||
'bindings' => [
|
||||
['id' => 1, 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL],
|
||||
['id' => 2, 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_CLOUD_ONLY],
|
||||
],
|
||||
'recent_commands' => [],
|
||||
'audit_logs' => [],
|
||||
'operations' => [],
|
||||
'fallback_summary' => ['cloud_only_relays' => 1, 'local_only_relays' => 0],
|
||||
];
|
||||
|
||||
edge_gateway_view_cache::syncGateway($freshGateway);
|
||||
|
||||
$allGatewaysPayload = edge_gateway_view_cache::getListPayload(null, false);
|
||||
$departmentPayload = edge_gateway_view_cache::getListPayload(1, false);
|
||||
$detailPayload = edge_gateway_view_cache::getDetailPayload(701);
|
||||
|
||||
expect($detailPayload)->not->toBeNull();
|
||||
expect($detailPayload['inventory_summary'])->toBe(['total' => 2, 'online' => 2, 'offline' => 0]);
|
||||
expect($detailPayload['binding_summary'])->toBe(['total' => 2, 'fallback_overrides' => 1]);
|
||||
|
||||
expect($allGatewaysPayload)->not->toBeNull();
|
||||
expect($allGatewaysPayload['gateways'][0]['status'])->toBe(edge_gateway_manager::STATUS_ONLINE);
|
||||
expect($allGatewaysPayload['gateways'][0]['inventory'])->toBe([]);
|
||||
expect($allGatewaysPayload['fleet_usage']['gateways']['online'])->toBe(2);
|
||||
expect($allGatewaysPayload['fleet_usage']['inventory']['online'])->toBe(3);
|
||||
expect($allGatewaysPayload['fleet_usage']['bindings']['cloud_only'])->toBe(2);
|
||||
|
||||
expect($departmentPayload)->not->toBeNull();
|
||||
expect($departmentPayload['gateways'][0]['status'])->toBe(edge_gateway_manager::STATUS_ONLINE);
|
||||
expect($departmentPayload['fleet_usage']['inventory']['online'])->toBe(2);
|
||||
});
|
||||
|
||||
it('removes deleted gateways from cached list and detail payloads', function (): void {
|
||||
$gatewayA = edgeGatewayCacheGatewayRow(701, 1, edge_gateway_manager::STATUS_ONLINE);
|
||||
$gatewayB = edgeGatewayCacheGatewayRow(702, 1, edge_gateway_manager::STATUS_OFFLINE);
|
||||
$payload = [
|
||||
'gateways' => [$gatewayA, $gatewayB],
|
||||
'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$gatewayA, $gatewayB]),
|
||||
];
|
||||
|
||||
edge_gateway_view_cache::storeListPayload(null, false, $payload);
|
||||
edge_gateway_view_cache::storeListPayload(1, false, $payload);
|
||||
edge_gateway_view_cache::storeDetailPayload(701, $gatewayA);
|
||||
|
||||
edge_gateway_view_cache::removeGateway(701, 1);
|
||||
|
||||
expect(edge_gateway_view_cache::getDetailPayload(701))->toBeNull();
|
||||
expect(edge_gateway_view_cache::getListPayload(null, false)['gateways'])->toHaveCount(1);
|
||||
expect(edge_gateway_view_cache::getListPayload(1, false)['gateways'])->toHaveCount(1);
|
||||
expect(edge_gateway_view_cache::getListPayload(null, false)['fleet_usage']['gateways']['total'])->toBe(1);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user