Compare commits

..
50 changed files with 7693 additions and 320 deletions
+46
View File
@@ -120,6 +120,52 @@ jobs:
working-directory: services/edge-broker working-directory: services/edge-broker
run: npm test 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: integration:
name: Integration (advisory) name: Integration (advisory)
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, default]
+1
View File
@@ -11,3 +11,4 @@
.env .env
/services/caddy/logs* /services/caddy/logs*
/.tmp/ /.tmp/
/.env.staging
+24
View File
@@ -91,6 +91,8 @@ For local Docker development, run the PHP suites inside `php1`:
docker exec php1 sh -lc "cd /var/www/html && composer test:unit" docker exec php1 sh -lc "cd /var/www/html && composer test:unit"
docker exec php1 sh -lc "cd /var/www/html && composer test:integration" 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"
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: 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 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 ### 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. `api.truckwash.io:4433` is the public staging ingress. For Edge Gateways v2, the router must serve the canonical artifacts from `services/nginx/app/resources/edge-gateway-agent`, not from the legacy `dist/agent.mjs` output or a separate runtime mount.
+16 -2
View File
@@ -52,9 +52,23 @@ services:
dockerfile: services/edge-broker/Dockerfile dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker container_name: edge-broker
environment: environment:
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
ports: labels:
- "4300:4300" - "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: caddy:
image: caddy:2.7.6-alpine image: caddy:2.7.6-alpine
+425
View File
@@ -0,0 +1,425 @@
services:
traefik:
image: traefik:2.11
container_name: traefik
ports:
- "80:80"
- "443:443"
- "4433:4433"
- "9100:9100"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./services/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./services/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
- ./services/traefik/acme.json:/acme.json
- ./services/traefik/acme-io.json:/acme-io.json
labels:
- "traefik.enable=true"
- "traefik.http.routers.traefik.rule=Host(`traefik.truckwash.dk`)"
- "traefik.http.routers.traefik.entrypoints=websecure"
- "traefik.http.routers.traefik.tls=true"
- "traefik.http.routers.traefik.tls.certresolver=le"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=dashboard-allow-local@file,dashboard-auth@file"
- "traefik.http.routers.traefik-http.rule=Host(`traefik.truckwash.dk`)"
- "traefik.http.routers.traefik-http.entrypoints=web"
- "traefik.http.routers.traefik-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.traefik-http.service=api@internal"
- "traefik.http.routers.traefik-local.rule=Host(`traefik.localhost`)"
- "traefik.http.routers.traefik-local.entrypoints=web"
- "traefik.http.routers.traefik-local.service=api@internal"
- "traefik.http.routers.traefik-local.middlewares=dashboard-allow-local@file,dashboard-auth@file"
redis:
image: redis:7
container_name: redis
volumes:
- nnks_redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
redis-staging:
image: redis:7
container_name: redis-staging
volumes:
- nnks_redis_staging:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
mysql-debug:
image: mysql:8.4
container_name: mysql-debug
environment:
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
ports:
- "3307:3306"
volumes:
- db_debug_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "MYSQL_PWD=$$MYSQL_ROOT_PASSWORD mysqladmin -u root ping --silent"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
edge-broker:
build:
context: .
dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker
environment:
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
labels:
- "traefik.enable=true"
- "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api.tls=true"
- "traefik.http.routers.edge-broker-api.tls.certresolver=le"
- "traefik.http.routers.edge-broker-api.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api.priority=200"
- "traefik.http.routers.edge-broker-api.service=edge-broker"
- "traefik.http.routers.edge-broker-api-io.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-io.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api-io.tls=true"
- "traefik.http.routers.edge-broker-api-io.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-io.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-io.priority=200"
- "traefik.http.routers.edge-broker-api-io.service=edge-broker"
- "traefik.http.routers.edge-broker-api-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-api-staging.tls=true"
- "traefik.http.routers.edge-broker-api-staging.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-staging.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-staging.priority=200"
- "traefik.http.routers.edge-broker-api-staging.service=edge-broker"
- "traefik.http.routers.edge-broker-local.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local.entrypoints=web"
- "traefik.http.routers.edge-broker-local.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local.priority=200"
- "traefik.http.routers.edge-broker-local.service=edge-broker"
- "traefik.http.routers.edge-broker-local-secure.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-secure.entrypoints=websecure"
- "traefik.http.routers.edge-broker-local-secure.tls=true"
- "traefik.http.routers.edge-broker-local-secure.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local-secure.priority=200"
- "traefik.http.routers.edge-broker-local-secure.service=edge-broker"
- "traefik.http.routers.edge-broker-local-staging.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-local-staging.tls=true"
- "traefik.http.routers.edge-broker-local-staging.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local-staging.priority=200"
- "traefik.http.routers.edge-broker-local-staging.service=edge-broker"
- "traefik.http.middlewares.edge-broker-strip.stripPrefix.prefixes=/edge-broker"
- "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker"
- "traefik.http.services.edge-broker.loadbalancer.server.port=4300"
caddy:
image: caddy:2.7.6-alpine
container_name: caddy
depends_on:
- php1
- php2
- php3
- php4
- php5
volumes:
- ./services/nginx/app:/var/www/html
- ./services/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./services/caddy/logs:/var/log/caddy
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.truckwash.dk`)"
- "traefik.http.routers.api.entrypoints=websecure"
- "traefik.http.routers.api.tls=true"
- "traefik.http.routers.api.tls.domains[0].main=api.truckwash.dk"
- "traefik.http.routers.api.tls.certresolver=le"
- "traefik.http.routers.api.service=caddy"
- "traefik.http.routers.api.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.api-io.rule=Host(`api.truckwash.io`)"
- "traefik.http.routers.api-io.entrypoints=websecure"
- "traefik.http.routers.api-io.tls=true"
- "traefik.http.routers.api-io.tls.domains[0].main=api.truckwash.io"
- "traefik.http.routers.api-io.tls.certresolver=le_io"
- "traefik.http.routers.api-io.service=caddy"
- "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`)"
- "traefik.http.routers.api-http.entrypoints=web"
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.api-http.service=caddy"
- "traefik.http.routers.local.rule=Host(`localhost`)"
- "traefik.http.routers.local.entrypoints=web"
- "traefik.http.routers.local.service=caddy"
- "traefik.http.routers.local.middlewares=secure-headers@file"
- "traefik.http.routers.local-secure.rule=Host(`localhost`)"
- "traefik.http.routers.local-secure.entrypoints=websecure"
- "traefik.http.routers.local-secure.tls=true"
- "traefik.http.routers.local-secure.service=caddy"
- "traefik.http.routers.local-secure.middlewares=secure-headers@file"
- "traefik.http.routers.local-api.rule=Host(`localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.local-api.entrypoints=web"
- "traefik.http.routers.local-api.middlewares=strip-api-prefix@file,secure-headers@file"
- "traefik.http.routers.local-api.service=caddy"
- "traefik.http.routers.local-api.priority=100"
- "traefik.http.routers.local-api-secure.rule=Host(`localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.local-api-secure.entrypoints=websecure"
- "traefik.http.routers.local-api-secure.tls=true"
- "traefik.http.routers.local-api-secure.middlewares=strip-api-prefix@file,secure-headers@file"
- "traefik.http.routers.local-api-secure.service=caddy"
- "traefik.http.routers.local-api-secure.priority=100"
- "traefik.http.services.caddy.loadbalancer.server.port=80"
caddy-staging:
image: caddy:2.7.6-alpine
container_name: caddy-staging
depends_on:
- php-staging
volumes:
- ./services/nginx/staging:/var/www/html
- ./services/caddy/Caddyfile-staging:/etc/caddy/Caddyfile:ro
- ./services/caddy/logs-staging:/var/log/caddy
labels:
- "traefik.enable=true"
- "traefik.http.routers.api-staging.rule=Host(`api.truckwash.io`)"
- "traefik.http.routers.api-staging.entrypoints=websecure-staging"
- "traefik.http.routers.api-staging.tls=true"
- "traefik.http.routers.api-staging.tls.domains[0].main=api.truckwash.io"
- "traefik.http.routers.api-staging.tls.certresolver=le_io"
- "traefik.http.routers.api-staging.service=caddy-staging"
- "traefik.http.routers.api-staging.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.local-staging.rule=Host(`localhost`)"
- "traefik.http.routers.local-staging.entrypoints=websecure-staging"
- "traefik.http.routers.local-staging.service=caddy-staging"
- "traefik.http.routers.local-staging.middlewares=secure-headers@file"
- "traefik.http.routers.local-staging-secure.rule=Host(`localhost`)"
- "traefik.http.routers.local-staging-secure.entrypoints=websecure-staging"
- "traefik.http.routers.local-staging-secure.tls=true"
- "traefik.http.routers.local-staging-secure.service=caddy-staging"
- "traefik.http.routers.local-staging-secure.middlewares=secure-headers@file"
- "traefik.http.routers.local-staging-api.rule=Host(`localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.local-staging-api.entrypoints=websecure-staging"
- "traefik.http.routers.local-staging-api.middlewares=strip-api-prefix@file,secure-headers@file"
- "traefik.http.routers.local-staging-api.service=caddy-staging"
- "traefik.http.routers.local-staging-api.priority=100"
- "traefik.http.routers.local-staging-api-secure.rule=Host(`localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.local-staging-api-secure.entrypoints=websecure-staging"
- "traefik.http.routers.local-staging-api-secure.tls=true"
- "traefik.http.routers.local-staging-api-secure.middlewares=strip-api-prefix@file,secure-headers@file"
- "traefik.http.routers.local-staging-api-secure.service=caddy-staging"
- "traefik.http.routers.local-staging-api-secure.priority=100"
- "traefik.http.services.caddy-staging.loadbalancer.server.port=80"
php1:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php1
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "true"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php2:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php2
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php3:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php3
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php4:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php4
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php5:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php5
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php-staging:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php-staging
depends_on:
- redis-staging
- edge-broker
command: ["php-fpm"]
env_file:
- .env.staging
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/staging:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs-staging:/var/log/php
php-cron:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php-cron
depends_on:
- redis
- edge-broker
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
portainer:
image: portainer/portainer-ce:2.21.4
container_name: portainer
profiles:
- dev
ports:
- "9443:9443"
- "9000:9000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer_data:/data
jaeger:
image: jaegertracing/all-in-one:1.53
container_name: jaeger
profiles:
- dev
environment:
- COLLECTOR_ZIPKIN_HTTP_PORT=9411
ports:
- "16686:16686"
n8n:
image: n8nio/n8n:latest
container_name: n8n
restart: always
environment:
- N8N_HOST=n8n.truckwash.io
- N8N_PORT=5678
- N8N_PROTOCOL=https
- NODE_ENV=production
- WEBHOOK_URL=https://n8n.truckwash.io/
- GENERIC_TIMEZONE=${CONFIG_TIMEZONE:-Europe/Copenhagen}
volumes:
- n8n_data:/home/node/.n8n
labels:
- "traefik.enable=true"
- "traefik.http.routers.n8n.rule=Host(`n8n.truckwash.io`)"
- "traefik.http.routers.n8n.entrypoints=websecure"
- "traefik.http.routers.n8n.tls=true"
- "traefik.http.routers.n8n.tls.certresolver=le_io"
- "traefik.http.routers.n8n.service=n8n"
- "traefik.http.routers.n8n-http.rule=Host(`n8n.truckwash.io`)"
- "traefik.http.routers.n8n-http.entrypoints=web"
- "traefik.http.routers.n8n-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.n8n-http.service=n8n"
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
volumes:
db_data:
db_debug_data:
nnks_redis:
nnks_redis_staging:
es_data:
portainer_data:
fleet-server-data:
elastic-agent-data:
n8n_data:
+44 -2
View File
@@ -86,9 +86,51 @@ services:
dockerfile: services/edge-broker/Dockerfile dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker container_name: edge-broker
environment: environment:
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
ports: labels:
- "4300:4300" - "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: caddy:
image: caddy:2.7.6-alpine image: caddy:2.7.6-alpine
+677
View File
@@ -0,0 +1,677 @@
import assert from "node:assert/strict";
import { execFile as execFileCallback, spawn as spawnCallback } from "node:child_process";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs";
const execFile = promisify(execFileCallback);
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"];
function composeArgs(projectName, args) {
return ["compose", "-p", projectName, ...args];
}
async function resolveRootDir(scriptPath) {
const cwd = process.cwd();
try {
await fs.access(path.join(cwd, "docker-compose.yml"));
return cwd;
} catch {
return path.resolve(path.dirname(scriptPath), "..");
}
}
async function runCommand(command, args, { cwd, allowFailure = false, stdio = "pipe" } = {}) {
if (stdio === "inherit") {
await new Promise((resolve, reject) => {
const child = spawnCallback(command, args, {
cwd,
stdio: "inherit",
windowsHide: true,
});
child.on("exit", (code) => {
if (code === 0 || allowFailure) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code}`));
});
child.on("error", reject);
});
return { stdout: "", stderr: "", code: 0 };
}
try {
const result = await execFile(command, args, {
cwd,
windowsHide: true,
encoding: "utf8",
});
return { stdout: result.stdout, stderr: result.stderr, code: 0 };
} catch (error) {
if (!allowFailure) {
throw error;
}
return {
stdout: error.stdout || "",
stderr: error.stderr || "",
code: typeof error.code === "number" ? error.code : 1,
};
}
}
function normalizeBaseUrl(url) {
return String(url || "").replace(/\/+$/, "");
}
async function waitForCondition(predicate, { timeoutMs = 30_000, intervalMs = 500, message = "Timed out" } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(message);
}
async function ensureComposeServices(rootDir, composeProject) {
await runCommand("docker", composeArgs(composeProject, ["up", "-d", ...COMPOSE_SERVICES]), {
cwd: rootDir,
stdio: "inherit",
});
}
async function waitForApiReady(baseUrl, attempts = 60) {
const root = normalizeBaseUrl(baseUrl);
let lastError = "API never responded";
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const response = await fetch(`${root}/ping`);
if (response.ok) {
return;
}
lastError = `Unexpected ping status ${response.status}`;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(`API did not become ready at ${root}/ping: ${lastError}`);
}
function parseLastJsonLine(output) {
const lines = String(output || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
for (let index = lines.length - 1; index >= 0; index -= 1) {
try {
return JSON.parse(lines[index]);
} catch {
// Continue scanning backwards for the JSON payload.
}
}
throw new Error(`Unable to parse JSON from command output:\n${output}`);
}
async function runPhpFixture(rootDir, composeProject, action, payload = null) {
const encodedPayload = payload === null
? ""
: ` ${Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")}`;
const command = `cd /var/www/html && CONFIG_DB_TARGET=debug php tests/Support/EdgeGatewayE2eFixture.php ${action}${encodedPayload}`;
const result = await runCommand("docker", composeArgs(composeProject, [
"exec",
"-T",
"php1",
"sh",
"-lc",
command,
]), {
cwd: rootDir,
});
return parseLastJsonLine(result.stdout);
}
async function apiRequest(baseUrl, method, endpoint, { token = null, body = null, headers = {} } = {}) {
const response = await fetch(`${normalizeBaseUrl(baseUrl)}${endpoint}`, {
method,
headers: {
...(body === null ? {} : { "content-type": "application/json" }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
},
body: body === null ? undefined : JSON.stringify(body),
});
const rawBody = await response.text();
let json = null;
if (rawBody !== "") {
try {
json = JSON.parse(rawBody);
} catch {
json = null;
}
}
if (!response.ok) {
const message =
json?.data?.message ||
json?.error ||
rawBody ||
`HTTP ${response.status}`;
throw new Error(`${method} ${endpoint} failed: ${message}`);
}
return json;
}
async function loadWebSocketImplementation() {
if (typeof WebSocket !== "undefined") {
return WebSocket;
}
const module = await import("ws");
return module.default;
}
function onSocket(socket, eventName, handler) {
if (typeof socket.addEventListener === "function") {
socket.addEventListener(eventName, (event) => {
if (eventName === "message") {
handler(event.data);
return;
}
handler(event);
});
return;
}
socket.on(eventName, handler);
}
function collectSocketMessages(socket) {
const messages = [];
onSocket(socket, "message", (payload) => {
const text = typeof payload === "string"
? payload
: Buffer.isBuffer(payload)
? payload.toString("utf8")
: typeof payload?.toString === "function"
? payload.toString()
: "";
if (text === "") {
return;
}
try {
messages.push(JSON.parse(text));
} catch {
// Ignore non-JSON frames.
}
});
return messages;
}
async function waitForSocketOpen(socket) {
if (socket.readyState === 1) {
return;
}
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Timed out waiting for websocket open.")), 10_000);
const socketUrl = typeof socket.url === "string" && socket.url !== "" ? ` (${socket.url})` : "";
const onOpen = () => {
clearTimeout(timeout);
resolve();
};
const onError = (error) => {
clearTimeout(timeout);
if (error instanceof Error) {
reject(error);
return;
}
const readyState = typeof socket.readyState === "number" ? socket.readyState : "unknown";
reject(new Error(`Websocket failed to open${socketUrl}; readyState=${readyState}.`));
};
const onClose = (event) => {
clearTimeout(timeout);
const code = event && typeof event === "object" && "code" in event ? event.code : "unknown";
const reason = event && typeof event === "object" && "reason" in event ? event.reason : "";
reject(new Error(`Websocket closed before open${socketUrl}; code=${code} reason=${reason || "none"}.`));
};
onSocket(socket, "open", onOpen);
onSocket(socket, "error", onError);
onSocket(socket, "close", onClose);
});
}
async function waitForSocketMessage(messages, predicate, options) {
await waitForCondition(() => messages.some(predicate), options);
}
function buildSocketUrl(wsUrl, token) {
const url = new URL(String(wsUrl));
url.searchParams.set("token", token);
return url.toString();
}
function closeSocket(socket) {
if (!socket || typeof socket.close !== "function") {
return;
}
const readyState = typeof socket.readyState === "number" ? socket.readyState : null;
if (readyState !== null && readyState >= 2) {
return;
}
socket.close();
}
function collectMessages(rows) {
return Array.isArray(rows)
? rows
.map((row) => (row && typeof row === "object" ? row.message : null))
.filter((value) => typeof value === "string")
: [];
}
function summarizeStreamMessages(messages, limit = 12) {
return messages
.slice(-limit)
.map((message) => {
if (!message || typeof message !== "object") {
return null;
}
const summary = {
type: message.type || "unknown",
};
if (message.operationId !== undefined) {
summary.operationId = Number(message.operationId || 0);
}
if (message.operation && typeof message.operation === "object") {
summary.operationStatus = message.operation.status || null;
}
if (message.gateway && typeof message.gateway === "object") {
summary.gatewayStatus = message.gateway.status || null;
}
return summary;
})
.filter(Boolean);
}
async function main() {
const scriptPath = fileURLToPath(import.meta.url);
const rootDir = await resolveRootDir(scriptPath);
const runId = randomUUID().slice(0, 8);
const containerName = `truckwash-edge-e2e-${runId}`;
const configDir = path.join(rootDir, ".tmp", "edge-gateway-e2e", runId);
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
const baseUrl = process.env.EDGE_GATEWAY_E2E_BASE_URL || DEFAULT_HOST_API_URL;
const composeProject =
process.env.EDGE_GATEWAY_E2E_COMPOSE_PROJECT
|| path.basename(rootDir);
let fixture = null;
let gatewayId = null;
let streamSocket = null;
let shellSocket = null;
try {
await ensureComposeServices(rootDir, composeProject);
await waitForApiReady(baseUrl);
fixture = await runPhpFixture(rootDir, composeProject, "create");
const authToken = String(fixture.auth_token || "");
const departmentId = Number(fixture.department_id || 0);
assert.ok(authToken !== "", "Fixture helper did not return an auth token.");
assert.ok(departmentId > 0, "Fixture helper did not return a department id.");
const installTokenResponse = await apiRequest(baseUrl, "POST", "/edge-gateways/install-token", {
token: authToken,
body: {
department_id: departmentId,
label: `Edge Gateway E2E ${runId}`,
},
});
const installToken = String(installTokenResponse?.data?.token || "");
assert.ok(installToken !== "", "Install token creation did not return a token.");
await runCommand(process.execPath, [
"scripts/test-gateway.mjs",
"start",
"--install-token",
installToken,
"--container-name",
containerName,
"--config-dir",
configDir,
"--heartbeat-seconds",
"3",
"--skip-compose-up",
], {
cwd: rootDir,
stdio: "inherit",
});
await waitForCondition(
async () => {
try {
await fs.access(configFilePath);
return true;
} catch {
return false;
}
},
{ message: `Gateway config file was not created at ${configFilePath}` }
);
const config = JSON.parse(await fs.readFile(configFilePath, "utf8"));
gatewayId = Number(config.gatewayId || 0);
assert.ok(gatewayId > 0, "Gateway config did not include a gateway id.");
await waitForCondition(
async () => {
const result = await runCommand("docker", [
"exec",
containerName,
"test",
"-f",
"/opt/truckwash-edge-agent/runtime/last-heartbeat-ok.txt",
], {
allowFailure: true,
});
return result.code === 0;
},
{
timeoutMs: 60_000,
message: "Gateway never wrote the successful heartbeat marker.",
}
);
await waitForCondition(
async () => {
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
token: authToken,
});
return detail?.data?.status === "ONLINE"
&& Object.keys(detail?.data?.metadata?.system_metrics || {}).length > 0;
},
{
timeoutMs: 60_000,
message: "Gateway detail never transitioned to ONLINE with fresh system metrics after install.",
}
);
await waitForCondition(
async () => {
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
token: authToken,
});
return Boolean(
detail?.data?.channel_status?.broker?.connected
|| detail?.data?.metadata?.broker_connected
);
},
{
timeoutMs: 90_000,
message: "Gateway never established a live broker connection after install.",
}
);
const WebSocketImpl = await loadWebSocketImplementation();
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
token: authToken,
body: {
scopes: ["overview", "tasks", "logs", "statistics"],
},
});
const streamWsUrl = buildSocketUrl(String(streamSession?.data?.ws_url || ""), String(streamSession?.data?.token || ""));
streamSocket = new WebSocketImpl(streamWsUrl);
const streamMessages = collectSocketMessages(streamSocket);
await waitForSocketOpen(streamSocket);
await waitForSocketMessage(
streamMessages,
(message) => message?.type === "gateway.stream.ready",
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
);
const readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready");
assert.equal(
Boolean(readyMessage?.connected),
true,
"Gateway stream became ready before the broker reported the gateway as connected."
);
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
body: {
type: "DISCOVERY",
request: {
inventory: [{
device_id: `edge-e2e-${runId}`,
local_ip: "10.70.80.90",
model: "TruckWash Edge E2E",
channel_count: 1,
online: true,
capabilities: {
gateway_management_v2: true,
},
metadata: {
hostname: `edge-e2e-${runId}`,
},
}],
},
},
});
const operationId = Number(operationResponse?.data?.operation?.id || 0);
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
try {
await waitForSocketMessage(
streamMessages,
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId,
{ timeoutMs: 180_000, message: "Live gateway stream never emitted task.updated for the queued operation." }
);
} catch (error) {
let operationSnapshot = null;
try {
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
});
operationSnapshot = Array.isArray(operations?.data)
? operations.data.find((item) => Number(item?.id || 0) === operationId) || null
: null;
} catch {
operationSnapshot = null;
}
const diagnostic = [
error instanceof Error ? error.message : String(error),
`Recent stream messages: ${JSON.stringify(summarizeStreamMessages(streamMessages))}`,
`Operation snapshot: ${JSON.stringify(operationSnapshot)}`,
].join("\n");
throw new Error(diagnostic);
}
await waitForCondition(
async () => {
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
});
const operation = Array.isArray(operations?.data)
? operations.data.find((item) => Number(item?.id || 0) === operationId)
: null;
return operation?.status === "COMPLETED";
},
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
);
await waitForSocketMessage(
streamMessages,
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
{ timeoutMs: 15_000, message: "Live gateway stream never emitted telemetry or statistics updates." }
);
await waitForCondition(
async () => {
const logs = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const timeline = Array.isArray(logs?.data?.entries)
? logs.data.entries
: (Array.isArray(logs?.data?.timeline) ? logs.data.timeline : []);
return timeline.some((entry) => {
const nestedEntry = entry?.entry && typeof entry.entry === "object" ? entry.entry : null;
const directOperationId = Number(nestedEntry?.operation_id || 0);
const contextualOperationId = Number(nestedEntry?.context?.operation_id || 0);
return directOperationId === operationId || contextualOperationId === operationId;
});
},
{ timeoutMs: 30_000, message: "Gateway logs page never reflected the live operation timeline." }
);
const statistics = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/statistics`, {
token: authToken,
});
assert.ok(
Object.keys(statistics?.data?.system_metrics || {}).length > 0,
"Gateway statistics page did not expose system metrics after live telemetry."
);
const shellSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/shell-sessions`, {
token: authToken,
body: {
reason: "Edge gateway E2E shell validation",
cwd: "/opt/truckwash-edge-agent",
cols: 120,
rows: 40,
},
});
const shellWsUrl = buildSocketUrl(String(shellSession?.data?.ws_url || ""), String(shellSession?.data?.token || ""));
shellSocket = new WebSocketImpl(shellWsUrl);
const shellMessages = collectSocketMessages(shellSocket);
await waitForSocketOpen(shellSocket);
await waitForSocketMessage(
shellMessages,
(message) => message?.type === "opened",
{ timeoutMs: 20_000, message: "Browser shell never opened against the live gateway." }
);
shellSocket.send(JSON.stringify({
type: "input",
data: "printf 'edge-e2e-shell\\n'; exit\n",
}));
await waitForSocketMessage(
shellMessages,
(message) => message?.type === "output" && String(message?.data || "").includes("edge-e2e-shell"),
{ timeoutMs: 20_000, message: "Browser shell never returned the expected command output." }
);
await waitForSocketMessage(
shellMessages,
(message) => message?.type === "closed",
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
);
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: [];
assert.ok(
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
"Gateway logs page did not persist the shell transcript."
);
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
"Gateway logs page did not include the shell close audit event."
);
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
} finally {
closeSocket(shellSocket);
closeSocket(streamSocket);
await runCommand(process.execPath, [
"scripts/test-gateway.mjs",
"stop",
"--container-name",
containerName,
], {
cwd: rootDir,
allowFailure: true,
}).catch(() => {});
if (gatewayId !== null && fixture?.auth_token) {
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
token: String(fixture.auth_token),
}).catch(() => {});
}
if (fixture !== null) {
await runPhpFixture(rootDir, composeProject, "cleanup", fixture).catch(() => {});
}
await fs.rm(configDir, { recursive: true, force: true }).catch(() => {});
}
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
+22 -3
View File
@@ -21,6 +21,21 @@ export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15;
export const DEFAULT_INSTALLED_VERSION = "php-agent-v1"; export const DEFAULT_INSTALLED_VERSION = "php-agent-v1";
const DEFAULT_COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "caddy"]; 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() { function printUsage() {
process.stdout.write(`Usage: process.stdout.write(`Usage:
node scripts/test-gateway.mjs start [--install-token <token>] [--container-name <name>] [--hostname <hostname>] node scripts/test-gateway.mjs start [--install-token <token>] [--container-name <name>] [--hostname <hostname>]
@@ -245,7 +260,7 @@ async function ensureComposeServices(rootDir, skipComposeUp) {
return; return;
} }
await runCommand("docker", ["compose", "up", "-d", ...DEFAULT_COMPOSE_SERVICES], { await runCommand("docker", composeArgs(resolveComposeProjectName(rootDir), ["up", "-d", ...DEFAULT_COMPOSE_SERVICES]), {
cwd: rootDir, cwd: rootDir,
stdio: "inherit", stdio: "inherit",
}); });
@@ -417,7 +432,7 @@ async function main() {
} }
const scriptPath = fileURLToPath(import.meta.url); 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 configDir = resolveConfigDirectory(rootDir, options.configDir);
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME); 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 currentFilePath = fileURLToPath(import.meta.url);
const currentRealPath = await fs.realpath(currentFilePath).catch(() => currentFilePath);
const invokedScript = process.argv[1] ? path.resolve(process.argv[1]) : ""; 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) => { main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1; process.exitCode = 1;
+1 -5
View File
@@ -4,11 +4,7 @@ WORKDIR /opt/truckwash-edge-agent
RUN set -eux; \ RUN set -eux; \
apt-get update; \ apt-get update; \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends ca-certificates; \
libcurl4-openssl-dev \
libsqlite3-dev \
ca-certificates; \
docker-php-ext-install curl sqlite3; \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY services/nginx/app/resources/edge-gateway-agent/ ./ COPY services/nginx/app/resources/edge-gateway-agent/ ./
+199 -154
View File
@@ -1,5 +1,6 @@
import http from "node:http"; import http from "node:http";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
function parseJsonBody(req) { function parseJsonBody(req) {
@@ -515,179 +516,183 @@ export function createBrokerServer(options = {}) {
return; return;
} }
if (ws.gatewayId) { try {
if (message.type === "COMMAND_RESULT") { if (ws.gatewayId) {
const pending = pendingCommands.get(message.commandId); if (message.type === "COMMAND_RESULT") {
if (!pending) { const pending = pendingCommands.get(message.commandId);
return; if (!pending) {
} return;
clearTimeout(pending.timeout); }
pendingCommands.delete(message.commandId); clearTimeout(pending.timeout);
pending.resolve({ pendingCommands.delete(message.commandId);
ok: Boolean(message.ok), pending.resolve({
payload: message.payload, ok: Boolean(message.ok),
error: message.error, payload: message.payload,
}); error: message.error,
return; });
}
if (message.type === "TELEMETRY") {
const payload = message.payload || {};
const ingested = await ingestTelemetry(String(ws.gatewayId), payload);
broadcastGatewayEvent(String(ws.gatewayId), {
type: "gateway.telemetry",
gatewayId: String(ws.gatewayId),
telemetry: payload,
gateway: ingested?.gateway || ingested || null,
});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "stats.updated",
gatewayId: String(ws.gatewayId),
statistics: ingested?.statistics || ingested || null,
});
return;
}
if (message.type === "TASK_EVENT") {
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
if (!Number.isFinite(operationId) || operationId <= 0) {
return; return;
} }
const operation = await ingestTaskEvent(String(ws.gatewayId), operationId, message.payload || {}); if (message.type === "TELEMETRY") {
broadcastGatewayEvent(String(ws.gatewayId), { const payload = message.payload || {};
type: "task.updated", const ingested = await ingestTelemetry(String(ws.gatewayId), payload);
gatewayId: String(ws.gatewayId), broadcastGatewayEvent(String(ws.gatewayId), {
operationId, type: "gateway.telemetry",
operation, gatewayId: String(ws.gatewayId),
}); telemetry: payload,
return; gateway: ingested?.gateway || ingested || null,
} });
broadcastGatewayEvent(String(ws.gatewayId), {
if (message.type === "TASK_RESULT") { type: "stats.updated",
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0); gatewayId: String(ws.gatewayId),
if (!Number.isFinite(operationId) || operationId <= 0) { statistics: ingested?.statistics || ingested || null,
});
return; return;
} }
const operation = await ingestTaskResult(String(ws.gatewayId), operationId, message.payload || {}); if (message.type === "TASK_EVENT") {
broadcastGatewayEvent(String(ws.gatewayId), { const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
type: "task.updated", if (!Number.isFinite(operationId) || operationId <= 0) {
gatewayId: String(ws.gatewayId), return;
operationId, }
operation,
}); const operation = await ingestTaskEvent(String(ws.gatewayId), operationId, message.payload || {});
await syncGatewayBacklog(String(ws.gatewayId), ws).catch(() => {}); broadcastGatewayEvent(String(ws.gatewayId), {
type: "task.updated",
gatewayId: String(ws.gatewayId),
operationId,
operation,
});
return;
}
if (message.type === "TASK_RESULT") {
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
if (!Number.isFinite(operationId) || operationId <= 0) {
return;
}
const operation = await ingestTaskResult(String(ws.gatewayId), operationId, message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "task.updated",
gatewayId: String(ws.gatewayId),
operationId,
operation,
});
await syncGatewayBacklog(String(ws.gatewayId), ws).catch(() => {});
return;
}
if (message.type === "LOG_FRAME") {
const logEntry = await ingestLogEntry(String(ws.gatewayId), message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "log.append",
gatewayId: String(ws.gatewayId),
entry: logEntry,
});
return;
}
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
const sessionRecord = browserShellSessions.get(String(message.sessionId));
if (!sessionRecord) {
return;
}
if (message.type === "SHELL_OUTPUT") {
sessionRecord.transcript += String(message.data || "");
sendJson(sessionRecord.ws, { type: "output", data: String(message.data || "") });
return;
}
if (message.type === "SHELL_OPENED") {
await markShellSessionOpened(sessionRecord.ws.sessionToken, ws.connectionId || null).catch(() => {});
sendJson(sessionRecord.ws, { type: "opened" });
return;
}
if (message.type === "SHELL_EXIT") {
sendJson(sessionRecord.ws, { type: "closed", code: message.code ?? 0 });
await closeBrowserShellSession(sessionRecord, "agent_exit");
browserShellSessions.delete(String(message.sessionId));
if (sessionRecord.ws.readyState < 2) {
sessionRecord.ws.close();
}
}
}
return; return;
} }
if (message.type === "LOG_FRAME") { if (ws.sessionInfo) {
const logEntry = await ingestLogEntry(String(ws.gatewayId), message.payload || {}); const sessionId = String(ws.sessionInfo.id);
broadcastGatewayEvent(String(ws.gatewayId), { const agent = agents.get(String(ws.sessionInfo.gateway_id));
type: "log.append", if (!agent || agent.readyState !== 1) {
gatewayId: String(ws.gatewayId), return;
entry: logEntry, }
}); if (message.type === "input") {
sendJson(agent, {
type: "SHELL_INPUT",
payload: {
sessionId,
data: String(message.data || ""),
},
});
return;
}
if (message.type === "resize") {
sendJson(agent, {
type: "RESIZE_ROOT_SHELL",
payload: {
sessionId,
cols: Number(message.cols || 0),
rows: Number(message.rows || 0),
},
});
return;
}
if (message.type === "close") {
sendJson(agent, {
type: "CLOSE_ROOT_SHELL",
payload: { sessionId },
});
}
return; return;
} }
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) { if (ws.streamSessionInfo) {
const sessionRecord = browserShellSessions.get(String(message.sessionId)); const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id));
if (!sessionRecord) { if (!sessionRecord) {
return; return;
} }
if (message.type === "SHELL_OUTPUT") { if (message.type === "SUBSCRIBE") {
sessionRecord.transcript += String(message.data || ""); for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sendJson(sessionRecord.ws, { type: "output", data: String(message.data || "") }); sessionRecord.subscriptions.add(scope);
return;
}
if (message.type === "SHELL_OPENED") {
await markShellSessionOpened(sessionRecord.ws.sessionToken, ws.connectionId || null).catch(() => {});
sendJson(sessionRecord.ws, { type: "opened" });
return;
}
if (message.type === "SHELL_EXIT") {
sendJson(sessionRecord.ws, { type: "closed", code: message.code ?? 0 });
await closeBrowserShellSession(sessionRecord, "agent_exit");
browserShellSessions.delete(String(message.sessionId));
if (sessionRecord.ws.readyState < 2) {
sessionRecord.ws.close();
} }
sendJson(sessionRecord.ws, {
type: "subscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "UNSUBSCRIBE") {
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sessionRecord.subscriptions.delete(scope);
}
sendJson(sessionRecord.ws, {
type: "unsubscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "PING") {
sendJson(sessionRecord.ws, { type: "PONG" });
} }
} }
return; } catch {
} // Ignore stale gateway/session delivery errors without killing the broker process.
if (ws.sessionInfo) {
const sessionId = String(ws.sessionInfo.id);
const agent = agents.get(String(ws.sessionInfo.gateway_id));
if (!agent || agent.readyState !== 1) {
return;
}
if (message.type === "input") {
sendJson(agent, {
type: "SHELL_INPUT",
payload: {
sessionId,
data: String(message.data || ""),
},
});
return;
}
if (message.type === "resize") {
sendJson(agent, {
type: "RESIZE_ROOT_SHELL",
payload: {
sessionId,
cols: Number(message.cols || 0),
rows: Number(message.rows || 0),
},
});
return;
}
if (message.type === "close") {
sendJson(agent, {
type: "CLOSE_ROOT_SHELL",
payload: { sessionId },
});
}
return;
}
if (ws.streamSessionInfo) {
const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id));
if (!sessionRecord) {
return;
}
if (message.type === "SUBSCRIBE") {
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sessionRecord.subscriptions.add(scope);
}
sendJson(sessionRecord.ws, {
type: "subscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "UNSUBSCRIBE") {
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sessionRecord.subscriptions.delete(scope);
}
sendJson(sessionRecord.ws, {
type: "unsubscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "PING") {
sendJson(sessionRecord.ws, { type: "PONG" });
}
} }
}); });
@@ -788,3 +793,43 @@ export function createBrokerServer(options = {}) {
}, },
}; };
} }
async function runBrokerFromCli() {
const broker = createBrokerServer();
let shuttingDown = false;
const shutdown = async (signal) => {
if (shuttingDown) {
return;
}
shuttingDown = true;
try {
await broker.close();
process.exit(0);
} catch (error) {
console.error(`Failed to shut down broker after ${signal}:`, error);
process.exit(1);
}
};
process.on("SIGINT", () => {
void shutdown("SIGINT");
});
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});
const requestedPort = Number(process.env.PORT || 4300);
const address = await broker.listen(Number.isFinite(requestedPort) ? requestedPort : 4300);
const normalizedPort =
typeof address === "object" && address !== null && "port" in address
? address.port
: requestedPort;
console.log(`TruckWash edge broker listening on ${normalizedPort}`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
runBrokerFromCli().catch((error) => {
console.error("TruckWash edge broker failed to start:", error);
process.exit(1);
});
}
+30
View File
@@ -350,3 +350,33 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
agent.terminate(); agent.terminate();
await broker.close(); await broker.close();
}); });
test("broker survives telemetry ingestion failures for stale gateways", async () => {
const broker = createBrokerServer({
authMode: "stub",
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
ingestTelemetry: async () => {
throw new Error("Edge gateway not found");
},
});
const address = await broker.listen(0);
const port = address.port;
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
await new Promise((resolve) => agent.once("open", resolve));
agent.send(
JSON.stringify({
type: "TELEMETRY",
payload: {
status: "ONLINE",
},
})
);
await new Promise((resolve) => setTimeout(resolve, 100));
assert.equal(broker.server.listening, true);
agent.terminate();
await broker.close();
});
+32 -6
View File
@@ -15,6 +15,7 @@ function readRequiredSource(...pathSegments) {
const baseComposeSource = readRequiredSource("docker-compose.yml"); const baseComposeSource = readRequiredSource("docker-compose.yml");
const exampleComposeSource = readRequiredSource("docker-compose.example.yml"); const exampleComposeSource = readRequiredSource("docker-compose.example.yml");
const standaloneProdComposeSource = readRequiredSource("docker-compose.prod.standalone.yml");
const traefikSource = [ const traefikSource = [
readRequiredSource("services", "traefik", "traefik.yml"), readRequiredSource("services", "traefik", "traefik.yml"),
readRequiredSource("services", "traefik", "traefik.prod.yml"), readRequiredSource("services", "traefik", "traefik.prod.yml"),
@@ -35,14 +36,39 @@ test("traefik does not expose a dedicated public edge broker port", () => {
assert.doesNotMatch(traefikSource, /edge-broker:\s*\n\s*address:\s*":4300"/); assert.doesNotMatch(traefikSource, /edge-broker:\s*\n\s*address:\s*":4300"/);
}); });
test("base docker compose exposes the edge broker service on port 4300", () => { test("base docker compose routes edge broker traffic through traefik", () => {
assert.match(baseComposeSource, /\bedge-broker:\b/); const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
assert.match(baseComposeSource, /edge-broker:\s*\n[\s\S]*?\n\s+ports:\s*\n\s+- "4300:4300"/); 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", () => { test("example docker compose routes edge broker traffic through traefik", () => {
assert.match(exampleComposeSource, /\bedge-broker:\b/); const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
assert.match(exampleComposeSource, /edge-broker:\s*\n[\s\S]*?\n\s+ports:\s*\n\s+- "4300:4300"/); 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", () => { test("php services receive broker websocket environment defaults", () => {
@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
const brokerEntryPath = path.resolve(testDirectory, "../server.mjs");
test("server entrypoint starts the broker and stays alive until terminated", async () => {
const child = spawn(process.execPath, [brokerEntryPath], {
env: {
...process.env,
PORT: "0",
EDGE_AUTH_MODE: "stub",
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Broker entrypoint did not report readiness.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
}, 10_000);
child.once("exit", (code, signal) => {
clearTimeout(timeout);
reject(new Error(`Broker entrypoint exited early with code=${code} signal=${signal}.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
});
const poll = () => {
if (/TruckWash edge broker listening on \d+/.test(stdout)) {
clearTimeout(timeout);
resolve();
return;
}
setTimeout(poll, 25);
};
poll();
});
assert.equal(child.exitCode, null, `Broker exited unexpectedly.\nstdout:\n${stdout}\nstderr:\n${stderr}`);
const exitResult = await new Promise((resolve, reject) => {
child.once("exit", (code, signal) => resolve({ code, signal }));
child.kill("SIGTERM");
setTimeout(() => reject(new Error("Broker did not exit after SIGTERM.")), 10_000);
});
const exitedCleanly = exitResult.code === 0 || exitResult.signal === "SIGTERM";
assert.equal(exitedCleanly, true, `Broker exited unsuccessfully.\nstdout:\n${stdout}\nstderr:\n${stderr}`);
});
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,497 @@
<?php
namespace classes;
use objects\logs_o;
use objects\users_o;
class customer_mass_import_service
{
/**
* Import or create a company customer from one normalized spreadsheet row.
*
* @throws \RuntimeException
*/
public function import(array $payload): array
{
$normalized = $this->normalizePayload($payload);
$this->assertValidNormalizedPayload($normalized);
$customerNumber = (int)$normalized['customer_number'];
$cvr = (string)$normalized['cvr'];
$warnings = [];
$economicCustomers = $this->searchEconomicCustomersByCvr($cvr);
$localUserExistsBefore = $this->localCustomerNumberExists($customerNumber);
$localUser = $localUserExistsBefore ? $this->loadLocalCustomerByNumber($customerNumber) : null;
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economicCustomers, $customerNumber);
if ($matchingEconomicCustomer !== null) {
$customer = $this->resolveLocalCustomer($customerNumber, $localUserExistsBefore, $localUser);
$this->syncLocalCustomer($customer, $normalized, $warnings);
[$action, $message] = $this->resolveExistingCustomerOutcome($localUserExistsBefore, $this->hasLocalAccount($customer));
return $this->buildSuccessResult(
$normalized,
$customer,
$action,
$message,
$localUserExistsBefore,
true,
false,
$warnings
);
}
if (count($economicCustomers) > 0) {
$existingEconomicCustomerNumber = $this->extractEconomicCustomerNumber($economicCustomers[0]);
$this->logIssue('CUSTOMER_MASS_IMPORT_CONFLICT', [
'phase' => 'search',
'cvr' => $cvr,
'requestedCustomerNumber' => $customerNumber,
'existingCustomerNumber' => $existingEconomicCustomerNumber,
]);
throw new \RuntimeException(
'CVR already registered under customer number '
. $existingEconomicCustomerNumber
. '. The submitted phone number must match the customer id. Manual cleanup or reassignment is required before retrying.',
409
);
}
$normalized['name'] = $this->resolveCreateName($normalized);
$normalized['email'] = $this->resolveCreateEmail($normalized, $warnings);
$createResponse = $this->createEconomicCustomer($normalized);
$createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse);
if ($createdCustomerNumber !== $customerNumber) {
$this->logIssue('CUSTOMER_MASS_IMPORT_CONFLICT', [
'phase' => 'create',
'cvr' => $cvr,
'requestedCustomerNumber' => $customerNumber,
'createdCustomerNumber' => $createdCustomerNumber,
'response' => $createResponse,
]);
throw new \RuntimeException(
'E-conomic created the customer under customer number '
. $createdCustomerNumber
. ' instead of the submitted phone number '
. $customerNumber
. '. Manual cleanup or reassignment is required before retrying.',
409
);
}
$customer = $this->resolveLocalCustomer($customerNumber, $localUserExistsBefore, $localUser);
$this->syncLocalCustomer($customer, $normalized, $warnings);
[$action, $message] = $this->resolveCreatedCustomerOutcome($localUserExistsBefore, $this->hasLocalAccount($customer));
return $this->buildSuccessResult(
$normalized,
$customer,
$action,
$message,
$localUserExistsBefore,
false,
true,
$warnings
);
}
protected function normalizePayload(array $payload): array
{
return [
'customer_number' => $this->normalizePositiveInt($payload['customer_number'] ?? $payload['phone'] ?? null),
'phone' => $this->normalizePositiveInt($payload['phone'] ?? $payload['customer_number'] ?? null),
'cvr' => $this->normalizeDigitString($payload['cvr'] ?? null),
'name' => $this->normalizeText($payload['name'] ?? $payload['company_name'] ?? null),
'email' => $this->normalizeEmail($payload['email'] ?? null),
'ean' => $this->normalizeDigitString($payload['ean'] ?? null),
];
}
protected function assertValidNormalizedPayload(array $normalized): void
{
$customerNumber = $normalized['customer_number'];
$cvr = $normalized['cvr'];
if ($customerNumber === null) {
throw new \RuntimeException('Phone number is required.', 400);
}
$customerNumberLength = strlen((string)$customerNumber);
if ($customerNumberLength < 8 || $customerNumberLength > 10) {
throw new \RuntimeException('Phone number must be between 8 and 10 digits.', 400);
}
if ($cvr === null) {
throw new \RuntimeException('CVR is required.', 400);
}
$cvrLength = strlen($cvr);
if ($cvrLength < 8 || $cvrLength > 20) {
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
}
}
protected function normalizePositiveInt(mixed $value): ?int
{
$digits = $this->normalizeDigitString($value);
if ($digits === null) {
return null;
}
$normalized = (int)$digits;
return $normalized > 0 ? $normalized : null;
}
protected function normalizeDigitString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', (string)$value);
if (!is_string($digits)) {
return null;
}
$digits = trim($digits);
return $digits !== '' ? $digits : null;
}
protected function normalizeText(mixed $value): ?string
{
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
protected function normalizeEmail(mixed $value): ?string
{
$email = $this->normalizeText($value);
if ($email === null) {
return null;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \RuntimeException('Invalid email address.', 400);
}
return $email;
}
protected function resolveCreateName(array $normalized): string
{
if ($normalized['name'] !== null) {
return $normalized['name'];
}
$name = trim($this->fetchCompanyNameByCvr((string)$normalized['cvr']));
if ($name === '') {
throw new \RuntimeException('Customer name is required to create a new company.', 400);
}
return $name;
}
protected function resolveCreateEmail(array $normalized, array &$warnings): string
{
if ($normalized['email'] !== null) {
return $normalized['email'];
}
$warnings[] = 'No email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
return 'jb@truckwash.dk';
}
protected function searchEconomicCustomersByCvr(string $cvr): array
{
$response = (new economic())->customers->customers->search([
'corporateIdentificationNumber' => $cvr,
], [
'skipPages' => 0,
'pageSize' => 1000,
])->collection ?? [];
return is_array($response) ? $response : [];
}
protected function createEconomicCustomer(array $normalized): object
{
$payload = [
'customerNumber' => (int)$normalized['customer_number'],
'corporateIdentificationNumber' => (string)$normalized['cvr'],
'customerGroup' => [
'customerGroupNumber' => 1,
],
'paymentTerms' => [
'paymentTermsNumber' => 12,
],
'name' => (string)$normalized['name'],
'email' => (string)$normalized['email'],
'phone' => (int)$normalized['phone'],
'telephoneAndFaxNumber' => (string)$normalized['phone'],
'mobilePhone' => (string)$normalized['phone'],
'currency' => 'DKK',
'vatZone' => [
'vatZoneNumber' => 1,
],
];
if ($normalized['ean'] !== null) {
$payload['ean'] = (string)$normalized['ean'];
}
return (new economic())->customers->customers->create($payload);
}
protected function localCustomerNumberExists(int $customerNumber): bool
{
$rows = (new users_o())->getFieldsWhere([
'customer_number' => (string)$customerNumber,
], ['id']);
return count($rows) > 0;
}
protected function loadLocalCustomerByNumber(int $customerNumber): ?object
{
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
return $this->localUserExists($customer) ? $customer : null;
}
protected function resolveLocalCustomer(int $customerNumber, bool $localUserExistsBefore, ?object $localUser): object
{
if ($localUserExistsBefore && $this->localUserExists($localUser)) {
return $localUser;
}
return $this->bootstrapLocalCustomerOrFail($customerNumber);
}
protected function bootstrapLocalCustomerOrFail(int $customerNumber): object
{
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
if ($this->localUserExists($customer)) {
return $customer;
}
$this->logIssue('CUSTOMER_MASS_IMPORT_LOCAL_BOOTSTRAP_FAILED', [
'customerNumber' => $customerNumber,
]);
throw new \RuntimeException('Customer was created in e-conomic but could not be imported locally.', 500);
}
protected function fetchCompanyNameByCvr(string $cvr): string
{
return (string)((new virkdata())->getCompanyInformation($cvr, '', [])->name ?? '');
}
protected function logIssue(string $action, array $context): void
{
$message = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($message === false) {
$message = 'Unable to encode customer mass import context';
}
(new logs_o())->add('customers', 'global', 0, 0, $action, $message);
}
protected function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
{
foreach ($customers as $customer) {
if (!is_object($customer)) {
continue;
}
if ($this->extractEconomicCustomerNumber($customer) === $customerNumber) {
return $customer;
}
}
return null;
}
protected function extractEconomicCustomerNumber(object $customer): int
{
if (!isset($customer->customerNumber) || !is_numeric($customer->customerNumber)) {
return 0;
}
return (int)$customer->customerNumber;
}
protected function resolveExistingCustomerOutcome(bool $localUserExistsBefore, bool $hasAccount): array
{
if ($localUserExistsBefore && $hasAccount) {
return [
'account_already_exists',
'Customer already exists locally and already has a login account.',
];
}
if ($localUserExistsBefore) {
return [
'customer_already_exists',
'Customer already exists locally but does not have a login password yet.',
];
}
return [
'imported_existing_customer',
'Imported an existing e-conomic customer into the local customer database.',
];
}
protected function resolveCreatedCustomerOutcome(bool $localUserExistsBefore, bool $hasAccount): array
{
if ($localUserExistsBefore && $hasAccount) {
return [
'economic_customer_created_for_existing_account',
'Created the e-conomic customer for an existing local login account.',
];
}
if ($localUserExistsBefore) {
return [
'economic_customer_created_for_existing_customer',
'Created the e-conomic customer for an existing local customer record.',
];
}
return [
'created_customer',
'Created the customer in e-conomic and imported it locally.',
];
}
protected function buildSuccessResult(
array $normalized,
object $customer,
string $action,
string $message,
bool $existingLocalCustomer,
bool $existingEconomicCustomer,
bool $createdEconomicCustomer,
array $warnings
): array {
$customerName = $this->extractLocalUserDisplayName($customer) ?? $normalized['name'];
return [
'customer_number' => (int)$normalized['customer_number'],
'cvr' => (string)$normalized['cvr'],
'name' => $customerName,
'email' => $normalized['email'],
'ean' => $normalized['ean'],
'action' => $action,
'message' => $message,
'user_id' => $this->extractLocalUserId($customer),
'has_account' => $this->hasLocalAccount($customer),
'existing_local_customer' => $existingLocalCustomer,
'existing_economic_customer' => $existingEconomicCustomer,
'created_economic_customer' => $createdEconomicCustomer,
'warnings' => array_values(array_filter($warnings, static fn(mixed $warning): bool => is_string($warning) && trim($warning) !== '')),
];
}
protected function syncLocalCustomer(object $customer, array $normalized, array &$warnings): void
{
if (!$customer instanceof users_o || !$customer->exists()) {
return;
}
$name = $normalized['name'] ?? null;
$email = $normalized['email'] ?? null;
$phone = $normalized['phone'] ?? null;
$displayName = trim((string)($customer->display_name->value() ?? ''));
if ($name !== null && ($displayName === '' || strtolower($displayName) === 'unnamed')) {
$customer->display_name->set($name);
}
if ($email !== null && trim((string)($customer->email->value() ?? '')) === '') {
try {
$customer->setEmail($email);
} catch (\Throwable $throwable) {
$warnings[] = 'Unable to update local email: ' . $throwable->getMessage();
}
}
if ($phone !== null && empty($customer->phone->value())) {
try {
$customer->setPhoneNumber((int)$phone);
} catch (\Throwable $throwable) {
$warnings[] = 'Unable to update local phone number: ' . $throwable->getMessage();
}
}
}
protected function localUserExists(?object $user): bool
{
if (!is_object($user)) {
return false;
}
if (method_exists($user, 'exists')) {
try {
return (bool)$user->exists();
} catch (\Throwable) {
return false;
}
}
return isset($user->id) && is_numeric($user->id) && (int)$user->id > 0;
}
protected function hasLocalAccount(?object $user): bool
{
if (!$this->localUserExists($user)) {
return false;
}
if (method_exists($user, 'hasPassword')) {
try {
return (bool)$user->hasPassword();
} catch (\Throwable) {
return false;
}
}
return (bool)($user->has_password ?? false);
}
protected function extractLocalUserId(?object $user): ?int
{
if (!is_object($user) || !isset($user->id) || !is_numeric($user->id)) {
return null;
}
$userId = (int)$user->id;
return $userId > 0 ? $userId : null;
}
protected function extractLocalUserDisplayName(?object $user): ?string
{
if (!is_object($user)) {
return null;
}
if ($user instanceof users_o) {
$name = trim((string)($user->display_name->value() ?? ''));
return $name !== '' ? $name : null;
}
$name = trim((string)($user->display_name ?? $user->name ?? ''));
return $name !== '' ? $name : null;
}
}
+50 -3
View File
@@ -54,7 +54,7 @@ class shelly implements shelly_i
self::requireValidSecretKey(); self::requireValidSecretKey();
// Send the request // Send the request
$response = match ($method) { $response = match ($method) {
//'GET' => self::sendGetRequest($endpoint, $data), 'GET' => self::sendGetRequest($endpoint, $data),
'POST' => self::sendPostRequest($endpoint, $data), 'POST' => self::sendPostRequest($endpoint, $data),
//'PUT' => self::sendPutRequest($endpoint, $data), //'PUT' => self::sendPutRequest($endpoint, $data),
//'DELETE' => self::sendDeleteRequest($endpoint, $data), //'DELETE' => self::sendDeleteRequest($endpoint, $data),
@@ -186,9 +186,56 @@ class shelly implements shelly_i
return json_decode($response); return json_decode($response);
} }
function appendAuthKeyToQuery(string $url): string /**
* @inheritDoc
* @throws Exception
*/
function sendGetRequest(string $endpoint, array $data): array|object|null
{ {
return $url . '?auth_key=' . $this->config->secret_key->getVariableValue(); self::requireModuleEnabled();
self::requireValidSecretKey();
self::requireValidServerURL();
$this->waitForShellyRateLimitWindow();
$ch = curl_init();
curl_setopt(
$ch,
CURLOPT_URL,
self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint, $data)
);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPGET, true);
$response = curl_exec($ch);
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
self::exception(
[
'method' => 'GET',
'endpoint' => $endpoint,
'data' => $data,
'response' => $response,
],
$status_code
);
}
curl_close($ch);
if ($response === false) {
return null;
}
return json_decode($response);
}
function appendAuthKeyToQuery(string $url, array $query = []): string
{
$query['auth_key'] = $this->config->secret_key->getVariableValue();
$separator = str_contains($url, '?') ? '&' : '?';
return $url . $separator . http_build_query($query);
} }
/** /**
@@ -0,0 +1,436 @@
<?php
namespace classes;
use Exception;
class shelly_relay_inventory
{
private ?shelly $client;
/** @var callable|null */
private $inventory_fetcher = null;
/** @var callable|null */
private $device_list_fetcher = null;
public function __construct(?shelly $client = null)
{
$this->client = $client;
}
public function setInventoryFetcher(callable $fetcher): self
{
$this->inventory_fetcher = $fetcher;
return $this;
}
public function setDeviceListFetcher(callable $fetcher): self
{
$this->device_list_fetcher = $fetcher;
return $this;
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listRelayOptions(): array
{
$devices_status = $this->fetchOwnedDevicesStatus();
$device_catalog = $this->fetchOwnedDeviceCatalog();
$options_by_id = [];
foreach ($devices_status as $device) {
$normalized_device = $this->normalizeToArray($device);
$device_id = $this->extractFirstString([
$normalized_device['_dev_info']['id'] ?? null,
$normalized_device['id'] ?? null,
]);
$catalog_entry = $device_id !== '' ? ($device_catalog[$device_id] ?? null) : null;
$option = $this->buildRelayOption(
$normalized_device,
is_array($catalog_entry) ? $catalog_entry : null
);
if ($option === null) {
continue;
}
$options_by_id[$option['id']] = $option;
}
$options = array_values($options_by_id);
usort($options, static function (array $left, array $right): int {
$status_compare = self::statusSortWeight((string)($left['status_color'] ?? ''))
<=> self::statusSortWeight((string)($right['status_color'] ?? ''));
if ($status_compare !== 0) {
return $status_compare;
}
$name_compare = strcasecmp((string)($left['name'] ?? ''), (string)($right['name'] ?? ''));
if ($name_compare !== 0) {
return $name_compare;
}
return strcmp((string)($left['id'] ?? ''), (string)($right['id'] ?? ''));
});
return $options;
}
/**
* @return array<string,array<string,mixed>>
*/
private function fetchOwnedDeviceCatalog(): array
{
try {
$payload = is_callable($this->device_list_fetcher)
? ($this->device_list_fetcher)()
: $this->getClient()->sendGetRequest('/interface/device/list', [
'no_shared' => 'true',
]);
} catch (\Throwable) {
return [];
}
$normalized = $this->normalizeToArray($payload);
if (($normalized['isok'] ?? true) === false) {
return [];
}
$devices = $normalized['data']['devices'] ?? null;
if (!is_array($devices)) {
return [];
}
$catalog_by_id = [];
foreach ($devices as $device_key => $device) {
$normalized_device = $this->normalizeToArray($device);
$device_id = $this->extractFirstString([
$normalized_device['id'] ?? null,
is_string($device_key) ? $device_key : null,
]);
if ($device_id === '') {
continue;
}
$catalog_by_id[$device_id] = $normalized_device;
}
return $catalog_by_id;
}
/**
* @return array<string,mixed>
* @throws Exception
*/
private function fetchOwnedDevicesStatus(): array
{
$payload = is_callable($this->inventory_fetcher)
? ($this->inventory_fetcher)()
: $this->getClient()->sendGetRequest('/device/all_status', [
'show_info' => 'true',
'no_shared' => 'true',
]);
$normalized = $this->normalizeToArray($payload);
$is_ok = $normalized['isok'] ?? null;
if ($is_ok === false) {
throw new Exception('Shelly relay inventory request failed');
}
$devices_status = $normalized['data']['devices_status'] ?? null;
if (!is_array($devices_status)) {
throw new Exception('Shelly relay inventory response was missing devices_status');
}
return $devices_status;
}
private function getClient(): shelly
{
if ($this->client instanceof shelly) {
return $this->client;
}
$this->client = new shelly();
return $this->client;
}
/**
* @param array<string,mixed> $device
* @return array<string,mixed>|null
*/
private function buildRelayOption(array $device, ?array $catalog_entry = null): ?array
{
if ($device === [] || !$this->isRelayCapableDevice($device)) {
return null;
}
$device_id = $this->extractFirstString([
$device['_dev_info']['id'] ?? null,
$device['id'] ?? null,
]);
if ($device_id === '') {
return null;
}
$cloud_name = $this->extractFirstString([
$catalog_entry['name'] ?? null,
]);
$local_device_name = $this->extractFirstString([
$device['name'] ?? null,
$device['_dev_info']['name'] ?? null,
$device['settings']['name'] ?? null,
$device['settings']['device']['name'] ?? null,
$device['status']['name'] ?? null,
$device['status']['sys']['device']['name'] ?? null,
]);
$device_name = $cloud_name !== '' ? $cloud_name : $local_device_name;
$device_code = $this->extractFirstString([
$device['_dev_info']['code'] ?? null,
$device['code'] ?? null,
]);
$device_type = $this->extractDeviceType($device, $device_code, $catalog_entry);
$control_type = $this->extractControlType($device);
$control_name = $this->extractControlName($device);
$online = $this->extractOnlineState($device);
if ($online === null) {
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
}
$status_color = $this->extractStatusColor($online);
return [
'id' => $device_id,
'name' => $this->buildRelayLabel(
$device_type,
$cloud_name,
$local_device_name,
$control_name,
$device_id
),
'device_id' => $device_id,
'device_name' => $device_name !== '' ? $device_name : null,
'cloud_name' => $cloud_name !== '' ? $cloud_name : null,
'device_type' => $device_type,
'code' => $device_code !== '' ? $device_code : null,
'control_type' => $control_type,
'control_name' => $control_name !== '' ? $control_name : null,
'status_color' => $status_color,
'online' => $online,
];
}
/**
* @param array<string,mixed> $device
*/
private function isRelayCapableDevice(array $device): bool
{
if ($this->payloadContainsRelayState($device)) {
return true;
}
if ($this->payloadContainsRelayState($device['status'] ?? null)) {
return true;
}
return $this->payloadContainsRelayState($device['settings'] ?? null);
}
private function payloadContainsRelayState(array|object|null $payload): bool
{
$normalized = $this->normalizeToArray($payload);
if ($normalized === []) {
return false;
}
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
if (array_key_exists($switch_key, $normalized)) {
return true;
}
}
foreach (['relays', 'switches'] as $collection_key) {
if (isset($normalized[$collection_key]) && is_array($normalized[$collection_key]) && $normalized[$collection_key] !== []) {
return true;
}
}
return false;
}
/**
* @param array<string,mixed> $device
*/
private function extractOnlineState(array $device): ?bool
{
$online = $device['_dev_info']['online'] ?? $device['online'] ?? null;
if (is_bool($online)) {
return $online;
}
if (is_numeric($online)) {
return (int)$online === 1;
}
return null;
}
private function extractStatusColor(?bool $online): string
{
if ($online === true) {
return 'Green';
}
if ($online === false) {
return 'Red';
}
return 'Yellow';
}
/**
* @param array<string,mixed> $device
*/
private function extractDeviceType(array $device, string $device_code, ?array $catalog_entry = null): ?string
{
$device_type = $this->extractFirstString([
$device['_dev_info']['model'] ?? null,
$device['_dev_info']['type'] ?? null,
$device['model'] ?? null,
$device['type'] ?? null,
$device['settings']['device']['type'] ?? null,
$catalog_entry['type'] ?? null,
$device_code,
]);
return $device_type !== '' ? $device_type : null;
}
/**
* @param array<string,mixed> $device
*/
private function extractControlType(array $device): string
{
$status = $this->normalizeToArray($device['status'] ?? null);
$settings = $this->normalizeToArray($device['settings'] ?? null);
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
if (array_key_exists($switch_key, $device) || array_key_exists($switch_key, $status)) {
return 'Switch';
}
}
if ((isset($device['switches']) && is_array($device['switches']) && $device['switches'] !== [])
|| (isset($settings['switches']) && is_array($settings['switches']) && $settings['switches'] !== [])) {
return 'Switch';
}
if ((isset($device['relays']) && is_array($device['relays']) && $device['relays'] !== [])
|| (isset($settings['relays']) && is_array($settings['relays']) && $settings['relays'] !== [])) {
return 'Relay';
}
return 'Device';
}
/**
* @param array<string,mixed> $device
*/
private function extractControlName(array $device): string
{
$status = $this->normalizeToArray($device['status'] ?? null);
$settings = $this->normalizeToArray($device['settings'] ?? null);
return $this->extractFirstString([
$status['switch:0']['name'] ?? null,
$status['switch_0']['name'] ?? null,
$status['switch0']['name'] ?? null,
$device['switches'][0]['name'] ?? null,
$settings['switches'][0]['name'] ?? null,
$device['relays'][0]['name'] ?? null,
$settings['relays'][0]['name'] ?? null,
]);
}
private function buildRelayLabel(
?string $device_type,
string $cloud_name,
string $local_device_name,
string $control_name,
string $device_id
): string {
if ($cloud_name !== '') {
$display_name = $cloud_name;
} elseif ($local_device_name !== '' && $control_name !== '' && strcasecmp($local_device_name, $control_name) !== 0) {
$display_name = $local_device_name . ' / ' . $control_name;
} else {
$display_name = $control_name !== '' ? $control_name : ($local_device_name !== '' ? $local_device_name : $device_id);
}
if ($device_type !== null && $device_type !== '') {
return $display_name . ' (' . $device_type . ')';
}
return $display_name;
}
/**
* @param array<int,mixed> $values
*/
private function extractFirstString(array $values): string
{
foreach ($values as $value) {
$normalized = trim((string)($value ?? ''));
if ($normalized !== '') {
return $normalized;
}
}
return '';
}
private function normalizeBoolean(mixed $value): ?bool
{
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int)$value === 1;
}
return null;
}
private static function statusSortWeight(string $status_color): int
{
return match (strtolower($status_color)) {
'green' => 0,
'yellow' => 1,
'red' => 2,
default => 3,
};
}
/**
* @return array<string,mixed>
*/
private function normalizeToArray(array|object|null $payload): array
{
if (is_array($payload)) {
return $payload;
}
if (is_object($payload)) {
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE);
if (!is_string($encoded)) {
return [];
}
$decoded = json_decode($encoded, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
}
+8
View File
@@ -7,6 +7,14 @@
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"@php -r \"putenv('RUN_API_TESTS=1'); passthru('vendor/bin/pest --testsuite=Api --colors=always', $exitCode); exit($exitCode);\"" "@php -r \"putenv('RUN_API_TESTS=1'); 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": [ "test:coverage": [
"@php -r \"is_dir('build/logs') || mkdir('build/logs', 0777, true);\"", "@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);\"" "@php -r \"if (extension_loaded('pcov')) { passthru('php -d pcov.enabled=1 vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } if (extension_loaded('xdebug')) { passthru('php -d xdebug.mode=coverage vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } fwrite(STDERR, 'No coverage driver available. Enable pcov or xdebug for test:coverage.' . PHP_EOL); exit(1);\""
+9 -1
View File
@@ -37,6 +37,14 @@ interface shelly_i
*/ */
function requireValidServerUrl(): void; 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 * Send a POST request to the shelly
* @param string $endpoint The endpoint to send the request to (e.g. "latest") * @param string $endpoint The endpoint to send the request to (e.g. "latest")
@@ -44,4 +52,4 @@ interface shelly_i
* @return array|object|null The response from the shelly * @return array|object|null The response from the shelly
*/ */
function sendPostRequest(string $endpoint, array $data): array|object|null; function sendPostRequest(string $endpoint, array $data): array|object|null;
} }
@@ -84,6 +84,7 @@ class edge_gateway_department_workspace_service
$lanes = $this->buildLanePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId); $lanes = $this->buildLanePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId);
$gates = $this->buildGatePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId); $gates = $this->buildGatePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId);
$relays = $this->buildRelayPayloads($relayCatalog, $bindingsByRelayId, $consumersByRelayId);
$gateways = $this->applyBindingConsumerContexts($gateways, $consumersByRelayId); $gateways = $this->applyBindingConsumerContexts($gateways, $consumersByRelayId);
$selfServe = $this->buildSelfServePayload($department, $lanes); $selfServe = $this->buildSelfServePayload($department, $lanes);
$scanners = $this->buildScannerPayloads($departmentId, $lanes); $scanners = $this->buildScannerPayloads($departmentId, $lanes);
@@ -108,6 +109,7 @@ class edge_gateway_department_workspace_service
'lanes' => $lanes, 'lanes' => $lanes,
'self_serve' => $selfServe, 'self_serve' => $selfServe,
'gates' => $gates, 'gates' => $gates,
'relays' => $relays,
'scanners' => $scanners, 'scanners' => $scanners,
'issues' => $issues, 'issues' => $issues,
'actions' => $actions, 'actions' => $actions,
@@ -326,6 +328,39 @@ class edge_gateway_department_workspace_service
return $gates; 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 * @param array<int,array<string,mixed>> $lanes
* @return array<string,mixed> * @return array<string,mixed>
@@ -124,7 +124,7 @@ class edge_gateway_manager
'status' => self::INSTALL_SESSION_STATUS_PENDING, 'status' => self::INSTALL_SESSION_STATUS_PENDING,
'step' => self::INSTALL_SESSION_STATUS_PENDING, 'step' => self::INSTALL_SESSION_STATUS_PENDING,
'message' => 'Installer command generated. Run it on the gateway host.', 'message' => 'Installer command generated. Run it on the gateway host.',
], strtotime($expiresAt) - self::INSTALL_TOKEN_TTL_SECONDS), ], (self::parseApplicationDateTime($expiresAt) ?? time()) - self::INSTALL_TOKEN_TTL_SECONDS),
], ],
]); ]);
@@ -1735,8 +1735,10 @@ BASH;
throw new Exception('Shell session is closed'); throw new Exception('Shell session is closed');
} }
$expiresAt = $session->expires_at->value() === null ? null : strtotime((string)$session->expires_at->value()); $expiresAt = self::parseApplicationDateTime(
if ($expiresAt !== null && $expiresAt !== false && $expiresAt <= time()) { $session->expires_at->value() === null ? null : (string)$session->expires_at->value()
);
if ($expiresAt !== null && $expiresAt <= time()) {
$session->status->set('EXPIRED'); $session->status->set('EXPIRED');
$session->closed_at->set($this->now()); $session->closed_at->set($this->now());
throw new Exception('Shell session expired'); throw new Exception('Shell session expired');
@@ -2071,7 +2073,11 @@ BASH;
'events' => self::sanitizeInstallSessionEvents($session['events'] ?? []), 'events' => self::sanitizeInstallSessionEvents($session['events'] ?? []),
]; ];
$status = (string)$normalized['status']; $status = (string)$normalized['status'];
if (!self::installSessionStatusIsTerminal($status) && $expiresAt !== null && strtotime($expiresAt) < ($now ?? time())) { if (
!self::installSessionStatusIsTerminal($status)
&& $expiresAt !== null
&& (self::parseApplicationDateTime($expiresAt) ?? PHP_INT_MAX) < ($now ?? time())
) {
$status = self::INSTALL_SESSION_STATUS_EXPIRED; $status = self::INSTALL_SESSION_STATUS_EXPIRED;
$normalized['status'] = $status; $normalized['status'] = $status;
$normalized['message'] = $normalized['message'] ?: 'Installer token expired before the gateway claimed successfully.'; $normalized['message'] = $normalized['message'] ?: 'Installer token expired before the gateway claimed successfully.';
@@ -2257,7 +2263,16 @@ BASH;
$host .= ':' . $port; $host .= ':' . $port;
} }
return $scheme . '://' . $host; $forwardedPrefix = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null);
if ($forwardedPrefix !== null) {
$normalizedForwardedPrefix = '/' . trim($forwardedPrefix, '/');
$basePath = $normalizedForwardedPrefix === '/' ? '' : $normalizedForwardedPrefix;
} else {
$requestPath = parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
$basePath = (is_string($requestPath) && preg_match('#^/api(?:/|$)#', $requestPath) === 1) ? '/api' : '';
}
return $scheme . '://' . $host . $basePath;
} }
private function detectForwardedScheme(): ?string private function detectForwardedScheme(): ?string
@@ -2383,7 +2398,7 @@ BASH;
if (!$claimToken->exists()) { if (!$claimToken->exists()) {
throw new Exception('Invalid install token'); throw new Exception('Invalid install token');
} }
if (strtotime((string)$claimToken->expires_at->value()) < time()) { if ((self::parseApplicationDateTime((string)$claimToken->expires_at->value()) ?? 0) < time()) {
throw new Exception('Install token has expired'); throw new Exception('Install token has expired');
} }
@@ -2597,7 +2612,7 @@ BASH;
try { try {
$statement = $pdo->prepare( $statement = $pdo->prepare(
'SELECT id 'SELECT id, delivery_json
FROM edge_gateway_command_jobs FROM edge_gateway_command_jobs
WHERE gateway_id = :gateway_id WHERE gateway_id = :gateway_id
AND deleted_at IS NULL AND deleted_at IS NULL
@@ -2626,16 +2641,25 @@ BASH;
return null; return null;
} }
$delivery = isset($row['delivery_json']) && is_string($row['delivery_json'])
? json_decode($row['delivery_json'], true)
: [];
if (!is_array($delivery)) {
$delivery = [];
}
$delivery['delivery_channel'] = self::DELIVERY_CHANNEL_API;
$delivery['attempt_count'] = ((int)($delivery['attempt_count'] ?? 0)) + 1;
$delivery['last_dispatch_error'] = null;
$encodedDelivery = json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encodedDelivery)) {
$encodedDelivery = '{}';
}
$update = $pdo->prepare( $update = $pdo->prepare(
'UPDATE edge_gateway_command_jobs 'UPDATE edge_gateway_command_jobs
SET status = :status, SET status = :status,
response_json = :response_json, response_json = :response_json,
delivery_json = JSON_SET( delivery_json = :delivery_json,
COALESCE(delivery_json, JSON_OBJECT()),
\'$.delivery_channel\', :delivery_channel,
\'$.attempt_count\', COALESCE(JSON_EXTRACT(COALESCE(delivery_json, JSON_OBJECT()), \'$.attempt_count\'), 0) + 1,
\'$.last_dispatch_error\', CAST(NULL AS JSON)
),
error_message = NULL, error_message = NULL,
completed_at = NULL completed_at = NULL
WHERE id = :id' WHERE id = :id'
@@ -2643,7 +2667,7 @@ BASH;
$update->execute([ $update->execute([
':status' => 'DISPATCHING', ':status' => 'DISPATCHING',
':response_json' => json_encode([], JSON_UNESCAPED_UNICODE), ':response_json' => json_encode([], JSON_UNESCAPED_UNICODE),
':delivery_channel' => json_encode(self::DELIVERY_CHANNEL_API, JSON_UNESCAPED_UNICODE), ':delivery_json' => $encodedDelivery,
':id' => (int)$row['id'], ':id' => (int)$row['id'],
]); ]);
@@ -2655,6 +2679,7 @@ BASH;
throw $throwable; throw $throwable;
} }
$this->clearObjectPropertyCache('edge_gateway_command_jobs', (int)$row['id']);
return (new edge_gateway_command_jobs_o())->select((int)$row['id']); return (new edge_gateway_command_jobs_o())->select((int)$row['id']);
} }
@@ -3959,11 +3984,19 @@ BASH;
$channelStatus = self::deriveChannelStatus($gateway, $effectiveStatus, $now); $channelStatus = self::deriveChannelStatus($gateway, $effectiveStatus, $now);
$relayHealth = self::buildRelayHealth($gateway, $effectiveStatus, $now); $relayHealth = self::buildRelayHealth($gateway, $effectiveStatus, $now);
$fallbackSummary = self::buildFallbackSummary($relayHealth); $fallbackSummary = self::buildFallbackSummary($relayHealth);
$gateway['outbox_status'] = self::buildOutboxStatusSummary($gateway, $now);
$lastSyncAt = self::resolveLastSyncAt($gateway);
$gateway['channel_status'] = $channelStatus; $gateway['channel_status'] = $channelStatus;
$gateway['relay_health'] = $relayHealth; $gateway['relay_health'] = $relayHealth;
$gateway['fallback_summary'] = $fallbackSummary; $gateway['fallback_summary'] = $fallbackSummary;
$gateway['transport_health'] = self::deriveTransportHealth($effectiveStatus, $channelStatus, $fallbackSummary); $gateway['transport_health'] = self::deriveTransportHealth(
$gateway,
$effectiveStatus,
$channelStatus,
$fallbackSummary,
$lastSyncAt
);
$gateway['last_successful_command_at'] = $gateway['operational_snapshot']['last_successful_command_at'] $gateway['last_successful_command_at'] = $gateway['operational_snapshot']['last_successful_command_at']
?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null); ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null);
$gateway['last_successful_discovery_at'] = $gateway['operational_snapshot']['last_successful_discovery_at'] $gateway['last_successful_discovery_at'] = $gateway['operational_snapshot']['last_successful_discovery_at']
@@ -3976,8 +4009,7 @@ BASH;
$gateway['version_drift'] = self::buildVersionDriftSummary($gateway); $gateway['version_drift'] = self::buildVersionDriftSummary($gateway);
$gateway['credential_freshness'] = self::buildCredentialFreshnessSummary($gateway, $now); $gateway['credential_freshness'] = self::buildCredentialFreshnessSummary($gateway, $now);
$gateway['container_health'] = self::buildContainerHealthSummary($gateway, $effectiveStatus); $gateway['container_health'] = self::buildContainerHealthSummary($gateway, $effectiveStatus);
$gateway['outbox_status'] = self::buildOutboxStatusSummary($gateway, $now); $gateway['last_sync_at'] = $lastSyncAt;
$gateway['last_sync_at'] = self::resolveLastSyncAt($gateway);
$gateway['update_window'] = self::buildUpdateWindowSummary($gateway); $gateway['update_window'] = self::buildUpdateWindowSummary($gateway);
$gateway['staged_version'] = self::buildStagedVersionSummary($gateway); $gateway['staged_version'] = self::buildStagedVersionSummary($gateway);
$gateway['rollback_status'] = self::buildRollbackStatusSummary($gateway); $gateway['rollback_status'] = self::buildRollbackStatusSummary($gateway);
@@ -4150,8 +4182,18 @@ BASH;
return $summary; return $summary;
} }
private static function deriveTransportHealth(string $effectiveStatus, array $channelStatus, array $fallbackSummary): array private static function deriveTransportHealth(
array $gateway,
string $effectiveStatus,
array $channelStatus,
array $fallbackSummary,
?string $lastSyncAt
): array
{ {
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
$controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status'])
? (array)$metadata['control_plane_status']
: [];
$brokerState = (string)($channelStatus['broker']['state'] ?? self::STATUS_OFFLINE); $brokerState = (string)($channelStatus['broker']['state'] ?? self::STATUS_OFFLINE);
$affectedRelayCount = count((array)($fallbackSummary['affected_relays'] ?? [])); $affectedRelayCount = count((array)($fallbackSummary['affected_relays'] ?? []));
$transportState = $effectiveStatus; $transportState = $effectiveStatus;
@@ -4169,6 +4211,13 @@ BASH;
? 'Broker fast path er aktiv med API polling som fallback' ? 'Broker fast path er aktiv med API polling som fallback'
: 'API polling er aktiv som primær kontrolkanal'), : 'API polling er aktiv som primær kontrolkanal'),
'recommended_action' => $fallbackSummary['recommended_action'] ?? ($channelStatus['broker']['last_error'] ?? null), 'recommended_action' => $fallbackSummary['recommended_action'] ?? ($channelStatus['broker']['last_error'] ?? null),
'last_successful_sync_at' => $lastSyncAt,
'last_transport_failure_at' => isset($controlPlaneStatus['last_transport_failure_at'])
? (string)$controlPlaneStatus['last_transport_failure_at']
: null,
'last_transport_error' => isset($controlPlaneStatus['last_transport_error'])
? (string)$controlPlaneStatus['last_transport_error']
: null,
]; ];
} }
@@ -4320,17 +4369,23 @@ BASH;
private static function resolveLastSyncAt(array $gateway): ?string private static function resolveLastSyncAt(array $gateway): ?string
{ {
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
if (!empty($metadata['last_sync_at'])) { $controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status'])
return (string)$metadata['last_sync_at']; ? (array)$metadata['control_plane_status']
} : [];
$outbox = isset($gateway['outbox_status']) && is_array($gateway['outbox_status']) $outbox = isset($gateway['outbox_status']) && is_array($gateway['outbox_status'])
? (array)$gateway['outbox_status'] ? (array)$gateway['outbox_status']
: []; : [];
$outboxMetadata = isset($metadata['outbox_status']) && is_array($metadata['outbox_status'])
? (array)$metadata['outbox_status']
: [];
return isset($outbox['last_replayed_at']) && $outbox['last_replayed_at'] !== null return self::latestTimestamp([
? (string)$outbox['last_replayed_at'] $controlPlaneStatus['last_successful_sync_at'] ?? null,
: null; $metadata['last_sync_at'] ?? null,
$outbox['last_replayed_at'] ?? null,
$outboxMetadata['last_replayed_at'] ?? null,
$gateway['last_heartbeat_at'] ?? null,
]);
} }
private static function buildUpdateWindowSummary(array $gateway): array private static function buildUpdateWindowSummary(array $gateway): array
@@ -4411,8 +4466,8 @@ BASH;
? (array)$gateway['active_operation'] ? (array)$gateway['active_operation']
: null; : null;
if ($activeOperation !== null && !empty($activeOperation['started_at'])) { if ($activeOperation !== null && !empty($activeOperation['started_at'])) {
$startedAt = strtotime((string)$activeOperation['started_at']); $startedAt = self::parseApplicationDateTime((string)$activeOperation['started_at']);
if ($startedAt !== false && (($now ?? time()) - $startedAt) >= edge_gateway_operation_service::OPERATION_TIMEOUT_SECONDS) { if ($startedAt !== null && (($now ?? time()) - $startedAt) >= edge_gateway_operation_service::OPERATION_TIMEOUT_SECONDS) {
$diagnostics[] = [ $diagnostics[] = [
'code' => edge_gateway_operation_service::ERROR_OPERATION_TIMEOUT, 'code' => edge_gateway_operation_service::ERROR_OPERATION_TIMEOUT,
'severity' => 'warning', 'severity' => 'warning',
@@ -4551,6 +4606,33 @@ BASH;
return null; return null;
} }
/**
* @param array<int,mixed> $timestamps
*/
private static function latestTimestamp(array $timestamps): ?string
{
$latestValue = null;
$latestEpoch = 0;
foreach ($timestamps as $timestamp) {
if (!is_string($timestamp) || trim($timestamp) === '') {
continue;
}
$epoch = self::parseApplicationDateTime($timestamp);
if ($epoch === null) {
continue;
}
if ($latestValue === null || $epoch >= $latestEpoch) {
$latestValue = $timestamp;
$latestEpoch = $epoch;
}
}
return $latestValue;
}
private static function normalizeFallbackMode(?string $fallbackMode): string private static function normalizeFallbackMode(?string $fallbackMode): string
{ {
$normalized = strtoupper(trim((string)$fallbackMode)); $normalized = strtoupper(trim((string)$fallbackMode));
@@ -4565,6 +4647,16 @@ BASH;
return self::RELAY_FALLBACK_PREFER_LOCAL; return self::RELAY_FALLBACK_PREFER_LOCAL;
} }
private function clearObjectPropertyCache(string $table, int $id): void
{
if ($id <= 0 || !defined('redis')) {
return;
}
$normalizedTable = trim($table, " `\t\n\r\0\x0B");
redis->clear_keys('obj_prop:' . $normalizedTable . ':' . $id . ':*');
}
private static function resolveDeviceFreshnessState(?array $device, ?int $ageSeconds): string private static function resolveDeviceFreshnessState(?array $device, ?int $ageSeconds): string
{ {
if ($device === null) { if ($device === null) {
@@ -4635,8 +4727,8 @@ BASH;
return null; return null;
} }
$heartbeatTimestamp = strtotime($lastHeartbeatAt); $heartbeatTimestamp = self::parseApplicationDateTime($lastHeartbeatAt);
if ($heartbeatTimestamp === false) { if ($heartbeatTimestamp === null) {
return null; return null;
} }
@@ -4649,8 +4741,7 @@ BASH;
return 0; return 0;
} }
$heartbeatTimestamp = strtotime($lastHeartbeatAt); return self::parseApplicationDateTime($lastHeartbeatAt) ?? 0;
return $heartbeatTimestamp === false ? 0 : $heartbeatTimestamp;
} }
private static function statusPriority(string $status): int private static function statusPriority(string $status): int
@@ -4765,14 +4856,55 @@ BASH;
return hash('sha256', $plainToken); return hash('sha256', $plainToken);
} }
public static function parseApplicationDateTime(?string $value): ?int
{
$normalized = trim((string)$value);
if ($normalized === '') {
return null;
}
$timezone = self::applicationTimeZone();
$dateTime = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $normalized, $timezone);
if ($dateTime instanceof \DateTimeImmutable) {
return $dateTime->getTimestamp();
}
try {
return (new \DateTimeImmutable($normalized, $timezone))->getTimestamp();
} catch (\Throwable) {
return null;
}
}
public static function formatApplicationDateTime(int $timestamp): string
{
return (new \DateTimeImmutable('@' . $timestamp))
->setTimezone(self::applicationTimeZone())
->format('Y-m-d H:i:s');
}
private static function applicationTimeZone(): \DateTimeZone
{
$timezone = trim((string)($_ENV['CONFIG_TIMEZONE'] ?? getenv('CONFIG_TIMEZONE') ?: 'Europe/Copenhagen'));
if ($timezone === '') {
$timezone = 'Europe/Copenhagen';
}
try {
return new \DateTimeZone($timezone);
} catch (\Throwable) {
return new \DateTimeZone('Europe/Copenhagen');
}
}
private function now(): string private function now(): string
{ {
return date('Y-m-d H:i:s'); return self::formatApplicationDateTime(time());
} }
private function formatDateTime(int $timestamp): string private function formatDateTime(int $timestamp): string
{ {
return date('Y-m-d H:i:s', $timestamp); return self::formatApplicationDateTime($timestamp);
} }
private function remoteIp(): ?string private function remoteIp(): ?string
@@ -3,6 +3,7 @@
namespace classes; namespace classes;
use Exception; use Exception;
use RuntimeException;
use objects\edge_gateway_operation_events_o; use objects\edge_gateway_operation_events_o;
use objects\edge_gateway_operations_o; use objects\edge_gateway_operations_o;
use objects\edge_gateways_o; use objects\edge_gateways_o;
@@ -413,7 +414,7 @@ class edge_gateway_operation_service
self::STATUS_CANCEL_REQUESTED, self::STATUS_CANCEL_REQUESTED,
self::STATUS_CANCELLED, self::STATUS_CANCELLED,
], true)) { ], true)) {
return $this->serializeOperation($operation, true); return $this->serializePersistedOperation($operationId, true);
} }
$level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO))); $level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO)));
@@ -446,9 +447,10 @@ class edge_gateway_operation_service
$this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context); $this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context);
} }
$this->refreshGatewayViewCache($gatewayId); $this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
$this->clearGatewayViewCache($gatewayId);
return $this->serializeOperation($operation, true); return $this->serializePersistedOperation($operationId, true);
} }
/** /**
@@ -477,7 +479,7 @@ class edge_gateway_operation_service
self::STATUS_FAILED, self::STATUS_FAILED,
self::STATUS_CANCELLED, self::STATUS_CANCELLED,
], true)) { ], true)) {
return $this->serializeOperation($operation, true); return $this->serializePersistedOperation($operationId, true);
} }
$ok = (bool)($payload['ok'] ?? false); $ok = (bool)($payload['ok'] ?? false);
@@ -549,10 +551,11 @@ class edge_gateway_operation_service
$errorMessage $errorMessage
); );
} }
$this->refreshGatewayViewCache($gatewayId); $this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
$this->clearGatewayViewCache($gatewayId);
$this->manager()->notifyBrokerGatewaySync($gatewayId); $this->manager()->notifyBrokerGatewaySync($gatewayId);
return $this->serializeOperation($operation, true); return $this->serializePersistedOperation($operationId, true);
} }
/** /**
@@ -632,7 +635,7 @@ class edge_gateway_operation_service
try { try {
$statement = $pdo->prepare( $statement = $pdo->prepare(
"SELECT id "SELECT id, type, attempt_count, summary_json
FROM edge_gateway_operations FROM edge_gateway_operations
WHERE gateway_id = :gateway_id WHERE gateway_id = :gateway_id
AND deleted_at IS NULL AND deleted_at IS NULL
@@ -649,36 +652,69 @@ class edge_gateway_operation_service
return null; return null;
} }
$operation = (new edge_gateway_operations_o())->select((int)$row['id']); $operationId = (int)$row['id'];
$operation->status->set(self::STATUS_IN_PROGRESS); $startedAt = $this->now();
$operation->started_at->set($this->now()); $leaseExpiresAt = $this->leaseExpiry();
$operation->agent_instance_id->set($agentInstanceId); $attemptCount = ((int)($row['attempt_count'] ?? 0)) + 1;
$operation->last_progress_at->set($this->now()); $summary = isset($row['summary_json']) && is_string($row['summary_json'])
$operation->lease_expires_at->set($this->leaseExpiry()); ? json_decode($row['summary_json'], true)
$operation->attempt_count->set(((int)($operation->attempt_count->value() ?? 0)) + 1); : [];
$summary = (array)($operation->summary_json->value() ?? []); if (!is_array($summary)) {
$summary = [];
}
$summary['label'] = 'Gateway is processing the operation'; $summary['label'] = 'Gateway is processing the operation';
$summary['progress'] = max(5, (int)($summary['progress'] ?? 0)); $summary['progress'] = max(5, (int)($summary['progress'] ?? 0));
$summary['claimed_by'] = $agentInstanceId; $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(); $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( $this->appendEventRecord(
$gatewayId, $gatewayId,
(int)$operation->id, $operationId,
self::LEVEL_INFO, self::LEVEL_INFO,
'OPERATION_STARTED', 'OPERATION_STARTED',
'Gateway started processing the operation', 'Gateway started processing the operation',
[ [
'type' => (string)$operation->type->value(), 'type' => (string)($row['type'] ?? $operation['type'] ?? ''),
'agent_instance_id' => $agentInstanceId, 'agent_instance_id' => $agentInstanceId,
'attempt_count' => (int)($operation->attempt_count->value() ?? 1), 'attempt_count' => $attemptCount,
'stage' => self::STATUS_IN_PROGRESS,
] ]
); );
$this->refreshGatewayViewCache($gatewayId); $this->clearGatewayViewCache($gatewayId, $pdo);
return $this->serializeOperation($operation, true); return $this->serializeOperationRecord($operation, true);
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
if ($pdo->inTransaction()) { if ($pdo->inTransaction()) {
$pdo->rollBack(); $pdo->rollBack();
@@ -706,13 +742,15 @@ class edge_gateway_operation_service
foreach ($rows as $row) { foreach ($rows as $row) {
$operation = (new edge_gateway_operations_o())->select((int)$row['id']); $operation = (new edge_gateway_operations_o())->select((int)$row['id']);
$status = (string)$operation->status->value(); $status = (string)$operation->status->value();
$startedAt = $operation->started_at->value() === null ? null : strtotime((string)$operation->started_at->value()); $startedAt = edge_gateway_manager::parseApplicationDateTime(
$leaseExpiresAt = $operation->lease_expires_at->value() === null ? null : strtotime((string)$operation->lease_expires_at->value()); $operation->started_at->value() === null ? null : (string)$operation->started_at->value()
$timedOut = $startedAt !== false );
&& $startedAt !== null $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; && ($now - $startedAt) >= self::OPERATION_TIMEOUT_SECONDS;
$leaseExpired = $leaseExpiresAt !== false $leaseExpired = $leaseExpiresAt !== null
&& $leaseExpiresAt !== null
&& $leaseExpiresAt <= $now; && $leaseExpiresAt <= $now;
if (!$timedOut && !$leaseExpired) { if (!$timedOut && !$leaseExpired) {
@@ -924,9 +962,19 @@ class edge_gateway_operation_service
string $message, string $message,
array $context array $context
): array { ): 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([ $eventId = (new edge_gateway_operation_events_o())->add_object([
'operation_id' => $operationId, 'operation_id' => $operationId,
'gateway_id' => $gatewayId, 'gateway_id' => $gatewayId,
'stage' => $stage,
'level' => $level, 'level' => $level,
'code' => $code, 'code' => $code,
'message' => $message, 'message' => $message,
@@ -947,6 +995,136 @@ class edge_gateway_operation_service
edge_gateway_view_cache::syncGateway($this->manager()->getGateway($gatewayId)); 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 * @throws Exception
*/ */
@@ -986,12 +1164,12 @@ class edge_gateway_operation_service
private function now(): string private function now(): string
{ {
return date('Y-m-d H:i:s'); return edge_gateway_manager::formatApplicationDateTime(time());
} }
private function leaseExpiry(): string 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 private function normalizeAgentInstanceId(?string $agentInstanceId, int $gatewayId): string
@@ -1065,7 +1243,7 @@ class edge_gateway_operation_service
self::STATUS_CANCEL_REQUESTED, self::STATUS_CANCEL_REQUESTED,
self::STATUS_CANCELLED, self::STATUS_CANCELLED,
], true)) { ], true)) {
return $this->serializeOperation($operation, true); return $this->serializePersistedOperation($operationId, true);
} }
$level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO))); $level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO)));
@@ -1098,9 +1276,10 @@ class edge_gateway_operation_service
$this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context); $this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context);
} }
$this->refreshGatewayViewCache($gatewayId); $this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
$this->clearGatewayViewCache($gatewayId);
return $this->serializeOperation($operation, true); return $this->serializePersistedOperation($operationId, true);
} }
/** /**
@@ -1114,7 +1293,7 @@ class edge_gateway_operation_service
self::STATUS_FAILED, self::STATUS_FAILED,
self::STATUS_CANCELLED, self::STATUS_CANCELLED,
], true)) { ], true)) {
return $this->serializeOperation($operation, true); return $this->serializePersistedOperation($operationId, true);
} }
$ok = (bool)($payload['ok'] ?? false); $ok = (bool)($payload['ok'] ?? false);
@@ -1186,9 +1365,10 @@ class edge_gateway_operation_service
$errorMessage $errorMessage
); );
} }
$this->refreshGatewayViewCache($gatewayId); $this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
$this->clearGatewayViewCache($gatewayId);
$this->manager()->notifyBrokerGatewaySync($gatewayId); $this->manager()->notifyBrokerGatewaySync($gatewayId);
return $this->serializeOperation($operation, true); return $this->serializePersistedOperation($operationId, true);
} }
} }
@@ -157,11 +157,15 @@ class edge_gateway_schema_bootstrap
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
operation_id INT NOT NULL, operation_id INT NOT NULL,
gateway_id INT NOT NULL, gateway_id INT NOT NULL,
stage VARCHAR(64) NOT NULL DEFAULT 'RECORDED',
level VARCHAR(16) NOT NULL DEFAULT 'INFO', level VARCHAR(16) NOT NULL DEFAULT 'INFO',
code VARCHAR(128) NULL, code VARCHAR(128) NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
context_json JSON NULL, context_json JSON NULL,
counts_json JSON NULL,
payload_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 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_operation (operation_id),
INDEX idx_edge_gateway_operation_events_gateway (gateway_id), INDEX idx_edge_gateway_operation_events_gateway (gateway_id),
INDEX idx_edge_gateway_operation_events_level (level) INDEX idx_edge_gateway_operation_events_level (level)
@@ -254,8 +258,12 @@ class edge_gateway_schema_bootstrap
self::ensureColumn('edge_gateway_operations', 'updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at'); self::ensureColumn('edge_gateway_operations', '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_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', 'code', 'VARCHAR(128) NULL AFTER level');
self::ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message'); 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', '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', 'severity', "VARCHAR(16) NOT NULL DEFAULT 'INFO' AFTER actor_type");
@@ -13,6 +13,7 @@ class edge_gateway_operation_events_o extends db
public object_property $operation_id; public object_property $operation_id;
public object_property $gateway_id; public object_property $gateway_id;
public object_property $stage;
public object_property $level; public object_property $level;
public object_property $code; public object_property $code;
public object_property $message; 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->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->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->level = new object_property($this->table, $this->id, 'level', 'string', false);
$this->code = new object_property($this->table, $this->id, 'code', '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); $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, 'id' => (int)$this->id,
'operation_id' => (int)$this->operation_id->value(), 'operation_id' => (int)$this->operation_id->value(),
'gateway_id' => (int)$this->gateway_id->value(), 'gateway_id' => (int)$this->gateway_id->value(),
'stage' => (string)$this->stage->value(),
'level' => (string)$this->level->value(), 'level' => (string)$this->level->value(),
'code' => $this->code->value() === null ? null : (string)$this->code->value(), 'code' => $this->code->value() === null ? null : (string)$this->code->value(),
'message' => (string)$this->message->value(), 'message' => (string)$this->message->value(),
@@ -86,27 +86,17 @@ class plate_scanners_o extends db
bool $laneIdProvided = false bool $laneIdProvided = false
): void ): void
{ {
global $db, $response; global $response;
$this->id = $id;
try { try {
// Avoid SQL injection $this->select($id);
$name = $db->escape_string($name); // Use object_property setters so cached field values are invalidated before we serialize the scanner.
$notes = $db->escape_string($notes); $this->department_id->set($department_id);
$setParts = [ $this->name->set($name);
"department_id = $department_id", $this->notes->set($notes);
"name = '$name'",
"notes = '$notes'",
];
if ($laneIdProvided) { if ($laneIdProvided) {
$lane_id = $this->normalizeLaneId($department_id, $lane_id); $lane_id = $this->normalizeLaneId($department_id, $lane_id);
$setParts[] = 'lane_id = ' . ($lane_id === null ? 'NULL' : (string)$lane_id); $this->lane_id->set($lane_id);
} }
// Update the record in the database
$sql = "UPDATE $this->table SET " . implode(', ', $setParts) . " WHERE id = $id";
$db->query($sql);
// Set the values of the object properties
$this->getObjectProperties();
} catch (\Exception $e) { } catch (\Exception $e) {
$response->error($e->getMessage()); $response->error($e->getMessage());
} }
@@ -346,7 +346,7 @@ final class LocalStateStore
final class BrokerWebSocketClient final class BrokerWebSocketClient
{ {
private const CONNECT_TIMEOUT_SECONDS = 5; private const CONNECT_TIMEOUT_SECONDS = 15;
private const RECONNECT_DELAY_SECONDS = 2; private const RECONNECT_DELAY_SECONDS = 2;
/** @var resource|null */ /** @var resource|null */
@@ -998,6 +998,8 @@ final class TruckwashEdgeAgent
private const DEFAULT_UPDATE_WINDOW = '02:00-04:00'; private const DEFAULT_UPDATE_WINDOW = '02:00-04:00';
private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120; private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120;
private const BROKER_MESSAGE_PUMP_LIMIT = 12; private const BROKER_MESSAGE_PUMP_LIMIT = 12;
private const LOOP_STALE_AFTER_SECONDS = 30;
private const CONTROL_PLANE_SYNC_STALE_AFTER_SECONDS = 90;
private AgentConfig $config; private AgentConfig $config;
private HttpJsonClient $http; private HttpJsonClient $http;
@@ -1011,6 +1013,7 @@ final class TruckwashEdgeAgent
private string $statePath; private string $statePath;
private string $lastOperationSnapshotPath; private string $lastOperationSnapshotPath;
private string $lastHeartbeatMarkerPath; private string $lastHeartbeatMarkerPath;
private string $controlPlaneStatusPath;
private string $stagedUpdatePath; private string $stagedUpdatePath;
private int $lastHeartbeatAt = 0; private int $lastHeartbeatAt = 0;
private string $agentInstanceId; private string $agentInstanceId;
@@ -1031,12 +1034,14 @@ final class TruckwashEdgeAgent
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json'; $this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
$this->lastOperationSnapshotPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-operation.json'; $this->lastOperationSnapshotPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-operation.json';
$this->lastHeartbeatMarkerPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-heartbeat-ok.txt'; $this->lastHeartbeatMarkerPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-heartbeat-ok.txt';
$this->controlPlaneStatusPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'control-plane-status.json';
$this->stagedUpdatePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'staged-update.json'; $this->stagedUpdatePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'staged-update.json';
$this->agentInstanceId = $this->ensureAgentInstanceId(); $this->agentInstanceId = $this->ensureAgentInstanceId();
$this->brokerClient = new BrokerWebSocketClient($this->logger); $this->brokerClient = new BrokerWebSocketClient($this->logger);
$this->shellBridge = new AgentShellBridge($this->installDir); $this->shellBridge = new AgentShellBridge($this->installDir);
$this->logger->setSink(fn(string $level, string $message): bool => $this->emitLogFrame($level, $message)); $this->logger->setSink(fn(string $level, string $message): bool => $this->emitLogFrame($level, $message));
$this->configureBrokerClient(); $this->configureBrokerClient();
$this->initializeControlPlaneStatus();
} }
public function run(): void public function run(): void
@@ -1046,6 +1051,7 @@ final class TruckwashEdgeAgent
while (true) { while (true) {
try { try {
$this->touchLoopHeartbeat();
$this->reloadConfigFromDisk(); $this->reloadConfigFromDisk();
$this->ensureClaimed(); $this->ensureClaimed();
$this->configureBrokerClient(); $this->configureBrokerClient();
@@ -1081,23 +1087,29 @@ final class TruckwashEdgeAgent
} }
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v2'); $installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v2');
$response = $this->http->post('/edge-agent/claim', [ try {
'token' => (string)$this->config->get('installToken'), $response = $this->http->post('/edge-agent/claim', [
'hostname' => gethostname() ?: 'truckwash-edge', 'token' => (string)$this->config->get('installToken'),
'installed_version' => $installedVersion, 'hostname' => gethostname() ?: 'truckwash-edge',
'metadata' => [ 'installed_version' => $installedVersion,
'runtime' => 'compose-php', 'metadata' => [
'runtime_mode' => 'compose', 'runtime' => 'compose-php',
'php_version' => PHP_VERSION, 'runtime_mode' => 'compose',
'agent_instance_id' => $this->agentInstanceId, 'php_version' => PHP_VERSION,
'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW), 'agent_instance_id' => $this->agentInstanceId,
'container_health' => $this->buildContainerHealth(), 'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW),
'outbox_status' => $this->buildOutboxStatus(), 'container_health' => $this->buildContainerHealth(),
'last_sync_at' => $this->stateStore->getJson('last_sync_at'), 'outbox_status' => $this->buildOutboxStatus(),
'rollback_status' => $this->readRollbackStatus(), 'last_sync_at' => $this->currentLastSuccessfulSyncAt(),
'staged_version' => $this->currentStagedUpdate(), 'control_plane_status' => $this->buildControlPlaneStatusPayload(),
], 'rollback_status' => $this->readRollbackStatus(),
]); 'staged_version' => $this->currentStagedUpdate(),
],
]);
} catch (Throwable $throwable) {
$this->recordTransportFailure('Gateway claim failed', $throwable);
throw $throwable;
}
$payload = $response['data'] ?? []; $payload = $response['data'] ?? [];
$gateway = is_array($payload['gateway'] ?? null) ? (array)$payload['gateway'] : []; $gateway = is_array($payload['gateway'] ?? null) ? (array)$payload['gateway'] : [];
@@ -1110,7 +1122,7 @@ final class TruckwashEdgeAgent
$this->config->set('targetVersion', (string)($gateway['target_version'] ?? $installedVersion)); $this->config->set('targetVersion', (string)($gateway['target_version'] ?? $installedVersion));
$this->config->set('agentInstanceId', $this->agentInstanceId); $this->config->set('agentInstanceId', $this->agentInstanceId);
$this->config->save(); $this->config->save();
$this->stateStore->setJson('last_sync_at', date('c')); $this->recordSuccessfulSync();
$this->logger->info('Claimed gateway ' . (string)($gateway['id'] ?? 'unknown') . '.'); $this->logger->info('Claimed gateway ' . (string)($gateway['id'] ?? 'unknown') . '.');
} }
@@ -1145,7 +1157,7 @@ final class TruckwashEdgeAgent
{ {
$sent = $this->brokerClient->send($message); $sent = $this->brokerClient->send($message);
if ($sent) { if ($sent) {
$this->stateStore->setJson('last_sync_at', date('c')); $this->recordSuccessfulSync();
} }
return $sent; return $sent;
} }
@@ -1224,6 +1236,7 @@ final class TruckwashEdgeAgent
return; return;
} }
$this->recordHeartbeatAttempt();
$operationState = $this->readOperationState(); $operationState = $this->readOperationState();
$payload = [ $payload = [
'agent_token' => (string)$this->config->get('agentToken'), 'agent_token' => (string)$this->config->get('agentToken'),
@@ -1242,7 +1255,8 @@ final class TruckwashEdgeAgent
'system_metrics' => $this->buildSystemMetrics(), 'system_metrics' => $this->buildSystemMetrics(),
'container_health' => $this->buildContainerHealth(), 'container_health' => $this->buildContainerHealth(),
'outbox_status' => $this->buildOutboxStatus(), 'outbox_status' => $this->buildOutboxStatus(),
'last_sync_at' => $this->stateStore->getJson('last_sync_at'), 'last_sync_at' => $this->currentLastSuccessfulSyncAt(),
'control_plane_status' => $this->buildControlPlaneStatusPayload(),
'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW), 'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW),
'staged_version' => $this->currentStagedUpdate(), 'staged_version' => $this->currentStagedUpdate(),
'rollback_status' => $this->readRollbackStatus(), 'rollback_status' => $this->readRollbackStatus(),
@@ -1265,10 +1279,14 @@ final class TruckwashEdgeAgent
return; return;
} }
$heartbeatSucceededAt = date('c');
$this->lastHeartbeatAt = time(); $this->lastHeartbeatAt = time();
$this->recordSuccessfulSync($heartbeatSucceededAt, [
'last_heartbeat_success_at' => $heartbeatSucceededAt,
]);
file_put_contents($this->lastHeartbeatMarkerPath, json_encode([ file_put_contents($this->lastHeartbeatMarkerPath, json_encode([
'gateway_id' => $gatewayId, 'gateway_id' => $gatewayId,
'at' => date('c'), 'at' => $heartbeatSucceededAt,
'agent_instance_id' => $this->agentInstanceId, 'agent_instance_id' => $this->agentInstanceId,
], JSON_UNESCAPED_SLASHES) . PHP_EOL); ], JSON_UNESCAPED_SLASHES) . PHP_EOL);
} }
@@ -1991,6 +2009,143 @@ final class TruckwashEdgeAgent
]; ];
} }
private function initializeControlPlaneStatus(): void
{
$this->writeControlPlaneStatus();
}
private function touchLoopHeartbeat(): void
{
$this->writeControlPlaneStatus([
'last_loop_at' => date('c'),
]);
}
private function currentLastSuccessfulSyncAt(): ?string
{
$current = $this->readControlPlaneStatus()['last_successful_sync_at'] ?? $this->stateStore->getJson('last_sync_at');
return $this->normalizeControlPlaneStatusTimestamp($current);
}
private function recordHeartbeatAttempt(): string
{
$attemptedAt = date('c');
$this->writeControlPlaneStatus([
'last_heartbeat_attempt_at' => $attemptedAt,
]);
return $attemptedAt;
}
private function recordSuccessfulSync(?string $at = null, array $statusOverrides = []): void
{
$syncedAt = $this->normalizeControlPlaneStatusTimestamp($at ?? date('c')) ?? date('c');
$this->stateStore->setJson('last_sync_at', $syncedAt);
$statusOverrides['last_successful_sync_at'] = $syncedAt;
$this->writeControlPlaneStatus($statusOverrides);
}
private function recordTransportFailure(string $context, Throwable $throwable): void
{
$message = $this->normalizeControlPlaneStatusString($throwable->getMessage()) ?? $throwable::class;
$this->writeControlPlaneStatus([
'last_transport_failure_at' => date('c'),
'last_transport_error' => $message,
]);
$this->logger->warning($context . ': ' . $message);
}
/**
* @return array<string,mixed>
*/
private function buildControlPlaneStatusPayload(): array
{
return $this->writeControlPlaneStatus();
}
/**
* @return array<string,mixed>
*/
private function readControlPlaneStatus(): array
{
if (!is_file($this->controlPlaneStatusPath)) {
return [];
}
$decoded = json_decode((string)file_get_contents($this->controlPlaneStatusPath), true);
return is_array($decoded) ? $decoded : [];
}
/**
* @param array<string,mixed> $overrides
* @return array<string,mixed>
*/
private function writeControlPlaneStatus(array $overrides = []): array
{
$current = $this->readControlPlaneStatus();
$outboxSummary = $this->stateStore->outboxSummary();
$lastSuccessfulSyncAt = array_key_exists('last_successful_sync_at', $overrides)
? $overrides['last_successful_sync_at']
: ($current['last_successful_sync_at'] ?? $this->stateStore->getJson('last_sync_at'));
$lastTransportError = array_key_exists('last_transport_error', $overrides)
? $overrides['last_transport_error']
: ($current['last_transport_error'] ?? null);
$lastTransportFailureAt = array_key_exists('last_transport_failure_at', $overrides)
? $overrides['last_transport_failure_at']
: ($current['last_transport_failure_at'] ?? null);
$lastHeartbeatAttemptAt = array_key_exists('last_heartbeat_attempt_at', $overrides)
? $overrides['last_heartbeat_attempt_at']
: ($current['last_heartbeat_attempt_at'] ?? null);
$lastHeartbeatSuccessAt = array_key_exists('last_heartbeat_success_at', $overrides)
? $overrides['last_heartbeat_success_at']
: ($current['last_heartbeat_success_at'] ?? null);
$lastLoopAt = array_key_exists('last_loop_at', $overrides)
? $overrides['last_loop_at']
: ($current['last_loop_at'] ?? null);
$status = [
'started_at' => $this->normalizeControlPlaneStatusTimestamp($current['started_at'] ?? null) ?? date('c'),
'agent_instance_id' => $this->agentInstanceId,
'last_loop_at' => $this->normalizeControlPlaneStatusTimestamp($lastLoopAt),
'last_heartbeat_attempt_at' => $this->normalizeControlPlaneStatusTimestamp($lastHeartbeatAttemptAt),
'last_heartbeat_success_at' => $this->normalizeControlPlaneStatusTimestamp($lastHeartbeatSuccessAt),
'last_successful_sync_at' => $this->normalizeControlPlaneStatusTimestamp($lastSuccessfulSyncAt),
'outbox_queued' => array_key_exists('outbox_queued', $overrides)
? max(0, (int)$overrides['outbox_queued'])
: max(0, (int)($outboxSummary['queued'] ?? 0)),
'broker_connected' => array_key_exists('broker_connected', $overrides)
? (bool)$overrides['broker_connected']
: $this->isBrokerConnected(),
'last_transport_error' => $this->normalizeControlPlaneStatusString($lastTransportError),
'last_transport_failure_at' => $this->normalizeControlPlaneStatusTimestamp($lastTransportFailureAt),
];
file_put_contents(
$this->controlPlaneStatusPath,
json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
return $status;
}
private function normalizeControlPlaneStatusTimestamp(mixed $value): ?string
{
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
private function normalizeControlPlaneStatusString(mixed $value): ?string
{
if (is_string($value)) {
$normalized = trim($value);
return $normalized !== '' ? $normalized : null;
}
if (is_scalar($value)) {
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
return null;
}
private function flushOutbox(): void private function flushOutbox(): void
{ {
$items = $this->stateStore->queuedItems(25); $items = $this->stateStore->queuedItems(25);
@@ -2006,9 +2161,12 @@ final class TruckwashEdgeAgent
$this->http->post($endpoint, $payload, $timeoutSeconds); $this->http->post($endpoint, $payload, $timeoutSeconds);
} }
$this->stateStore->removeOutboxItem((int)$item['id']); $this->stateStore->removeOutboxItem((int)$item['id']);
$this->stateStore->setJson('last_sync_at', date('c')); $this->recordSuccessfulSync();
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$this->logger->warning('Outbox replay blocked on ' . (string)$item['type'] . ': ' . $throwable->getMessage()); $this->recordTransportFailure(
'Outbox replay blocked on ' . (string)$item['type'] . ' for ' . (string)$item['endpoint'],
$throwable
);
break; break;
} }
} }
@@ -2106,17 +2264,19 @@ final class TruckwashEdgeAgent
{ {
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload); $brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
if ($brokerDispatch === true) { if ($brokerDispatch === true) {
$this->stateStore->setJson('last_sync_at', date('c'));
return true; return true;
} }
try { try {
$this->http->post($endpoint, $payload, 20); $this->http->post($endpoint, $payload, 20);
$this->stateStore->setJson('last_sync_at', date('c')); $this->recordSuccessfulSync();
return true; return true;
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$this->stateStore->enqueue($type, $endpoint, $payload); $this->stateStore->enqueue($type, $endpoint, $payload);
$this->logger->warning('Queued ' . $type . ' to local outbox after transport failure: ' . $throwable->getMessage()); $this->recordTransportFailure(
'Queued ' . $type . ' to local outbox after transport failure on ' . $endpoint,
$throwable
);
return false; return false;
} }
} }
@@ -2125,17 +2285,19 @@ final class TruckwashEdgeAgent
{ {
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload); $brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
if ($brokerDispatch === true) { if ($brokerDispatch === true) {
$this->stateStore->setJson('last_sync_at', date('c'));
return ['data' => ['status' => 'ACKNOWLEDGED']]; return ['data' => ['status' => 'ACKNOWLEDGED']];
} }
try { try {
$response = $this->http->post($endpoint, $payload, $timeoutSeconds); $response = $this->http->post($endpoint, $payload, $timeoutSeconds);
$this->stateStore->setJson('last_sync_at', date('c')); $this->recordSuccessfulSync();
return is_array($response) ? $response : null; return is_array($response) ? $response : null;
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$this->stateStore->enqueue($type, $endpoint, $payload); $this->stateStore->enqueue($type, $endpoint, $payload);
$this->logger->warning('Queued ' . $type . ' to local outbox after transport failure: ' . $throwable->getMessage()); $this->recordTransportFailure(
'Queued ' . $type . ' to local outbox after transport failure on ' . $endpoint,
$throwable
);
return null; return null;
} }
} }
@@ -2356,22 +2518,27 @@ final class TruckwashEdgeAgent
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload); $brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
if ($brokerDispatch === true) { if ($brokerDispatch === true) {
$this->stateStore->setJson('last_sync_at', date('c'));
return true; return true;
} }
try { try {
$this->http->post($endpoint, $payload, self::OPERATION_COMPLETE_TIMEOUT_SECONDS); $this->http->post($endpoint, $payload, self::OPERATION_COMPLETE_TIMEOUT_SECONDS);
$this->stateStore->setJson('last_sync_at', date('c')); $this->recordSuccessfulSync();
return true; return true;
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
if ($queueOnFailure) { if ($queueOnFailure) {
$this->stateStore->enqueue('operation_complete', $endpoint, $payload); $this->stateStore->enqueue('operation_complete', $endpoint, $payload);
$this->logger->warning('Queued operation_complete to local outbox after completion acknowledgement failure: ' . $throwable->getMessage()); $this->recordTransportFailure(
'Queued operation_complete to local outbox after completion acknowledgement failure on ' . $endpoint,
$throwable
);
return false; return false;
} }
$this->logger->warning('Retrying backend completion acknowledgement later: ' . $throwable->getMessage()); $this->recordTransportFailure(
'Retrying backend completion acknowledgement later for ' . $endpoint,
$throwable
);
return false; return false;
} }
} }
@@ -89,7 +89,11 @@ services:
- ./config.json:/config/config.json - ./config.json:/config/config.json
- ./runtime:/opt/truckwash-edge-agent/runtime - ./runtime:/opt/truckwash-edge-agent/runtime
healthcheck: healthcheck:
test: ["CMD-SHELL", "kill -0 1"] test:
[
"CMD-SHELL",
"php -r '$$path=\"/opt/truckwash-edge-agent/runtime/control-plane-status.json\"; if (!is_file($$path)) { exit(1); } $$data=json_decode((string)file_get_contents($$path), true); if (!is_array($$data)) { exit(1); } $$loopAt=strtotime((string)($$data[\"last_loop_at\"] ?? \"\")); $$syncAt=strtotime((string)($$data[\"last_successful_sync_at\"] ?? $$data[\"started_at\"] ?? \"\")); if ($$loopAt === false || $$syncAt === false) { exit(1); } $$now=time(); exit((($$now - $$loopAt) <= 30 && ($$now - $$syncAt) <= 90) ? 0 : 1);'",
]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
@@ -3,6 +3,7 @@
namespace routes; namespace routes;
use classes\authentication; use classes\authentication;
use classes\customer_mass_import_service;
use customers\economicCustomers; use customers\economicCustomers;
use objects\logs_o; use objects\logs_o;
use objects\users_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' '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 private static function parseFunction($collection, \Closure $param): array
@@ -4,6 +4,7 @@ namespace routes;
use classes\authentication; use classes\authentication;
use classes\response; use classes\response;
use classes\shelly_relay_inventory;
use dynamicimages\images\machine_1; use dynamicimages\images\machine_1;
use objects\categories_o; use objects\categories_o;
use objects\department_lanes_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) * Generate dynamic image for a department lane (machine UI)
* *
@@ -255,11 +302,11 @@ class departmentLanesRoute
// Get the request data // Get the request data
$name = $response->getRequestParameter('name') ?? null; $name = $response->getRequestParameter('name') ?? null;
$department = $response->getRequestParameter('department') ?? null; $department = $response->getRequestParameter('department') ?? null;
$relay_in_id = $response->getRequestParameter('relay_in_id') ?? null; $relay_in_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_in_id') ?? null);
$relay_out_id = $response->getRequestParameter('relay_out_id') ?? null; $relay_out_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_out_id') ?? null);
$relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null; $relay_machine_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_id') ?? null);
$relay_machine_program_picker_id = $response->getRequestParameter('relay_machine_program_picker_id') ?? null; $relay_machine_program_picker_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_program_picker_id') ?? null);
$relay_machine_cleaner_id = $response->getRequestParameter('relay_machine_cleaner_id') ?? null; $relay_machine_cleaner_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_cleaner_id') ?? null);
$dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null; $dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null;
$machine_type_id = $response->getRequestParameter('machine_type_id') ?? null; $machine_type_id = $response->getRequestParameter('machine_type_id') ?? null;
@@ -276,7 +323,6 @@ class departmentLanesRoute
} else { } else {
$machine_type_id = null; $machine_type_id = null;
} }
// Remove spaces from the relay_in_id and relay_out_id
// Check if the required fields are set // Check if the required fields are set
if ($name && $department) { if ($name && $department) {
// Add the department lane // Add the department lane
@@ -315,11 +361,11 @@ class departmentLanesRoute
$id = $response->getRequestParameter('id') ?? null; $id = $response->getRequestParameter('id') ?? null;
$name = $response->getRequestParameter('name') ?? null; $name = $response->getRequestParameter('name') ?? null;
$department = $response->getRequestParameter('department') ?? null; $department = $response->getRequestParameter('department') ?? null;
$relay_in_id = $response->getRequestParameter('relay_in_id') ?? null; $relay_in_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_in_id') ?? null);
$relay_out_id = $response->getRequestParameter('relay_out_id') ?? null; $relay_out_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_out_id') ?? null);
$relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null; $relay_machine_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_id') ?? null);
$relay_machine_program_picker_id = $response->getRequestParameter('relay_machine_program_picker_id') ?? null; $relay_machine_program_picker_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_program_picker_id') ?? null);
$relay_machine_cleaner_id = $response->getRequestParameter('relay_machine_cleaner_id') ?? null; $relay_machine_cleaner_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_cleaner_id') ?? null);
$dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null; $dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null;
$machine_type_id = $response->getRequestParameter('machine_type_id') ?? null; $machine_type_id = $response->getRequestParameter('machine_type_id') ?? null;
@@ -340,19 +386,19 @@ class departmentLanesRoute
$department_lane->department->set((int)$department); $department_lane->department->set((int)$department);
} }
if (self::isParametersSet(['relay_in_id'])) { 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'])) { 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'])) { 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'])) { 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'])) { 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'])) { if (self::isParametersSet(['dynamic_image_id'])) {
$param = $response->getRequestParameter('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);
}
} }
@@ -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', 'GET /orders',
'POST /orders', 'POST /orders',
'PUT /orders', 'PUT /orders',
'PUT /numberplatescanners',
'DELETE /orders', 'DELETE /orders',
'POST /bird/voice/calls/webhook/inbound', 'POST /bird/voice/calls/webhook/inbound',
], ],
@@ -0,0 +1,447 @@
<?php
declare(strict_types=1);
use classes\db;
use classes\edge_gateway_manager;
use classes\edge_gateway_operation_service;
use Predis\Client as PredisClient;
use Tests\Support\Api\ApiCleanup;
use Tests\Support\Api\ApiFixtures;
use Tests\Support\Api\ApiSchemaBootstrap;
require_once dirname(__DIR__, 2) . '/Support/Api/ApiCleanup.php';
require_once dirname(__DIR__, 2) . '/Support/Api/ApiFixtures.php';
require_once dirname(__DIR__, 2) . '/Support/Api/ApiSchemaBootstrap.php';
app_require('classes/db.php');
app_require('classes/edge_gateway_manager.php');
app_require('classes/edge_gateway_operation_service.php');
it('persists install-session updates and derives gateway runtime status from heartbeats', function (): void {
$context = edge_gateway_integration_context();
try {
$department = $context['fixtures']->createDepartment([
'name' => 'Edge Integration Install Department',
]);
$token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Install Token');
$claimTokenId = (int)($token['claim_token_id'] ?? 0);
$context['manager']->reportInstallTokenStatus((string)$token['token'], [
'status' => 'FAILED',
'step' => 'DOWNLOAD_FAILED',
'message' => 'The installer could not download the runtime bundle.',
'diagnostics' => [
'diag-1',
'diag-2',
'diag-3',
'diag-4',
'diag-5',
'diag-6',
'diag-7',
'diag-8',
],
]);
$failedStatus = $context['manager']->getInstallTokenStatus($claimTokenId);
expect($failedStatus)
->toHaveKey('status', 'FAILED')
->toHaveKey('step', 'DOWNLOAD_FAILED')
->toHaveKey('last_error', 'The installer could not download the runtime bundle.')
->and($failedStatus['diagnostics'] ?? [])
->toBeArray()
->toHaveCount(6);
$claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-integration-host', 'php-agent-v1', [
'source' => 'integration-test',
]);
$gatewayId = (int)($claimed['gateway']['id'] ?? 0);
$agentToken = (string)($claimed['agent_token'] ?? '');
expect($gatewayId)->toBeGreaterThan(0)
->and($agentToken)->not->toBe('');
$claimedStatus = $context['manager']->getInstallTokenStatus($claimTokenId);
expect($claimedStatus)
->toHaveKey('status', 'CLAIMED')
->toHaveKey('gateway_id', $gatewayId)
->toHaveKey('last_error', null);
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_DEGRADED_AFTER_SECONDS + 1);
$degraded = $context['manager']->getGateway($gatewayId);
expect($degraded)->toHaveKey('status', 'DEGRADED');
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_OFFLINE_AFTER_SECONDS + 1);
$offline = $context['manager']->getGateway($gatewayId);
expect($offline)->toHaveKey('status', 'OFFLINE');
$context['manager']->recordHeartbeat($gatewayId, $agentToken, [
'status' => 'ONLINE',
'hostname' => 'edge-integration-host',
'metadata' => [
'system_metrics' => [
'cpu_percent' => 44,
],
],
]);
$online = $context['manager']->getGateway($gatewayId);
expect($online)
->toHaveKey('status', 'ONLINE')
->and($online['metadata']['system_metrics']['cpu_percent'] ?? null)
->toBe(44);
} finally {
$context['cleanup']->run();
}
});
it('assembles tasks, logs, statistics, operations, commands, and shell lifecycle state from persisted records', function (): void {
$context = edge_gateway_integration_context();
try {
$department = $context['fixtures']->createDepartment([
'name' => 'Edge Integration Runtime Department',
]);
$user = $context['fixtures']->createUser([
'display_name' => 'Edge Integration User',
]);
$token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Runtime Token', (int)$user['id']);
$claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-runtime-host', 'php-agent-v1');
$gatewayId = (int)($claimed['gateway']['id'] ?? 0);
$agentToken = (string)($claimed['agent_token'] ?? '');
$operation = $context['operations']->queueOperation(
$gatewayId,
edge_gateway_operation_service::TYPE_DISCOVERY,
['inventory' => edge_gateway_integration_inventory('runtime')],
(int)$user['id']
);
$operationId = (int)($operation['id'] ?? 0);
expect($operationId)->toBeGreaterThan(0);
$claimedOperation = $context['operations']->claimNextOperation($gatewayId, $agentToken, 0, 'integration-agent-1');
expect($claimedOperation)
->toBeArray()
->toHaveKey('status', 'IN_PROGRESS');
$context['operations']->appendAgentOperationEvent($gatewayId, $operationId, $agentToken, [
'level' => 'INFO',
'code' => 'DISCOVERY_RUNNING',
'message' => 'Integration discovery is executing.',
'context' => [
'progress' => 70,
],
]);
$context['operations']->completeAgentOperation($gatewayId, $operationId, $agentToken, [
'ok' => true,
'result' => [
'inventory' => edge_gateway_integration_inventory('completed'),
],
]);
$job = $context['fixtures']->createEdgeCommandJob([
'gateway_id' => $gatewayId,
'command_type' => 'GET_RELAY_STATUS',
'request' => [
'relayId' => 'relay-main',
],
'requested_by' => (int)$user['id'],
]);
$jobId = (int)($job['id'] ?? 0);
$polledCommand = $context['manager']->pollCommand($gatewayId, $agentToken, 0);
expect($polledCommand)
->toBeArray()
->toHaveKey('id', $jobId)
->toHaveKey('command_type', 'GET_RELAY_STATUS');
$commandResult = $context['manager']->submitCommandResult($gatewayId, $jobId, $agentToken, true, [
'relayId' => 'relay-main',
'online' => true,
]);
expect($commandResult)
->toHaveKey('acknowledged', true)
->and($commandResult['job']['status'] ?? null)
->toBe('COMPLETED');
$shellSession = $context['manager']->createShellSession(
$gatewayId,
(int)$user['id'],
'Integration shell session',
120,
40,
'/opt/truckwash-edge-agent'
);
$shellToken = (string)($shellSession['token'] ?? '');
expect($shellToken)->not->toBe('');
$validatedShell = $context['manager']->validateShellSessionToken($shellToken);
expect($validatedShell)->toHaveKey('status', 'PENDING');
$openedShell = $context['manager']->markShellSessionOpened($shellToken, 'shell-connection-1');
expect($openedShell)->toHaveKey('status', 'OPEN');
$closedShell = $context['manager']->closeShellSessionByToken(
$shellToken,
"edge-shell-output\n",
'agent_exit'
);
expect($closedShell)
->toHaveKey('status', 'COMPLETED')
->and($closedShell['transcript'] ?? null)
->toBe("edge-shell-output\n");
$context['manager']->appendGatewayLogEntry(
$gatewayId,
'Integration log line',
'INFO',
'agent',
'BROKER',
['source' => 'integration']
);
$context['manager']->recordBrokerPresence(
$gatewayId,
'connected',
'broker-connection-1',
null,
['transport' => 'ws']
);
$context['manager']->recordTelemetryFromBroker($gatewayId, [
'status' => 'ONLINE',
'metadata' => [
'system_metrics' => [
'cpu_percent' => 17,
'memory_mb' => 256,
],
],
'inventory' => edge_gateway_integration_inventory('telemetry'),
]);
$tasks = $context['manager']->buildGatewayTasksPage($gatewayId);
$logs = $context['manager']->buildGatewayLogsPage($gatewayId);
$statistics = $context['manager']->buildGatewayStatisticsPage($gatewayId);
expect($tasks['operations'] ?? [])
->toBeArray()
->not->toBeEmpty()
->and(($tasks['operations'][0]['status'] ?? null))
->toBe('COMPLETED');
expect($tasks['recent_commands'] ?? [])
->toBeArray()
->not->toBeEmpty()
->and(($tasks['recent_commands'][0]['status'] ?? null))
->toBe('COMPLETED');
expect(edge_gateway_integration_messages($logs['log_entries'] ?? []))
->toContain('Integration log line');
expect(edge_gateway_integration_messages($logs['timeline'] ?? []))
->toContain('Integration discovery is executing.');
expect($logs['shell_sessions'] ?? [])
->toBeArray()
->not->toBeEmpty()
->and(($logs['shell_sessions'][0]['transcript'] ?? null))
->toBe("edge-shell-output\n");
expect($statistics['system_metrics']['cpu_percent'] ?? null)
->toBe(17);
expect($statistics['gateway']['inventory'] ?? [])
->toBeArray()
->not->toBeEmpty();
} finally {
$context['cleanup']->run();
}
});
/**
* @return array{cleanup:ApiCleanup,fixtures:ApiFixtures,manager:edge_gateway_manager,operations:edge_gateway_operation_service,mysqli:mysqli,redis:?PredisClient}
*/
function edge_gateway_integration_context(): array
{
if (!integration_enabled()) {
test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run edge gateway integration tests.');
}
static $bootstrapped = null;
if ($bootstrapped === null) {
$dbConfig = edge_gateway_integration_db_config();
$GLOBALS['CONFIG_DB'] = $dbConfig;
$GLOBALS['response'] = new class {
public function internal_server_error(string $message): void
{
throw new RuntimeException($message);
}
};
$db = new db($dbConfig);
$db->connect();
$GLOBALS['db'] = $db;
$mysqli = $db->conn();
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$mysqli->set_charset('utf8mb4');
(new ApiSchemaBootstrap($mysqli))->ensureSchema();
$bootstrapped = [
'mysqli' => $mysqli,
'redis' => edge_gateway_integration_redis_client(),
];
}
$cleanup = new ApiCleanup();
return [
'cleanup' => $cleanup,
'fixtures' => new ApiFixtures($bootstrapped['mysqli'], $bootstrapped['redis'], $cleanup),
'manager' => new edge_gateway_manager(),
'operations' => new edge_gateway_operation_service(),
'mysqli' => $bootstrapped['mysqli'],
'redis' => $bootstrapped['redis'],
];
}
/**
* @return array{host:string,user:string,password:string,database:string,port:int}
*/
function edge_gateway_integration_db_config(): array
{
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
if ($target !== 'debug') {
$target = 'live';
}
$host = edge_gateway_integration_config_value('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target);
$user = edge_gateway_integration_config_value('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target);
$password = edge_gateway_integration_config_value('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target);
$database = edge_gateway_integration_config_value('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
$port = (int)(edge_gateway_integration_config_value('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
if ($host === '' || $user === '' || $database === '') {
test()->markTestSkipped('Edge gateway integration tests require configured database environment variables.');
}
return [
'host' => $host,
'user' => $user,
'password' => $password,
'database' => $database,
'port' => $port > 0 ? $port : 3306,
];
}
function edge_gateway_integration_redis_client(): ?PredisClient
{
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
if ($target !== 'debug') {
$target = 'live';
}
$host = edge_gateway_integration_config_value('REDIS_CONFIG_HOST', 'REDIS_CONFIG_DEBUG_HOST', $target);
if ($host === '') {
return null;
}
$parameters = [
'scheme' => 'tcp',
'host' => $host,
'port' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_PORT', $target) ?: '6379'),
'database' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_DEBUG_DATABASE', $target) ?: '0'),
'password' => edge_gateway_integration_config_value('REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DEBUG_PASSWORD', $target),
];
$user = edge_gateway_integration_config_value('REDIS_CONFIG_USER', 'REDIS_CONFIG_DEBUG_USER', $target);
if ($user !== '') {
$parameters['username'] = $user;
}
return new PredisClient($parameters);
}
function edge_gateway_integration_config_value(string $liveKey, string $debugKey, string $target): string
{
$liveValue = trim((string)(getenv($liveKey) ?: ''));
$debugValue = trim((string)(getenv($debugKey) ?: ''));
if ($target === 'debug' && $debugValue !== '') {
return $debugValue;
}
return $liveValue;
}
function edge_gateway_integration_set_heartbeat_age(mysqli $mysqli, ?PredisClient $redis, int $gatewayId, int $secondsAgo): void
{
$result = $mysqli->query("SELECT last_heartbeat_at, updated_at, created_at FROM edge_gateways WHERE id = " . (int)$gatewayId . " LIMIT 1");
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if ($result instanceof mysqli_result) {
$result->free();
}
$referenceTimestamp = strtotime((string)($row['last_heartbeat_at'] ?? $row['updated_at'] ?? $row['created_at'] ?? ''));
if ($referenceTimestamp === false || $referenceTimestamp <= 0) {
$referenceTimestamp = time();
}
$timestamp = date('Y-m-d H:i:s', $referenceTimestamp - max(0, $secondsAgo));
$escaped = $mysqli->real_escape_string($timestamp);
$mysqli->query("UPDATE edge_gateways SET last_heartbeat_at = '{$escaped}', status = 'ONLINE' WHERE id = " . (int)$gatewayId);
if ($redis !== null) {
foreach ([
'obj_prop:*:' . $gatewayId . ':status',
'obj_prop:*:' . $gatewayId . ':last_heartbeat_at',
'obj_prop:*:' . $gatewayId . ':updated_at',
'edge_gateway:view:v1:*',
] as $pattern) {
$keys = $redis->keys($pattern);
if (is_array($keys) && $keys !== []) {
$redis->del($keys);
}
}
}
}
function edge_gateway_integration_inventory(string $suffix): array
{
return [[
'device_id' => 'integration-' . $suffix,
'local_ip' => '10.50.60.70',
'model' => 'TruckWash Integration Gateway',
'channel_count' => 1,
'online' => true,
'capabilities' => [
'gateway_management_v2' => true,
],
'metadata' => [
'hostname' => 'integration-' . $suffix,
],
]];
}
/**
* @param mixed $rows
* @return array<int, string>
*/
function edge_gateway_integration_messages(mixed $rows): array
{
if (!is_array($rows)) {
return [];
}
$messages = [];
foreach ($rows as $row) {
if (is_array($row) && isset($row['message']) && is_string($row['message'])) {
$messages[] = $row['message'];
}
}
return $messages;
}
@@ -445,6 +445,25 @@ final class ApiFixtures
]; ];
} }
/**
* @param array<int, string> $permissions
* @param array<string, mixed> $userAttributes
* @return array{user:array<string,mixed>,token:string,headers:array<string,string>}
*/
public function createEdgeOperatorSession(int $departmentId, array $permissions = [], array $userAttributes = []): array
{
if ($departmentId <= 0) {
throw new RuntimeException('Edge operator sessions require a positive department id.');
}
$permissions = array_values(array_unique(array_merge(
['modules_shelly_config', 'department_access_' . $departmentId],
$permissions
)));
return $this->createUserSession($permissions, $userAttributes);
}
/** /**
* @param array<int, string> $permissions * @param array<int, string> $permissions
* @return array{user:array<string,mixed>,subuser:array<string,mixed>,token:string,headers:array<string,string>} * @return array{user:array<string,mixed>,subuser:array<string,mixed>,token:string,headers:array<string,string>}
@@ -593,6 +612,15 @@ final class ApiFixtures
], $extraHeaders); ], $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 public function fetchRowById(string $table, int $id): ?array
{ {
$table = $this->sanitizeIdentifier($table); $table = $this->sanitizeIdentifier($table);
@@ -600,6 +628,395 @@ final class ApiFixtures
return $this->queryOneBySql("SELECT * FROM `{$table}` WHERE id = {$id} LIMIT 1"); 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 public function cleanupDeleteById(string $table, int $id): void
{ {
$this->cleanup->add(fn() => $this->deleteById($table, $id)); $this->cleanup->add(fn() => $this->deleteById($table, $id));
@@ -49,3 +49,21 @@ function assert_api_envelope(ApiResponse $response): ApiResponse
{ {
return $response->assertEnvelope(); return $response->assertEnvelope();
} }
function edge_test_broker_secret(): string
{
$secret = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
return $secret !== '' ? $secret : 'truckwash-edge-test-secret';
}
/**
* @param array<string, string> $extraHeaders
* @return array<string, string>
*/
function edge_test_broker_headers(array $extraHeaders = []): array
{
return array_merge([
'X-Edge-Broker-Secret' => edge_test_broker_secret(),
], $extraHeaders);
}
@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
use classes\db;
use Predis\Client as PredisClient;
use Tests\Support\Api\ApiCleanup;
use Tests\Support\Api\ApiFixtures;
use Tests\Support\Api\ApiSchemaBootstrap;
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/Api/ApiCleanup.php';
require_once __DIR__ . '/Api/ApiFixtures.php';
require_once __DIR__ . '/Api/ApiSchemaBootstrap.php';
app_require('classes/db.php');
app_require('classes/edge_gateway_manager.php');
$action = strtolower(trim((string)($argv[1] ?? '')));
if ($action === '') {
edge_gateway_e2e_fixture_fail('Missing action. Expected create or cleanup.');
}
try {
if ($action === 'create') {
$context = edge_gateway_e2e_fixture_context();
$fixtures = $context['fixtures'];
$department = $fixtures->createDepartment([
'name' => 'Edge Gateway E2E Department',
]);
$session = $fixtures->createEdgeOperatorSession((int)$department['id'], [], [
'display_name' => 'Edge Gateway E2E Operator',
]);
edge_gateway_e2e_fixture_output([
'department_id' => (int)$department['id'],
'user_id' => (int)$session['user']['id'],
'group_id' => (int)$session['user']['group_id'],
'customer_number' => (int)$session['user']['customer_number'],
'auth_token' => (string)$session['token'],
'broker_secret' => edge_test_broker_secret(),
]);
}
if ($action === 'cleanup') {
$encoded = trim((string)($argv[2] ?? ''));
if ($encoded === '') {
edge_gateway_e2e_fixture_fail('Cleanup requires a base64url payload argument.');
}
$payload = json_decode(base64_decode(strtr($encoded, '-_', '+/')) ?: '', true);
if (!is_array($payload)) {
edge_gateway_e2e_fixture_fail('Invalid cleanup payload.');
}
$context = edge_gateway_e2e_fixture_context();
$db = $context['mysqli'];
$departmentId = (int)($payload['department_id'] ?? 0);
$userId = (int)($payload['user_id'] ?? 0);
$groupId = (int)($payload['group_id'] ?? 0);
if ($departmentId > 0) {
$gatewayIds = edge_gateway_e2e_gateway_ids($db, $departmentId);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_operation_events', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_operations', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_command_jobs', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_log_entries', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_shell_sessions', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_device_inventory', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_relay_bindings', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_audit_logs', $gatewayIds);
$db->query('DELETE FROM edge_gateway_claim_tokens WHERE department_id = ' . $departmentId);
$db->query('DELETE FROM edge_gateways WHERE department_id = ' . $departmentId);
$db->query('DELETE FROM department_variables WHERE department_id = ' . $departmentId);
}
if ($userId > 0) {
$db->query('DELETE FROM tokens WHERE user_id = ' . $userId);
$db->query('DELETE FROM users WHERE id = ' . $userId);
}
if ($groupId > 0) {
$db->query('DELETE FROM groups_permissions WHERE group_id = ' . $groupId);
$db->query('DELETE FROM groups WHERE id = ' . $groupId);
}
if ($departmentId > 0) {
$db->query('DELETE FROM departments WHERE id = ' . $departmentId);
}
edge_gateway_e2e_fixture_output(['ok' => true]);
}
edge_gateway_e2e_fixture_fail('Unsupported action: ' . $action);
} catch (Throwable $throwable) {
edge_gateway_e2e_fixture_fail($throwable->getMessage());
}
/**
* @return array{mysqli:mysqli,fixtures:ApiFixtures}
*/
function edge_gateway_e2e_fixture_context(): array
{
static $context = null;
if ($context !== null) {
return $context;
}
$dbConfig = edge_gateway_e2e_db_config();
$GLOBALS['CONFIG_DB'] = $dbConfig;
$GLOBALS['response'] = new class {
public function internal_server_error(string $message): void
{
throw new RuntimeException($message);
}
};
$db = new db($dbConfig);
$db->connect();
$GLOBALS['db'] = $db;
$mysqli = $db->conn();
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$mysqli->set_charset('utf8mb4');
(new ApiSchemaBootstrap($mysqli))->ensureSchema();
$context = [
'mysqli' => $mysqli,
'fixtures' => new ApiFixtures($mysqli, edge_gateway_e2e_redis_client(), new ApiCleanup()),
];
return $context;
}
/**
* @return array{host:string,user:string,password:string,database:string,port:int}
*/
function edge_gateway_e2e_db_config(): array
{
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
if ($target !== 'debug') {
$target = 'live';
}
$host = edge_gateway_e2e_config_value('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target);
$user = edge_gateway_e2e_config_value('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target);
$password = edge_gateway_e2e_config_value('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target);
$database = edge_gateway_e2e_config_value('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
$port = (int)(edge_gateway_e2e_config_value('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
if ($host === '' || $user === '' || $database === '') {
throw new RuntimeException('Missing database configuration for edge gateway E2E fixtures.');
}
return [
'host' => $host,
'user' => $user,
'password' => $password,
'database' => $database,
'port' => $port > 0 ? $port : 3306,
];
}
function edge_gateway_e2e_config_value(string $liveKey, string $debugKey, string $target): string
{
$liveValue = trim((string)(getenv($liveKey) ?: ''));
$debugValue = trim((string)(getenv($debugKey) ?: ''));
if ($target === 'debug' && $debugValue !== '') {
return $debugValue;
}
return $liveValue;
}
function edge_gateway_e2e_redis_client(): ?PredisClient
{
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
if ($target !== 'debug') {
$target = 'live';
}
$host = edge_gateway_e2e_config_value('REDIS_CONFIG_HOST', 'REDIS_CONFIG_DEBUG_HOST', $target);
if ($host === '') {
return null;
}
$parameters = [
'scheme' => 'tcp',
'host' => $host,
'port' => (int)(edge_gateway_e2e_config_value('REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_PORT', $target) ?: '6379'),
'database' => (int)(edge_gateway_e2e_config_value('REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_DEBUG_DATABASE', $target) ?: '0'),
'password' => edge_gateway_e2e_config_value('REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DEBUG_PASSWORD', $target),
];
$user = edge_gateway_e2e_config_value('REDIS_CONFIG_USER', 'REDIS_CONFIG_DEBUG_USER', $target);
if ($user !== '') {
$parameters['username'] = $user;
}
return new PredisClient($parameters);
}
/**
* @return array<int, int>
*/
function edge_gateway_e2e_gateway_ids(mysqli $db, int $departmentId): array
{
$ids = [];
$result = $db->query('SELECT id FROM edge_gateways WHERE department_id = ' . $departmentId);
if ($result === false) {
return [];
}
while ($row = $result->fetch_assoc()) {
if (isset($row['id']) && is_numeric($row['id'])) {
$ids[] = (int)$row['id'];
}
}
$result->free();
return $ids;
}
/**
* @param array<int, int> $gatewayIds
*/
function edge_gateway_e2e_delete_by_gateway_ids(mysqli $db, string $table, array $gatewayIds): void
{
$gatewayIds = array_values(array_unique(array_filter(array_map('intval', $gatewayIds), static fn(int $id): bool => $id > 0)));
if ($gatewayIds === []) {
return;
}
$db->query('DELETE FROM `' . $table . '` WHERE gateway_id IN (' . implode(', ', $gatewayIds) . ')');
}
/**
* @param array<string, mixed> $payload
*/
function edge_gateway_e2e_fixture_output(array $payload): void
{
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
exit(0);
}
function edge_gateway_e2e_fixture_fail(string $message): never
{
fwrite(STDERR, $message . PHP_EOL);
exit(1);
}
+11 -1
View File
@@ -95,6 +95,14 @@ function integration_enabled(): bool
return getenv('RUN_INTEGRATION_TESTS') === '1'; 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 function run_legacy_script(string $relativeScriptPath): array
{ {
$script = app_path($relativeScriptPath); $script = app_path($relativeScriptPath);
@@ -112,4 +120,6 @@ function run_legacy_script(string $relativeScriptPath): array
} }
require_once __DIR__ . '/ApiTestSupport.php'; require_once __DIR__ . '/ApiTestSupport.php';
require_once __DIR__ . '/Api/ApiTestCase.php'; if (class_exists(\PHPUnit\Framework\TestCase::class)) {
require_once __DIR__ . '/Api/ApiTestCase.php';
}
@@ -0,0 +1,49 @@
#!/bin/sh
set -eu
tmp_dir="$(mktemp -d)"
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT HUP INT TERM
cat > "$tmp_dir/composer.json" <<'JSON'
{
"name": "truckwash/composer-entrypoint-fixture",
"autoload": {
"classmap": [
"src/"
]
}
}
JSON
mkdir -p "$tmp_dir/src"
cat > "$tmp_dir/src/FixtureClass.php" <<'PHP'
<?php
class FixtureClass
{
}
PHP
COMPOSER_ALLOW_SUPERUSER=1 composer install \
--no-dev \
--prefer-dist \
--optimize-autoloader \
--no-interaction \
-d "$tmp_dir" >/dev/null 2>&1
printf '%s\n' '<?php' > "$tmp_dir/vendor/composer/autoload_real.php"
AUTO_COMPOSER_INSTALL=true \
APP_DIR="$tmp_dir" \
MODULE_DIR="$tmp_dir/no-module" \
LOG_FILE="$tmp_dir/composer-install.log" \
/usr/local/bin/docker-entrypoint.sh \
php -r "require \$argv[1]; echo class_exists('FixtureClass') ? 'autoload-ok' . PHP_EOL : 'autoload-missing' . PHP_EOL;" \
"$tmp_dir/vendor/autoload.php" >/dev/null
php -d display_errors=1 -r "require \$argv[1]; exit(class_exists('FixtureClass') ? 0 : 1);" "$tmp_dir/vendor/autoload.php"
echo "composer-entrypoint-autoload-recovery-ok"
@@ -0,0 +1,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,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');
});
@@ -10,8 +10,10 @@ it('defines the department hardware workspace service payload surface', function
expect($service)->toContain("'lanes' => \$lanes"); expect($service)->toContain("'lanes' => \$lanes");
expect($service)->toContain("'self_serve' => \$selfServe"); expect($service)->toContain("'self_serve' => \$selfServe");
expect($service)->toContain("'gates' => \$gates"); expect($service)->toContain("'gates' => \$gates");
expect($service)->toContain("'relays' => \$relays");
expect($service)->toContain("'scanners' => \$scanners"); expect($service)->toContain("'scanners' => \$scanners");
expect($service)->toContain("'issues' => \$issues"); expect($service)->toContain("'issues' => \$issues");
expect($service)->toContain("'actions' => \$actions"); expect($service)->toContain("'actions' => \$actions");
expect($service)->toContain("'consumer_contexts'"); expect($service)->toContain("'consumer_contexts'");
expect($service)->toContain("'coverage'");
}); });
@@ -74,11 +74,17 @@ it('derives relay fallback and transport health details without shell or update
'last_heartbeat_at' => '2026-04-08 10:04:30', 'last_heartbeat_at' => '2026-04-08 10:04:30',
'department_transport_mode' => edge_gateway_manager::TRANSPORT_MODE_GATEWAY, 'department_transport_mode' => edge_gateway_manager::TRANSPORT_MODE_GATEWAY,
'metadata' => [ 'metadata' => [
'last_sync_at' => '2026-04-08 10:04:20',
'broker_presence' => [ 'broker_presence' => [
'connected' => false, 'connected' => false,
'last_seen_at' => '2026-04-08 10:03:00', 'last_seen_at' => '2026-04-08 10:03:00',
'last_error' => 'broker timeout', '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' => [ 'operational_snapshot' => [
'command_backlog' => 2, '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['fallback_summary']['local_only_relays'])->toBe(1);
expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED); expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED);
expect($gateway['transport_health']['recommended_action'])->toBe('retry_discovery'); 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['last_successful_discovery_at'])->toBe('2026-04-08 10:02:00');
expect($gateway['diagnostics'])->not->toBeEmpty(); expect($gateway['diagnostics'])->not->toBeEmpty();
expect($gateway['error_state']['code'])->toBe('EDGE_GATEWAY_OPERATION_TIMEOUT'); 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 { it('builds install script urls with the compose edge gateway artifacts and forwarded https scheme', function (): void {
with_edge_gateway_server_state([ with_edge_gateway_server_state([
'HTTP_HOST' => 'api.truckwash.io:4433', 'HTTP_HOST' => 'api.truckwash.io:4433',
@@ -64,7 +71,7 @@ it('builds install script urls with the compose edge gateway artifacts and forwa
expect($script)->toContain('"composeFileName":"docker-compose.gateway.yml"'); expect($script)->toContain('"composeFileName":"docker-compose.gateway.yml"');
expect($script)->toContain('"stateDatabasePath":"/opt/truckwash-edge-agent/runtime/gateway-state.sqlite"'); expect($script)->toContain('"stateDatabasePath":"/opt/truckwash-edge-agent/runtime/gateway-state.sqlite"');
expect($script)->toContain('"operationPollTimeoutSeconds":20'); expect($script)->toContain('"operationPollTimeoutSeconds":20');
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"'); expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4433/edge-broker"');
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"'); expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
}); });
}); });
@@ -85,6 +92,7 @@ it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
with_edge_gateway_server_state([ with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost', 'HTTP_HOST' => 'localhost',
'HTTP_X_FORWARDED_PROTO' => 'http', 'HTTP_X_FORWARDED_PROTO' => 'http',
'HTTP_X_FORWARDED_PREFIX' => '/api',
], function (): void { ], function (): void {
putenv('EDGE_PUBLIC_API_URL=https://edge.example.test/api'); putenv('EDGE_PUBLIC_API_URL=https://edge.example.test/api');
@@ -94,6 +102,54 @@ it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
expect($manager->getApiBaseUrl())->toBe('https://edge.example.test/api'); expect($manager->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($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1');
expect($script)->toContain('"apiUrl":"https://edge.example.test/api"'); expect($script)->toContain('"apiUrl":"https://edge.example.test/api"');
expect($script)->toContain('"brokerUrl":"https://edge.example.test:4300"'); expect($script)->toContain('"brokerUrl":"https://edge.example.test/api/edge-broker"');
});
});
it('builds websocket broker urls on the traefik broker path', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'api.truckwash.io:4433',
'HTTP_X_FORWARDED_PROTO' => 'https',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicUrl'))
->toBe('https://api.truckwash.io:4433/edge-broker');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicWebSocketUrl', '/ws/browser-shell'))
->toBe('wss://api.truckwash.io:4433/edge-broker/ws/browser-shell');
});
});
it('builds localhost websocket broker urls on the local traefik api prefix', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost',
'HTTP_X_FORWARDED_PROTO' => 'http',
'HTTP_X_FORWARDED_PREFIX' => '/api',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
expect($manager->getApiBaseUrl())
->toBe('http://localhost/api');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicUrl'))
->toBe('http://localhost/api/edge-broker');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicWebSocketUrl', '/ws/browser-gateway-stream'))
->toBe('ws://localhost/api/edge-broker/ws/browser-gateway-stream');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicWebSocketUrl', '/ws/browser-shell'))
->toBe('ws://localhost/api/edge-broker/ws/browser-shell');
});
});
it('keeps root-host api urls unprefixed when the request is not under the local api alias', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost',
'HTTP_X_FORWARDED_PROTO' => 'http',
'REQUEST_URI' => '/edge-gateways/84/stream-session',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
expect($manager->getApiBaseUrl())
->toBe('http://localhost');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicUrl'))
->toBe('http://localhost/edge-broker');
}); });
}); });
@@ -6,6 +6,7 @@ it('builds the installer around the compose stack artifacts and management polli
$stackServiceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-gateway-stack.service')); $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')); $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')); $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')); $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')); $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')); $autoUpdaterSource = file_get_contents(app_path('resources/edge-gateway-agent/auto-updater.php'));
@@ -14,6 +15,7 @@ it('builds the installer around the compose stack artifacts and management polli
expect($managerSource)->not->toBeFalse(); expect($managerSource)->not->toBeFalse();
expect($launcherSource)->not->toBeFalse(); expect($launcherSource)->not->toBeFalse();
expect($composeSource)->not->toBeFalse(); expect($composeSource)->not->toBeFalse();
expect($agentSource)->not->toBeFalse();
expect($edgeDockerfileSource)->not->toBeFalse(); expect($edgeDockerfileSource)->not->toBeFalse();
expect($workerDockerfileSource)->not->toBeFalse(); expect($workerDockerfileSource)->not->toBeFalse();
expect($autoUpdaterSource)->not->toBeFalse(); expect($autoUpdaterSource)->not->toBeFalse();
@@ -66,7 +68,11 @@ it('builds the installer around the compose stack artifacts and management polli
expect($composeSource)->toContain('condition: service_healthy'); expect($composeSource)->toContain('condition: service_healthy');
expect($composeSource)->toContain("minio:\n condition: service_started"); expect($composeSource)->toContain("minio:\n condition: service_started");
expect($composeSource)->toContain("mariadb:\n condition: service_started"); expect($composeSource)->toContain("mariadb:\n condition: service_started");
expect($composeSource)->toContain("test: [\"CMD-SHELL\", \"kill -0 1\"]"); expect($composeSource)->toContain('/opt/truckwash-edge-agent/runtime/control-plane-status.json');
expect($composeSource)->toContain('$$data[\\"last_loop_at\\"]');
expect($composeSource)->toContain('$$data[\\"last_successful_sync_at\\"]');
expect($composeSource)->toContain('<= 30');
expect($composeSource)->toContain('<= 90');
expect($composeSource)->toContain("http://127.0.0.1:8090/health"); expect($composeSource)->toContain("http://127.0.0.1:8090/health");
expect($composeSource)->toContain('$$json=@file_get_contents(\'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)->toContain('$$path=\"/opt/truckwash-edge-agent/runtime/auto-updater-heartbeat.json\"');
@@ -76,6 +82,15 @@ it('builds the installer around the compose stack artifacts and management polli
expect($composeSource)->toContain('container_name: truckwash-mariadb'); expect($composeSource)->toContain('container_name: truckwash-mariadb');
expect($composeSource)->toContain('container_name: truckwash-minio'); expect($composeSource)->toContain('container_name: truckwash-minio');
expect($composeSource)->toContain('container_name: truckwash-auto-updater'); 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('FROM ${BASE_IMAGE}');
expect($edgeDockerfileSource)->toContain('COPY agent.php /opt/truckwash-edge-agent/agent.php'); expect($edgeDockerfileSource)->toContain('COPY agent.php /opt/truckwash-edge-agent/agent.php');
expect($edgeDockerfileSource)->not->toContain('docker-php-ext-install'); expect($edgeDockerfileSource)->not->toContain('docker-php-ext-install');
@@ -0,0 +1,193 @@
<?php
app_require('classes/shelly_relay_inventory.php');
use classes\shelly_relay_inventory;
it('normalizes owned Shelly devices into relay select options', function (): void {
$inventory = (new shelly_relay_inventory())
->setInventoryFetcher(static function (): array {
return [
'isok' => true,
'data' => [
'devices_status' => [
'device-key-1' => [
'_dev_info' => [
'id' => 'shelly-plus-01',
'code' => 'SPSW-001PE16EU',
'model' => 'Shelly Plus 1PM',
'online' => 1,
],
'name' => 'Entry Gate',
'status' => [
'switch:0' => [
'output' => false,
'name' => 'Entrance Relay',
],
],
],
'device-key-2' => [
'_dev_info' => [
'id' => 'shelly-pro-03',
'code' => 'SPSW-201XE16EU',
'model' => 'Shelly Pro 2PM',
],
'name' => 'Machine Cabinet',
'relays' => [
['ison' => false, 'name' => 'Program Picker'],
],
],
'device-key-3' => [
'_dev_info' => [
'id' => 'shelly-legacy-02',
'code' => 'SHSW-1',
'model' => 'Shelly 1',
'online' => 0,
],
'relays' => [
['ison' => false],
],
],
'device-key-4' => [
'_dev_info' => [
'id' => 'shelly-sensor-01',
'code' => 'SHHT-1',
'model' => 'Shelly H&T',
'online' => 1,
],
'sensor' => [
'temperature' => 21.5,
],
],
],
],
];
})
->setDeviceListFetcher(static function (): array {
return [
'isok' => true,
'data' => [
'devices' => [
'shelly-plus-01' => [
'id' => 'shelly-plus-01',
'name' => 'Gate From Shelly Cloud',
'type' => 'SPSW-001PE16EU',
'cloud_online' => true,
],
'shelly-pro-03' => [
'id' => 'shelly-pro-03',
'name' => 'Program Picker From Shelly Cloud',
'type' => 'SPSW-201XE16EU',
'cloud_online' => false,
],
],
],
];
});
expect($inventory->listRelayOptions())->toBe([
[
'id' => 'shelly-plus-01',
'name' => 'Gate From Shelly Cloud (Shelly Plus 1PM)',
'device_id' => 'shelly-plus-01',
'device_name' => 'Gate From Shelly Cloud',
'cloud_name' => 'Gate From Shelly Cloud',
'device_type' => 'Shelly Plus 1PM',
'code' => 'SPSW-001PE16EU',
'control_type' => 'Switch',
'control_name' => 'Entrance Relay',
'status_color' => 'Green',
'online' => true,
],
[
'id' => 'shelly-pro-03',
'name' => 'Program Picker From Shelly Cloud (Shelly Pro 2PM)',
'device_id' => 'shelly-pro-03',
'device_name' => 'Program Picker From Shelly Cloud',
'cloud_name' => 'Program Picker From Shelly Cloud',
'device_type' => 'Shelly Pro 2PM',
'code' => 'SPSW-201XE16EU',
'control_type' => 'Relay',
'control_name' => 'Program Picker',
'status_color' => 'Red',
'online' => false,
],
[
'id' => 'shelly-legacy-02',
'name' => 'shelly-legacy-02 (Shelly 1)',
'device_id' => 'shelly-legacy-02',
'device_name' => null,
'cloud_name' => null,
'device_type' => 'Shelly 1',
'code' => 'SHSW-1',
'control_type' => 'Relay',
'control_name' => null,
'status_color' => 'Red',
'online' => false,
],
]);
});
it('falls back to local device and control names when Shelly cloud list metadata is unavailable', function (): void {
$inventory = (new shelly_relay_inventory())
->setInventoryFetcher(static function (): array {
return [
'isok' => true,
'data' => [
'devices_status' => [
'device-key-1' => [
'_dev_info' => [
'id' => 'shelly-plus-01',
'code' => 'SPSW-001PE16EU',
'model' => 'Shelly Plus 1PM',
'online' => 1,
],
'name' => 'Entry Gate',
'status' => [
'switch:0' => [
'output' => false,
'name' => 'Entrance Relay',
],
],
],
],
],
];
})
->setDeviceListFetcher(static function (): array {
return [
'isok' => false,
'errors' => [
'404' => 'Requested method was not found',
],
];
});
expect($inventory->listRelayOptions())->toBe([
[
'id' => 'shelly-plus-01',
'name' => 'Entry Gate / Entrance Relay (Shelly Plus 1PM)',
'device_id' => 'shelly-plus-01',
'device_name' => 'Entry Gate',
'cloud_name' => null,
'device_type' => 'Shelly Plus 1PM',
'code' => 'SPSW-001PE16EU',
'control_type' => 'Switch',
'control_name' => 'Entrance Relay',
'status_color' => 'Green',
'online' => true,
],
]);
});
it('fails fast when Shelly inventory does not include owned devices status', function (): void {
$inventory = (new shelly_relay_inventory())->setInventoryFetcher(static function (): array {
return [
'isok' => true,
'data' => [],
];
});
expect(fn() => $inventory->listRelayOptions())
->toThrow(Exception::class, 'Shelly relay inventory response was missing devices_status');
});
+14 -3
View File
@@ -2,9 +2,9 @@
set -e set -e
# Config # Config
APP_DIR="/var/www/html" APP_DIR="${APP_DIR:-/var/www/html}"
MODULE_DIR="$APP_DIR/modules/washcertificates" MODULE_DIR="${MODULE_DIR:-$APP_DIR/modules/washcertificates}"
LOG_FILE="/var/log/php/composer-install.log" LOG_FILE="${LOG_FILE:-/var/log/php/composer-install.log}"
# Gate auto-install (set to "true" only on one PHP container, e.g. php1) # Gate auto-install (set to "true" only on one PHP container, e.g. php1)
AUTO_COMPOSER_INSTALL="${AUTO_COMPOSER_INSTALL:-true}" AUTO_COMPOSER_INSTALL="${AUTO_COMPOSER_INSTALL:-true}"
@@ -14,17 +14,28 @@ log() { printf "[entrypoint] %s\n" "$*"; }
vendor_sanity_ok() { vendor_sanity_ok() {
dir="$1" dir="$1"
autoload_file="$dir/vendor/autoload.php" 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" 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 if [ ! -f "$autoload_file" ]; then
return 1 return 1
fi 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 if [ -f "$dir/composer.lock" ] && [ "$dir/composer.lock" -nt "$autoload_file" ]; then
log "composer.lock is newer than vendor/autoload.php in $dir" log "composer.lock is newer than vendor/autoload.php in $dir"
return 1 return 1
fi 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 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" log "Vendor sanity check failed for $aws_s3_api_file"
return 1 return 1