Replace CI PHP suite execution script with Composer commands and integrate Edge Gateway Agent stack artifacts

This commit is contained in:
Jeppe Bundgaard
2026-04-22 12:03:08 +02:00
parent a842edea97
commit 9cfb19a3ba
26 changed files with 1722 additions and 487 deletions
+133 -57
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
-184
View File
@@ -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
+16 -2
View File
@@ -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,
+2 -1
View File
@@ -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/ ./
+10 -41
View File
@@ -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,
+34 -6
View File
@@ -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);");
});
+2 -2
View File
@@ -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/*