Add auto-updater service with Docker integration, stack dependencies, and heartbeat management
This commit is contained in:
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'");
|
||||
|
||||
Reference in New Issue
Block a user