Replace CI PHP suite execution script with Composer commands and integrate Edge Gateway Agent stack artifacts
This commit is contained in:
+133
-57
@@ -22,26 +22,25 @@ jobs:
|
||||
- name: Check AI workflow sync
|
||||
run: node scripts/sync-ai-workflow.mjs --check
|
||||
|
||||
- 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
|
||||
chmod +x scripts/ci/run-php-suite.sh
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: mysqli, curl, openssl, json, redis, xdebug
|
||||
coverage: xdebug
|
||||
ini-values: variables_order=EGPCS,xdebug.mode=coverage
|
||||
|
||||
- name: Run unit suite in php1
|
||||
run: scripts/ci/run-php-suite.sh unit
|
||||
- name: Resolve dependencies
|
||||
working-directory: services/nginx/app
|
||||
run: composer update --no-interaction --prefer-dist
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: services/nginx/app
|
||||
run: composer test:unit
|
||||
|
||||
- name: Generate coverage report
|
||||
working-directory: services/nginx/app
|
||||
run: composer test:coverage
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
@@ -126,57 +125,134 @@ jobs:
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
continue-on-error: true
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
mysql:
|
||||
image: mysql:8
|
||||
env:
|
||||
MYSQL_DATABASE: app_test
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 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
|
||||
chmod +x scripts/ci/run-php-suite.sh
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: mysqli, curl, openssl, json, redis
|
||||
ini-values: variables_order=EGPCS
|
||||
|
||||
- name: Run integration suite in php1
|
||||
run: scripts/ci/run-php-suite.sh integration
|
||||
- name: Resolve dependencies
|
||||
working-directory: services/nginx/app
|
||||
run: composer update --no-interaction --prefer-dist
|
||||
|
||||
- name: Run integration tests
|
||||
working-directory: services/nginx/app
|
||||
env:
|
||||
RUN_INTEGRATION_TESTS: '1'
|
||||
USE_ENV: 'true'
|
||||
DEBUG: '0'
|
||||
ENCRYPTION_KEY: test-key
|
||||
CORS: '*'
|
||||
CONFIG_TIMEZONE: Europe/Copenhagen
|
||||
ECONOMIC_API_APP_ACCESS_GRANT: test
|
||||
ECONOMIC_API_APP_ACCESS_GRANT2: test
|
||||
ECONOMIC_API_APP_SECRET_TOKEN: test
|
||||
WORDPRESS_STATIC_TOKEN: ''
|
||||
EMAIL_WASH_CERTIFICATE_TOKEN: ''
|
||||
WORDPRESS_API_URL: http://localhost
|
||||
MINIO_ENDPOINT: ''
|
||||
MINIO_ACCESS_KEY: ''
|
||||
MINIO_SECRET_KEY: ''
|
||||
SLACK_DEFAULT_WEBHOOK: ''
|
||||
REDIS_CONFIG_HOST: 127.0.0.1
|
||||
REDIS_CONFIG_DATABASE: '0'
|
||||
REDIS_CONFIG_PASSWORD: ''
|
||||
REDIS_CONFIG_PORT: '6379'
|
||||
CONFIG_DB_HOST: 127.0.0.1
|
||||
CONFIG_DB_USER: root
|
||||
CONFIG_DB_PASSWORD: root
|
||||
CONFIG_DB_DATABASE: app_test
|
||||
CONFIG_DB_PORT: '3306'
|
||||
run: composer test:integration
|
||||
|
||||
api:
|
||||
name: API (advisory)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
continue-on-error: true
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
mysql:
|
||||
image: mysql:8
|
||||
env:
|
||||
MYSQL_DATABASE: app_test
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 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
|
||||
chmod +x scripts/ci/run-php-suite.sh
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: mysqli, curl, openssl, json, redis
|
||||
ini-values: variables_order=EGPCS
|
||||
|
||||
- name: Run API suite in php1
|
||||
run: scripts/ci/run-php-suite.sh api
|
||||
- name: Resolve dependencies
|
||||
working-directory: services/nginx/app
|
||||
run: composer update --no-interaction --prefer-dist
|
||||
|
||||
- name: Run API tests
|
||||
working-directory: services/nginx/app
|
||||
env:
|
||||
RUN_API_TESTS: '1'
|
||||
API_TEST_BOOTSTRAP_SCHEMA: '1'
|
||||
USE_ENV: 'true'
|
||||
DEBUG: '0'
|
||||
ENCRYPTION_KEY: test-key
|
||||
CORS: '*'
|
||||
CONFIG_TIMEZONE: Europe/Copenhagen
|
||||
ECONOMIC_API_APP_ACCESS_GRANT: test
|
||||
ECONOMIC_API_APP_ACCESS_GRANT2: test
|
||||
ECONOMIC_API_APP_SECRET_TOKEN: test
|
||||
WORDPRESS_STATIC_TOKEN: ''
|
||||
EMAIL_WASH_CERTIFICATE_TOKEN: ''
|
||||
WORDPRESS_API_URL: http://localhost
|
||||
MINIO_ENDPOINT: ''
|
||||
MINIO_ACCESS_KEY: ''
|
||||
MINIO_SECRET_KEY: ''
|
||||
SLACK_DEFAULT_WEBHOOK: ''
|
||||
REDIS_CONFIG_HOST: 127.0.0.1
|
||||
REDIS_CONFIG_DATABASE: '0'
|
||||
REDIS_CONFIG_PASSWORD: ''
|
||||
REDIS_CONFIG_PORT: '6379'
|
||||
CONFIG_DB_HOST: 127.0.0.1
|
||||
CONFIG_DB_USER: root
|
||||
CONFIG_DB_PASSWORD: root
|
||||
CONFIG_DB_DATABASE: app_test
|
||||
CONFIG_DB_PORT: '3306'
|
||||
run: composer test:api
|
||||
|
||||
@@ -16,9 +16,9 @@ Backend API for Copenhagen Truck Wash services.
|
||||
- PHP 8.2 CLI (optional, for host-side testing)
|
||||
|
||||
### Local Development
|
||||
To bring up the minimal development stack (Traefik, Redis, MySQL debug DB, Edge Broker, Caddy, and one PHP worker):
|
||||
To bring up the minimal development stack (Traefik, Redis, MySQL debug DB, Caddy, and one PHP worker):
|
||||
```powershell
|
||||
docker compose up -d traefik redis mysql-debug edge-broker php1 caddy
|
||||
docker compose up -d traefik redis mysql-debug php1 caddy
|
||||
```
|
||||
|
||||
The API is accessible at:
|
||||
|
||||
+1
-32
@@ -80,16 +80,6 @@ services:
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
edge-broker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/edge-broker/Dockerfile
|
||||
container_name: edge-broker
|
||||
environment:
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
ports:
|
||||
- "4300:4300"
|
||||
|
||||
caddy:
|
||||
image: caddy:2.7.6-alpine
|
||||
container_name: caddy
|
||||
@@ -206,14 +196,11 @@ services:
|
||||
container_name: php1
|
||||
depends_on:
|
||||
- redis
|
||||
- edge-broker
|
||||
command: ["php-fpm"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "${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}
|
||||
AUTO_COMPOSER_INSTALL: "true"
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -226,14 +213,11 @@ services:
|
||||
container_name: php2
|
||||
depends_on:
|
||||
- redis
|
||||
- edge-broker
|
||||
command: ["php-fpm"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -246,14 +230,11 @@ services:
|
||||
container_name: php3
|
||||
depends_on:
|
||||
- redis
|
||||
- edge-broker
|
||||
command: ["php-fpm"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -266,14 +247,11 @@ services:
|
||||
container_name: php4
|
||||
depends_on:
|
||||
- redis
|
||||
- edge-broker
|
||||
command: ["php-fpm"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -286,14 +264,11 @@ services:
|
||||
container_name: php5
|
||||
depends_on:
|
||||
- redis
|
||||
- edge-broker
|
||||
command: ["php-fpm"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -306,14 +281,11 @@ services:
|
||||
container_name: php-staging
|
||||
depends_on:
|
||||
- redis-staging
|
||||
- edge-broker
|
||||
command: ["php-fpm"]
|
||||
env_file:
|
||||
- .env.staging
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -326,14 +298,11 @@ services:
|
||||
container_name: php-cron
|
||||
depends_on:
|
||||
- redis
|
||||
- edge-broker
|
||||
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
suite="${1:-}"
|
||||
|
||||
if [[ -z "$suite" ]]; then
|
||||
echo "Usage: $0 <unit|integration|api>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
if [[ -f .env ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. ./.env
|
||||
set +a
|
||||
fi
|
||||
|
||||
export AUTO_COMPOSER_INSTALL=false
|
||||
|
||||
cleanup() {
|
||||
docker compose down -v --remove-orphans || true
|
||||
}
|
||||
|
||||
wait_for_container_running() {
|
||||
local name="$1"
|
||||
local status=""
|
||||
|
||||
for _ in $(seq 1 90); do
|
||||
status="$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || true)"
|
||||
if [[ "$status" == "running" ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
docker logs "$name" || true
|
||||
echo "Container $name did not reach running state." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_for_container_healthy() {
|
||||
local name="$1"
|
||||
local status=""
|
||||
|
||||
for _ in $(seq 1 90); do
|
||||
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$name" 2>/dev/null || true)"
|
||||
if [[ "$status" == "healthy" || "$status" == "running" ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
docker logs "$name" || true
|
||||
echo "Container $name did not reach a healthy state." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
docker_exec_php1() {
|
||||
docker exec "$@" php1 sh -lc "cd /var/www/html && ${PHP_COMMAND}"
|
||||
}
|
||||
|
||||
run_php_command() {
|
||||
local -a env_args=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
env_args+=(-e "$1")
|
||||
shift
|
||||
done
|
||||
|
||||
docker exec "${env_args[@]}" php1 sh -lc "cd /var/www/html && ${PHP_COMMAND}"
|
||||
}
|
||||
|
||||
sync_php_app_into_container() {
|
||||
docker exec php1 sh -lc 'mkdir -p /var/www/html && find /var/www/html -mindepth 1 -maxdepth 1 -exec rm -rf {} +'
|
||||
docker cp "${repo_root}/services/nginx/app/." php1:/var/www/html/
|
||||
}
|
||||
|
||||
install_php_dependencies() {
|
||||
PHP_COMMAND="composer install --no-interaction --prefer-dist"
|
||||
docker_exec_php1
|
||||
docker exec php1 sh -lc '
|
||||
module_dir="/var/www/html/modules/washcertificates"
|
||||
if [ -f "$module_dir/composer.json" ]; then
|
||||
cd "$module_dir"
|
||||
composer install --no-interaction --prefer-dist
|
||||
fi
|
||||
'
|
||||
}
|
||||
|
||||
docker compose down -v --remove-orphans || true
|
||||
trap cleanup EXIT
|
||||
|
||||
services=(redis edge-broker php1)
|
||||
if [[ "$suite" != "unit" ]]; then
|
||||
services+=(mysql-debug)
|
||||
fi
|
||||
|
||||
docker compose up -d --build "${services[@]}"
|
||||
wait_for_container_running php1
|
||||
wait_for_container_healthy redis
|
||||
if [[ "$suite" != "unit" ]]; then
|
||||
wait_for_container_healthy mysql-debug
|
||||
fi
|
||||
|
||||
sync_php_app_into_container
|
||||
install_php_dependencies
|
||||
|
||||
case "$suite" in
|
||||
unit)
|
||||
PHP_COMMAND="composer test:unit"
|
||||
docker_exec_php1
|
||||
PHP_COMMAND="composer test:coverage"
|
||||
docker_exec_php1
|
||||
mkdir -p "${repo_root}/services/nginx/app/build/logs"
|
||||
docker cp php1:/var/www/html/build/logs/clover.xml "${repo_root}/services/nginx/app/build/logs/clover.xml" 2>/dev/null || true
|
||||
;;
|
||||
integration)
|
||||
PHP_COMMAND="composer test:integration"
|
||||
run_php_command \
|
||||
"RUN_INTEGRATION_TESTS=1" \
|
||||
"USE_ENV=true" \
|
||||
"DEBUG=0" \
|
||||
"ENCRYPTION_KEY=test-key" \
|
||||
"CORS=*" \
|
||||
"CONFIG_TIMEZONE=Europe/Copenhagen" \
|
||||
"ECONOMIC_API_APP_ACCESS_GRANT=test" \
|
||||
"ECONOMIC_API_APP_ACCESS_GRANT2=test" \
|
||||
"ECONOMIC_API_APP_SECRET_TOKEN=test" \
|
||||
"WORDPRESS_STATIC_TOKEN=" \
|
||||
"EMAIL_WASH_CERTIFICATE_TOKEN=" \
|
||||
"WORDPRESS_API_URL=http://localhost" \
|
||||
"MINIO_ENDPOINT=" \
|
||||
"MINIO_ACCESS_KEY=" \
|
||||
"MINIO_SECRET_KEY=" \
|
||||
"SLACK_DEFAULT_WEBHOOK=" \
|
||||
"REDIS_CONFIG_HOST=redis" \
|
||||
"REDIS_CONFIG_DATABASE=0" \
|
||||
"REDIS_CONFIG_PASSWORD=" \
|
||||
"REDIS_CONFIG_PORT=6379" \
|
||||
"CONFIG_DB_TARGET=debug" \
|
||||
"CONFIG_DB_DEBUG_HOST=mysql-debug" \
|
||||
"CONFIG_DB_DEBUG_USER=root" \
|
||||
"CONFIG_DB_DEBUG_PASSWORD=${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}" \
|
||||
"CONFIG_DB_DEBUG_DATABASE=${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}" \
|
||||
"CONFIG_DB_DEBUG_PORT=3306"
|
||||
;;
|
||||
api)
|
||||
PHP_COMMAND="composer test:api"
|
||||
run_php_command \
|
||||
"RUN_API_TESTS=1" \
|
||||
"API_TEST_BOOTSTRAP_SCHEMA=1" \
|
||||
"USE_ENV=true" \
|
||||
"DEBUG=0" \
|
||||
"ENCRYPTION_KEY=test-key" \
|
||||
"CORS=*" \
|
||||
"CONFIG_TIMEZONE=Europe/Copenhagen" \
|
||||
"ECONOMIC_API_APP_ACCESS_GRANT=test" \
|
||||
"ECONOMIC_API_APP_ACCESS_GRANT2=test" \
|
||||
"ECONOMIC_API_APP_SECRET_TOKEN=test" \
|
||||
"WORDPRESS_STATIC_TOKEN=" \
|
||||
"EMAIL_WASH_CERTIFICATE_TOKEN=" \
|
||||
"WORDPRESS_API_URL=http://localhost" \
|
||||
"MINIO_ENDPOINT=" \
|
||||
"MINIO_ACCESS_KEY=" \
|
||||
"MINIO_SECRET_KEY=" \
|
||||
"SLACK_DEFAULT_WEBHOOK=" \
|
||||
"REDIS_CONFIG_HOST=redis" \
|
||||
"REDIS_CONFIG_DATABASE=0" \
|
||||
"REDIS_CONFIG_PASSWORD=" \
|
||||
"REDIS_CONFIG_PORT=6379" \
|
||||
"CONFIG_DB_TARGET=debug" \
|
||||
"CONFIG_DB_DEBUG_HOST=mysql-debug" \
|
||||
"CONFIG_DB_DEBUG_USER=root" \
|
||||
"CONFIG_DB_DEBUG_PASSWORD=${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}" \
|
||||
"CONFIG_DB_DEBUG_DATABASE=${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}" \
|
||||
"CONFIG_DB_DEBUG_PORT=3306"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported suite: $suite" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -14,6 +14,9 @@ export const DEFAULT_HOST_API_URL = "http://localhost/api";
|
||||
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
|
||||
export const DEFAULT_CONTAINER_BROKER_URL = "http://edge-broker:4300";
|
||||
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
|
||||
export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`;
|
||||
export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`;
|
||||
export const DEFAULT_STACK_SERVICE_NAME = "truckwash-edge-gateway-stack.service";
|
||||
export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15;
|
||||
export const DEFAULT_INSTALLED_VERSION = "php-agent-v1";
|
||||
const DEFAULT_COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "caddy"];
|
||||
@@ -91,15 +94,24 @@ export function buildGatewayConfig({
|
||||
installedVersion,
|
||||
targetVersion,
|
||||
installDir: DEFAULT_INSTALL_DIR,
|
||||
runtimeDir: DEFAULT_RUNTIME_DIR,
|
||||
stateDatabasePath: DEFAULT_STATE_DATABASE_PATH,
|
||||
agentPath: `${DEFAULT_INSTALL_DIR}/agent.php`,
|
||||
lanWorkerPath: `${DEFAULT_INSTALL_DIR}/lan-worker.php`,
|
||||
serviceUnitPath: `${DEFAULT_INSTALL_DIR}/truckwash-edge-agent.service`,
|
||||
stackServiceUnitPath: `${DEFAULT_INSTALL_DIR}/${DEFAULT_STACK_SERVICE_NAME}`,
|
||||
serviceName: String(existingConfig.serviceName || hostname || DEFAULT_CONTAINER_NAME),
|
||||
stackServiceName: String(existingConfig.stackServiceName || DEFAULT_STACK_SERVICE_NAME),
|
||||
composeFileName: String(existingConfig.composeFileName || "docker-compose.gateway.yml"),
|
||||
composeProjectName: String(existingConfig.composeProjectName || "truckwash-edge-gateway"),
|
||||
launcherScriptName: String(existingConfig.launcherScriptName || "gateway-launcher.sh"),
|
||||
workerBaseUrl: String(existingConfig.workerBaseUrl || "http://lan-worker:8090"),
|
||||
updateWindow: String(existingConfig.updateWindow || "02:00-04:00"),
|
||||
runtimeMode: String(existingConfig.runtimeMode || "compose"),
|
||||
restartMode: "spawn",
|
||||
heartbeatIntervalSeconds,
|
||||
commandPollTimeoutSeconds: Number(existingConfig.commandPollTimeoutSeconds || 20),
|
||||
shellActionPollTimeoutSeconds: Number(existingConfig.shellActionPollTimeoutSeconds || 20),
|
||||
commandPollRetryDelayMs: Number(existingConfig.commandPollRetryDelayMs || 1000),
|
||||
shellActionPollRetryDelayMs: Number(existingConfig.shellActionPollRetryDelayMs || 1000),
|
||||
brokerReconnectDelayMs: Number(existingConfig.brokerReconnectDelayMs || 1500),
|
||||
};
|
||||
}
|
||||
@@ -387,8 +399,10 @@ async function printStatus({ imageTag, configDir, containerName }) {
|
||||
configFilePath,
|
||||
gatewayId: config.gatewayId ?? null,
|
||||
installDir: config.installDir ?? null,
|
||||
runtimeDir: config.runtimeDir ?? null,
|
||||
agentPath: config.agentPath ?? null,
|
||||
serviceUnitPath: config.serviceUnitPath ?? null,
|
||||
stackServiceUnitPath: config.stackServiceUnitPath ?? null,
|
||||
containerStatus: container?.State?.Status ?? "missing",
|
||||
running: Boolean(container?.State?.Running),
|
||||
image: container?.Config?.Image ?? imageTag,
|
||||
|
||||
@@ -6,8 +6,9 @@ RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev \
|
||||
libsqlite3-dev \
|
||||
ca-certificates; \
|
||||
docker-php-ext-install curl; \
|
||||
docker-php-ext-install curl sqlite3; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY services/nginx/app/resources/edge-gateway-agent/ ./
|
||||
|
||||
Vendored
+10
-41
@@ -137,7 +137,6 @@ function buildTransportHeartbeatState(brokerState = {}) {
|
||||
status: "ONLINE",
|
||||
metadata: {
|
||||
command_transport: brokerConnected ? "BROKER_FAST_PATH" : "API_POLLING",
|
||||
shell_transport: brokerConnected ? "BROKER_FAST_PATH" : "API_POLLING",
|
||||
broker_connected: brokerConnected,
|
||||
broker_url: brokerState.url || null,
|
||||
broker_last_error: brokerState.lastError || null,
|
||||
@@ -1762,22 +1761,22 @@ export async function startAgent({
|
||||
});
|
||||
config.configPath = configPath;
|
||||
const intervalMs = Number(config.heartbeatIntervalSeconds || 15) * 1000;
|
||||
const commandPollTimeoutSeconds = Number(config.commandPollTimeoutSeconds ?? 20);
|
||||
const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs ?? 1000);
|
||||
const shellActionPollTimeoutSeconds = Number(config.shellActionPollTimeoutSeconds ?? 20);
|
||||
const shellActionPollRetryDelayMs = Number(config.shellActionPollRetryDelayMs ?? 1000);
|
||||
const commandPollTimeoutSeconds = Number(config.commandPollTimeoutSeconds || 20);
|
||||
const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs || 1000);
|
||||
const brokerReconnectDelayMs = Number(config.brokerReconnectDelayMs || DEFAULT_BROKER_RECONNECT_DELAY_MS);
|
||||
|
||||
let stopped = false;
|
||||
let cpuSnapshot = null;
|
||||
let lastHeartbeatLatencyMs = null;
|
||||
void createShellBridgeImpl;
|
||||
let brokerBridge = null;
|
||||
const shellEventPublisher = createShellEventPublisher(config, fetchImpl);
|
||||
const sendShellMessage = (message) => {
|
||||
shellEventPublisher.publish(message);
|
||||
brokerBridge?.send(message);
|
||||
const shell = {
|
||||
open: async () => {},
|
||||
input: () => {},
|
||||
resize: () => {},
|
||||
close: () => {},
|
||||
dispose: () => {},
|
||||
};
|
||||
const shell = createShellBridgeImpl(sendShellMessage);
|
||||
brokerBridge = createBrokerBridge({
|
||||
config,
|
||||
shell,
|
||||
@@ -1842,37 +1841,8 @@ export async function startAgent({
|
||||
}
|
||||
};
|
||||
|
||||
const runShellActionPollLoop = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
if (brokerBridge?.state?.connected) {
|
||||
await new Promise((resolve) => setTimeout(resolve, shellActionPollRetryDelayMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
const action = await pollShellActionJob(config, fetchImpl, shellActionPollTimeoutSeconds);
|
||||
if (stopped) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await processPolledShellAction(config, action, shell, fetchImpl);
|
||||
} catch {
|
||||
if (stopped) {
|
||||
break;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, shellActionPollRetryDelayMs));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await sendTransportHeartbeat();
|
||||
const commandPollPromise = runCommandPollLoop();
|
||||
const shellActionPollPromise = runShellActionPollLoop();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
sendTransportHeartbeat().catch(() => {});
|
||||
@@ -1884,8 +1854,7 @@ export async function startAgent({
|
||||
clearInterval(timer);
|
||||
brokerBridge?.stop();
|
||||
shell.dispose();
|
||||
await Promise.allSettled([commandPollPromise, shellActionPollPromise]);
|
||||
await shellEventPublisher.drain();
|
||||
await Promise.allSettled([commandPollPromise]);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,12 @@ class edge_gateway_install_service
|
||||
{
|
||||
private const ARTIFACTS = [
|
||||
'agent.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
'lan-worker.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
'docker-compose.gateway.yml' => 'text/yaml; charset=utf-8',
|
||||
'Dockerfile.edge-agent' => 'text/plain; charset=utf-8',
|
||||
'Dockerfile.lan-worker' => 'text/plain; charset=utf-8',
|
||||
'gateway-launcher.sh' => 'text/x-shellscript; charset=utf-8',
|
||||
'truckwash-edge-gateway-stack.service' => 'text/plain; charset=utf-8',
|
||||
'truckwash-edge-agent.service' => 'text/plain; charset=utf-8',
|
||||
];
|
||||
|
||||
|
||||
@@ -29,6 +29,20 @@ class edge_gateway_manager
|
||||
public const STATUS_OFFLINE = 'OFFLINE';
|
||||
public const DEFAULT_RELEASE_CHANNEL = 'stable';
|
||||
public const DEFAULT_AGENT_SERVICE_NAME = 'truckwash-edge-agent.service';
|
||||
public const DEFAULT_STACK_SERVICE_NAME = 'truckwash-edge-gateway-stack.service';
|
||||
public const DEFAULT_COMPOSE_STACK_FILE = 'docker-compose.gateway.yml';
|
||||
public const DEFAULT_LAUNCHER_SCRIPT_NAME = 'gateway-launcher.sh';
|
||||
public const DEFAULT_LAN_WORKER_ARTIFACT = 'lan-worker.php';
|
||||
public const DEFAULT_EDGE_AGENT_DOCKERFILE = 'Dockerfile.edge-agent';
|
||||
public const DEFAULT_LAN_WORKER_DOCKERFILE = 'Dockerfile.lan-worker';
|
||||
public const DEFAULT_INSTALL_DIR = '/opt/truckwash-edge-agent';
|
||||
public const DEFAULT_RUNTIME_DIR = '/opt/truckwash-edge-agent/runtime';
|
||||
public const DEFAULT_STATE_DATABASE_PATH = '/opt/truckwash-edge-agent/runtime/gateway-state.sqlite';
|
||||
public const DEFAULT_UPDATE_WINDOW = '02:00-04:00';
|
||||
public const DEFAULT_COMPOSE_PROJECT_NAME = 'truckwash-edge-gateway';
|
||||
public const DEFAULT_EDGE_AGENT_BASE_IMAGE = 'php:8.2-cli-bookworm';
|
||||
public const DEFAULT_LAN_WORKER_BASE_IMAGE = 'php:8.2-cli-bookworm';
|
||||
public const DEFAULT_WORKER_BASE_URL = 'http://lan-worker:8090';
|
||||
public const INSTALL_TOKEN_TTL_SECONDS = 1800;
|
||||
public const HEARTBEAT_DEGRADED_AFTER_SECONDS = 60;
|
||||
public const HEARTBEAT_OFFLINE_AFTER_SECONDS = 300;
|
||||
@@ -113,7 +127,34 @@ class edge_gateway_manager
|
||||
'is_primary' => 1,
|
||||
'metadata_json' => array_merge($metadata, [
|
||||
'credentials_rotated_at' => $this->now(),
|
||||
'agent_runtime' => 'php-cli',
|
||||
'agent_runtime' => 'compose-php',
|
||||
'runtime_mode' => 'compose',
|
||||
'update_window' => self::DEFAULT_UPDATE_WINDOW,
|
||||
'container_health' => [
|
||||
'overall_status' => self::STATUS_PENDING,
|
||||
'services' => [
|
||||
[
|
||||
'name' => 'edge-agent',
|
||||
'status' => self::STATUS_PENDING,
|
||||
],
|
||||
[
|
||||
'name' => 'lan-worker',
|
||||
'status' => self::STATUS_PENDING,
|
||||
],
|
||||
],
|
||||
],
|
||||
'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,
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -755,8 +796,22 @@ class edge_gateway_manager
|
||||
'installToken' => $plainToken,
|
||||
'gatewayId' => null,
|
||||
'agentToken' => null,
|
||||
'installDir' => '/opt/truckwash-edge-agent',
|
||||
'installDir' => self::DEFAULT_INSTALL_DIR,
|
||||
'runtimeDir' => self::DEFAULT_RUNTIME_DIR,
|
||||
'runtimeMode' => 'compose',
|
||||
'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME,
|
||||
'stackServiceName' => self::DEFAULT_STACK_SERVICE_NAME,
|
||||
'composeFileName' => self::DEFAULT_COMPOSE_STACK_FILE,
|
||||
'composeProjectName' => self::DEFAULT_COMPOSE_PROJECT_NAME,
|
||||
'launcherScriptName' => self::DEFAULT_LAUNCHER_SCRIPT_NAME,
|
||||
'lanWorkerArtifactName' => self::DEFAULT_LAN_WORKER_ARTIFACT,
|
||||
'edgeAgentDockerfileName' => self::DEFAULT_EDGE_AGENT_DOCKERFILE,
|
||||
'lanWorkerDockerfileName' => self::DEFAULT_LAN_WORKER_DOCKERFILE,
|
||||
'stateDatabasePath' => self::DEFAULT_STATE_DATABASE_PATH,
|
||||
'workerBaseUrl' => self::DEFAULT_WORKER_BASE_URL,
|
||||
'updateWindow' => self::DEFAULT_UPDATE_WINDOW,
|
||||
'edgeAgentBaseImage' => self::DEFAULT_EDGE_AGENT_BASE_IMAGE,
|
||||
'lanWorkerBaseImage' => self::DEFAULT_LAN_WORKER_BASE_IMAGE,
|
||||
'heartbeatIntervalSeconds' => 15,
|
||||
'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS,
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
@@ -935,11 +990,24 @@ heartbeat_marker_is_fresh() {
|
||||
[ "${modified_epoch:-0}" -ge "$minimum_epoch" ]
|
||||
}
|
||||
print_service_diagnostics() {
|
||||
log_error "truckwash-edge-agent.service did not complete installation verification."
|
||||
log_error "systemctl status --no-pager truckwash-edge-agent.service"
|
||||
systemctl status --no-pager truckwash-edge-agent.service || true
|
||||
log_error "journalctl -u truckwash-edge-agent.service -n 40 --no-pager"
|
||||
journalctl -u truckwash-edge-agent.service -n 40 --no-pager || true
|
||||
log_error "truckwash-edge-gateway-stack.service did not complete installation verification."
|
||||
log_error "systemctl status --no-pager truckwash-edge-gateway-stack.service"
|
||||
systemctl status --no-pager truckwash-edge-gateway-stack.service || true
|
||||
log_error "journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager"
|
||||
journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager || true
|
||||
log_error "docker ps --format '{{.Names}} {{.Status}}'"
|
||||
docker ps --format '{{.Names}} {{.Status}}' || true
|
||||
}
|
||||
resolve_compose_command() {
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
echo "docker compose"
|
||||
return 0
|
||||
fi
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
echo "docker-compose"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
wait_for_gateway_claim() {
|
||||
local config_path="$1"
|
||||
@@ -977,16 +1045,24 @@ wait_for_post_restart_heartbeat() {
|
||||
|
||||
fetch_http "Verify install token" "__VERIFY_URL__"
|
||||
INSTALL_DIR=/opt/truckwash-edge-agent
|
||||
RUNTIME_DIR="$INSTALL_DIR/runtime"
|
||||
CONFIG_PATH="$INSTALL_DIR/config.json"
|
||||
CONFIG_TEMPLATE_PATH="$INSTALL_DIR/config.template.json"
|
||||
HEARTBEAT_MARKER_PATH="$INSTALL_DIR/runtime/last-heartbeat-ok.txt"
|
||||
HEARTBEAT_MARKER_PATH="$RUNTIME_DIR/last-heartbeat-ok.txt"
|
||||
STACK_SERVICE_PATH="/etc/systemd/system/truckwash-edge-gateway-stack.service"
|
||||
REUSE_EXISTING_CREDENTIALS=0
|
||||
run_step "Creating install directory" mkdir -p "$INSTALL_DIR"
|
||||
run_step "Creating install directory" mkdir -p "$INSTALL_DIR" "$RUNTIME_DIR" "$RUNTIME_DIR/backups"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
run_step "Updating package lists" apt-get update
|
||||
run_step "Installing required packages" apt-get install -y curl ca-certificates php-cli php-curl php-mbstring
|
||||
run_step "Installing required packages" apt-get install -y curl ca-certificates docker.io docker-compose-plugin php-cli php-curl php-mbstring php-sqlite3
|
||||
fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"
|
||||
fetch_http "Download systemd service unit" "__SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service"
|
||||
fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"
|
||||
fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml"
|
||||
fetch_http "Download edge-agent Dockerfile" "__EDGE_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.edge-agent"
|
||||
fetch_http "Download lan-worker Dockerfile" "__WORKER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.lan-worker"
|
||||
fetch_http "Download gateway launcher" "__LAUNCHER_URL__" "$INSTALL_DIR/gateway-launcher.sh"
|
||||
fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"
|
||||
fetch_http "Download compatibility service unit" "__LEGACY_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service"
|
||||
if config_has_claimed_gateway "$CONFIG_PATH"; then
|
||||
REUSE_EXISTING_CREDENTIALS=1
|
||||
log_info "Existing claimed gateway detected; reinstall will reuse saved gateway credentials."
|
||||
@@ -996,12 +1072,15 @@ __CONFIG_JSON__
|
||||
EOF_JSON
|
||||
run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"
|
||||
rm -f "$CONFIG_TEMPLATE_PATH"
|
||||
run_step "Installing systemd service definition" install -m 0644 "$INSTALL_DIR/truckwash-edge-agent.service" "/etc/systemd/system/truckwash-edge-agent.service"
|
||||
run_step "Setting PHP agent permissions" chmod 0755 "$INSTALL_DIR/agent.php"
|
||||
run_step "Installing systemd stack definition" install -m 0644 "$INSTALL_DIR/truckwash-edge-gateway-stack.service" "$STACK_SERVICE_PATH"
|
||||
run_step "Setting executable permissions" chmod 0755 "$INSTALL_DIR/agent.php" "$INSTALL_DIR/lan-worker.php" "$INSTALL_DIR/gateway-launcher.sh"
|
||||
run_step "Ensuring Docker is enabled" systemctl enable docker
|
||||
run_step "Starting Docker" systemctl restart docker
|
||||
run_step "Checking Docker Compose availability" resolve_compose_command >/dev/null
|
||||
run_step "Reloading systemd" systemctl daemon-reload
|
||||
run_step "Enabling truckwash-edge-agent.service" systemctl enable truckwash-edge-agent.service
|
||||
run_step "Restarting truckwash-edge-agent.service" systemctl restart truckwash-edge-agent.service
|
||||
run_step "Verifying truckwash-edge-agent.service is active" systemctl is-active --quiet truckwash-edge-agent.service
|
||||
run_step "Enabling truckwash-edge-gateway-stack.service" systemctl enable truckwash-edge-gateway-stack.service
|
||||
run_step "Restarting truckwash-edge-gateway-stack.service" systemctl restart truckwash-edge-gateway-stack.service
|
||||
run_step "Verifying truckwash-edge-gateway-stack.service is active" systemctl is-active --quiet truckwash-edge-gateway-stack.service
|
||||
if [ "$REUSE_EXISTING_CREDENTIALS" -eq 1 ]; then
|
||||
run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30
|
||||
log_info "Reinstall reused gateway $(read_config_value "$CONFIG_PATH" gatewayId)."
|
||||
@@ -1009,13 +1088,19 @@ else
|
||||
run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30
|
||||
log_info "Gateway claim completed for gateway $(read_config_value "$CONFIG_PATH" gatewayId)."
|
||||
fi
|
||||
echo 'TruckWash edge agent installed.'
|
||||
echo 'TruckWash edge gateway stack installed.'
|
||||
BASH;
|
||||
|
||||
return strtr($script, [
|
||||
'__VERIFY_URL__' => $this->buildInstallTokenVerifyUrl($plainToken),
|
||||
'__AGENT_URL__' => $this->buildAgentArtifactUrl('agent.php'),
|
||||
'__SERVICE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AGENT_SERVICE_NAME),
|
||||
'__WORKER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_ARTIFACT),
|
||||
'__COMPOSE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_COMPOSE_STACK_FILE),
|
||||
'__EDGE_DOCKERFILE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_EDGE_AGENT_DOCKERFILE),
|
||||
'__WORKER_DOCKERFILE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_DOCKERFILE),
|
||||
'__LAUNCHER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAUNCHER_SCRIPT_NAME),
|
||||
'__STACK_SERVICE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_STACK_SERVICE_NAME),
|
||||
'__LEGACY_SERVICE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AGENT_SERVICE_NAME),
|
||||
'__CONFIG_JSON__' => (string)$configJson,
|
||||
]);
|
||||
}
|
||||
@@ -1052,8 +1137,17 @@ BASH;
|
||||
'apiUrl' => $this->getApiBaseUrl(),
|
||||
'gatewayId' => (int)$gateway->id,
|
||||
'agentToken' => $newToken,
|
||||
'installDir' => '/opt/truckwash-edge-agent',
|
||||
'installDir' => self::DEFAULT_INSTALL_DIR,
|
||||
'runtimeDir' => self::DEFAULT_RUNTIME_DIR,
|
||||
'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME,
|
||||
'stackServiceName' => self::DEFAULT_STACK_SERVICE_NAME,
|
||||
'composeFileName' => self::DEFAULT_COMPOSE_STACK_FILE,
|
||||
'composeProjectName' => self::DEFAULT_COMPOSE_PROJECT_NAME,
|
||||
'launcherScriptName' => self::DEFAULT_LAUNCHER_SCRIPT_NAME,
|
||||
'stateDatabasePath' => self::DEFAULT_STATE_DATABASE_PATH,
|
||||
'workerBaseUrl' => self::DEFAULT_WORKER_BASE_URL,
|
||||
'updateWindow' => (string)($metadata['update_window'] ?? self::DEFAULT_UPDATE_WINDOW),
|
||||
'runtimeMode' => (string)($metadata['runtime_mode'] ?? 'compose'),
|
||||
'heartbeatIntervalSeconds' => 15,
|
||||
'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS,
|
||||
'installedVersion' => $gateway->installed_version->value() === null ? null : (string)$gateway->installed_version->value(),
|
||||
@@ -1064,7 +1158,10 @@ BASH;
|
||||
(int)$gateway->department_id->value(),
|
||||
'GATEWAY_CREDENTIALS_ROTATED',
|
||||
$userId,
|
||||
['service_name' => self::DEFAULT_AGENT_SERVICE_NAME]
|
||||
[
|
||||
'service_name' => self::DEFAULT_AGENT_SERVICE_NAME,
|
||||
'stack_service_name' => self::DEFAULT_STACK_SERVICE_NAME,
|
||||
]
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -1074,8 +1171,9 @@ BASH;
|
||||
'config' => $payload,
|
||||
'config_json' => json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
|
||||
'restart_instructions' => [
|
||||
'sudo systemctl restart ' . self::DEFAULT_AGENT_SERVICE_NAME,
|
||||
'sudo systemctl status ' . self::DEFAULT_AGENT_SERVICE_NAME . ' --no-pager',
|
||||
'sudo systemctl restart ' . self::DEFAULT_STACK_SERVICE_NAME,
|
||||
'sudo systemctl status ' . self::DEFAULT_STACK_SERVICE_NAME . ' --no-pager',
|
||||
'cd ' . self::DEFAULT_INSTALL_DIR . ' && sudo ./gateway-launcher.sh reconcile',
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -1100,6 +1198,30 @@ BASH;
|
||||
'serviceUnitUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AGENT_SERVICE_NAME),
|
||||
'serviceUnitSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AGENT_SERVICE_NAME),
|
||||
'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME,
|
||||
'runtimeMode' => 'compose',
|
||||
'installDir' => self::DEFAULT_INSTALL_DIR,
|
||||
'runtimeDir' => self::DEFAULT_RUNTIME_DIR,
|
||||
'stackServiceName' => self::DEFAULT_STACK_SERVICE_NAME,
|
||||
'stackServiceUnitUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_STACK_SERVICE_NAME),
|
||||
'stackServiceUnitSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_STACK_SERVICE_NAME),
|
||||
'composeFileName' => self::DEFAULT_COMPOSE_STACK_FILE,
|
||||
'composeFileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_COMPOSE_STACK_FILE),
|
||||
'composeFileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_COMPOSE_STACK_FILE),
|
||||
'composeProjectName' => self::DEFAULT_COMPOSE_PROJECT_NAME,
|
||||
'launcherScriptName' => self::DEFAULT_LAUNCHER_SCRIPT_NAME,
|
||||
'launcherScriptUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_LAUNCHER_SCRIPT_NAME),
|
||||
'launcherScriptSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAUNCHER_SCRIPT_NAME),
|
||||
'lanWorkerArtifactUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_ARTIFACT),
|
||||
'lanWorkerArtifactSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAN_WORKER_ARTIFACT),
|
||||
'edgeAgentDockerfileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_EDGE_AGENT_DOCKERFILE),
|
||||
'edgeAgentDockerfileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_EDGE_AGENT_DOCKERFILE),
|
||||
'lanWorkerDockerfileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_DOCKERFILE),
|
||||
'lanWorkerDockerfileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAN_WORKER_DOCKERFILE),
|
||||
'stateDatabasePath' => self::DEFAULT_STATE_DATABASE_PATH,
|
||||
'workerBaseUrl' => self::DEFAULT_WORKER_BASE_URL,
|
||||
'updateWindow' => self::DEFAULT_UPDATE_WINDOW,
|
||||
'edgeAgentBaseImage' => self::DEFAULT_EDGE_AGENT_BASE_IMAGE,
|
||||
'lanWorkerBaseImage' => self::DEFAULT_LAN_WORKER_BASE_IMAGE,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2500,6 +2622,12 @@ BASH;
|
||||
];
|
||||
$gateway['version_drift'] = self::buildVersionDriftSummary($gateway);
|
||||
$gateway['credential_freshness'] = self::buildCredentialFreshnessSummary($gateway, $now);
|
||||
$gateway['container_health'] = self::buildContainerHealthSummary($gateway, $effectiveStatus);
|
||||
$gateway['outbox_status'] = self::buildOutboxStatusSummary($gateway, $now);
|
||||
$gateway['last_sync_at'] = self::resolveLastSyncAt($gateway);
|
||||
$gateway['update_window'] = self::buildUpdateWindowSummary($gateway);
|
||||
$gateway['staged_version'] = self::buildStagedVersionSummary($gateway);
|
||||
$gateway['rollback_status'] = self::buildRollbackStatusSummary($gateway);
|
||||
$gateway['diagnostics'] = self::buildGatewayDiagnostics($gateway, $effectiveStatus, $now);
|
||||
$gateway['error_state'] = self::primaryGatewayErrorState($gateway['diagnostics'], $gateway);
|
||||
|
||||
@@ -2722,6 +2850,147 @@ BASH;
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildContainerHealthSummary(array $gateway, string $effectiveStatus): array
|
||||
{
|
||||
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
|
||||
$raw = isset($metadata['container_health']) && is_array($metadata['container_health'])
|
||||
? (array)$metadata['container_health']
|
||||
: [];
|
||||
$rawServices = isset($raw['services']) && is_array($raw['services']) ? (array)$raw['services'] : [];
|
||||
$defaultServices = [
|
||||
['name' => 'edge-agent', 'status' => $effectiveStatus === self::STATUS_OFFLINE ? 'offline' : 'healthy'],
|
||||
['name' => 'lan-worker', 'status' => $effectiveStatus === self::STATUS_OFFLINE ? 'offline' : 'healthy'],
|
||||
];
|
||||
$services = $rawServices !== [] ? $rawServices : $defaultServices;
|
||||
$healthyCount = 0;
|
||||
$degradedCount = 0;
|
||||
foreach ($services as $index => $service) {
|
||||
if (!is_array($service)) {
|
||||
$services[$index] = ['name' => 'service-' . $index, 'status' => 'unknown'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = strtolower(trim((string)($service['status'] ?? 'unknown')));
|
||||
$name = trim((string)($service['name'] ?? 'service-' . $index));
|
||||
if (in_array($status, ['healthy', 'running', 'online'], true)) {
|
||||
$status = 'healthy';
|
||||
$healthyCount += 1;
|
||||
} elseif (in_array($status, ['degraded', 'starting', 'unknown'], true)) {
|
||||
$status = 'degraded';
|
||||
$degradedCount += 1;
|
||||
} else {
|
||||
$status = $status === 'offline' ? 'offline' : 'degraded';
|
||||
$degradedCount += 1;
|
||||
}
|
||||
|
||||
$services[$index] = array_merge($service, [
|
||||
'name' => $name,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
$state = $effectiveStatus === self::STATUS_OFFLINE
|
||||
? self::STATUS_OFFLINE
|
||||
: ($degradedCount > 0 ? self::STATUS_DEGRADED : self::STATUS_ONLINE);
|
||||
|
||||
return [
|
||||
'state' => strtoupper((string)($raw['state'] ?? $state)),
|
||||
'summary' => (string)($raw['summary'] ?? sprintf('%d/%d containers healthy', $healthyCount, count($services))),
|
||||
'services' => $services,
|
||||
'healthy_count' => $healthyCount,
|
||||
'total' => count($services),
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildOutboxStatusSummary(array $gateway, ?int $now = null): array
|
||||
{
|
||||
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
|
||||
$raw = isset($metadata['outbox_status']) && is_array($metadata['outbox_status'])
|
||||
? (array)$metadata['outbox_status']
|
||||
: [];
|
||||
$queued = max(0, (int)($raw['queued'] ?? $raw['queue_depth'] ?? 0));
|
||||
$oldestQueuedAt = isset($raw['oldest_queued_at']) ? (string)$raw['oldest_queued_at'] : null;
|
||||
$oldestAgeSeconds = self::heartbeatAgeSeconds($oldestQueuedAt, $now);
|
||||
$state = $queued === 0
|
||||
? 'IN_SYNC'
|
||||
: (($oldestAgeSeconds !== null && $oldestAgeSeconds >= self::HEARTBEAT_OFFLINE_AFTER_SECONDS) ? 'DEGRADED' : 'QUEUED');
|
||||
|
||||
return [
|
||||
'state' => (string)($raw['state'] ?? $state),
|
||||
'queued' => $queued,
|
||||
'oldest_queued_at' => $oldestQueuedAt,
|
||||
'oldest_age_seconds' => $oldestAgeSeconds,
|
||||
'last_replayed_at' => isset($raw['last_replayed_at']) ? (string)$raw['last_replayed_at'] : null,
|
||||
'summary' => (string)($raw['summary'] ?? ($queued === 0 ? 'Outbox is empty' : sprintf('%d outbound items queued', $queued))),
|
||||
];
|
||||
}
|
||||
|
||||
private static function resolveLastSyncAt(array $gateway): ?string
|
||||
{
|
||||
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
|
||||
if (!empty($metadata['last_sync_at'])) {
|
||||
return (string)$metadata['last_sync_at'];
|
||||
}
|
||||
|
||||
$outbox = isset($gateway['outbox_status']) && is_array($gateway['outbox_status'])
|
||||
? (array)$gateway['outbox_status']
|
||||
: [];
|
||||
|
||||
return isset($outbox['last_replayed_at']) && $outbox['last_replayed_at'] !== null
|
||||
? (string)$outbox['last_replayed_at']
|
||||
: null;
|
||||
}
|
||||
|
||||
private static function buildUpdateWindowSummary(array $gateway): array
|
||||
{
|
||||
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
|
||||
$window = trim((string)($metadata['update_window'] ?? self::DEFAULT_UPDATE_WINDOW));
|
||||
if ($window === '') {
|
||||
$window = self::DEFAULT_UPDATE_WINDOW;
|
||||
}
|
||||
|
||||
return [
|
||||
'window' => $window,
|
||||
'timezone' => isset($metadata['timezone']) ? (string)$metadata['timezone'] : null,
|
||||
'strategy' => 'nightly',
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildStagedVersionSummary(array $gateway): ?array
|
||||
{
|
||||
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
|
||||
$raw = isset($metadata['staged_version']) && is_array($metadata['staged_version'])
|
||||
? (array)$metadata['staged_version']
|
||||
: [];
|
||||
$targetVersion = trim((string)($raw['target_version'] ?? $raw['version'] ?? ''));
|
||||
if ($targetVersion === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'target_version' => $targetVersion,
|
||||
'staged_at' => isset($raw['staged_at']) ? (string)$raw['staged_at'] : null,
|
||||
'apply_after' => isset($raw['apply_after']) ? (string)$raw['apply_after'] : null,
|
||||
'status' => isset($raw['status']) ? (string)$raw['status'] : 'STAGED',
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildRollbackStatusSummary(array $gateway): array
|
||||
{
|
||||
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
|
||||
$raw = isset($metadata['rollback_status']) && is_array($metadata['rollback_status'])
|
||||
? (array)$metadata['rollback_status']
|
||||
: [];
|
||||
$state = trim((string)($raw['state'] ?? 'IDLE'));
|
||||
|
||||
return [
|
||||
'state' => $state !== '' ? $state : 'IDLE',
|
||||
'reason' => isset($raw['reason']) ? (string)$raw['reason'] : null,
|
||||
'rolled_back_to' => isset($raw['rolled_back_to']) ? (string)$raw['rolled_back_to'] : null,
|
||||
'at' => isset($raw['at']) ? (string)$raw['at'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildGatewayDiagnostics(array $gateway, string $effectiveStatus, ?int $now = null): array
|
||||
{
|
||||
$diagnostics = [];
|
||||
@@ -2779,6 +3048,51 @@ BASH;
|
||||
];
|
||||
}
|
||||
|
||||
$containerHealth = isset($gateway['container_health']) && is_array($gateway['container_health'])
|
||||
? (array)$gateway['container_health']
|
||||
: [];
|
||||
$containerState = strtoupper((string)($containerHealth['state'] ?? self::STATUS_ONLINE));
|
||||
if (in_array($containerState, [self::STATUS_DEGRADED, self::STATUS_OFFLINE], true)) {
|
||||
$diagnostics[] = [
|
||||
'code' => 'EDGE_GATEWAY_CONTAINER_DEGRADED',
|
||||
'severity' => $containerState === self::STATUS_OFFLINE ? 'danger' : 'warning',
|
||||
'message' => 'One or more compose services are not healthy on the gateway.',
|
||||
'recommended_action' => 'restart_agent',
|
||||
];
|
||||
}
|
||||
|
||||
$outboxStatus = isset($gateway['outbox_status']) && is_array($gateway['outbox_status'])
|
||||
? (array)$gateway['outbox_status']
|
||||
: [];
|
||||
if ((int)($outboxStatus['queued'] ?? 0) > 0) {
|
||||
$diagnostics[] = [
|
||||
'code' => 'EDGE_GATEWAY_OUTBOX_BACKLOG',
|
||||
'severity' => 'warning',
|
||||
'message' => 'The gateway has queued outbound control-plane items waiting for replay.',
|
||||
'recommended_action' => 'inspect_connectivity',
|
||||
];
|
||||
}
|
||||
|
||||
$rollbackStatus = isset($gateway['rollback_status']) && is_array($gateway['rollback_status'])
|
||||
? (array)$gateway['rollback_status']
|
||||
: [];
|
||||
$rollbackState = strtoupper((string)($rollbackStatus['state'] ?? 'IDLE'));
|
||||
if ($rollbackState === 'ROLLED_BACK') {
|
||||
$diagnostics[] = [
|
||||
'code' => 'EDGE_GATEWAY_UPDATE_ROLLED_BACK',
|
||||
'severity' => 'warning',
|
||||
'message' => 'The last container rollout was rolled back automatically.',
|
||||
'recommended_action' => 'review_diagnostics',
|
||||
];
|
||||
} elseif ($rollbackState === 'FAILED') {
|
||||
$diagnostics[] = [
|
||||
'code' => 'EDGE_GATEWAY_ROLLBACK_FAILED',
|
||||
'severity' => 'danger',
|
||||
'message' => 'Gateway rollback failed and manual intervention is required.',
|
||||
'recommended_action' => 'review_diagnostics',
|
||||
];
|
||||
}
|
||||
|
||||
return $diagnostics;
|
||||
}
|
||||
|
||||
|
||||
@@ -428,8 +428,8 @@ class edge_gateway_operation_service
|
||||
|
||||
if ($type === self::TYPE_UNINSTALL) {
|
||||
return array_merge([
|
||||
'service_name' => edge_gateway_manager::DEFAULT_AGENT_SERVICE_NAME,
|
||||
'install_dir' => '/opt/truckwash-edge-agent',
|
||||
'service_name' => edge_gateway_manager::DEFAULT_STACK_SERVICE_NAME,
|
||||
'install_dir' => edge_gateway_manager::DEFAULT_INSTALL_DIR,
|
||||
], $request);
|
||||
}
|
||||
|
||||
@@ -594,14 +594,33 @@ class edge_gateway_operation_service
|
||||
];
|
||||
}
|
||||
} elseif ($type === self::TYPE_UPDATE) {
|
||||
$requestedUpdate = is_array($operation->request_json->value()) ? (array)$operation->request_json->value() : [];
|
||||
$stagedVersion = trim((string)($result['staged_version'] ?? $result['target_version'] ?? $requestedUpdate['target_version'] ?? ''));
|
||||
$applied = array_key_exists('applied', $result)
|
||||
? (bool)$result['applied']
|
||||
: trim((string)($result['installed_version'] ?? '')) !== '';
|
||||
if ($ok) {
|
||||
$installedVersion = trim((string)($result['installed_version'] ?? $result['target_version'] ?? $operation->request_json->value()['target_version'] ?? ''));
|
||||
if ($installedVersion !== '') {
|
||||
$installedVersion = trim((string)($result['installed_version'] ?? ($applied ? $stagedVersion : '')));
|
||||
if ($applied && $installedVersion !== '') {
|
||||
$gateway->installed_version->set($installedVersion);
|
||||
$gateway->target_version->set($installedVersion);
|
||||
}
|
||||
$metadata['last_update_completed_at'] = $now;
|
||||
$metadata['last_update_error'] = null;
|
||||
if ($stagedVersion !== '') {
|
||||
$metadata['staged_version'] = [
|
||||
'target_version' => $stagedVersion,
|
||||
'staged_at' => $result['staged_at'] ?? $now,
|
||||
'apply_after' => $result['apply_after'] ?? null,
|
||||
'status' => $applied ? 'APPLIED' : 'STAGED',
|
||||
];
|
||||
}
|
||||
if (isset($result['update_window'])) {
|
||||
$metadata['update_window'] = (string)$result['update_window'];
|
||||
}
|
||||
if (isset($result['rollback_status']) && is_array($result['rollback_status'])) {
|
||||
$metadata['rollback_status'] = (array)$result['rollback_status'];
|
||||
}
|
||||
} else {
|
||||
$metadata['last_update_error'] = [
|
||||
'code' => $errorCode,
|
||||
|
||||
@@ -500,6 +500,27 @@ class users_o extends db
|
||||
return $fallbackName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{name:string}|null
|
||||
*/
|
||||
private static function buildCustomerNameCachePayload(mixed $cached_name, ?string $fallback_name): ?array
|
||||
{
|
||||
if (
|
||||
is_object($cached_name)
|
||||
&& isset($cached_name->name)
|
||||
&& is_string($cached_name->name)
|
||||
&& trim($cached_name->name) !== ''
|
||||
) {
|
||||
return ['name' => $cached_name->name];
|
||||
}
|
||||
|
||||
if ($fallback_name !== null && trim($fallback_name) !== '') {
|
||||
return ['name' => $fallback_name];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getCustomerEcocomicData(int $customer_number = null): users_o
|
||||
{
|
||||
// Check if the customer number is set
|
||||
@@ -1547,25 +1568,32 @@ class users_o extends db
|
||||
if (count($customer_numbers_to_fetch) > 0) {
|
||||
foreach ( $customer_numbers_to_fetch as $customer_number ) {
|
||||
// Get the customer name from the external source
|
||||
$fallback_name = null;
|
||||
try {
|
||||
// Try to get the economic customer data cached in the user
|
||||
$tmp_user = new users_o();
|
||||
$tmp_user->getUserByCustomerNumber($customer_number);
|
||||
if ($tmp_user->exists()) {
|
||||
$display_name = $tmp_user->display_name->value();
|
||||
if (is_string($display_name) && trim($display_name) !== '') {
|
||||
$fallback_name = $display_name;
|
||||
}
|
||||
}
|
||||
$cached_name = $tmp_user->getCached('economic_customer');
|
||||
// If not cached, fetch from E-conomic
|
||||
if (!$cached_name) {
|
||||
$tmp_user->getCustomerEcocomicData($customer_number);
|
||||
$cached_name = $tmp_user->getCached('economic_customer');
|
||||
}
|
||||
if ($cached_name) {
|
||||
$customer_names[(string)$customer_number] = $cached_name->name;
|
||||
}
|
||||
// Cache the name
|
||||
$this->cache('economic_customer_name', $cached_name, $customer_number);
|
||||
$cache_payload = self::buildCustomerNameCachePayload($cached_name, $fallback_name);
|
||||
if ($cache_payload !== null) {
|
||||
$customer_names[(string)$customer_number] = $cache_payload['name'];
|
||||
$this->cache('economic_customer_name', $cache_payload, $customer_number);
|
||||
$this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number);
|
||||
}
|
||||
} catch ( Exception $e ) {
|
||||
// Ignore exceptions
|
||||
$customer_names[(string)$customer_number] = 'Unable to fetch name';
|
||||
$customer_names[(string)$customer_number] = $fallback_name ?? 'Unable to fetch name';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
ARG BASE_IMAGE=php:8.2-cli-bookworm
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
WORKDIR /opt/truckwash-edge-agent
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
libcurl4-openssl-dev \
|
||||
libsqlite3-dev; \
|
||||
docker-php-ext-install curl sqlite3; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY agent.php /opt/truckwash-edge-agent/agent.php
|
||||
|
||||
ENTRYPOINT ["php", "/opt/truckwash-edge-agent/agent.php"]
|
||||
CMD ["--config", "/config/config.json"]
|
||||
@@ -0,0 +1,17 @@
|
||||
ARG BASE_IMAGE=php:8.2-cli-bookworm
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
WORKDIR /opt/truckwash-edge-agent
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
libcurl4-openssl-dev; \
|
||||
docker-php-ext-install curl; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php
|
||||
|
||||
CMD ["php", "-S", "0.0.0.0:8090", "/opt/truckwash-edge-agent/lan-worker.php"]
|
||||
@@ -177,46 +177,193 @@ final class Logger
|
||||
}
|
||||
}
|
||||
|
||||
final class LocalStateStore
|
||||
{
|
||||
private SQLite3 $db;
|
||||
|
||||
public function __construct(string $path)
|
||||
{
|
||||
if (!class_exists('SQLite3')) {
|
||||
throw new RuntimeException('The edge-agent container requires sqlite3 support.');
|
||||
}
|
||||
|
||||
$directory = dirname($path);
|
||||
if (!is_dir($directory)) {
|
||||
@mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
$this->db = new SQLite3($path);
|
||||
$this->db->busyTimeout(5000);
|
||||
$this->db->exec('PRAGMA journal_mode = WAL;');
|
||||
$this->db->exec('PRAGMA synchronous = NORMAL;');
|
||||
$this->db->exec(
|
||||
'CREATE TABLE IF NOT EXISTS kv (
|
||||
key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)'
|
||||
);
|
||||
$this->db->exec(
|
||||
'CREATE TABLE IF NOT EXISTS outbox (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_type TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)'
|
||||
);
|
||||
}
|
||||
|
||||
public function getJson(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$statement = $this->db->prepare('SELECT value_json FROM kv WHERE key = :key LIMIT 1');
|
||||
$statement->bindValue(':key', $key, SQLITE3_TEXT);
|
||||
$result = $statement->execute();
|
||||
$row = $result instanceof SQLite3Result ? $result->fetchArray(SQLITE3_ASSOC) : false;
|
||||
if (!is_array($row) || !array_key_exists('value_json', $row)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)$row['value_json'], true);
|
||||
return json_last_error() === JSON_ERROR_NONE ? $decoded : $default;
|
||||
}
|
||||
|
||||
public function setJson(string $key, mixed $value): void
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO kv (key, value_json, updated_at)
|
||||
VALUES (:key, :value_json, :updated_at)
|
||||
ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at'
|
||||
);
|
||||
$statement->bindValue(':key', $key, SQLITE3_TEXT);
|
||||
$statement->bindValue(':value_json', json_encode($value, JSON_UNESCAPED_SLASHES), SQLITE3_TEXT);
|
||||
$statement->bindValue(':updated_at', date('c'), SQLITE3_TEXT);
|
||||
$statement->execute();
|
||||
}
|
||||
|
||||
public function enqueue(string $type, string $endpoint, array $payload): void
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'INSERT INTO outbox (item_type, endpoint, payload_json, created_at)
|
||||
VALUES (:item_type, :endpoint, :payload_json, :created_at)'
|
||||
);
|
||||
$statement->bindValue(':item_type', $type, SQLITE3_TEXT);
|
||||
$statement->bindValue(':endpoint', $endpoint, SQLITE3_TEXT);
|
||||
$statement->bindValue(':payload_json', json_encode($payload, JSON_UNESCAPED_SLASHES), SQLITE3_TEXT);
|
||||
$statement->bindValue(':created_at', date('c'), SQLITE3_TEXT);
|
||||
$statement->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function queuedItems(int $limit = 25): array
|
||||
{
|
||||
$statement = $this->db->prepare(
|
||||
'SELECT id, item_type, endpoint, payload_json, created_at
|
||||
FROM outbox
|
||||
ORDER BY id ASC
|
||||
LIMIT :limit'
|
||||
);
|
||||
$statement->bindValue(':limit', max(1, $limit), SQLITE3_INTEGER);
|
||||
$result = $statement->execute();
|
||||
$items = [];
|
||||
if (!$result instanceof SQLite3Result) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
while (($row = $result->fetchArray(SQLITE3_ASSOC)) !== false) {
|
||||
$items[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'type' => (string)$row['item_type'],
|
||||
'endpoint' => (string)$row['endpoint'],
|
||||
'payload' => json_decode((string)$row['payload_json'], true) ?: [],
|
||||
'created_at' => (string)$row['created_at'],
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function removeOutboxItem(int $id): void
|
||||
{
|
||||
$statement = $this->db->prepare('DELETE FROM outbox WHERE id = :id');
|
||||
$statement->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$statement->execute();
|
||||
}
|
||||
|
||||
public function outboxSummary(): array
|
||||
{
|
||||
$countResult = $this->db->querySingle('SELECT COUNT(*) FROM outbox');
|
||||
$oldestResult = $this->db->querySingle('SELECT created_at FROM outbox ORDER BY id ASC LIMIT 1');
|
||||
$lastSyncAt = $this->getJson('last_sync_at');
|
||||
|
||||
return [
|
||||
'queued' => (int)$countResult,
|
||||
'oldest_queued_at' => is_string($oldestResult) && $oldestResult !== '' ? $oldestResult : null,
|
||||
'last_replayed_at' => is_string($lastSyncAt) && $lastSyncAt !== '' ? $lastSyncAt : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
final class TruckwashEdgeAgent
|
||||
{
|
||||
private const DEFAULT_INSTALL_DIR = '/opt/truckwash-edge-agent';
|
||||
private const DEFAULT_RUNTIME_DIR = '/opt/truckwash-edge-agent/runtime';
|
||||
private const DEFAULT_STATE_DATABASE = '/opt/truckwash-edge-agent/runtime/gateway-state.sqlite';
|
||||
private const DEFAULT_WORKER_BASE_URL = 'http://lan-worker:8090';
|
||||
private const DEFAULT_UPDATE_WINDOW = '02:00-04:00';
|
||||
|
||||
private AgentConfig $config;
|
||||
private HttpJsonClient $http;
|
||||
private HttpJsonClient $workerHttp;
|
||||
private Logger $logger;
|
||||
private LocalStateStore $stateStore;
|
||||
private string $installDir;
|
||||
private string $runtimeDir;
|
||||
private string $statePath;
|
||||
private string $lastOperationSnapshotPath;
|
||||
private string $lastHeartbeatMarkerPath;
|
||||
private string $stagedUpdatePath;
|
||||
private int $lastHeartbeatAt = 0;
|
||||
private string $agentInstanceId;
|
||||
|
||||
public function __construct(string $configPath)
|
||||
{
|
||||
$this->config = AgentConfig::load($configPath);
|
||||
$this->http = new HttpJsonClient((string)$this->config->get('apiUrl'));
|
||||
$this->installDir = rtrim((string)$this->config->get('installDir', dirname($configPath)), DIRECTORY_SEPARATOR);
|
||||
$this->runtimeDir = $this->installDir . DIRECTORY_SEPARATOR . 'runtime';
|
||||
$this->installDir = rtrim((string)$this->config->get('installDir', self::DEFAULT_INSTALL_DIR), DIRECTORY_SEPARATOR);
|
||||
$this->runtimeDir = rtrim((string)$this->config->get('runtimeDir', self::DEFAULT_RUNTIME_DIR), DIRECTORY_SEPARATOR);
|
||||
if (!is_dir($this->runtimeDir)) {
|
||||
@mkdir($this->runtimeDir, 0777, true);
|
||||
}
|
||||
|
||||
$this->http = new HttpJsonClient((string)$this->config->get('apiUrl'));
|
||||
$this->workerHttp = new HttpJsonClient((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL));
|
||||
$this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log');
|
||||
$this->stateStore = new LocalStateStore((string)$this->config->get('stateDatabasePath', self::DEFAULT_STATE_DATABASE));
|
||||
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
|
||||
$this->lastOperationSnapshotPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-operation.json';
|
||||
$this->lastHeartbeatMarkerPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-heartbeat-ok.txt';
|
||||
$this->stagedUpdatePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'staged-update.json';
|
||||
$this->agentInstanceId = $this->ensureAgentInstanceId();
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->logger->info('Truckwash PHP edge agent starting.');
|
||||
$this->logger->info('Truckwash compose edge-agent starting.');
|
||||
$this->recoverPreviousOperationState();
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$this->reloadConfigFromDisk();
|
||||
$this->ensureClaimed();
|
||||
$this->flushOutbox();
|
||||
$this->heartbeat();
|
||||
$processedManagementOperation = $this->processManagementOperation();
|
||||
if (!$processedManagementOperation) {
|
||||
$this->processCommandQueue();
|
||||
}
|
||||
$this->flushOutbox();
|
||||
} catch (Throwable $throwable) {
|
||||
$this->logger->error($throwable->getMessage());
|
||||
sleep(2);
|
||||
@@ -232,26 +379,34 @@ final class TruckwashEdgeAgent
|
||||
return;
|
||||
}
|
||||
|
||||
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v2');
|
||||
$response = $this->http->post('/edge-agent/claim', [
|
||||
'token' => (string)$this->config->get('installToken'),
|
||||
'hostname' => gethostname() ?: 'truckwash-edge',
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'),
|
||||
'installed_version' => $installedVersion,
|
||||
'metadata' => [
|
||||
'runtime' => 'php-cli',
|
||||
'runtime' => 'compose-php',
|
||||
'runtime_mode' => 'compose',
|
||||
'php_version' => PHP_VERSION,
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW),
|
||||
'container_health' => $this->buildContainerHealth(),
|
||||
'outbox_status' => $this->buildOutboxStatus(),
|
||||
'last_sync_at' => $this->stateStore->getJson('last_sync_at'),
|
||||
'rollback_status' => $this->readRollbackStatus(),
|
||||
'staged_version' => $this->currentStagedUpdate(),
|
||||
],
|
||||
]);
|
||||
|
||||
$payload = $response['data'] ?? [];
|
||||
$gateway = $payload['gateway'] ?? [];
|
||||
$installedVersion = (string)$this->config->get('installedVersion', 'php-agent-v1');
|
||||
$gateway = is_array($payload['gateway'] ?? null) ? (array)$payload['gateway'] : [];
|
||||
$this->config->set('gatewayId', $gateway['id'] ?? null);
|
||||
$this->config->set('agentToken', $payload['agent_token'] ?? null);
|
||||
$this->config->set('installedVersion', $installedVersion);
|
||||
$this->config->set('targetVersion', (string)($gateway['target_version'] ?? $installedVersion));
|
||||
$this->config->set('agentInstanceId', $this->agentInstanceId);
|
||||
$this->config->save();
|
||||
$this->stateStore->setJson('last_sync_at', date('c'));
|
||||
$this->logger->info('Claimed gateway ' . (string)($gateway['id'] ?? 'unknown') . '.');
|
||||
}
|
||||
|
||||
@@ -268,22 +423,38 @@ final class TruckwashEdgeAgent
|
||||
}
|
||||
|
||||
$operationState = $this->readOperationState();
|
||||
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/heartbeat', [
|
||||
$payload = [
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'status' => 'ONLINE',
|
||||
'hostname' => gethostname() ?: 'truckwash-edge',
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'),
|
||||
'target_version' => (string)$this->config->get('targetVersion', $this->config->get('installedVersion', 'php-agent-v1')),
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'),
|
||||
'target_version' => (string)$this->config->get(
|
||||
'targetVersion',
|
||||
$this->config->get('installedVersion', 'compose-php-agent-v2')
|
||||
),
|
||||
'metadata' => array_merge([
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
'runtime' => 'compose-php',
|
||||
'runtime_mode' => 'compose',
|
||||
'current_operation' => $operationState,
|
||||
'system_metrics' => [
|
||||
'memory_usage_bytes' => memory_get_usage(true),
|
||||
'memory_peak_bytes' => memory_get_peak_usage(true),
|
||||
'load_average' => function_exists('sys_getloadavg') ? sys_getloadavg() : [],
|
||||
],
|
||||
'system_metrics' => $this->buildSystemMetrics(),
|
||||
'container_health' => $this->buildContainerHealth(),
|
||||
'outbox_status' => $this->buildOutboxStatus(),
|
||||
'last_sync_at' => $this->stateStore->getJson('last_sync_at'),
|
||||
'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW),
|
||||
'staged_version' => $this->currentStagedUpdate(),
|
||||
'rollback_status' => $this->readRollbackStatus(),
|
||||
], $metadata),
|
||||
]);
|
||||
];
|
||||
|
||||
$posted = $this->sendControlPlaneEvent(
|
||||
'/edge-agent/gateways/' . $gatewayId . '/heartbeat',
|
||||
$payload,
|
||||
'heartbeat'
|
||||
);
|
||||
if (!$posted) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->lastHeartbeatAt = time();
|
||||
file_put_contents($this->lastHeartbeatMarkerPath, json_encode([
|
||||
@@ -296,10 +467,20 @@ final class TruckwashEdgeAgent
|
||||
private function processCommandQueue(): void
|
||||
{
|
||||
$gatewayId = (int)$this->config->get('gatewayId');
|
||||
if ($gatewayId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/poll', [
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'wait_seconds' => (int)$this->config->get('operationPollTimeoutSeconds', 20),
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
$this->logger->warning('Command polling failed: ' . $throwable->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$command = $response['data'] ?? null;
|
||||
if (!is_array($command) || empty($command['id'])) {
|
||||
return;
|
||||
@@ -317,28 +498,46 @@ final class TruckwashEdgeAgent
|
||||
default => throw new RuntimeException('Unsupported command type: ' . $type),
|
||||
};
|
||||
|
||||
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [
|
||||
$this->sendControlPlaneEvent(
|
||||
'/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result',
|
||||
[
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'ok' => true,
|
||||
'result' => $result,
|
||||
]);
|
||||
],
|
||||
'command_result'
|
||||
);
|
||||
} catch (Throwable $throwable) {
|
||||
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [
|
||||
$this->sendControlPlaneEvent(
|
||||
'/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result',
|
||||
[
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'ok' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
]);
|
||||
],
|
||||
'command_result'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function processManagementOperation(): bool
|
||||
{
|
||||
$gatewayId = (int)$this->config->get('gatewayId');
|
||||
if ($gatewayId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/next', [
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'wait_seconds' => (int)$this->config->get('operationPollTimeoutSeconds', 20),
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
$this->logger->warning('Operation polling failed: ' . $throwable->getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
$operation = $response['data'] ?? null;
|
||||
if (!is_array($operation) || empty($operation['id'])) {
|
||||
return false;
|
||||
@@ -363,7 +562,7 @@ final class TruckwashEdgeAgent
|
||||
$gatewayId,
|
||||
$operationId,
|
||||
'OPERATION_AGENT_STARTED',
|
||||
'PHP edge agent started processing the operation',
|
||||
'Compose edge-agent started processing the operation',
|
||||
10,
|
||||
['type' => $type, 'agent_instance_id' => $this->agentInstanceId],
|
||||
'starting'
|
||||
@@ -377,11 +576,15 @@ final class TruckwashEdgeAgent
|
||||
default => throw new RuntimeException('Unsupported operation type: ' . $type),
|
||||
};
|
||||
|
||||
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [
|
||||
$this->sendControlPlaneEvent(
|
||||
'/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete',
|
||||
[
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'ok' => true,
|
||||
'result' => $result,
|
||||
]);
|
||||
],
|
||||
'operation_complete'
|
||||
);
|
||||
$this->snapshotOperationState('COMPLETED', ['result' => $result]);
|
||||
$this->clearOperationState();
|
||||
} catch (Throwable $throwable) {
|
||||
@@ -393,12 +596,16 @@ final class TruckwashEdgeAgent
|
||||
'context' => ['type' => $type, 'progress' => 100, 'agent_instance_id' => $this->agentInstanceId],
|
||||
]);
|
||||
|
||||
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [
|
||||
$this->sendControlPlaneEvent(
|
||||
'/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete',
|
||||
[
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'ok' => false,
|
||||
'error_code' => $errorCode,
|
||||
'error_message' => $throwable->getMessage(),
|
||||
]);
|
||||
],
|
||||
'operation_complete'
|
||||
);
|
||||
$this->snapshotOperationState('FAILED', [
|
||||
'error_code' => $errorCode,
|
||||
'error_message' => $throwable->getMessage(),
|
||||
@@ -411,13 +618,17 @@ final class TruckwashEdgeAgent
|
||||
|
||||
private function postOperationEvent(int $gatewayId, int $operationId, array $payload): void
|
||||
{
|
||||
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/events', [
|
||||
$this->sendControlPlaneEvent(
|
||||
'/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/events',
|
||||
[
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'level' => (string)($payload['level'] ?? 'INFO'),
|
||||
'code' => $payload['code'] ?? null,
|
||||
'message' => (string)($payload['message'] ?? 'Operation event'),
|
||||
'context' => is_array($payload['context'] ?? null) ? (array)$payload['context'] : [],
|
||||
]);
|
||||
],
|
||||
'operation_event'
|
||||
);
|
||||
}
|
||||
|
||||
private function emitOperationProgress(
|
||||
@@ -454,14 +665,16 @@ final class TruckwashEdgeAgent
|
||||
|
||||
private function runDiscovery(): array
|
||||
{
|
||||
return ['inventory' => []];
|
||||
}
|
||||
|
||||
private function runManagementDiscovery(int $gatewayId, int $operationId, array $request): array
|
||||
{
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COLLECTING', 'Collecting local gateway inventory', 35, [], 'discovering');
|
||||
try {
|
||||
$response = $this->workerHttp->post('/discover', [
|
||||
'hostname' => gethostname() ?: 'truckwash-edge',
|
||||
'gateway_id' => (int)$this->config->get('gatewayId', 0),
|
||||
], 20);
|
||||
$inventory = isset($response['inventory']) && is_array($response['inventory']) ? (array)$response['inventory'] : [];
|
||||
return ['inventory' => $inventory];
|
||||
} catch (Throwable) {
|
||||
$hostname = gethostname() ?: 'truckwash-edge';
|
||||
$fallbackInventory = [[
|
||||
return ['inventory' => [[
|
||||
'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10),
|
||||
'local_ip' => gethostbyname($hostname),
|
||||
'model' => 'TruckWash Edge Gateway',
|
||||
@@ -474,14 +687,28 @@ final class TruckwashEdgeAgent
|
||||
],
|
||||
'metadata' => [
|
||||
'hostname' => $hostname,
|
||||
'runtime' => 'php-cli',
|
||||
'runtime' => 'compose-php',
|
||||
'php_version' => PHP_VERSION,
|
||||
],
|
||||
]];
|
||||
]]];
|
||||
}
|
||||
}
|
||||
|
||||
private function runManagementDiscovery(int $gatewayId, int $operationId, array $request): array
|
||||
{
|
||||
$this->emitOperationProgress(
|
||||
$gatewayId,
|
||||
$operationId,
|
||||
'DISCOVERY_COLLECTING',
|
||||
'Collecting local gateway inventory from lan-worker',
|
||||
35,
|
||||
[],
|
||||
'discovering'
|
||||
);
|
||||
$inventory = isset($request['inventory']) && is_array($request['inventory']) && $request['inventory'] !== []
|
||||
? (array)$request['inventory']
|
||||
: $fallbackInventory;
|
||||
: (array)($this->runDiscovery()['inventory'] ?? []);
|
||||
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COMPLETED', 'Inventory collected', 90, [
|
||||
'device_count' => count($inventory),
|
||||
], 'finishing');
|
||||
@@ -496,59 +723,158 @@ final class TruckwashEdgeAgent
|
||||
throw new RuntimeException('Update request is missing target version');
|
||||
}
|
||||
|
||||
$agentArtifactUrl = trim((string)($request['artifactUrl'] ?? ''));
|
||||
$serviceUnitUrl = trim((string)($request['serviceUnitUrl'] ?? ''));
|
||||
$agentArtifactSha = trim((string)($request['artifactSha256'] ?? ''));
|
||||
$serviceUnitSha = trim((string)($request['serviceUnitSha256'] ?? ''));
|
||||
$updateWindow = trim((string)($request['updateWindow'] ?? $this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW)));
|
||||
if ($updateWindow === '') {
|
||||
$updateWindow = self::DEFAULT_UPDATE_WINDOW;
|
||||
}
|
||||
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_VALIDATED', 'Validated update request', 20, [
|
||||
$requiredArtifacts = [
|
||||
[
|
||||
'url' => (string)($request['artifactUrl'] ?? ''),
|
||||
'sha256' => (string)($request['artifactSha256'] ?? ''),
|
||||
'path' => $this->installDir . DIRECTORY_SEPARATOR . 'agent.php',
|
||||
'label' => 'edge-agent runtime',
|
||||
'progress' => 35,
|
||||
'code' => 'UPDATE_DOWNLOAD_AGENT',
|
||||
],
|
||||
[
|
||||
'url' => (string)($request['lanWorkerArtifactUrl'] ?? ''),
|
||||
'sha256' => (string)($request['lanWorkerArtifactSha256'] ?? ''),
|
||||
'path' => $this->installDir . DIRECTORY_SEPARATOR . 'lan-worker.php',
|
||||
'label' => 'lan-worker runtime',
|
||||
'progress' => 45,
|
||||
'code' => 'UPDATE_DOWNLOAD_WORKER',
|
||||
],
|
||||
[
|
||||
'url' => (string)($request['composeFileUrl'] ?? ''),
|
||||
'sha256' => (string)($request['composeFileSha256'] ?? ''),
|
||||
'path' => $this->installDir . DIRECTORY_SEPARATOR . 'docker-compose.gateway.yml',
|
||||
'label' => 'compose stack',
|
||||
'progress' => 55,
|
||||
'code' => 'UPDATE_DOWNLOAD_COMPOSE',
|
||||
],
|
||||
[
|
||||
'url' => (string)($request['edgeAgentDockerfileUrl'] ?? ''),
|
||||
'sha256' => (string)($request['edgeAgentDockerfileSha256'] ?? ''),
|
||||
'path' => $this->installDir . DIRECTORY_SEPARATOR . 'Dockerfile.edge-agent',
|
||||
'label' => 'edge-agent Dockerfile',
|
||||
'progress' => 60,
|
||||
'code' => 'UPDATE_DOWNLOAD_EDGE_DOCKERFILE',
|
||||
],
|
||||
[
|
||||
'url' => (string)($request['lanWorkerDockerfileUrl'] ?? ''),
|
||||
'sha256' => (string)($request['lanWorkerDockerfileSha256'] ?? ''),
|
||||
'path' => $this->installDir . DIRECTORY_SEPARATOR . 'Dockerfile.lan-worker',
|
||||
'label' => 'lan-worker Dockerfile',
|
||||
'progress' => 65,
|
||||
'code' => 'UPDATE_DOWNLOAD_WORKER_DOCKERFILE',
|
||||
],
|
||||
[
|
||||
'url' => (string)($request['launcherScriptUrl'] ?? ''),
|
||||
'sha256' => (string)($request['launcherScriptSha256'] ?? ''),
|
||||
'path' => $this->installDir . DIRECTORY_SEPARATOR . 'gateway-launcher.sh',
|
||||
'label' => 'gateway launcher',
|
||||
'progress' => 72,
|
||||
'code' => 'UPDATE_DOWNLOAD_LAUNCHER',
|
||||
],
|
||||
[
|
||||
'url' => (string)($request['stackServiceUnitUrl'] ?? ''),
|
||||
'sha256' => (string)($request['stackServiceUnitSha256'] ?? ''),
|
||||
'path' => $this->installDir . DIRECTORY_SEPARATOR . 'truckwash-edge-gateway-stack.service',
|
||||
'label' => 'compose systemd unit',
|
||||
'progress' => 80,
|
||||
'code' => 'UPDATE_DOWNLOAD_STACK_SERVICE',
|
||||
],
|
||||
[
|
||||
'url' => (string)($request['serviceUnitUrl'] ?? ''),
|
||||
'sha256' => (string)($request['serviceUnitSha256'] ?? ''),
|
||||
'path' => $this->installDir . DIRECTORY_SEPARATOR . 'truckwash-edge-agent.service',
|
||||
'label' => 'compatibility systemd unit',
|
||||
'progress' => 84,
|
||||
'code' => 'UPDATE_DOWNLOAD_LEGACY_SERVICE',
|
||||
],
|
||||
];
|
||||
|
||||
foreach (['composeFileUrl', 'launcherScriptUrl', 'stackServiceUnitUrl'] as $requiredKey) {
|
||||
if (trim((string)($request[$requiredKey] ?? '')) === '') {
|
||||
throw new RuntimeException('Update request is missing compose artifact ' . $requiredKey);
|
||||
}
|
||||
}
|
||||
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_VALIDATED', 'Validated compose update request', 20, [
|
||||
'target_version' => $targetVersion,
|
||||
'update_window' => $updateWindow,
|
||||
], 'validating');
|
||||
|
||||
$changedFiles = [];
|
||||
if ($agentArtifactUrl !== '') {
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_AGENT', 'Downloading PHP edge agent artifact', 40, [
|
||||
'url' => $agentArtifactUrl,
|
||||
], 'downloading-agent');
|
||||
foreach ($requiredArtifacts as $artifact) {
|
||||
if ($artifact['url'] === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->emitOperationProgress(
|
||||
$gatewayId,
|
||||
$operationId,
|
||||
(string)$artifact['code'],
|
||||
'Downloading ' . (string)$artifact['label'],
|
||||
(int)$artifact['progress'],
|
||||
['url' => (string)$artifact['url']],
|
||||
'downloading'
|
||||
);
|
||||
$changedFiles[] = $this->downloadToFileAtomic(
|
||||
$agentArtifactUrl,
|
||||
$this->installDir . DIRECTORY_SEPARATOR . 'agent.php',
|
||||
$agentArtifactSha
|
||||
(string)$artifact['url'],
|
||||
(string)$artifact['path'],
|
||||
(string)$artifact['sha256']
|
||||
);
|
||||
}
|
||||
|
||||
if ($serviceUnitUrl !== '') {
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_SERVICE', 'Downloading service unit', 65, [
|
||||
'url' => $serviceUnitUrl,
|
||||
], 'downloading-service');
|
||||
$changedFiles[] = $this->downloadToFileAtomic(
|
||||
$serviceUnitUrl,
|
||||
$this->installDir . DIRECTORY_SEPARATOR . 'truckwash-edge-agent.service',
|
||||
$serviceUnitSha
|
||||
);
|
||||
}
|
||||
$this->ensureExecutable($this->installDir . DIRECTORY_SEPARATOR . 'agent.php');
|
||||
$this->ensureExecutable($this->installDir . DIRECTORY_SEPARATOR . 'lan-worker.php');
|
||||
$this->ensureExecutable($this->installDir . DIRECTORY_SEPARATOR . 'gateway-launcher.sh');
|
||||
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_WRITE_CONFIG', 'Writing staged update metadata', 85, [], 'writing-config');
|
||||
$this->config->set('installedVersion', $targetVersion);
|
||||
$this->config->set('targetVersion', $targetVersion);
|
||||
$this->config->set('lastStagedUpdate', [
|
||||
$stagedAt = date('c');
|
||||
$stagedUpdate = [
|
||||
'target_version' => $targetVersion,
|
||||
'staged_at' => date('c'),
|
||||
'staged_at' => $stagedAt,
|
||||
'apply_after' => $this->nextUpdateWindowStartIso($updateWindow),
|
||||
'status' => 'STAGED',
|
||||
'update_window' => $updateWindow,
|
||||
'compose_project_name' => (string)($request['composeProjectName'] ?? $this->config->get('composeProjectName', 'truckwash-edge-gateway')),
|
||||
'stack_service_name' => (string)($request['stackServiceName'] ?? $this->config->get('stackServiceName', 'truckwash-edge-gateway-stack.service')),
|
||||
'changed_files' => $changedFiles,
|
||||
]);
|
||||
$this->config->save();
|
||||
];
|
||||
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_STAGED', 'Artifacts staged successfully', 95, [
|
||||
'changed_files' => $changedFiles,
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_STAGE_METADATA', 'Writing staged update metadata', 90, [], 'writing-config');
|
||||
$this->config->set('targetVersion', $targetVersion);
|
||||
$this->config->set('lastStagedUpdate', $stagedUpdate);
|
||||
$this->config->save();
|
||||
$this->stateStore->setJson('staged_update', $stagedUpdate);
|
||||
$this->stateStore->setJson('rollback_status', [
|
||||
'state' => 'IDLE',
|
||||
'reason' => null,
|
||||
'rolled_back_to' => null,
|
||||
'at' => null,
|
||||
]);
|
||||
file_put_contents($this->stagedUpdatePath, json_encode($stagedUpdate, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_STAGED', 'Compose rollout staged for the maintenance window', 96, [
|
||||
'target_version' => $targetVersion,
|
||||
'apply_after' => $stagedUpdate['apply_after'],
|
||||
], 'staged');
|
||||
|
||||
return [
|
||||
'installed_version' => $targetVersion,
|
||||
'applied' => false,
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'),
|
||||
'staged_version' => $targetVersion,
|
||||
'target_version' => $targetVersion,
|
||||
'staged_at' => $stagedAt,
|
||||
'apply_after' => $stagedUpdate['apply_after'],
|
||||
'update_window' => $updateWindow,
|
||||
'restart_required' => true,
|
||||
'service_name' => (string)$this->config->get('serviceName', 'truckwash-edge-agent.service'),
|
||||
'service_name' => (string)($request['stackServiceName'] ?? $this->config->get('stackServiceName', 'truckwash-edge-gateway-stack.service')),
|
||||
'changed_files' => $changedFiles,
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
'rollback_status' => $this->readRollbackStatus(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -557,7 +883,7 @@ final class TruckwashEdgeAgent
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UNINSTALL_PREPARE', 'Preparing uninstall manifest', 40, [], 'preparing-uninstall');
|
||||
$manifestPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'uninstall-plan.json';
|
||||
$manifest = [
|
||||
'service_name' => (string)($request['service_name'] ?? $this->config->get('serviceName', 'truckwash-edge-agent.service')),
|
||||
'service_name' => (string)($request['service_name'] ?? $this->config->get('stackServiceName', 'truckwash-edge-gateway-stack.service')),
|
||||
'install_dir' => (string)($request['install_dir'] ?? $this->installDir),
|
||||
'generated_at' => date('c'),
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
@@ -584,8 +910,16 @@ final class TruckwashEdgeAgent
|
||||
{
|
||||
$localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? '');
|
||||
$channel = (int)($request['channel'] ?? 0);
|
||||
|
||||
try {
|
||||
return $this->workerHttp->post('/relay/status', [
|
||||
'local_ip' => $localIp,
|
||||
'channel' => $channel,
|
||||
], 8) ?? [];
|
||||
} catch (Throwable) {
|
||||
return $this->fetchShellyState($localIp, $channel);
|
||||
}
|
||||
}
|
||||
|
||||
private function switchRelay(array $request): array
|
||||
{
|
||||
@@ -593,6 +927,13 @@ final class TruckwashEdgeAgent
|
||||
$channel = (int)($request['channel'] ?? 0);
|
||||
$on = (bool)($request['on'] ?? false);
|
||||
|
||||
try {
|
||||
return $this->workerHttp->post('/relay/switch', [
|
||||
'local_ip' => $localIp,
|
||||
'channel' => $channel,
|
||||
'on' => $on,
|
||||
], 8) ?? [];
|
||||
} catch (Throwable) {
|
||||
$rpcUrl = sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false');
|
||||
try {
|
||||
$this->http->getJson($rpcUrl, 8);
|
||||
@@ -603,6 +944,7 @@ final class TruckwashEdgeAgent
|
||||
|
||||
return $this->fetchShellyState($localIp, $channel);
|
||||
}
|
||||
}
|
||||
|
||||
private function downloadToFileAtomic(string $url, string $path, string $expectedSha256 = ''): array
|
||||
{
|
||||
@@ -690,6 +1032,153 @@ final class TruckwashEdgeAgent
|
||||
}
|
||||
}
|
||||
|
||||
private function buildSystemMetrics(): array
|
||||
{
|
||||
$diskTotal = @disk_total_space($this->installDir);
|
||||
$diskFree = @disk_free_space($this->installDir);
|
||||
$diskUsed = (is_numeric($diskTotal) && is_numeric($diskFree)) ? ((float)$diskTotal - (float)$diskFree) : null;
|
||||
$diskUsagePct = ($diskTotal && $diskUsed !== null && $diskTotal > 0)
|
||||
? (int)round(($diskUsed / (float)$diskTotal) * 100)
|
||||
: null;
|
||||
$memoryLimitBytes = $this->iniBytes((string)ini_get('memory_limit'));
|
||||
$memoryUsage = memory_get_usage(true);
|
||||
$memoryUsagePct = ($memoryLimitBytes !== null && $memoryLimitBytes > 0)
|
||||
? (int)round(($memoryUsage / $memoryLimitBytes) * 100)
|
||||
: null;
|
||||
$loadAverage = function_exists('sys_getloadavg') ? sys_getloadavg() : [];
|
||||
|
||||
return [
|
||||
'memory_usage_bytes' => $memoryUsage,
|
||||
'memory_peak_bytes' => memory_get_peak_usage(true),
|
||||
'memory_usage_pct' => $memoryUsagePct,
|
||||
'cpu_usage_pct' => is_array($loadAverage) && isset($loadAverage[0]) ? max(0, (int)round((float)$loadAverage[0] * 100)) : null,
|
||||
'load_average' => $loadAverage,
|
||||
'disk_usage_pct' => $diskUsagePct,
|
||||
'disk_used_bytes' => $diskUsed,
|
||||
'disk_total_bytes' => is_numeric($diskTotal) ? (int)$diskTotal : null,
|
||||
'disk_mount' => $this->installDir,
|
||||
'latency_ms' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildContainerHealth(): array
|
||||
{
|
||||
$services = [[
|
||||
'name' => 'edge-agent',
|
||||
'status' => 'healthy',
|
||||
'updated_at' => date('c'),
|
||||
]];
|
||||
|
||||
try {
|
||||
$workerHealth = $this->workerHttp->getJson(rtrim((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL), '/') . '/health', 5);
|
||||
$services[] = [
|
||||
'name' => 'lan-worker',
|
||||
'status' => (string)($workerHealth['status'] ?? 'healthy'),
|
||||
'updated_at' => (string)($workerHealth['timestamp'] ?? date('c')),
|
||||
];
|
||||
} catch (Throwable $throwable) {
|
||||
$services[] = [
|
||||
'name' => 'lan-worker',
|
||||
'status' => 'degraded',
|
||||
'updated_at' => date('c'),
|
||||
'error' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
$healthyCount = count(array_filter($services, static fn(array $service): bool => (string)($service['status'] ?? '') === 'healthy'));
|
||||
$state = $healthyCount === count($services) ? 'ONLINE' : 'DEGRADED';
|
||||
|
||||
return [
|
||||
'state' => $state,
|
||||
'summary' => sprintf('%d/%d containers healthy', $healthyCount, count($services)),
|
||||
'services' => $services,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildOutboxStatus(): array
|
||||
{
|
||||
$summary = $this->stateStore->outboxSummary();
|
||||
$queued = (int)($summary['queued'] ?? 0);
|
||||
return [
|
||||
'state' => $queued > 0 ? 'QUEUED' : 'IN_SYNC',
|
||||
'queued' => $queued,
|
||||
'oldest_queued_at' => $summary['oldest_queued_at'] ?? null,
|
||||
'last_replayed_at' => $summary['last_replayed_at'] ?? null,
|
||||
'summary' => $queued > 0 ? sprintf('%d outbound items queued', $queued) : 'Outbox is empty',
|
||||
];
|
||||
}
|
||||
|
||||
private function flushOutbox(): void
|
||||
{
|
||||
$items = $this->stateStore->queuedItems(25);
|
||||
foreach ($items as $item) {
|
||||
try {
|
||||
$this->http->post((string)$item['endpoint'], is_array($item['payload'] ?? null) ? (array)$item['payload'] : [], 20);
|
||||
$this->stateStore->removeOutboxItem((int)$item['id']);
|
||||
$this->stateStore->setJson('last_sync_at', date('c'));
|
||||
} catch (Throwable $throwable) {
|
||||
$this->logger->warning('Outbox replay blocked on ' . (string)$item['type'] . ': ' . $throwable->getMessage());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sendControlPlaneEvent(string $endpoint, array $payload, string $type): bool
|
||||
{
|
||||
try {
|
||||
$this->http->post($endpoint, $payload, 20);
|
||||
$this->stateStore->setJson('last_sync_at', date('c'));
|
||||
return true;
|
||||
} catch (Throwable $throwable) {
|
||||
$this->stateStore->enqueue($type, $endpoint, $payload);
|
||||
$this->logger->warning('Queued ' . $type . ' to local outbox after transport failure: ' . $throwable->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function readRollbackStatus(): array
|
||||
{
|
||||
$rollbackPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'rollback-status.json';
|
||||
if (is_file($rollbackPath)) {
|
||||
$decoded = json_decode((string)file_get_contents($rollbackPath), true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
$rollback = $this->stateStore->getJson('rollback_status', null);
|
||||
if (is_array($rollback)) {
|
||||
return $rollback;
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => 'IDLE',
|
||||
'reason' => null,
|
||||
'rolled_back_to' => null,
|
||||
'at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function currentStagedUpdate(): ?array
|
||||
{
|
||||
if (is_file($this->stagedUpdatePath)) {
|
||||
$decoded = json_decode((string)file_get_contents($this->stagedUpdatePath), true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
$staged = $this->stateStore->getJson('staged_update', null);
|
||||
return is_array($staged) ? $staged : null;
|
||||
}
|
||||
|
||||
private function ensureExecutable(string $path): void
|
||||
{
|
||||
if (is_file($path)) {
|
||||
@chmod($path, 0755);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureAgentInstanceId(): string
|
||||
{
|
||||
$configured = trim((string)$this->config->get('agentInstanceId', ''));
|
||||
@@ -752,19 +1241,61 @@ final class TruckwashEdgeAgent
|
||||
$decoded = json_decode((string)file_get_contents($this->statePath), true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function nextUpdateWindowStartIso(string $window): string
|
||||
{
|
||||
$parts = explode('-', $window, 2);
|
||||
$start = trim((string)($parts[0] ?? '02:00'));
|
||||
if (!preg_match('/^\d{2}:\d{2}$/', $start)) {
|
||||
$start = '02:00';
|
||||
}
|
||||
|
||||
$now = new DateTimeImmutable('now');
|
||||
[$hour, $minute] = array_map('intval', explode(':', $start));
|
||||
$candidate = $now->setTime($hour, $minute, 0);
|
||||
if ($candidate <= $now) {
|
||||
$candidate = $candidate->modify('+1 day');
|
||||
}
|
||||
|
||||
return $candidate->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
private function reloadConfigFromDisk(): void
|
||||
{
|
||||
$reloaded = AgentConfig::load($this->config->path);
|
||||
$this->config = $reloaded;
|
||||
$this->workerHttp = new HttpJsonClient((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL));
|
||||
}
|
||||
|
||||
private function iniBytes(string $value): ?int
|
||||
{
|
||||
$normalized = trim(strtolower($value));
|
||||
if ($normalized === '' || $normalized === '-1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$unit = substr($normalized, -1);
|
||||
$number = (float)$normalized;
|
||||
return match ($unit) {
|
||||
'g' => (int)round($number * 1024 * 1024 * 1024),
|
||||
'm' => (int)round($number * 1024 * 1024),
|
||||
'k' => (int)round($number * 1024),
|
||||
default => is_numeric($normalized) ? (int)$normalized : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$configPath = null;
|
||||
foreach ($argv as $index => $argument) {
|
||||
if ($argument === '--config' && isset($argv[$index + 1])) {
|
||||
$configPath = $argv[$index + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_string($configPath) || trim($configPath) === '') {
|
||||
fwrite(STDERR, "Usage: agent.php --config /path/to/config.json\n");
|
||||
if ($configPath === null) {
|
||||
fwrite(STDERR, "Usage: php agent.php --config /path/to/config.json\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
(new TruckwashEdgeAgent($configPath))->run();
|
||||
$agent = new TruckwashEdgeAgent($configPath);
|
||||
$agent->run();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
services:
|
||||
lan-worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.lan-worker
|
||||
args:
|
||||
BASE_IMAGE: ${LAN_WORKER_BASE_IMAGE:-php:8.2-cli-bookworm}
|
||||
container_name: truckwash-lan-worker
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8090:8090"
|
||||
volumes:
|
||||
- ./runtime:/opt/truckwash-edge-agent/runtime
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8090/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
edge-agent:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.edge-agent
|
||||
args:
|
||||
BASE_IMAGE: ${EDGE_AGENT_BASE_IMAGE:-php:8.2-cli-bookworm}
|
||||
container_name: truckwash-edge-agent
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
lan-worker:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./config.json:/config/config.json
|
||||
- ./runtime:/opt/truckwash-edge-agent/runtime
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "test -f /opt/truckwash-edge-agent/runtime/last-heartbeat-ok.txt"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ACTION="${1:-up}"
|
||||
INSTALL_DIR="${TRUCKWASH_INSTALL_DIR:-/opt/truckwash-edge-agent}"
|
||||
CONFIG_PATH="$INSTALL_DIR/config.json"
|
||||
COMPOSE_FILE="$INSTALL_DIR/docker-compose.gateway.yml"
|
||||
RUNTIME_DIR="$INSTALL_DIR/runtime"
|
||||
ROLLBACK_STATUS_PATH="$RUNTIME_DIR/rollback-status.json"
|
||||
STAGED_UPDATE_PATH="$RUNTIME_DIR/staged-update.json"
|
||||
|
||||
log() {
|
||||
printf '[gateway-launcher] %s\n' "$1"
|
||||
}
|
||||
|
||||
compose_cmd() {
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
docker compose "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
docker-compose "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Docker Compose is required." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
config_value() {
|
||||
local key="$1"
|
||||
local fallback="${2:-}"
|
||||
php -r '
|
||||
$path = $argv[1];
|
||||
$key = $argv[2];
|
||||
$fallback = $argv[3];
|
||||
if (!is_file($path)) {
|
||||
echo $fallback;
|
||||
exit(0);
|
||||
}
|
||||
$decoded = json_decode((string)file_get_contents($path), true);
|
||||
if (!is_array($decoded) || !array_key_exists($key, $decoded) || $decoded[$key] === null || $decoded[$key] === "") {
|
||||
echo $fallback;
|
||||
exit(0);
|
||||
}
|
||||
echo is_scalar($decoded[$key]) ? (string)$decoded[$key] : json_encode($decoded[$key], JSON_UNESCAPED_SLASHES);
|
||||
' "$CONFIG_PATH" "$key" "$fallback"
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$RUNTIME_DIR" "$RUNTIME_DIR/backups"
|
||||
}
|
||||
|
||||
within_update_window() {
|
||||
local window
|
||||
window="$(config_value updateWindow '02:00-04:00')"
|
||||
php -r '
|
||||
$window = $argv[1];
|
||||
[$start, $end] = array_pad(explode("-", $window, 2), 2, "");
|
||||
$parse = static function (string $value): ?int {
|
||||
if (!preg_match("/^(\d{2}):(\d{2})$/", trim($value), $matches)) {
|
||||
return null;
|
||||
}
|
||||
return ((int)$matches[1] * 60) + (int)$matches[2];
|
||||
};
|
||||
$startMinutes = $parse($start);
|
||||
$endMinutes = $parse($end);
|
||||
if ($startMinutes === null || $endMinutes === null) {
|
||||
exit(1);
|
||||
}
|
||||
$nowMinutes = ((int)date("G") * 60) + (int)date("i");
|
||||
if ($startMinutes <= $endMinutes) {
|
||||
exit(($nowMinutes >= $startMinutes && $nowMinutes <= $endMinutes) ? 0 : 1);
|
||||
}
|
||||
exit(($nowMinutes >= $startMinutes || $nowMinutes <= $endMinutes) ? 0 : 1);
|
||||
' "$window"
|
||||
}
|
||||
|
||||
write_rollback_status() {
|
||||
local state="$1"
|
||||
local reason="${2:-}"
|
||||
local rolled_back_to="${3:-}"
|
||||
cat > "$ROLLBACK_STATUS_PATH" <<EOF_JSON
|
||||
{
|
||||
"state": "$state",
|
||||
"reason": "$reason",
|
||||
"rolled_back_to": "$rolled_back_to",
|
||||
"at": "$(date --iso-8601=seconds)"
|
||||
}
|
||||
EOF_JSON
|
||||
}
|
||||
|
||||
apply_stack() {
|
||||
local edge_base_image
|
||||
local worker_base_image
|
||||
edge_base_image="$(config_value edgeAgentBaseImage 'php:8.2-cli-bookworm')"
|
||||
worker_base_image="$(config_value lanWorkerBaseImage 'php:8.2-cli-bookworm')"
|
||||
cd "$INSTALL_DIR"
|
||||
EDGE_AGENT_BASE_IMAGE="$edge_base_image" LAN_WORKER_BASE_IMAGE="$worker_base_image" \
|
||||
compose_cmd -f "$COMPOSE_FILE" up -d --build
|
||||
}
|
||||
|
||||
rollback_stack() {
|
||||
local installed_version
|
||||
installed_version="$(config_value installedVersion '')"
|
||||
for file in \
|
||||
agent.php \
|
||||
lan-worker.php \
|
||||
docker-compose.gateway.yml \
|
||||
Dockerfile.edge-agent \
|
||||
Dockerfile.lan-worker \
|
||||
gateway-launcher.sh \
|
||||
truckwash-edge-gateway-stack.service \
|
||||
truckwash-edge-agent.service; do
|
||||
if [ -f "$INSTALL_DIR/$file.bak" ]; then
|
||||
mv -f "$INSTALL_DIR/$file.bak" "$INSTALL_DIR/$file"
|
||||
fi
|
||||
done
|
||||
apply_stack
|
||||
write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"
|
||||
if [ -f "$STAGED_UPDATE_PATH" ]; then
|
||||
php -r '
|
||||
$path = $argv[1];
|
||||
$decoded = is_file($path) ? json_decode((string)file_get_contents($path), true) : [];
|
||||
if (!is_array($decoded)) {
|
||||
$decoded = [];
|
||||
}
|
||||
$decoded["status"] = "ROLLED_BACK";
|
||||
$decoded["rolled_back_at"] = date(DATE_ATOM);
|
||||
file_put_contents($path, json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
' "$STAGED_UPDATE_PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
healthcheck_stack() {
|
||||
curl -fsS http://127.0.0.1:8090/health >/dev/null
|
||||
}
|
||||
|
||||
reconcile_stack() {
|
||||
ensure_dirs
|
||||
log "Reconciling compose stack"
|
||||
apply_stack
|
||||
sleep 5
|
||||
|
||||
if ! healthcheck_stack; then
|
||||
log "Healthcheck failed after compose rollout; reverting to previous artifacts"
|
||||
rollback_stack
|
||||
return 1
|
||||
fi
|
||||
|
||||
write_rollback_status "IDLE" "" ""
|
||||
if [ -f "$STAGED_UPDATE_PATH" ] && within_update_window; then
|
||||
php -r '
|
||||
$configPath = $argv[1];
|
||||
$stagedPath = $argv[2];
|
||||
$config = is_file($configPath) ? json_decode((string)file_get_contents($configPath), true) : [];
|
||||
$staged = is_file($stagedPath) ? json_decode((string)file_get_contents($stagedPath), true) : [];
|
||||
if (!is_array($config) || !is_array($staged)) {
|
||||
exit(0);
|
||||
}
|
||||
$targetVersion = trim((string)($staged["target_version"] ?? ""));
|
||||
if ($targetVersion !== "") {
|
||||
$config["installedVersion"] = $targetVersion;
|
||||
$config["targetVersion"] = $targetVersion;
|
||||
file_put_contents($configPath, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
$staged["status"] = "APPLIED";
|
||||
$staged["applied_at"] = date(DATE_ATOM);
|
||||
file_put_contents($stagedPath, json_encode($staged, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
}
|
||||
' "$CONFIG_PATH" "$STAGED_UPDATE_PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
up)
|
||||
reconcile_stack
|
||||
;;
|
||||
reconcile)
|
||||
if [ -f "$STAGED_UPDATE_PATH" ] && ! within_update_window; then
|
||||
log "Update is staged but outside the maintenance window; keeping current stack running"
|
||||
exit 0
|
||||
fi
|
||||
reconcile_stack
|
||||
;;
|
||||
down)
|
||||
cd "$INSTALL_DIR"
|
||||
compose_cmd -f "$COMPOSE_FILE" down
|
||||
;;
|
||||
*)
|
||||
echo "Usage: gateway-launcher.sh [up|reconcile|down]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function worker_json_response(int $status, array $payload): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
function worker_read_json_body(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
if (!is_string($raw) || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
function worker_http_get_json(string $url, int $timeoutSeconds = 8): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $timeoutSeconds,
|
||||
CURLOPT_CONNECTTIMEOUT => min(5, $timeoutSeconds),
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($raw === false || $status >= 400) {
|
||||
throw new RuntimeException($error !== '' ? $error : 'HTTP ' . $status);
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)$raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('Invalid JSON response from device');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
function worker_fetch_shelly_state(string $localIp, int $channel): array
|
||||
{
|
||||
if ($localIp === '') {
|
||||
throw new RuntimeException('Missing Shelly IP address');
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = worker_http_get_json(sprintf('http://%s/rpc/Switch.GetStatus?id=%d', $localIp, $channel));
|
||||
return [
|
||||
'online' => true,
|
||||
'on' => (bool)($payload['output'] ?? false),
|
||||
'output' => (bool)($payload['output'] ?? false),
|
||||
'raw' => $payload,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
$payload = worker_http_get_json(sprintf('http://%s/relay/%d', $localIp, $channel));
|
||||
return [
|
||||
'online' => true,
|
||||
'on' => (bool)($payload['ison'] ?? false),
|
||||
'output' => (bool)($payload['ison'] ?? false),
|
||||
'raw' => $payload,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function worker_switch_shelly_state(string $localIp, int $channel, bool $on): array
|
||||
{
|
||||
try {
|
||||
worker_http_get_json(sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false'));
|
||||
} catch (Throwable) {
|
||||
worker_http_get_json(sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off'));
|
||||
}
|
||||
|
||||
return worker_fetch_shelly_state($localIp, $channel);
|
||||
}
|
||||
|
||||
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?? '/');
|
||||
$body = worker_read_json_body();
|
||||
$hostname = gethostname() ?: 'truckwash-edge';
|
||||
|
||||
try {
|
||||
if ($method === 'GET' && $path === '/health') {
|
||||
worker_json_response(200, [
|
||||
'status' => 'healthy',
|
||||
'service' => 'lan-worker',
|
||||
'timestamp' => date(DateTimeInterface::ATOM),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/discover') {
|
||||
worker_json_response(200, [
|
||||
'inventory' => [[
|
||||
'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10),
|
||||
'local_ip' => gethostbyname($hostname),
|
||||
'model' => 'TruckWash Edge Gateway',
|
||||
'channel_count' => 1,
|
||||
'online' => true,
|
||||
'capabilities' => [
|
||||
'local_discovery' => true,
|
||||
'relay_commands' => true,
|
||||
'gateway_management_v2' => true,
|
||||
],
|
||||
'metadata' => [
|
||||
'hostname' => $hostname,
|
||||
'runtime' => 'compose-lan-worker',
|
||||
'php_version' => PHP_VERSION,
|
||||
],
|
||||
]],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/relay/status') {
|
||||
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
||||
$channel = (int)($body['channel'] ?? 0);
|
||||
worker_json_response(200, worker_fetch_shelly_state($localIp, $channel));
|
||||
return;
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/relay/switch') {
|
||||
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
||||
$channel = (int)($body['channel'] ?? 0);
|
||||
$on = (bool)($body['on'] ?? false);
|
||||
worker_json_response(200, worker_switch_shelly_state($localIp, $channel, $on));
|
||||
return;
|
||||
}
|
||||
|
||||
worker_json_response(404, ['message' => 'Not found']);
|
||||
} catch (Throwable $throwable) {
|
||||
worker_json_response(422, [
|
||||
'message' => $throwable->getMessage(),
|
||||
'error_code' => 'EDGE_GATEWAY_WORKER_FAILED',
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=TruckWash Edge Gateway Compose Stack
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=/opt/truckwash-edge-agent
|
||||
ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up
|
||||
ExecReload=/opt/truckwash-edge-agent/gateway-launcher.sh reconcile
|
||||
ExecStop=/opt/truckwash-edge-agent/gateway-launcher.sh down
|
||||
TimeoutStartSec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -59,6 +59,12 @@ class edgeGatewaysRoute
|
||||
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
|
||||
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
|
||||
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
|
||||
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
|
||||
$this->get('/edge-agent/artifacts/docker-compose.gateway.yml', fn() => $this->renderArtifact('docker-compose.gateway.yml'));
|
||||
$this->get('/edge-agent/artifacts/Dockerfile.edge-agent', fn() => $this->renderArtifact('Dockerfile.edge-agent'));
|
||||
$this->get('/edge-agent/artifacts/Dockerfile.lan-worker', fn() => $this->renderArtifact('Dockerfile.lan-worker'));
|
||||
$this->get('/edge-agent/artifacts/gateway-launcher.sh', fn() => $this->renderArtifact('gateway-launcher.sh'));
|
||||
$this->get('/edge-agent/artifacts/truckwash-edge-gateway-stack.service', fn() => $this->renderArtifact('truckwash-edge-gateway-stack.service'));
|
||||
$this->get('/edge-agent/artifacts/truckwash-edge-agent.service', fn() => $this->renderArtifact('truckwash-edge-agent.service'));
|
||||
$this->post('/edge-agent/claim', fn() => $this->handleAgentClaim());
|
||||
$this->post('/edge-agent/gateways/{id}/heartbeat', fn() => $this->handleAgentHeartbeat());
|
||||
|
||||
@@ -94,9 +94,9 @@ it('prioritizes router resources before mounted and baked-in artifact directorie
|
||||
|
||||
it('reads install artifacts through the shared locator', function (): void {
|
||||
$service = new EdgeGatewayInstallServiceHarness();
|
||||
$contents = $service->readArtifact('truckwash-edge-agent.service');
|
||||
$contents = $service->readArtifact('truckwash-edge-gateway-stack.service');
|
||||
|
||||
expect($contents)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php');
|
||||
expect($contents)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up');
|
||||
});
|
||||
|
||||
it('builds update payloads with checksums from resolved artifact paths', function (): void {
|
||||
@@ -105,6 +105,9 @@ it('builds update payloads with checksums from resolved artifact paths', functio
|
||||
|
||||
expect($payload['artifactSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/agent.php')));
|
||||
expect($payload['serviceUnitSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/truckwash-edge-agent.service')));
|
||||
expect($payload['stackServiceUnitSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/truckwash-edge-gateway-stack.service')));
|
||||
expect($payload['composeFileSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/docker-compose.gateway.yml')));
|
||||
expect($payload['launcherScriptSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/gateway-launcher.sh')));
|
||||
});
|
||||
|
||||
it('falls back to baked-in artifacts when the mount path is absent', function (): void {
|
||||
|
||||
@@ -30,7 +30,7 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
|
||||
}
|
||||
}
|
||||
|
||||
it('builds install script urls with the PHP agent 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([
|
||||
'HTTP_HOST' => 'api.truckwash.io:4433',
|
||||
'HTTP_X_FORWARDED_PROTO' => 'https',
|
||||
@@ -44,12 +44,17 @@ it('builds install script urls with the PHP agent artifacts and forwarded https
|
||||
expect($manager->buildInstallCommand('abc123'))->toContain("https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123");
|
||||
expect($script)->toContain('fetch_http "Verify install token" "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123"');
|
||||
expect($script)->toContain('fetch_http "Download PHP edge agent" "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php" "$INSTALL_DIR/agent.php"');
|
||||
expect($script)->toContain('fetch_http "Download systemd service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service" "$INSTALL_DIR/truckwash-edge-agent.service"');
|
||||
expect($script)->toContain('fetch_http "Download LAN worker" "https://api.truckwash.io:4433/edge-agent/artifacts/lan-worker.php" "$INSTALL_DIR/lan-worker.php"');
|
||||
expect($script)->toContain('fetch_http "Download compose stack" "https://api.truckwash.io:4433/edge-agent/artifacts/docker-compose.gateway.yml" "$INSTALL_DIR/docker-compose.gateway.yml"');
|
||||
expect($script)->toContain('fetch_http "Download compose stack service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-gateway-stack.service" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
|
||||
expect($script)->toContain('Installer failed during step: ${CURRENT_STEP:-unknown}');
|
||||
expect($script)->toContain('Last request: ${CURRENT_METHOD} ${CURRENT_URL}');
|
||||
expect($script)->toContain('Response body preview (first 400 bytes):');
|
||||
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
|
||||
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
|
||||
expect($script)->toContain('"stackServiceName":"truckwash-edge-gateway-stack.service"');
|
||||
expect($script)->toContain('"composeFileName":"docker-compose.gateway.yml"');
|
||||
expect($script)->toContain('"stateDatabasePath":"/opt/truckwash-edge-agent/runtime/gateway-state.sqlite"');
|
||||
expect($script)->toContain('"operationPollTimeoutSeconds":20');
|
||||
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
|
||||
expect($script)->not->toContain('"brokerUrl"');
|
||||
|
||||
@@ -25,6 +25,12 @@ it('registers PHP edge agent routes for operations and legacy relay command poll
|
||||
expect($route)->toContain("'/edge-agent/install-token/verify'");
|
||||
expect($route)->toContain("'/edge-agent/install.sh'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/agent.php'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/lan-worker.php'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/docker-compose.gateway.yml'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.edge-agent'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.lan-worker'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/gateway-launcher.sh'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/truckwash-edge-gateway-stack.service'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/truckwash-edge-agent.service'");
|
||||
expect($route)->toContain("'/edge-agent/claim'");
|
||||
expect($route)->toContain("'/edge-agent/gateways/{id}/heartbeat'");
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
<?php
|
||||
|
||||
it('builds the installer around the PHP agent artifacts and management polling config', function (): void {
|
||||
it('builds the installer around the compose stack artifacts and management polling config', function (): void {
|
||||
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
||||
$serviceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-agent.service'));
|
||||
$stackServiceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-gateway-stack.service'));
|
||||
|
||||
expect($managerSource)->not->toBeFalse();
|
||||
expect($managerSource)->toContain("'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS");
|
||||
expect($managerSource)->toContain('fetch_http "Verify install token" "__VERIFY_URL__"');
|
||||
expect($managerSource)->toContain('fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"');
|
||||
expect($managerSource)->toContain('fetch_http "Download systemd service unit" "__SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service"');
|
||||
expect($managerSource)->toContain('fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"');
|
||||
expect($managerSource)->toContain('fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml"');
|
||||
expect($managerSource)->toContain('fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
|
||||
expect($managerSource)->toContain('log_error "Request: GET ${url}"');
|
||||
expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"');
|
||||
expect($managerSource)->toContain('apt-get install -y curl ca-certificates php-cli php-curl php-mbstring');
|
||||
expect($managerSource)->toContain('apt-get install -y curl ca-certificates docker.io docker-compose-plugin php-cli php-curl php-mbstring php-sqlite3');
|
||||
expect($managerSource)->toContain('Existing claimed gateway detected; reinstall will reuse saved gateway credentials.');
|
||||
expect($managerSource)->toContain('run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"');
|
||||
expect($managerSource)->toContain('systemctl enable truckwash-edge-agent.service');
|
||||
expect($managerSource)->toContain('systemctl restart truckwash-edge-agent.service');
|
||||
expect($managerSource)->toContain('systemctl is-active --quiet truckwash-edge-agent.service');
|
||||
expect($managerSource)->toContain('systemctl enable truckwash-edge-gateway-stack.service');
|
||||
expect($managerSource)->toContain('systemctl restart truckwash-edge-gateway-stack.service');
|
||||
expect($managerSource)->toContain('systemctl is-active --quiet truckwash-edge-gateway-stack.service');
|
||||
expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
|
||||
expect($managerSource)->toContain('run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
|
||||
expect($managerSource)->toContain('journalctl -u truckwash-edge-agent.service -n 40 --no-pager || true');
|
||||
expect($managerSource)->toContain('journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager || true');
|
||||
expect($managerSource)->not->toContain('agent.mjs');
|
||||
expect($managerSource)->not->toContain('"brokerUrl"');
|
||||
expect($serviceSource)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json');
|
||||
expect($stackServiceSource)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up');
|
||||
expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use objects\users_o;
|
||||
|
||||
function invoke_users_private_static(string $method, array $args = []): mixed
|
||||
{
|
||||
$reflection = new ReflectionClass(users_o::class);
|
||||
$target = $reflection->getMethod($method);
|
||||
$target->setAccessible(true);
|
||||
return $target->invokeArgs(null, $args);
|
||||
}
|
||||
|
||||
it('builds customer name cache payloads from economic data or display-name fallbacks', function (): void {
|
||||
expect(invoke_users_private_static('buildCustomerNameCachePayload', [
|
||||
(object)['name' => 'Truckwash ApS'],
|
||||
'Fallback Name',
|
||||
]))->toBe(['name' => 'Truckwash ApS']);
|
||||
|
||||
expect(invoke_users_private_static('buildCustomerNameCachePayload', [
|
||||
null,
|
||||
'Fallback Name',
|
||||
]))->toBe(['name' => 'Fallback Name']);
|
||||
|
||||
expect(invoke_users_private_static('buildCustomerNameCachePayload', [
|
||||
(object)['name' => ' '],
|
||||
null,
|
||||
]))->toBeNull();
|
||||
});
|
||||
|
||||
it('guards bulk customer-name cache writes behind a resolved payload check', function (): void {
|
||||
$usersFile = app_path('objects/users_o.php');
|
||||
$content = file_get_contents($usersFile);
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain('$cache_payload = self::buildCustomerNameCachePayload($cached_name, $fallback_name);');
|
||||
expect($content)->toContain('if ($cache_payload !== null) {');
|
||||
expect($content)->toContain("\$this->cache('economic_customer_name', \$cache_payload, \$customer_number);");
|
||||
});
|
||||
@@ -43,8 +43,8 @@ RUN set -eux; \
|
||||
pdo_mysql \
|
||||
mysqli \
|
||||
zip; \
|
||||
pecl install imagick-3.7.0 redis xdebug; \
|
||||
docker-php-ext-enable imagick redis xdebug; \
|
||||
pecl install imagick-3.7.0; \
|
||||
docker-php-ext-enable imagick; \
|
||||
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false $PHPIZE_DEPS; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
Reference in New Issue
Block a user