Add auto-updater service with Docker integration, stack dependencies, and heartbeat management

This commit is contained in:
Jeppe Bundgaard
2026-04-22 15:49:21 +02:00
parent fd830deda9
commit 0d3c2d70bf
13 changed files with 750 additions and 70 deletions
File diff suppressed because one or more lines are too long
@@ -9,9 +9,11 @@ class edge_gateway_install_service
private const ARTIFACTS = [
'agent.php' => 'application/x-httpd-php; charset=utf-8',
'lan-worker.php' => 'application/x-httpd-php; charset=utf-8',
'auto-updater.php' => 'application/x-httpd-php; charset=utf-8',
'docker-compose.gateway.yml' => 'text/yaml; charset=utf-8',
'Dockerfile.edge-agent' => 'text/plain; charset=utf-8',
'Dockerfile.lan-worker' => 'text/plain; charset=utf-8',
'Dockerfile.auto-updater' => 'text/plain; charset=utf-8',
'gateway-launcher.sh' => 'text/x-shellscript; charset=utf-8',
'truckwash-edge-gateway-stack.service' => 'text/plain; charset=utf-8',
'truckwash-edge-agent.service' => 'text/plain; charset=utf-8',
@@ -33,8 +33,10 @@ class edge_gateway_manager
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_AUTO_UPDATER_ARTIFACT = 'auto-updater.php';
public const DEFAULT_EDGE_AGENT_DOCKERFILE = 'Dockerfile.edge-agent';
public const DEFAULT_LAN_WORKER_DOCKERFILE = 'Dockerfile.lan-worker';
public const DEFAULT_AUTO_UPDATER_DOCKERFILE = 'Dockerfile.auto-updater';
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';
@@ -42,6 +44,10 @@ class edge_gateway_manager
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_AUTO_UPDATER_BASE_IMAGE = 'php:8.2-cli-bookworm';
public const DEFAULT_REDIS_BASE_IMAGE = 'redis:7-alpine';
public const DEFAULT_MARIADB_BASE_IMAGE = 'mariadb:11';
public const DEFAULT_MINIO_BASE_IMAGE = 'minio/minio:latest';
public const DEFAULT_WORKER_BASE_URL = 'http://lan-worker:8090';
public const INSTALL_TOKEN_TTL_SECONDS = 1800;
public const HEARTBEAT_DEGRADED_AFTER_SECONDS = 60;
@@ -132,16 +138,7 @@ class edge_gateway_manager
'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,
],
],
'services' => self::defaultGatewayServiceHealth(self::STATUS_PENDING),
],
'outbox_status' => [
'depth' => 0,
@@ -875,13 +872,19 @@ class edge_gateway_manager
'composeProjectName' => self::DEFAULT_COMPOSE_PROJECT_NAME,
'launcherScriptName' => self::DEFAULT_LAUNCHER_SCRIPT_NAME,
'lanWorkerArtifactName' => self::DEFAULT_LAN_WORKER_ARTIFACT,
'autoUpdaterArtifactName' => self::DEFAULT_AUTO_UPDATER_ARTIFACT,
'edgeAgentDockerfileName' => self::DEFAULT_EDGE_AGENT_DOCKERFILE,
'lanWorkerDockerfileName' => self::DEFAULT_LAN_WORKER_DOCKERFILE,
'autoUpdaterDockerfileName' => self::DEFAULT_AUTO_UPDATER_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,
'autoUpdaterBaseImage' => self::DEFAULT_AUTO_UPDATER_BASE_IMAGE,
'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE,
'mariadbBaseImage' => self::DEFAULT_MARIADB_BASE_IMAGE,
'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE,
'heartbeatIntervalSeconds' => 15,
'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS,
], JSON_UNESCAPED_SLASHES);
@@ -1159,9 +1162,11 @@ run_step "Installing base packages" apt-get install -y curl ca-certificates dock
run_step "Installing Docker Compose runtime" install_compose_runtime
fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"
fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"
fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.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 auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.auto-updater"
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"
@@ -1175,7 +1180,7 @@ EOF_JSON
run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"
rm -f "$CONFIG_TEMPLATE_PATH"
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 "Setting executable permissions" chmod 0755 "$INSTALL_DIR/agent.php" "$INSTALL_DIR/lan-worker.php" "$INSTALL_DIR/auto-updater.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
@@ -1197,9 +1202,11 @@ BASH;
'__VERIFY_URL__' => $this->buildInstallTokenVerifyUrl($plainToken),
'__AGENT_URL__' => $this->buildAgentArtifactUrl('agent.php'),
'__WORKER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_ARTIFACT),
'__AUTO_UPDATER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_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),
'__AUTO_UPDATER_DOCKERFILE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_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),
@@ -1250,6 +1257,12 @@ BASH;
'workerBaseUrl' => self::DEFAULT_WORKER_BASE_URL,
'updateWindow' => (string)($metadata['update_window'] ?? self::DEFAULT_UPDATE_WINDOW),
'runtimeMode' => (string)($metadata['runtime_mode'] ?? 'compose'),
'edgeAgentBaseImage' => self::DEFAULT_EDGE_AGENT_BASE_IMAGE,
'lanWorkerBaseImage' => self::DEFAULT_LAN_WORKER_BASE_IMAGE,
'autoUpdaterBaseImage' => self::DEFAULT_AUTO_UPDATER_BASE_IMAGE,
'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE,
'mariadbBaseImage' => self::DEFAULT_MARIADB_BASE_IMAGE,
'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE,
'heartbeatIntervalSeconds' => 15,
'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS,
'installedVersion' => $gateway->installed_version->value() === null ? null : (string)$gateway->installed_version->value(),
@@ -1317,15 +1330,23 @@ BASH;
'launcherScriptSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAUNCHER_SCRIPT_NAME),
'lanWorkerArtifactUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_ARTIFACT),
'lanWorkerArtifactSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAN_WORKER_ARTIFACT),
'autoUpdaterArtifactUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT),
'autoUpdaterArtifactSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AUTO_UPDATER_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),
'autoUpdaterDockerfileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_DOCKERFILE),
'autoUpdaterDockerfileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AUTO_UPDATER_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,
'autoUpdaterBaseImage' => self::DEFAULT_AUTO_UPDATER_BASE_IMAGE,
'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE,
'mariadbBaseImage' => self::DEFAULT_MARIADB_BASE_IMAGE,
'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE,
];
}
@@ -3241,6 +3262,21 @@ BASH;
];
}
/**
* @return array<int,array<string,string>>
*/
private static function defaultGatewayServiceHealth(string $defaultStatus): array
{
return [
['name' => 'edge-agent', 'status' => $defaultStatus],
['name' => 'lan-worker', 'status' => $defaultStatus],
['name' => 'redis', 'status' => $defaultStatus],
['name' => 'mariadb', 'status' => $defaultStatus],
['name' => 'minio', 'status' => $defaultStatus],
['name' => 'auto-updater', 'status' => $defaultStatus],
];
}
private static function buildContainerHealthSummary(array $gateway, string $effectiveStatus): array
{
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
@@ -3248,11 +3284,35 @@ BASH;
? (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;
$defaultServices = self::defaultGatewayServiceHealth(
$effectiveStatus === self::STATUS_OFFLINE ? 'offline' : 'healthy'
);
$services = $defaultServices;
foreach ($rawServices as $rawService) {
if (!is_array($rawService)) {
continue;
}
$serviceName = trim((string)($rawService['name'] ?? ''));
if ($serviceName === '') {
continue;
}
$matched = false;
foreach ($services as $index => $defaultService) {
if ((string)($defaultService['name'] ?? '') !== $serviceName) {
continue;
}
$services[$index] = array_merge($defaultService, $rawService);
$matched = true;
break;
}
if (!$matched) {
$services[] = $rawService;
}
}
$healthyCount = 0;
$degradedCount = 0;
foreach ($services as $index => $service) {
@@ -3497,6 +3557,14 @@ BASH;
];
}
$activeStatus = strtoupper((string)($gateway['active_operation']['status'] ?? ''));
if (in_array($activeStatus, [
edge_gateway_operation_service::STATUS_CANCEL_REQUESTED,
edge_gateway_operation_service::STATUS_CANCELLED,
], true)) {
return null;
}
if (!empty($gateway['active_operation']['error_code']) || !empty($gateway['active_operation']['error_message'])) {
return [
'code' => $gateway['active_operation']['error_code'] ?? 'EDGE_GATEWAY_OPERATION_FAILED',
@@ -15,6 +15,8 @@ class edge_gateway_operation_service
public const STATUS_PENDING = 'PENDING';
public const STATUS_IN_PROGRESS = 'IN_PROGRESS';
public const STATUS_CANCEL_REQUESTED = 'CANCEL_REQUESTED';
public const STATUS_CANCELLED = 'CANCELLED';
public const STATUS_COMPLETED = 'COMPLETED';
public const STATUS_FAILED = 'FAILED';
@@ -29,6 +31,7 @@ class edge_gateway_operation_service
public const ERROR_UNSUPPORTED_VERSION = 'EDGE_GATEWAY_UNSUPPORTED_VERSION';
public const ERROR_CONFLICT = 'EDGE_GATEWAY_CONFLICT';
public const ERROR_VALIDATION = 'EDGE_GATEWAY_VALIDATION_FAILED';
public const ERROR_CANCELLED = 'EDGE_GATEWAY_CANCELLED';
public const POLL_INTERVAL_MICROSECONDS = 250000;
public const OPERATION_TIMEOUT_SECONDS = 900;
@@ -102,8 +105,8 @@ class edge_gateway_operation_service
FROM edge_gateway_operations
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
AND status IN ('PENDING', 'IN_PROGRESS')
ORDER BY FIELD(status, 'IN_PROGRESS', 'PENDING'), id ASC
AND status IN ('PENDING', 'IN_PROGRESS', 'CANCEL_REQUESTED')
ORDER BY FIELD(status, 'IN_PROGRESS', 'CANCEL_REQUESTED', 'PENDING'), id ASC
LIMIT 1"
);
$statement->execute([':gateway_id' => $gatewayId]);
@@ -126,8 +129,11 @@ class edge_gateway_operation_service
'total' => count($operations),
'pending' => 0,
'in_progress' => 0,
'cancel_requested' => 0,
'cancelled' => 0,
'completed' => 0,
'failed' => 0,
'latest_cancelled_at' => null,
'latest_completed_at' => null,
'latest_failed_at' => null,
'latest_type' => $operations[0]['type'] ?? null,
@@ -140,6 +146,11 @@ class edge_gateway_operation_service
$summary['pending'] += 1;
} elseif ($status === self::STATUS_IN_PROGRESS) {
$summary['in_progress'] += 1;
} elseif ($status === self::STATUS_CANCEL_REQUESTED) {
$summary['cancel_requested'] += 1;
} elseif ($status === self::STATUS_CANCELLED) {
$summary['cancelled'] += 1;
$summary['latest_cancelled_at'] ??= $operation['completed_at'] ?? null;
} elseif ($status === self::STATUS_COMPLETED) {
$summary['completed'] += 1;
$summary['latest_completed_at'] ??= $operation['completed_at'] ?? null;
@@ -239,6 +250,66 @@ class edge_gateway_operation_service
return $this->queueOperation($gatewayId, self::TYPE_DISCOVERY, [], $requestedBy);
}
/**
* @throws Exception
*/
public function cancelOperation(int $gatewayId, int $operationId, ?int $requestedBy = null): array
{
$gateway = $this->requireGateway($gatewayId);
$operation = $this->requireOperation($gatewayId, $operationId);
$status = (string)$operation->status->value();
if (in_array($status, [self::STATUS_COMPLETED, self::STATUS_FAILED, self::STATUS_CANCELLED], true)) {
return $this->serializeOperation($operation, true);
}
$message = 'Operation cancelled by operator';
if ($status === self::STATUS_PENDING) {
$this->markOperationCancelled(
$gatewayId,
$operation,
$message,
'OPERATION_CANCELLED',
['requested_by' => $requestedBy]
);
$this->manager()->logGatewayAudit(
$gatewayId,
(int)$gateway->department_id->value(),
'GATEWAY_OPERATION_CANCELLED',
$requestedBy,
['operation_id' => $operationId, 'type' => (string)$operation->type->value()]
);
} elseif ($status === self::STATUS_IN_PROGRESS) {
$operation->status->set(self::STATUS_CANCEL_REQUESTED);
$operation->error_code->set(self::ERROR_CANCELLED);
$operation->error_message->set('Operation cancellation requested by operator');
$operation->last_progress_at->set($this->now());
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = 'Cancellation requested';
$summary['retryable'] = true;
$operation->summary_json->set($summary);
$this->appendEventRecord(
$gatewayId,
$operationId,
self::LEVEL_WARNING,
'OPERATION_CANCEL_REQUESTED',
'Operation cancellation requested by operator',
['requested_by' => $requestedBy]
);
$this->manager()->logGatewayAudit(
$gatewayId,
(int)$gateway->department_id->value(),
'GATEWAY_OPERATION_CANCEL_REQUESTED',
$requestedBy,
['operation_id' => $operationId, 'type' => (string)$operation->type->value()]
);
}
$this->refreshGatewayViewCache($gatewayId);
return $this->serializeOperation($operation, true);
}
/**
* @throws Exception
*/
@@ -292,7 +363,12 @@ class edge_gateway_operation_service
}
$operation = $this->requireOperation($gatewayId, $operationId);
if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
if (in_array((string)$operation->status->value(), [
self::STATUS_COMPLETED,
self::STATUS_FAILED,
self::STATUS_CANCEL_REQUESTED,
self::STATUS_CANCELLED,
], true)) {
return $this->serializeOperation($operation, true);
}
@@ -322,6 +398,10 @@ class edge_gateway_operation_service
$operation->summary_json->set($summary);
$this->refreshOperationLease($operation);
if ($level === self::LEVEL_ERROR) {
$this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context);
}
$this->refreshGatewayViewCache($gatewayId);
return $this->serializeOperation($operation, true);
@@ -339,7 +419,11 @@ class edge_gateway_operation_service
}
$operation = $this->requireOperation($gatewayId, $operationId);
if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
if (in_array((string)$operation->status->value(), [
self::STATUS_COMPLETED,
self::STATUS_FAILED,
self::STATUS_CANCELLED,
], true)) {
return $this->serializeOperation($operation, true);
}
@@ -347,37 +431,71 @@ class edge_gateway_operation_service
$result = isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [];
$errorMessage = trim((string)($payload['error_message'] ?? $payload['error'] ?? ''));
$errorCode = trim((string)($payload['error_code'] ?? ''));
$status = (string)$operation->status->value();
if ($status === self::STATUS_CANCEL_REQUESTED && !$ok && $errorCode === '') {
$errorCode = self::ERROR_CANCELLED;
}
if (!$ok && $errorCode === '') {
$errorCode = $this->classifyCompletionError($gatewayId, $errorMessage);
}
if (!$ok && $errorMessage === '') {
$errorMessage = 'Gateway operation failed';
$errorMessage = $errorCode === self::ERROR_CANCELLED
? 'Gateway operation cancelled'
: 'Gateway operation failed';
}
$operation->status->set($ok ? self::STATUS_COMPLETED : self::STATUS_FAILED);
$finalStatus = $ok
? self::STATUS_COMPLETED
: (($status === self::STATUS_CANCEL_REQUESTED && $errorCode === self::ERROR_CANCELLED)
? self::STATUS_CANCELLED
: self::STATUS_FAILED);
$operation->status->set($finalStatus);
$operation->result_json->set($result);
$operation->error_code->set($ok ? null : $errorCode);
$operation->error_code->set($ok ? null : ($finalStatus === self::STATUS_CANCELLED ? self::ERROR_CANCELLED : $errorCode));
$operation->error_message->set($ok ? null : $errorMessage);
$operation->completed_at->set($this->now());
$operation->lease_expires_at->set(null);
$operation->last_progress_at->set($this->now());
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = $ok ? 'Completed' : 'Failed';
$summary['progress'] = 100;
$summary['retryable'] = !$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION;
$summary['label'] = match ($finalStatus) {
self::STATUS_COMPLETED => 'Completed',
self::STATUS_CANCELLED => 'Cancelled',
default => 'Failed',
};
$summary['progress'] = $finalStatus === self::STATUS_CANCELLED
? max(0, min(100, (int)($summary['progress'] ?? 0)))
: 100;
$summary['retryable'] = $finalStatus === self::STATUS_CANCELLED
? true
: (!$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION);
$operation->summary_json->set($summary);
$this->appendEventRecord(
$gatewayId,
$operationId,
$ok ? self::LEVEL_INFO : self::LEVEL_ERROR,
$ok ? 'OPERATION_COMPLETED' : $errorCode,
$ok ? 'Operation completed successfully' : $errorMessage,
$finalStatus === self::STATUS_COMPLETED
? self::LEVEL_INFO
: ($finalStatus === self::STATUS_CANCELLED ? self::LEVEL_WARNING : self::LEVEL_ERROR),
$finalStatus === self::STATUS_COMPLETED
? 'OPERATION_COMPLETED'
: ($finalStatus === self::STATUS_CANCELLED ? 'OPERATION_CANCELLED' : $errorCode),
$finalStatus === self::STATUS_COMPLETED
? 'Operation completed successfully'
: $errorMessage,
$result
);
$this->applyCompletionSideEffects($gatewayId, $operation, $ok, $result, $errorCode, $errorMessage);
if ($finalStatus !== self::STATUS_CANCELLED) {
$this->applyCompletionSideEffects(
$gatewayId,
$operation,
$ok,
$result,
$errorCode,
$errorMessage
);
}
$this->refreshGatewayViewCache($gatewayId);
return $this->serializeOperation($operation, true);
@@ -511,15 +629,20 @@ class edge_gateway_operation_service
*/
private function failTimedOutOperations(int $gatewayId): void
{
$rows = (new edge_gateway_operations_o())->getFieldsWhere([
'gateway_id' => $gatewayId,
'status' => self::STATUS_IN_PROGRESS,
'deleted_at' => null,
], ['id']);
$statement = db::getPDO()->prepare(
"SELECT id
FROM edge_gateway_operations
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
AND status IN ('IN_PROGRESS', 'CANCEL_REQUESTED')"
);
$statement->execute([':gateway_id' => $gatewayId]);
$rows = $statement->fetchAll();
$now = time();
foreach ($rows as $row) {
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
$status = (string)$operation->status->value();
$startedAt = $operation->started_at->value() === null ? null : strtotime((string)$operation->started_at->value());
$leaseExpiresAt = $operation->lease_expires_at->value() === null ? null : strtotime((string)$operation->lease_expires_at->value());
$timedOut = $startedAt !== false
@@ -533,6 +656,21 @@ class edge_gateway_operation_service
continue;
}
if ($status === self::STATUS_CANCEL_REQUESTED) {
$this->markOperationCancelled(
$gatewayId,
$operation,
'Gateway did not acknowledge cancellation before the operation lease expired',
'OPERATION_CANCELLED',
[
'agent_instance_id' => $operation->agent_instance_id->value(),
'last_progress_at' => $operation->last_progress_at->value(),
]
);
$this->refreshGatewayViewCache($gatewayId);
continue;
}
$errorMessage = $leaseExpired
? 'Gateway stopped reporting operation progress before the lease expired'
: 'Gateway operation timed out';
@@ -763,6 +901,9 @@ class edge_gateway_operation_service
}
$normalizedMessage = strtolower(trim($errorMessage));
if (str_contains($normalizedMessage, 'cancel')) {
return self::ERROR_CANCELLED;
}
if (str_contains($normalizedMessage, 'version')) {
return self::ERROR_UNSUPPORTED_VERSION;
}
@@ -797,4 +938,53 @@ class edge_gateway_operation_service
return substr($candidate, 0, 128);
}
private function markOperationFailedFromEvent(
int $gatewayId,
edge_gateway_operations_o $operation,
?string $code,
string $message,
array $context
): void {
$operation->status->set(self::STATUS_FAILED);
$operation->error_code->set($code !== null && trim($code) !== '' ? trim($code) : self::ERROR_VALIDATION);
$operation->error_message->set($message);
$operation->completed_at->set($this->now());
$operation->lease_expires_at->set(null);
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = 'Failed';
$summary['retryable'] = ((string)$operation->error_code->value()) !== self::ERROR_UNSUPPORTED_VERSION;
if (isset($context['progress'])) {
$summary['progress'] = max(0, min(100, (int)$context['progress']));
}
$operation->summary_json->set($summary);
}
private function markOperationCancelled(
int $gatewayId,
edge_gateway_operations_o $operation,
string $message,
string $eventCode,
array $context = []
): void {
$operation->status->set(self::STATUS_CANCELLED);
$operation->error_code->set(self::ERROR_CANCELLED);
$operation->error_message->set($message);
$operation->completed_at->set($this->now());
$operation->lease_expires_at->set(null);
$operation->last_progress_at->set($this->now());
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = 'Cancelled';
$summary['retryable'] = true;
$summary['progress'] = max(0, min(100, (int)($summary['progress'] ?? 0)));
$operation->summary_json->set($summary);
$this->appendEventRecord(
$gatewayId,
(int)$operation->id,
self::LEVEL_WARNING,
$eventCode,
$message,
$context
);
}
}
@@ -0,0 +1,11 @@
ARG BASE_IMAGE=php:8.2-cli-bookworm
FROM ${BASE_IMAGE}
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose; \
rm -rf /var/lib/apt/lists/*
COPY auto-updater.php /usr/local/bin/auto-updater.php
ENTRYPOINT ["php", "/usr/local/bin/auto-updater.php"]
@@ -42,6 +42,23 @@ final class AgentConfig
}
}
final class HttpRequestTimeoutException extends RuntimeException
{
}
final class OperationAbortException extends RuntimeException
{
public function __construct(string $message, private readonly bool $cancelled = false)
{
parent::__construct($message);
}
public function isCancellation(): bool
{
return $this->cancelled;
}
}
final class HttpJsonClient
{
public function __construct(private readonly string $baseUrl)
@@ -120,11 +137,17 @@ final class HttpJsonClient
$raw = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$errno = curl_errno($ch);
$error = curl_error($ch);
curl_close($ch);
if ($raw === false) {
throw new RuntimeException(sprintf('%s %s failed: %s', $method, $url, $error !== '' ? $error : 'unknown curl error'));
$message = sprintf('%s %s failed: %s', $method, $url, $error !== '' ? $error : 'unknown curl error');
if ($errno === CURLE_OPERATION_TIMEDOUT) {
throw new HttpRequestTimeoutException($message);
}
throw new RuntimeException($message);
}
if ($status >= 400) {
@@ -471,11 +494,14 @@ final class TruckwashEdgeAgent
return;
}
$waitSeconds = (int)$this->config->get('operationPollTimeoutSeconds', 20);
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),
]);
'wait_seconds' => $waitSeconds,
], $this->pollRequestTimeoutSeconds($waitSeconds));
} catch (HttpRequestTimeoutException) {
return;
} catch (Throwable $throwable) {
$this->logger->warning('Command polling failed: ' . $throwable->getMessage());
return;
@@ -527,12 +553,15 @@ final class TruckwashEdgeAgent
return false;
}
$waitSeconds = (int)$this->config->get('operationPollTimeoutSeconds', 20);
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),
'wait_seconds' => $waitSeconds,
'agent_instance_id' => $this->agentInstanceId,
]);
], $this->pollRequestTimeoutSeconds($waitSeconds));
} catch (HttpRequestTimeoutException) {
return false;
} catch (Throwable $throwable) {
$this->logger->warning('Operation polling failed: ' . $throwable->getMessage());
return false;
@@ -588,13 +617,17 @@ final class TruckwashEdgeAgent
$this->snapshotOperationState('COMPLETED', ['result' => $result]);
$this->clearOperationState();
} catch (Throwable $throwable) {
$errorCode = $this->classifyManagementError($throwable, $type);
$this->postOperationEvent($gatewayId, $operationId, [
'level' => 'ERROR',
'code' => $errorCode,
'message' => $throwable->getMessage(),
'context' => ['type' => $type, 'progress' => 100, 'agent_instance_id' => $this->agentInstanceId],
]);
$errorCode = $throwable instanceof OperationAbortException && $throwable->isCancellation()
? 'EDGE_GATEWAY_CANCELLED'
: $this->classifyManagementError($throwable, $type);
if (!($throwable instanceof OperationAbortException)) {
$this->postOperationEvent($gatewayId, $operationId, [
'level' => 'ERROR',
'code' => $errorCode,
'message' => $throwable->getMessage(),
'context' => ['type' => $type, 'progress' => 100, 'agent_instance_id' => $this->agentInstanceId],
]);
}
$this->sendControlPlaneEvent(
'/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete',
@@ -616,9 +649,9 @@ final class TruckwashEdgeAgent
return true;
}
private function postOperationEvent(int $gatewayId, int $operationId, array $payload): void
private function postOperationEvent(int $gatewayId, int $operationId, array $payload): ?array
{
$this->sendControlPlaneEvent(
return $this->requestControlPlaneEvent(
'/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/events',
[
'agent_token' => (string)$this->config->get('agentToken'),
@@ -652,7 +685,7 @@ final class TruckwashEdgeAgent
'current_operation' => $this->readOperationState(),
]);
$this->postOperationEvent($gatewayId, $operationId, [
$response = $this->postOperationEvent($gatewayId, $operationId, [
'level' => 'INFO',
'code' => $code,
'message' => $message,
@@ -661,6 +694,7 @@ final class TruckwashEdgeAgent
'agent_instance_id' => $this->agentInstanceId,
]),
]);
$this->guardOperationEventResponse($response);
}
private function runDiscovery(): array
@@ -745,6 +779,14 @@ final class TruckwashEdgeAgent
'progress' => 45,
'code' => 'UPDATE_DOWNLOAD_WORKER',
],
[
'url' => (string)($request['autoUpdaterArtifactUrl'] ?? ''),
'sha256' => (string)($request['autoUpdaterArtifactSha256'] ?? ''),
'path' => $this->installDir . DIRECTORY_SEPARATOR . 'auto-updater.php',
'label' => 'auto-updater runtime',
'progress' => 50,
'code' => 'UPDATE_DOWNLOAD_AUTO_UPDATER',
],
[
'url' => (string)($request['composeFileUrl'] ?? ''),
'sha256' => (string)($request['composeFileSha256'] ?? ''),
@@ -769,6 +811,14 @@ final class TruckwashEdgeAgent
'progress' => 65,
'code' => 'UPDATE_DOWNLOAD_WORKER_DOCKERFILE',
],
[
'url' => (string)($request['autoUpdaterDockerfileUrl'] ?? ''),
'sha256' => (string)($request['autoUpdaterDockerfileSha256'] ?? ''),
'path' => $this->installDir . DIRECTORY_SEPARATOR . 'Dockerfile.auto-updater',
'label' => 'auto-updater Dockerfile',
'progress' => 68,
'code' => 'UPDATE_DOWNLOAD_AUTO_UPDATER_DOCKERFILE',
],
[
'url' => (string)($request['launcherScriptUrl'] ?? ''),
'sha256' => (string)($request['launcherScriptSha256'] ?? ''),
@@ -795,7 +845,13 @@ final class TruckwashEdgeAgent
],
];
foreach (['composeFileUrl', 'launcherScriptUrl', 'stackServiceUnitUrl'] as $requiredKey) {
foreach ([
'composeFileUrl',
'launcherScriptUrl',
'stackServiceUnitUrl',
'autoUpdaterArtifactUrl',
'autoUpdaterDockerfileUrl',
] as $requiredKey) {
if (trim((string)($request[$requiredKey] ?? '')) === '') {
throw new RuntimeException('Update request is missing compose artifact ' . $requiredKey);
}
@@ -830,6 +886,7 @@ final class TruckwashEdgeAgent
$this->ensureExecutable($this->installDir . DIRECTORY_SEPARATOR . 'agent.php');
$this->ensureExecutable($this->installDir . DIRECTORY_SEPARATOR . 'lan-worker.php');
$this->ensureExecutable($this->installDir . DIRECTORY_SEPARATOR . 'auto-updater.php');
$this->ensureExecutable($this->installDir . DIRECTORY_SEPARATOR . 'gateway-launcher.sh');
$stagedAt = date('c');
@@ -1069,21 +1126,11 @@ final class TruckwashEdgeAgent
'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(),
];
}
$services[] = $this->probeWorkerHealth();
$services[] = $this->probeTcpService('redis', 'redis', 6379);
$services[] = $this->probeTcpService('mariadb', 'mariadb', 3306);
$services[] = $this->probeHttpService('minio', 'http://minio:9000/minio/health/live');
$services[] = $this->probeAutoUpdaterHealth();
$healthyCount = count(array_filter($services, static fn(array $service): bool => (string)($service['status'] ?? '') === 'healthy'));
$state = $healthyCount === count($services) ? 'ONLINE' : 'DEGRADED';
@@ -1123,6 +1170,94 @@ final class TruckwashEdgeAgent
}
}
private function probeWorkerHealth(): array
{
try {
$workerHealth = $this->workerHttp->getJson(
rtrim((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL), '/') . '/health',
5
);
return [
'name' => 'lan-worker',
'status' => (string)($workerHealth['status'] ?? 'healthy'),
'updated_at' => (string)($workerHealth['timestamp'] ?? date('c')),
];
} catch (Throwable $throwable) {
return [
'name' => 'lan-worker',
'status' => 'degraded',
'updated_at' => date('c'),
'error' => $throwable->getMessage(),
];
}
}
private function probeHttpService(string $name, string $url): array
{
try {
$this->http->download($url, 5);
return [
'name' => $name,
'status' => 'healthy',
'updated_at' => date('c'),
];
} catch (Throwable $throwable) {
return [
'name' => $name,
'status' => 'degraded',
'updated_at' => date('c'),
'error' => $throwable->getMessage(),
];
}
}
private function probeTcpService(string $name, string $host, int $port): array
{
$socket = @fsockopen($host, $port, $errno, $error, 3);
if (is_resource($socket)) {
fclose($socket);
return [
'name' => $name,
'status' => 'healthy',
'updated_at' => date('c'),
];
}
return [
'name' => $name,
'status' => 'degraded',
'updated_at' => date('c'),
'error' => trim((string)$error) !== '' ? trim((string)$error) : 'TCP connection failed',
'code' => $errno > 0 ? $errno : null,
];
}
private function probeAutoUpdaterHealth(): array
{
$path = $this->runtimeDir . DIRECTORY_SEPARATOR . 'auto-updater-heartbeat.json';
if (!is_file($path)) {
return [
'name' => 'auto-updater',
'status' => 'degraded',
'updated_at' => date('c'),
'error' => 'No auto-updater heartbeat recorded',
];
}
$decoded = json_decode((string)file_get_contents($path), true);
$status = trim((string)($decoded['status'] ?? 'idle'));
$ageSeconds = max(0, time() - filemtime($path));
$healthy = $ageSeconds <= 90 && $status !== 'error';
return [
'name' => 'auto-updater',
'status' => $healthy ? 'healthy' : 'degraded',
'updated_at' => (string)($decoded['updated_at'] ?? date('c')),
'mode' => $status,
'error' => $healthy ? null : (string)($decoded['last_output'] ?? 'Auto-updater heartbeat is stale'),
];
}
private function sendControlPlaneEvent(string $endpoint, array $payload, string $type): bool
{
try {
@@ -1136,6 +1271,40 @@ final class TruckwashEdgeAgent
}
}
private function requestControlPlaneEvent(string $endpoint, array $payload, string $type, int $timeoutSeconds = 20): ?array
{
try {
$response = $this->http->post($endpoint, $payload, $timeoutSeconds);
$this->stateStore->setJson('last_sync_at', date('c'));
return is_array($response) ? $response : null;
} catch (Throwable $throwable) {
$this->stateStore->enqueue($type, $endpoint, $payload);
$this->logger->warning('Queued ' . $type . ' to local outbox after transport failure: ' . $throwable->getMessage());
return null;
}
}
private function guardOperationEventResponse(?array $response): void
{
$operation = is_array($response['data'] ?? null) ? (array)$response['data'] : null;
if ($operation === null) {
return;
}
$status = strtoupper(trim((string)($operation['status'] ?? '')));
if ($status === 'CANCEL_REQUESTED' || $status === 'CANCELLED') {
throw new OperationAbortException('Operation cancelled by operator', true);
}
if ($status === 'FAILED') {
throw new OperationAbortException(
trim((string)($operation['error_message'] ?? '')) !== ''
? (string)$operation['error_message']
: 'Gateway operation failed'
);
}
}
private function readRollbackStatus(): array
{
$rollbackPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'rollback-status.json';
@@ -1267,6 +1436,11 @@ final class TruckwashEdgeAgent
$this->workerHttp = new HttpJsonClient((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL));
}
private function pollRequestTimeoutSeconds(int $waitSeconds): int
{
return max(5, $waitSeconds + 2);
}
private function iniBytes(string $value): ?int
{
$normalized = trim(strtolower($value));
@@ -0,0 +1,60 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This updater must run from the CLI.\n");
exit(1);
}
$installDir = rtrim((string)(getenv('TRUCKWASH_INSTALL_DIR') ?: '/opt/truckwash-edge-agent'), DIRECTORY_SEPARATOR);
$runtimeDir = $installDir . DIRECTORY_SEPARATOR . 'runtime';
$launcherPath = $installDir . DIRECTORY_SEPARATOR . 'gateway-launcher.sh';
$stagedUpdatePath = $runtimeDir . DIRECTORY_SEPARATOR . 'staged-update.json';
$heartbeatPath = $runtimeDir . DIRECTORY_SEPARATOR . 'auto-updater-heartbeat.json';
$intervalSeconds = max(15, (int)(getenv('AUTO_UPDATER_INTERVAL_SECONDS') ?: 30));
if (!is_dir($runtimeDir)) {
@mkdir($runtimeDir, 0777, true);
}
$writeHeartbeat = static function (array $payload) use ($heartbeatPath): void {
$payload['updated_at'] = date(DATE_ATOM);
file_put_contents($heartbeatPath, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
};
$writeHeartbeat([
'status' => 'starting',
'interval_seconds' => $intervalSeconds,
]);
while (true) {
$stagedUpdatePresent = is_file($stagedUpdatePath);
$writeHeartbeat([
'status' => $stagedUpdatePresent ? 'waiting_for_window' : 'idle',
'interval_seconds' => $intervalSeconds,
'staged_update_present' => $stagedUpdatePresent,
]);
if ($stagedUpdatePresent) {
$writeHeartbeat([
'status' => 'reconciling',
'interval_seconds' => $intervalSeconds,
'staged_update_present' => true,
]);
$output = [];
$exitCode = 0;
exec('/bin/bash ' . escapeshellarg($launcherPath) . ' reconcile 2>&1', $output, $exitCode);
$writeHeartbeat([
'status' => $exitCode === 0 ? 'idle' : 'error',
'interval_seconds' => $intervalSeconds,
'staged_update_present' => is_file($stagedUpdatePath),
'last_exit_code' => $exitCode,
'last_output' => implode("\n", array_slice($output, -40)),
'last_reconciled_at' => date(DATE_ATOM),
]);
}
sleep($intervalSeconds);
}
@@ -1,6 +1,52 @@
version: "2.4"
services:
redis:
image: ${REDIS_BASE_IMAGE:-redis:7-alpine}
container_name: truckwash-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- ./runtime/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 5s
retries: 5
mariadb:
image: ${MARIADB_BASE_IMAGE:-mariadb:11}
container_name: truckwash-mariadb
restart: unless-stopped
environment:
MARIADB_DATABASE: truckwash_edge
MARIADB_USER: truckwash_edge
MARIADB_PASSWORD: truckwash_edge
MARIADB_ROOT_PASSWORD: truckwash_edge_root
volumes:
- ./runtime/mariadb:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -ptruckwash_edge_root --silent"]
interval: 30s
timeout: 10s
retries: 10
minio:
image: ${MINIO_BASE_IMAGE:-minio/minio:latest}
container_name: truckwash-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: truckwashminio
MINIO_ROOT_PASSWORD: truckwash_edge_storage
volumes:
- ./runtime/minio:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/minio/health/live"]
interval: 30s
timeout: 5s
retries: 5
lan-worker:
build:
context: .
@@ -11,6 +57,13 @@ services:
restart: unless-stopped
ports:
- "127.0.0.1:8090:8090"
depends_on:
redis:
condition: service_healthy
mariadb:
condition: service_healthy
minio:
condition: service_healthy
volumes:
- ./runtime:/opt/truckwash-edge-agent/runtime
healthcheck:
@@ -28,6 +81,12 @@ services:
container_name: truckwash-edge-agent
restart: unless-stopped
depends_on:
redis:
condition: service_healthy
mariadb:
condition: service_healthy
minio:
condition: service_healthy
lan-worker:
condition: service_healthy
volumes:
@@ -38,3 +97,29 @@ services:
interval: 30s
timeout: 5s
retries: 3
auto-updater:
build:
context: .
dockerfile: Dockerfile.auto-updater
args:
BASE_IMAGE: ${AUTO_UPDATER_BASE_IMAGE:-php:8.2-cli-bookworm}
container_name: truckwash-auto-updater
restart: unless-stopped
depends_on:
edge-agent:
condition: service_started
environment:
AUTO_UPDATER_INTERVAL_SECONDS: 30
volumes:
- .:/opt/truckwash-edge-agent
- /var/run/docker.sock:/var/run/docker.sock
healthcheck:
test:
[
"CMD-SHELL",
"php -r '$path=\"/opt/truckwash-edge-agent/runtime/auto-updater-heartbeat.json\"; if (!is_file($path)) { exit(1); } exit((time() - filemtime($path)) <= 90 ? 0 : 1);'",
]
interval: 30s
timeout: 5s
retries: 3
@@ -106,10 +106,23 @@ EOF_JSON
apply_stack() {
local edge_base_image
local worker_base_image
local auto_updater_base_image
local redis_base_image
local mariadb_base_image
local minio_base_image
edge_base_image="$(config_value edgeAgentBaseImage 'php:8.2-cli-bookworm')"
worker_base_image="$(config_value lanWorkerBaseImage 'php:8.2-cli-bookworm')"
auto_updater_base_image="$(config_value autoUpdaterBaseImage 'php:8.2-cli-bookworm')"
redis_base_image="$(config_value redisBaseImage 'redis:7-alpine')"
mariadb_base_image="$(config_value mariadbBaseImage 'mariadb:11')"
minio_base_image="$(config_value minioBaseImage 'minio/minio:latest')"
cd "$INSTALL_DIR"
EDGE_AGENT_BASE_IMAGE="$edge_base_image" LAN_WORKER_BASE_IMAGE="$worker_base_image" \
EDGE_AGENT_BASE_IMAGE="$edge_base_image" \
LAN_WORKER_BASE_IMAGE="$worker_base_image" \
AUTO_UPDATER_BASE_IMAGE="$auto_updater_base_image" \
REDIS_BASE_IMAGE="$redis_base_image" \
MARIADB_BASE_IMAGE="$mariadb_base_image" \
MINIO_BASE_IMAGE="$minio_base_image" \
compose_cmd -f "$COMPOSE_FILE" up -d --build
}
@@ -119,9 +132,11 @@ rollback_stack() {
for file in \
agent.php \
lan-worker.php \
auto-updater.php \
docker-compose.gateway.yml \
Dockerfile.edge-agent \
Dockerfile.lan-worker \
Dockerfile.auto-updater \
gateway-launcher.sh \
truckwash-edge-gateway-stack.service \
truckwash-edge-agent.service; do
@@ -145,8 +160,20 @@ rollback_stack() {
fi
}
container_is_healthy() {
local container_name="$1"
local health
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || echo missing)"
[ "$health" = "healthy" ] || [ "$health" = "running" ]
}
healthcheck_stack() {
curl -fsS http://127.0.0.1:8090/health >/dev/null
container_is_healthy truckwash-redis &&
container_is_healthy truckwash-mariadb &&
container_is_healthy truckwash-minio &&
container_is_healthy truckwash-lan-worker &&
container_is_healthy truckwash-edge-agent &&
container_is_healthy truckwash-auto-updater
}
wait_for_stack_health() {
@@ -34,6 +34,9 @@ class edgeGatewaysRoute
$this->post('/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationCreate(), [
'modules_shelly_config' => 'Queue an edge gateway operation',
]);
$this->post('/edge-gateways/{id}/operations/{operationId}/cancel', fn() => $this->handleGatewayOperationCancel(), [
'modules_shelly_config' => 'Cancel an active edge gateway operation',
]);
$this->get('/edge-gateways/{id}/operations/{operationId}/events', fn() => $this->handleGatewayOperationEvents(), [
'modules_shelly_config' => 'List edge gateway operation events',
]);
@@ -60,9 +63,11 @@ class edgeGatewaysRoute
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
$this->get('/edge-agent/artifacts/docker-compose.gateway.yml', fn() => $this->renderArtifact('docker-compose.gateway.yml'));
$this->get('/edge-agent/artifacts/Dockerfile.edge-agent', fn() => $this->renderArtifact('Dockerfile.edge-agent'));
$this->get('/edge-agent/artifacts/Dockerfile.lan-worker', fn() => $this->renderArtifact('Dockerfile.lan-worker'));
$this->get('/edge-agent/artifacts/Dockerfile.auto-updater', fn() => $this->renderArtifact('Dockerfile.auto-updater'));
$this->get('/edge-agent/artifacts/gateway-launcher.sh', fn() => $this->renderArtifact('gateway-launcher.sh'));
$this->get('/edge-agent/artifacts/truckwash-edge-gateway-stack.service', fn() => $this->renderArtifact('truckwash-edge-gateway-stack.service'));
$this->get('/edge-agent/artifacts/truckwash-edge-agent.service', fn() => $this->renderArtifact('truckwash-edge-agent.service'));
@@ -166,6 +171,29 @@ class edgeGatewaysRoute
$response->success($this->operations()->listOperationEvents($gatewayId, $operationId));
}
private function handleGatewayOperationCancel(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$operationId = (int)$this->fromRoute('operationId');
self::requireParameterIntPositive($operationId, 'operationId');
$this->requireGatewayAccess($gatewayId);
try {
$operation = $this->operations()->cancelOperation($gatewayId, $operationId, $this->actorUserId());
$response->success([
'operation' => $operation,
'gateway' => $this->views()->getGateway($gatewayId),
]);
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
}
private function handleGatewayCredentialRotate(): void
{
global /** @var response $response */ $response;
@@ -16,8 +16,12 @@ it('keeps relay dispatch and discovery queueing on the edge gateway manager', fu
expect($managerSource)->toContain('public function buildUpdateOperationRequest');
expect($managerSource)->toContain('public function rotateGatewayCredentials');
expect($operationServiceSource)->toContain('public function queueOperation');
expect($operationServiceSource)->toContain('public function cancelOperation');
expect($operationServiceSource)->toContain('public function claimNextOperation');
expect($operationServiceSource)->toContain('public function completeAgentOperation');
expect($operationServiceSource)->toContain("public const STATUS_CANCEL_REQUESTED = 'CANCEL_REQUESTED';");
expect($operationServiceSource)->toContain("public const STATUS_CANCELLED = 'CANCELLED';");
expect($operationServiceSource)->toContain("public const ERROR_CANCELLED = 'EDGE_GATEWAY_CANCELLED';");
expect($operationServiceSource)->toContain('public const OPERATION_LEASE_SECONDS = 45;');
expect($operationServiceSource)->toContain('private function refreshOperationLease');
expect($operationServiceSource)->toContain('agent_instance_id');
@@ -41,6 +45,7 @@ it('loads relay command helpers on the manager and gateway operations on the ded
expect($reflection->getMethod('syncDeviceInventory')->isPrivate())->toBeTrue();
expect($reflection->hasMethod('syncGatewayInventory'))->toBeTrue();
expect($operationServiceReflection->hasMethod('queueOperation'))->toBeTrue();
expect($operationServiceReflection->hasMethod('cancelOperation'))->toBeTrue();
expect($operationServiceReflection->hasMethod('listOperations'))->toBeTrue();
expect($operationServiceReflection->hasMethod('claimNextOperation'))->toBeTrue();
expect($operationServiceReflection->hasMethod('appendAgentOperationEvent'))->toBeTrue();
@@ -10,6 +10,7 @@ it('registers the v2 operator-facing edge gateway routes', function (): void {
expect($route)->toContain("'/edge-gateways/{id}/discovery'");
expect($route)->toContain("'/edge-gateways/{id}/bindings'");
expect($route)->toContain("'/edge-gateways/{id}/operations'");
expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/cancel'");
expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/events'");
expect($route)->toContain("'/edge-gateways/{id}/rotate-credentials'");
expect($route)->toContain("'/departments/{id}/gateway-cutover'");
@@ -27,9 +28,11 @@ it('registers PHP edge agent routes for operations and legacy relay command poll
expect($route)->toContain("'/edge-agent/install.sh'");
expect($route)->toContain("'/edge-agent/artifacts/agent.php'");
expect($route)->toContain("'/edge-agent/artifacts/lan-worker.php'");
expect($route)->toContain("'/edge-agent/artifacts/auto-updater.php'");
expect($route)->toContain("'/edge-agent/artifacts/docker-compose.gateway.yml'");
expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.edge-agent'");
expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.lan-worker'");
expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.auto-updater'");
expect($route)->toContain("'/edge-agent/artifacts/gateway-launcher.sh'");
expect($route)->toContain("'/edge-agent/artifacts/truckwash-edge-gateway-stack.service'");
expect($route)->toContain("'/edge-agent/artifacts/truckwash-edge-agent.service'");
@@ -8,17 +8,23 @@ it('builds the installer around the compose stack artifacts and management polli
$composeSource = file_get_contents(app_path('resources/edge-gateway-agent/docker-compose.gateway.yml'));
$edgeDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.edge-agent'));
$workerDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.lan-worker'));
$autoUpdaterSource = file_get_contents(app_path('resources/edge-gateway-agent/auto-updater.php'));
$autoUpdaterDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.auto-updater'));
expect($managerSource)->not->toBeFalse();
expect($launcherSource)->not->toBeFalse();
expect($composeSource)->not->toBeFalse();
expect($edgeDockerfileSource)->not->toBeFalse();
expect($workerDockerfileSource)->not->toBeFalse();
expect($autoUpdaterSource)->not->toBeFalse();
expect($autoUpdaterDockerfileSource)->not->toBeFalse();
expect($managerSource)->toContain("'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS");
expect($managerSource)->toContain('fetch_http "Verify install token" "__VERIFY_URL__"');
expect($managerSource)->toContain('fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"');
expect($managerSource)->toContain('fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"');
expect($managerSource)->toContain('fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.php"');
expect($managerSource)->toContain('fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml"');
expect($managerSource)->toContain('fetch_http "Download auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.auto-updater"');
expect($managerSource)->toContain('fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
expect($managerSource)->toContain('log_error "Request: GET ${url}"');
expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"');
@@ -29,6 +35,7 @@ it('builds the installer around the compose stack artifacts and management polli
expect($managerSource)->toContain('Unable to install Docker Compose using docker-compose-plugin or docker-compose.');
expect($managerSource)->toContain('Existing claimed gateway detected; reinstall will reuse saved gateway credentials.');
expect($managerSource)->toContain('run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"');
expect($managerSource)->toContain('chmod 0755 "$INSTALL_DIR/agent.php" "$INSTALL_DIR/lan-worker.php" "$INSTALL_DIR/auto-updater.php" "$INSTALL_DIR/gateway-launcher.sh"');
expect($managerSource)->toContain('systemctl enable truckwash-edge-gateway-stack.service');
expect($managerSource)->toContain('systemctl restart truckwash-edge-gateway-stack.service');
expect($managerSource)->toContain('systemctl is-active --quiet truckwash-edge-gateway-stack.service');
@@ -42,29 +49,49 @@ it('builds the installer around the compose stack artifacts and management polli
expect($stackServiceSource)->toContain('TimeoutStartSec=900');
expect($launcherSource)->toContain('STACK_HEALTHCHECK_TIMEOUT_SECONDS="${STACK_HEALTHCHECK_TIMEOUT_SECONDS:-120}"');
expect($launcherSource)->toContain('wait_for_stack_health');
expect($launcherSource)->toContain('container_is_healthy truckwash-auto-updater');
expect($launcherSource)->toContain('AUTO_UPDATER_BASE_IMAGE="$auto_updater_base_image"');
expect($launcherSource)->toContain('compose_cmd -f "$COMPOSE_FILE" logs --tail=80 || true');
expect($composeSource)->toContain('version: "2.4"');
expect($composeSource)->toContain('condition: service_healthy');
expect($composeSource)->toContain('container_name: truckwash-redis');
expect($composeSource)->toContain('container_name: truckwash-mariadb');
expect($composeSource)->toContain('container_name: truckwash-minio');
expect($composeSource)->toContain('container_name: truckwash-auto-updater');
expect($edgeDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
expect($edgeDockerfileSource)->toContain('COPY agent.php /opt/truckwash-edge-agent/agent.php');
expect($edgeDockerfileSource)->not->toContain('docker-php-ext-install');
expect($workerDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
expect($workerDockerfileSource)->toContain('COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php');
expect($workerDockerfileSource)->not->toContain('docker-php-ext-install');
expect($autoUpdaterSource)->toContain("'/bin/bash ' . escapeshellarg(\$launcherPath) . ' reconcile 2>&1'");
expect($autoUpdaterDockerfileSource)->toContain('COPY auto-updater.php /usr/local/bin/auto-updater.php');
expect($autoUpdaterDockerfileSource)->toContain('apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose;');
expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs');
});
it('exposes update payload, credential rotation, and operation endpoints without shell transport wiring', function (): void {
it('exposes update payload, credential rotation, cancel endpoints, and operation endpoints without shell transport wiring', function (): void {
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
$routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
$agentSource = file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
expect($managerSource)->toContain('public function buildUpdateOperationRequest');
expect($managerSource)->toContain('public function rotateGatewayCredentials');
expect($managerSource)->toContain("'autoUpdaterArtifactUrl' => \$this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT)");
expect($managerSource)->toContain("'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE");
expect($routeSource)->toContain("'/edge-gateways/{id}/rotate-credentials'");
expect($routeSource)->toContain("'/edge-gateways/{id}/operations/{operationId}/cancel'");
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/operations/next'");
expect($agentSource)->toContain("/operations/next");
expect($agentSource)->toContain("/operations/' . \$operationId . '/complete");
expect($agentSource)->toContain('final class OperationAbortException extends RuntimeException');
expect($agentSource)->toContain('private function requestControlPlaneEvent(string $endpoint, array $payload, string $type, int $timeoutSeconds = 20): ?array');
expect($agentSource)->toContain('throw new OperationAbortException(\'Operation cancelled by operator\', true);');
expect($agentSource)->toContain('$services[] = $this->probeTcpService(\'redis\', \'redis\', 6379);');
expect($agentSource)->toContain('final class HttpRequestTimeoutException extends RuntimeException');
expect($agentSource)->toContain('], $this->pollRequestTimeoutSeconds($waitSeconds));');
expect($agentSource)->toContain('} catch (HttpRequestTimeoutException) {');
expect($agentSource)->toContain('private function pollRequestTimeoutSeconds(int $waitSeconds): int');
expect($agentSource)->toContain('last-heartbeat-ok.txt');
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions'");
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");