Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2258a609e6 | ||
|
|
b98cef0caa | ||
|
|
0813bfc0f0 | ||
|
|
4420d76cd2 | ||
|
|
b9ddc585db | ||
|
|
c38a4379bd |
@@ -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]
|
||||||
|
|||||||
@@ -11,3 +11,4 @@
|
|||||||
.env
|
.env
|
||||||
/services/caddy/logs*
|
/services/caddy/logs*
|
||||||
/.tmp/
|
/.tmp/
|
||||||
|
/.env.staging
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ For local Docker development, run the PHP suites inside `php1`:
|
|||||||
docker exec php1 sh -lc "cd /var/www/html && composer test:unit"
|
docker exec php1 sh -lc "cd /var/www/html && composer test: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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);\""
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -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
|
||||||
|
|||||||
+215
-35
@@ -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',
|
||||||
],
|
],
|
||||||
|
|||||||
+447
@@ -0,0 +1,447 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use classes\db;
|
||||||
|
use classes\edge_gateway_manager;
|
||||||
|
use classes\edge_gateway_operation_service;
|
||||||
|
use Predis\Client as PredisClient;
|
||||||
|
use Tests\Support\Api\ApiCleanup;
|
||||||
|
use Tests\Support\Api\ApiFixtures;
|
||||||
|
use Tests\Support\Api\ApiSchemaBootstrap;
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 2) . '/Support/Api/ApiCleanup.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/Support/Api/ApiFixtures.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/Support/Api/ApiSchemaBootstrap.php';
|
||||||
|
|
||||||
|
app_require('classes/db.php');
|
||||||
|
app_require('classes/edge_gateway_manager.php');
|
||||||
|
app_require('classes/edge_gateway_operation_service.php');
|
||||||
|
|
||||||
|
it('persists install-session updates and derives gateway runtime status from heartbeats', function (): void {
|
||||||
|
$context = edge_gateway_integration_context();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$department = $context['fixtures']->createDepartment([
|
||||||
|
'name' => 'Edge Integration Install Department',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Install Token');
|
||||||
|
$claimTokenId = (int)($token['claim_token_id'] ?? 0);
|
||||||
|
|
||||||
|
$context['manager']->reportInstallTokenStatus((string)$token['token'], [
|
||||||
|
'status' => 'FAILED',
|
||||||
|
'step' => 'DOWNLOAD_FAILED',
|
||||||
|
'message' => 'The installer could not download the runtime bundle.',
|
||||||
|
'diagnostics' => [
|
||||||
|
'diag-1',
|
||||||
|
'diag-2',
|
||||||
|
'diag-3',
|
||||||
|
'diag-4',
|
||||||
|
'diag-5',
|
||||||
|
'diag-6',
|
||||||
|
'diag-7',
|
||||||
|
'diag-8',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$failedStatus = $context['manager']->getInstallTokenStatus($claimTokenId);
|
||||||
|
|
||||||
|
expect($failedStatus)
|
||||||
|
->toHaveKey('status', 'FAILED')
|
||||||
|
->toHaveKey('step', 'DOWNLOAD_FAILED')
|
||||||
|
->toHaveKey('last_error', 'The installer could not download the runtime bundle.')
|
||||||
|
->and($failedStatus['diagnostics'] ?? [])
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveCount(6);
|
||||||
|
|
||||||
|
$claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-integration-host', 'php-agent-v1', [
|
||||||
|
'source' => 'integration-test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gatewayId = (int)($claimed['gateway']['id'] ?? 0);
|
||||||
|
$agentToken = (string)($claimed['agent_token'] ?? '');
|
||||||
|
expect($gatewayId)->toBeGreaterThan(0)
|
||||||
|
->and($agentToken)->not->toBe('');
|
||||||
|
|
||||||
|
$claimedStatus = $context['manager']->getInstallTokenStatus($claimTokenId);
|
||||||
|
expect($claimedStatus)
|
||||||
|
->toHaveKey('status', 'CLAIMED')
|
||||||
|
->toHaveKey('gateway_id', $gatewayId)
|
||||||
|
->toHaveKey('last_error', null);
|
||||||
|
|
||||||
|
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_DEGRADED_AFTER_SECONDS + 1);
|
||||||
|
$degraded = $context['manager']->getGateway($gatewayId);
|
||||||
|
expect($degraded)->toHaveKey('status', 'DEGRADED');
|
||||||
|
|
||||||
|
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_OFFLINE_AFTER_SECONDS + 1);
|
||||||
|
$offline = $context['manager']->getGateway($gatewayId);
|
||||||
|
expect($offline)->toHaveKey('status', 'OFFLINE');
|
||||||
|
|
||||||
|
$context['manager']->recordHeartbeat($gatewayId, $agentToken, [
|
||||||
|
'status' => 'ONLINE',
|
||||||
|
'hostname' => 'edge-integration-host',
|
||||||
|
'metadata' => [
|
||||||
|
'system_metrics' => [
|
||||||
|
'cpu_percent' => 44,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$online = $context['manager']->getGateway($gatewayId);
|
||||||
|
expect($online)
|
||||||
|
->toHaveKey('status', 'ONLINE')
|
||||||
|
->and($online['metadata']['system_metrics']['cpu_percent'] ?? null)
|
||||||
|
->toBe(44);
|
||||||
|
} finally {
|
||||||
|
$context['cleanup']->run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assembles tasks, logs, statistics, operations, commands, and shell lifecycle state from persisted records', function (): void {
|
||||||
|
$context = edge_gateway_integration_context();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$department = $context['fixtures']->createDepartment([
|
||||||
|
'name' => 'Edge Integration Runtime Department',
|
||||||
|
]);
|
||||||
|
$user = $context['fixtures']->createUser([
|
||||||
|
'display_name' => 'Edge Integration User',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Runtime Token', (int)$user['id']);
|
||||||
|
$claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-runtime-host', 'php-agent-v1');
|
||||||
|
$gatewayId = (int)($claimed['gateway']['id'] ?? 0);
|
||||||
|
$agentToken = (string)($claimed['agent_token'] ?? '');
|
||||||
|
|
||||||
|
$operation = $context['operations']->queueOperation(
|
||||||
|
$gatewayId,
|
||||||
|
edge_gateway_operation_service::TYPE_DISCOVERY,
|
||||||
|
['inventory' => edge_gateway_integration_inventory('runtime')],
|
||||||
|
(int)$user['id']
|
||||||
|
);
|
||||||
|
$operationId = (int)($operation['id'] ?? 0);
|
||||||
|
expect($operationId)->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
$claimedOperation = $context['operations']->claimNextOperation($gatewayId, $agentToken, 0, 'integration-agent-1');
|
||||||
|
expect($claimedOperation)
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveKey('status', 'IN_PROGRESS');
|
||||||
|
|
||||||
|
$context['operations']->appendAgentOperationEvent($gatewayId, $operationId, $agentToken, [
|
||||||
|
'level' => 'INFO',
|
||||||
|
'code' => 'DISCOVERY_RUNNING',
|
||||||
|
'message' => 'Integration discovery is executing.',
|
||||||
|
'context' => [
|
||||||
|
'progress' => 70,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$context['operations']->completeAgentOperation($gatewayId, $operationId, $agentToken, [
|
||||||
|
'ok' => true,
|
||||||
|
'result' => [
|
||||||
|
'inventory' => edge_gateway_integration_inventory('completed'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$job = $context['fixtures']->createEdgeCommandJob([
|
||||||
|
'gateway_id' => $gatewayId,
|
||||||
|
'command_type' => 'GET_RELAY_STATUS',
|
||||||
|
'request' => [
|
||||||
|
'relayId' => 'relay-main',
|
||||||
|
],
|
||||||
|
'requested_by' => (int)$user['id'],
|
||||||
|
]);
|
||||||
|
$jobId = (int)($job['id'] ?? 0);
|
||||||
|
|
||||||
|
$polledCommand = $context['manager']->pollCommand($gatewayId, $agentToken, 0);
|
||||||
|
expect($polledCommand)
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveKey('id', $jobId)
|
||||||
|
->toHaveKey('command_type', 'GET_RELAY_STATUS');
|
||||||
|
|
||||||
|
$commandResult = $context['manager']->submitCommandResult($gatewayId, $jobId, $agentToken, true, [
|
||||||
|
'relayId' => 'relay-main',
|
||||||
|
'online' => true,
|
||||||
|
]);
|
||||||
|
expect($commandResult)
|
||||||
|
->toHaveKey('acknowledged', true)
|
||||||
|
->and($commandResult['job']['status'] ?? null)
|
||||||
|
->toBe('COMPLETED');
|
||||||
|
|
||||||
|
$shellSession = $context['manager']->createShellSession(
|
||||||
|
$gatewayId,
|
||||||
|
(int)$user['id'],
|
||||||
|
'Integration shell session',
|
||||||
|
120,
|
||||||
|
40,
|
||||||
|
'/opt/truckwash-edge-agent'
|
||||||
|
);
|
||||||
|
$shellToken = (string)($shellSession['token'] ?? '');
|
||||||
|
expect($shellToken)->not->toBe('');
|
||||||
|
|
||||||
|
$validatedShell = $context['manager']->validateShellSessionToken($shellToken);
|
||||||
|
expect($validatedShell)->toHaveKey('status', 'PENDING');
|
||||||
|
|
||||||
|
$openedShell = $context['manager']->markShellSessionOpened($shellToken, 'shell-connection-1');
|
||||||
|
expect($openedShell)->toHaveKey('status', 'OPEN');
|
||||||
|
|
||||||
|
$closedShell = $context['manager']->closeShellSessionByToken(
|
||||||
|
$shellToken,
|
||||||
|
"edge-shell-output\n",
|
||||||
|
'agent_exit'
|
||||||
|
);
|
||||||
|
expect($closedShell)
|
||||||
|
->toHaveKey('status', 'COMPLETED')
|
||||||
|
->and($closedShell['transcript'] ?? null)
|
||||||
|
->toBe("edge-shell-output\n");
|
||||||
|
|
||||||
|
$context['manager']->appendGatewayLogEntry(
|
||||||
|
$gatewayId,
|
||||||
|
'Integration log line',
|
||||||
|
'INFO',
|
||||||
|
'agent',
|
||||||
|
'BROKER',
|
||||||
|
['source' => 'integration']
|
||||||
|
);
|
||||||
|
|
||||||
|
$context['manager']->recordBrokerPresence(
|
||||||
|
$gatewayId,
|
||||||
|
'connected',
|
||||||
|
'broker-connection-1',
|
||||||
|
null,
|
||||||
|
['transport' => 'ws']
|
||||||
|
);
|
||||||
|
|
||||||
|
$context['manager']->recordTelemetryFromBroker($gatewayId, [
|
||||||
|
'status' => 'ONLINE',
|
||||||
|
'metadata' => [
|
||||||
|
'system_metrics' => [
|
||||||
|
'cpu_percent' => 17,
|
||||||
|
'memory_mb' => 256,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'inventory' => edge_gateway_integration_inventory('telemetry'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tasks = $context['manager']->buildGatewayTasksPage($gatewayId);
|
||||||
|
$logs = $context['manager']->buildGatewayLogsPage($gatewayId);
|
||||||
|
$statistics = $context['manager']->buildGatewayStatisticsPage($gatewayId);
|
||||||
|
|
||||||
|
expect($tasks['operations'] ?? [])
|
||||||
|
->toBeArray()
|
||||||
|
->not->toBeEmpty()
|
||||||
|
->and(($tasks['operations'][0]['status'] ?? null))
|
||||||
|
->toBe('COMPLETED');
|
||||||
|
expect($tasks['recent_commands'] ?? [])
|
||||||
|
->toBeArray()
|
||||||
|
->not->toBeEmpty()
|
||||||
|
->and(($tasks['recent_commands'][0]['status'] ?? null))
|
||||||
|
->toBe('COMPLETED');
|
||||||
|
|
||||||
|
expect(edge_gateway_integration_messages($logs['log_entries'] ?? []))
|
||||||
|
->toContain('Integration log line');
|
||||||
|
expect(edge_gateway_integration_messages($logs['timeline'] ?? []))
|
||||||
|
->toContain('Integration discovery is executing.');
|
||||||
|
expect($logs['shell_sessions'] ?? [])
|
||||||
|
->toBeArray()
|
||||||
|
->not->toBeEmpty()
|
||||||
|
->and(($logs['shell_sessions'][0]['transcript'] ?? null))
|
||||||
|
->toBe("edge-shell-output\n");
|
||||||
|
|
||||||
|
expect($statistics['system_metrics']['cpu_percent'] ?? null)
|
||||||
|
->toBe(17);
|
||||||
|
expect($statistics['gateway']['inventory'] ?? [])
|
||||||
|
->toBeArray()
|
||||||
|
->not->toBeEmpty();
|
||||||
|
} finally {
|
||||||
|
$context['cleanup']->run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{cleanup:ApiCleanup,fixtures:ApiFixtures,manager:edge_gateway_manager,operations:edge_gateway_operation_service,mysqli:mysqli,redis:?PredisClient}
|
||||||
|
*/
|
||||||
|
function edge_gateway_integration_context(): array
|
||||||
|
{
|
||||||
|
if (!integration_enabled()) {
|
||||||
|
test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run edge gateway integration tests.');
|
||||||
|
}
|
||||||
|
|
||||||
|
static $bootstrapped = null;
|
||||||
|
|
||||||
|
if ($bootstrapped === null) {
|
||||||
|
$dbConfig = edge_gateway_integration_db_config();
|
||||||
|
$GLOBALS['CONFIG_DB'] = $dbConfig;
|
||||||
|
$GLOBALS['response'] = new class {
|
||||||
|
public function internal_server_error(string $message): void
|
||||||
|
{
|
||||||
|
throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$db = new db($dbConfig);
|
||||||
|
$db->connect();
|
||||||
|
$GLOBALS['db'] = $db;
|
||||||
|
|
||||||
|
$mysqli = $db->conn();
|
||||||
|
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
||||||
|
$mysqli->set_charset('utf8mb4');
|
||||||
|
|
||||||
|
(new ApiSchemaBootstrap($mysqli))->ensureSchema();
|
||||||
|
|
||||||
|
$bootstrapped = [
|
||||||
|
'mysqli' => $mysqli,
|
||||||
|
'redis' => edge_gateway_integration_redis_client(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cleanup = new ApiCleanup();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cleanup' => $cleanup,
|
||||||
|
'fixtures' => new ApiFixtures($bootstrapped['mysqli'], $bootstrapped['redis'], $cleanup),
|
||||||
|
'manager' => new edge_gateway_manager(),
|
||||||
|
'operations' => new edge_gateway_operation_service(),
|
||||||
|
'mysqli' => $bootstrapped['mysqli'],
|
||||||
|
'redis' => $bootstrapped['redis'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{host:string,user:string,password:string,database:string,port:int}
|
||||||
|
*/
|
||||||
|
function edge_gateway_integration_db_config(): array
|
||||||
|
{
|
||||||
|
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
|
||||||
|
if ($target !== 'debug') {
|
||||||
|
$target = 'live';
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = edge_gateway_integration_config_value('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target);
|
||||||
|
$user = edge_gateway_integration_config_value('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target);
|
||||||
|
$password = edge_gateway_integration_config_value('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target);
|
||||||
|
$database = edge_gateway_integration_config_value('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
|
||||||
|
$port = (int)(edge_gateway_integration_config_value('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
|
||||||
|
|
||||||
|
if ($host === '' || $user === '' || $database === '') {
|
||||||
|
test()->markTestSkipped('Edge gateway integration tests require configured database environment variables.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'host' => $host,
|
||||||
|
'user' => $user,
|
||||||
|
'password' => $password,
|
||||||
|
'database' => $database,
|
||||||
|
'port' => $port > 0 ? $port : 3306,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function edge_gateway_integration_redis_client(): ?PredisClient
|
||||||
|
{
|
||||||
|
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
|
||||||
|
if ($target !== 'debug') {
|
||||||
|
$target = 'live';
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = edge_gateway_integration_config_value('REDIS_CONFIG_HOST', 'REDIS_CONFIG_DEBUG_HOST', $target);
|
||||||
|
if ($host === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameters = [
|
||||||
|
'scheme' => 'tcp',
|
||||||
|
'host' => $host,
|
||||||
|
'port' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_PORT', $target) ?: '6379'),
|
||||||
|
'database' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_DEBUG_DATABASE', $target) ?: '0'),
|
||||||
|
'password' => edge_gateway_integration_config_value('REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DEBUG_PASSWORD', $target),
|
||||||
|
];
|
||||||
|
|
||||||
|
$user = edge_gateway_integration_config_value('REDIS_CONFIG_USER', 'REDIS_CONFIG_DEBUG_USER', $target);
|
||||||
|
if ($user !== '') {
|
||||||
|
$parameters['username'] = $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PredisClient($parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
function edge_gateway_integration_config_value(string $liveKey, string $debugKey, string $target): string
|
||||||
|
{
|
||||||
|
$liveValue = trim((string)(getenv($liveKey) ?: ''));
|
||||||
|
$debugValue = trim((string)(getenv($debugKey) ?: ''));
|
||||||
|
|
||||||
|
if ($target === 'debug' && $debugValue !== '') {
|
||||||
|
return $debugValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $liveValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edge_gateway_integration_set_heartbeat_age(mysqli $mysqli, ?PredisClient $redis, int $gatewayId, int $secondsAgo): void
|
||||||
|
{
|
||||||
|
$result = $mysqli->query("SELECT last_heartbeat_at, updated_at, created_at FROM edge_gateways WHERE id = " . (int)$gatewayId . " LIMIT 1");
|
||||||
|
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||||
|
if ($result instanceof mysqli_result) {
|
||||||
|
$result->free();
|
||||||
|
}
|
||||||
|
|
||||||
|
$referenceTimestamp = strtotime((string)($row['last_heartbeat_at'] ?? $row['updated_at'] ?? $row['created_at'] ?? ''));
|
||||||
|
if ($referenceTimestamp === false || $referenceTimestamp <= 0) {
|
||||||
|
$referenceTimestamp = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = date('Y-m-d H:i:s', $referenceTimestamp - max(0, $secondsAgo));
|
||||||
|
$escaped = $mysqli->real_escape_string($timestamp);
|
||||||
|
$mysqli->query("UPDATE edge_gateways SET last_heartbeat_at = '{$escaped}', status = 'ONLINE' WHERE id = " . (int)$gatewayId);
|
||||||
|
|
||||||
|
if ($redis !== null) {
|
||||||
|
foreach ([
|
||||||
|
'obj_prop:*:' . $gatewayId . ':status',
|
||||||
|
'obj_prop:*:' . $gatewayId . ':last_heartbeat_at',
|
||||||
|
'obj_prop:*:' . $gatewayId . ':updated_at',
|
||||||
|
'edge_gateway:view:v1:*',
|
||||||
|
] as $pattern) {
|
||||||
|
$keys = $redis->keys($pattern);
|
||||||
|
if (is_array($keys) && $keys !== []) {
|
||||||
|
$redis->del($keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function edge_gateway_integration_inventory(string $suffix): array
|
||||||
|
{
|
||||||
|
return [[
|
||||||
|
'device_id' => 'integration-' . $suffix,
|
||||||
|
'local_ip' => '10.50.60.70',
|
||||||
|
'model' => 'TruckWash Integration Gateway',
|
||||||
|
'channel_count' => 1,
|
||||||
|
'online' => true,
|
||||||
|
'capabilities' => [
|
||||||
|
'gateway_management_v2' => true,
|
||||||
|
],
|
||||||
|
'metadata' => [
|
||||||
|
'hostname' => 'integration-' . $suffix,
|
||||||
|
],
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $rows
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
function edge_gateway_integration_messages(mixed $rows): array
|
||||||
|
{
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (is_array($row) && isset($row['message']) && is_string($row['message'])) {
|
||||||
|
$messages[] = $row['message'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $messages;
|
||||||
|
}
|
||||||
@@ -445,6 +445,25 @@ final class ApiFixtures
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $permissions
|
||||||
|
* @param array<string, mixed> $userAttributes
|
||||||
|
* @return array{user:array<string,mixed>,token:string,headers:array<string,string>}
|
||||||
|
*/
|
||||||
|
public function createEdgeOperatorSession(int $departmentId, array $permissions = [], array $userAttributes = []): array
|
||||||
|
{
|
||||||
|
if ($departmentId <= 0) {
|
||||||
|
throw new RuntimeException('Edge operator sessions require a positive department id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissions = array_values(array_unique(array_merge(
|
||||||
|
['modules_shelly_config', 'department_access_' . $departmentId],
|
||||||
|
$permissions
|
||||||
|
)));
|
||||||
|
|
||||||
|
return $this->createUserSession($permissions, $userAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, string> $permissions
|
* @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);
|
||||||
|
}
|
||||||
@@ -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');");
|
||||||
|
});
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('nullifies department lane relay fields when blank select values are submitted', function (): void {
|
||||||
|
$route = file_get_contents(app_path('routes/departmentLanesRoute.php'));
|
||||||
|
|
||||||
|
expect($route)->not->toBeFalse();
|
||||||
|
expect($route)->toContain('private static function normalizeRelayRequestParameter');
|
||||||
|
expect($route)->toContain('private static function syncDepartmentLaneRelayValue');
|
||||||
|
expect($route)->toContain('$field->nullify();');
|
||||||
|
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_in_id, $relay_in_id);');
|
||||||
|
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_out_id, $relay_out_id);');
|
||||||
|
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_machine_id, $relay_machine_id);');
|
||||||
|
expect($route)->toContain(
|
||||||
|
'self::syncDepartmentLaneRelayValue($department_lane->relay_machine_program_picker_id, $relay_machine_program_picker_id);'
|
||||||
|
);
|
||||||
|
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_machine_cleaner_id, $relay_machine_cleaner_id);');
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('registers a department lane relay options endpoint backed by Shelly inventory', function (): void {
|
||||||
|
$route = file_get_contents(app_path('routes/departmentLanesRoute.php'));
|
||||||
|
|
||||||
|
expect($route)->not->toBeFalse();
|
||||||
|
expect($route)->toContain("'/department/lanes/relay-options'");
|
||||||
|
expect($route)->toContain('new shelly_relay_inventory()');
|
||||||
|
expect($route)->toContain('LIST_DEPARTMENT_LANE_RELAY_OPTIONS');
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user