Remove outdated edge gateway object classes, add new agent implementation
Transitioned from obsolete gateway object classes (`edge_gateway_shell_action_jobs_o`, `edge_gateway_shell_events_o`, `edge_gateway_shell_sessions_o`, `edge_gateway_update_jobs_o`) to the new agent implementation (`edge-gateway-agent/agent.php`).
This commit is contained in:
@@ -0,0 +1,770 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "This agent must run from the CLI.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
final class AgentConfig
|
||||
{
|
||||
public function __construct(public array $data, public string $path)
|
||||
{
|
||||
}
|
||||
|
||||
public static function load(string $path): self
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
throw new RuntimeException('Missing config file: ' . $path);
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)file_get_contents($path), true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('Invalid config file: ' . $path);
|
||||
}
|
||||
|
||||
return new self($decoded, $path);
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return array_key_exists($key, $this->data) ? $this->data[$key] : $default;
|
||||
}
|
||||
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
$this->data[$key] = $value;
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
file_put_contents($this->path, json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
final class HttpJsonClient
|
||||
{
|
||||
public function __construct(private readonly string $baseUrl)
|
||||
{
|
||||
}
|
||||
|
||||
public function post(string $path, array $payload, int $timeoutSeconds = 30): ?array
|
||||
{
|
||||
$response = $this->requestJson(
|
||||
'POST',
|
||||
rtrim($this->baseUrl, '/') . '/' . ltrim($path, '/'),
|
||||
$payload,
|
||||
$timeoutSeconds
|
||||
);
|
||||
|
||||
return $response['decoded'];
|
||||
}
|
||||
|
||||
public function getJson(string $url, int $timeoutSeconds = 10): array
|
||||
{
|
||||
$response = $this->requestJson('GET', $url, null, $timeoutSeconds);
|
||||
return $response['decoded'] ?? [];
|
||||
}
|
||||
|
||||
public function download(string $url, int $timeoutSeconds = 60): string
|
||||
{
|
||||
$response = $this->requestRaw('GET', $url, null, $timeoutSeconds, ['Accept: */*']);
|
||||
return $response['body'];
|
||||
}
|
||||
|
||||
private function requestJson(string $method, string $url, ?array $payload, int $timeoutSeconds): array
|
||||
{
|
||||
$headers = ['Accept: application/json'];
|
||||
if ($payload !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
}
|
||||
|
||||
$response = $this->requestRaw($method, $url, $payload, $timeoutSeconds, $headers);
|
||||
$decoded = json_decode($response['body'], true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('Invalid JSON response from ' . $method . ' ' . $url);
|
||||
}
|
||||
|
||||
$response['decoded'] = $decoded;
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function requestRaw(
|
||||
string $method,
|
||||
string $url,
|
||||
?array $payload,
|
||||
int $timeoutSeconds,
|
||||
array $headers
|
||||
): array {
|
||||
$responseHeaders = [];
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $timeoutSeconds,
|
||||
CURLOPT_CONNECTTIMEOUT => min(10, $timeoutSeconds),
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_HEADERFUNCTION => static function ($curl, string $headerLine) use (&$responseHeaders): int {
|
||||
$trimmed = trim($headerLine);
|
||||
if ($trimmed !== '') {
|
||||
$responseHeaders[] = $trimmed;
|
||||
}
|
||||
return strlen($headerLine);
|
||||
},
|
||||
]);
|
||||
|
||||
if ($payload !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$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'));
|
||||
}
|
||||
|
||||
if ($status >= 400) {
|
||||
$preview = substr(preg_replace('/\s+/', ' ', (string)$raw) ?? '', 0, 240);
|
||||
$headerPreview = implode('; ', array_slice($responseHeaders, 0, 8));
|
||||
throw new RuntimeException(sprintf(
|
||||
'%s %s returned HTTP %d. Headers: %s. Body preview: %s',
|
||||
$method,
|
||||
$url,
|
||||
$status,
|
||||
$headerPreview !== '' ? $headerPreview : 'none',
|
||||
$preview !== '' ? $preview : 'empty'
|
||||
));
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'body' => (string)$raw,
|
||||
'headers' => $responseHeaders,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
final class Logger
|
||||
{
|
||||
public function __construct(private readonly string $logFile)
|
||||
{
|
||||
}
|
||||
|
||||
public function info(string $message): void
|
||||
{
|
||||
$this->write('INFO', $message);
|
||||
}
|
||||
|
||||
public function warning(string $message): void
|
||||
{
|
||||
$this->write('WARNING', $message);
|
||||
}
|
||||
|
||||
public function error(string $message): void
|
||||
{
|
||||
$this->write('ERROR', $message);
|
||||
}
|
||||
|
||||
private function write(string $level, string $message): void
|
||||
{
|
||||
$line = '[' . date('c') . "] {$level} {$message}\n";
|
||||
file_put_contents($this->logFile, $line, FILE_APPEND);
|
||||
fwrite($level === 'ERROR' ? STDERR : STDOUT, $line);
|
||||
}
|
||||
}
|
||||
|
||||
final class TruckwashEdgeAgent
|
||||
{
|
||||
private AgentConfig $config;
|
||||
private HttpJsonClient $http;
|
||||
private Logger $logger;
|
||||
private string $installDir;
|
||||
private string $runtimeDir;
|
||||
private string $statePath;
|
||||
private string $lastOperationSnapshotPath;
|
||||
private string $lastHeartbeatMarkerPath;
|
||||
private int $lastHeartbeatAt = 0;
|
||||
private string $agentInstanceId;
|
||||
|
||||
public function __construct(string $configPath)
|
||||
{
|
||||
$this->config = AgentConfig::load($configPath);
|
||||
$this->http = new HttpJsonClient((string)$this->config->get('apiUrl'));
|
||||
$this->installDir = rtrim((string)$this->config->get('installDir', dirname($configPath)), DIRECTORY_SEPARATOR);
|
||||
$this->runtimeDir = $this->installDir . DIRECTORY_SEPARATOR . 'runtime';
|
||||
@mkdir($this->runtimeDir, 0777, true);
|
||||
$this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log');
|
||||
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
|
||||
$this->lastOperationSnapshotPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-operation.json';
|
||||
$this->lastHeartbeatMarkerPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-heartbeat-ok.txt';
|
||||
$this->agentInstanceId = $this->ensureAgentInstanceId();
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->logger->info('Truckwash PHP edge agent starting.');
|
||||
$this->recoverPreviousOperationState();
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$this->ensureClaimed();
|
||||
$this->heartbeat();
|
||||
$processedManagementOperation = $this->processManagementOperation();
|
||||
if (!$processedManagementOperation) {
|
||||
$this->processCommandQueue();
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
$this->logger->error($throwable->getMessage());
|
||||
sleep(2);
|
||||
}
|
||||
|
||||
usleep(250000);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureClaimed(): void
|
||||
{
|
||||
if ($this->config->get('gatewayId') && $this->config->get('agentToken')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->http->post('/edge-agent/claim', [
|
||||
'token' => (string)$this->config->get('installToken'),
|
||||
'hostname' => gethostname() ?: 'truckwash-edge',
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'),
|
||||
'metadata' => [
|
||||
'runtime' => 'php-cli',
|
||||
'php_version' => PHP_VERSION,
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
],
|
||||
]);
|
||||
|
||||
$payload = $response['data'] ?? [];
|
||||
$gateway = $payload['gateway'] ?? [];
|
||||
$installedVersion = (string)$this->config->get('installedVersion', 'php-agent-v1');
|
||||
$this->config->set('gatewayId', $gateway['id'] ?? null);
|
||||
$this->config->set('agentToken', $payload['agent_token'] ?? null);
|
||||
$this->config->set('installedVersion', $installedVersion);
|
||||
$this->config->set('targetVersion', (string)($gateway['target_version'] ?? $installedVersion));
|
||||
$this->config->set('agentInstanceId', $this->agentInstanceId);
|
||||
$this->config->save();
|
||||
$this->logger->info('Claimed gateway ' . (string)($gateway['id'] ?? 'unknown') . '.');
|
||||
}
|
||||
|
||||
private function heartbeat(bool $force = false, array $metadata = []): void
|
||||
{
|
||||
$interval = (int)$this->config->get('heartbeatIntervalSeconds', 15);
|
||||
if (!$force && (time() - $this->lastHeartbeatAt) < max(5, $interval)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$gatewayId = (int)$this->config->get('gatewayId');
|
||||
if ($gatewayId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operationState = $this->readOperationState();
|
||||
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/heartbeat', [
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'status' => 'ONLINE',
|
||||
'hostname' => gethostname() ?: 'truckwash-edge',
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'),
|
||||
'target_version' => (string)$this->config->get('targetVersion', $this->config->get('installedVersion', 'php-agent-v1')),
|
||||
'metadata' => array_merge([
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
'current_operation' => $operationState,
|
||||
'system_metrics' => [
|
||||
'memory_usage_bytes' => memory_get_usage(true),
|
||||
'memory_peak_bytes' => memory_get_peak_usage(true),
|
||||
'load_average' => function_exists('sys_getloadavg') ? sys_getloadavg() : [],
|
||||
],
|
||||
], $metadata),
|
||||
]);
|
||||
|
||||
$this->lastHeartbeatAt = time();
|
||||
file_put_contents($this->lastHeartbeatMarkerPath, json_encode([
|
||||
'gateway_id' => $gatewayId,
|
||||
'at' => date('c'),
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
}
|
||||
|
||||
private function processCommandQueue(): void
|
||||
{
|
||||
$gatewayId = (int)$this->config->get('gatewayId');
|
||||
$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),
|
||||
]);
|
||||
$command = $response['data'] ?? null;
|
||||
if (!is_array($command) || empty($command['id'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$jobId = (int)$command['id'];
|
||||
$type = (string)($command['command_type'] ?? $command['commandType'] ?? '');
|
||||
$request = is_array($command['payload'] ?? null) ? (array)$command['payload'] : [];
|
||||
|
||||
try {
|
||||
$result = match ($type) {
|
||||
'DISCOVER_SHELLY' => $this->runDiscovery(),
|
||||
'GET_RELAY_STATUS' => $this->readRelayStatus($request),
|
||||
'SET_RELAY_STATE' => $this->switchRelay($request),
|
||||
default => throw new RuntimeException('Unsupported command type: ' . $type),
|
||||
};
|
||||
|
||||
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'ok' => true,
|
||||
'result' => $result,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'ok' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function processManagementOperation(): bool
|
||||
{
|
||||
$gatewayId = (int)$this->config->get('gatewayId');
|
||||
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/next', [
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'wait_seconds' => (int)$this->config->get('operationPollTimeoutSeconds', 20),
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
]);
|
||||
$operation = $response['data'] ?? null;
|
||||
if (!is_array($operation) || empty($operation['id'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$operationId = (int)$operation['id'];
|
||||
$type = (string)($operation['type'] ?? '');
|
||||
$request = is_array($operation['request'] ?? null) ? (array)$operation['request'] : [];
|
||||
$this->persistOperationState([
|
||||
'gateway_id' => $gatewayId,
|
||||
'operation_id' => $operationId,
|
||||
'type' => $type,
|
||||
'status' => 'IN_PROGRESS',
|
||||
'stage' => 'claimed',
|
||||
'progress' => 5,
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
'started_at' => date('c'),
|
||||
'request' => $request,
|
||||
]);
|
||||
|
||||
$this->emitOperationProgress(
|
||||
$gatewayId,
|
||||
$operationId,
|
||||
'OPERATION_AGENT_STARTED',
|
||||
'PHP edge agent started processing the operation',
|
||||
10,
|
||||
['type' => $type, 'agent_instance_id' => $this->agentInstanceId],
|
||||
'starting'
|
||||
);
|
||||
|
||||
try {
|
||||
$result = match ($type) {
|
||||
'DISCOVERY' => $this->runManagementDiscovery($gatewayId, $operationId, $request),
|
||||
'UPDATE' => $this->runUpdate($gatewayId, $operationId, $request),
|
||||
'UNINSTALL' => $this->runUninstall($gatewayId, $operationId, $request),
|
||||
default => throw new RuntimeException('Unsupported operation type: ' . $type),
|
||||
};
|
||||
|
||||
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'ok' => true,
|
||||
'result' => $result,
|
||||
]);
|
||||
$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],
|
||||
]);
|
||||
|
||||
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'ok' => false,
|
||||
'error_code' => $errorCode,
|
||||
'error_message' => $throwable->getMessage(),
|
||||
]);
|
||||
$this->snapshotOperationState('FAILED', [
|
||||
'error_code' => $errorCode,
|
||||
'error_message' => $throwable->getMessage(),
|
||||
]);
|
||||
$this->clearOperationState();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function postOperationEvent(int $gatewayId, int $operationId, array $payload): void
|
||||
{
|
||||
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/events', [
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'level' => (string)($payload['level'] ?? 'INFO'),
|
||||
'code' => $payload['code'] ?? null,
|
||||
'message' => (string)($payload['message'] ?? 'Operation event'),
|
||||
'context' => is_array($payload['context'] ?? null) ? (array)$payload['context'] : [],
|
||||
]);
|
||||
}
|
||||
|
||||
private function emitOperationProgress(
|
||||
int $gatewayId,
|
||||
int $operationId,
|
||||
string $code,
|
||||
string $message,
|
||||
int $progress,
|
||||
array $context = [],
|
||||
?string $stage = null
|
||||
): void {
|
||||
$operationState = $this->readOperationState();
|
||||
if ($operationState !== null) {
|
||||
$operationState['progress'] = max(0, min(100, $progress));
|
||||
$operationState['stage'] = $stage ?? ($operationState['stage'] ?? 'running');
|
||||
$operationState['updated_at'] = date('c');
|
||||
$this->persistOperationState($operationState);
|
||||
}
|
||||
|
||||
$this->heartbeat(true, [
|
||||
'current_operation' => $this->readOperationState(),
|
||||
]);
|
||||
|
||||
$this->postOperationEvent($gatewayId, $operationId, [
|
||||
'level' => 'INFO',
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'context' => array_merge($context, [
|
||||
'progress' => max(0, min(100, $progress)),
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function runDiscovery(): array
|
||||
{
|
||||
return ['inventory' => []];
|
||||
}
|
||||
|
||||
private function runManagementDiscovery(int $gatewayId, int $operationId, array $request): array
|
||||
{
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COLLECTING', 'Collecting local gateway inventory', 35, [], 'discovering');
|
||||
$hostname = gethostname() ?: 'truckwash-edge';
|
||||
$fallbackInventory = [[
|
||||
'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10),
|
||||
'local_ip' => gethostbyname($hostname),
|
||||
'model' => 'TruckWash Edge Gateway',
|
||||
'channel_count' => 1,
|
||||
'online' => true,
|
||||
'capabilities' => [
|
||||
'local_discovery' => true,
|
||||
'relay_commands' => true,
|
||||
'gateway_management_v2' => true,
|
||||
],
|
||||
'metadata' => [
|
||||
'hostname' => $hostname,
|
||||
'runtime' => 'php-cli',
|
||||
'php_version' => PHP_VERSION,
|
||||
],
|
||||
]];
|
||||
|
||||
$inventory = isset($request['inventory']) && is_array($request['inventory']) && $request['inventory'] !== []
|
||||
? (array)$request['inventory']
|
||||
: $fallbackInventory;
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COMPLETED', 'Inventory collected', 90, [
|
||||
'device_count' => count($inventory),
|
||||
], 'finishing');
|
||||
|
||||
return ['inventory' => $inventory];
|
||||
}
|
||||
|
||||
private function runUpdate(int $gatewayId, int $operationId, array $request): array
|
||||
{
|
||||
$targetVersion = trim((string)($request['target_version'] ?? $request['targetVersion'] ?? ''));
|
||||
if ($targetVersion === '') {
|
||||
throw new RuntimeException('Update request is missing target version');
|
||||
}
|
||||
|
||||
$agentArtifactUrl = trim((string)($request['artifactUrl'] ?? ''));
|
||||
$serviceUnitUrl = trim((string)($request['serviceUnitUrl'] ?? ''));
|
||||
$agentArtifactSha = trim((string)($request['artifactSha256'] ?? ''));
|
||||
$serviceUnitSha = trim((string)($request['serviceUnitSha256'] ?? ''));
|
||||
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_VALIDATED', 'Validated update request', 20, [
|
||||
'target_version' => $targetVersion,
|
||||
], 'validating');
|
||||
|
||||
$changedFiles = [];
|
||||
if ($agentArtifactUrl !== '') {
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_AGENT', 'Downloading PHP edge agent artifact', 40, [
|
||||
'url' => $agentArtifactUrl,
|
||||
], 'downloading-agent');
|
||||
$changedFiles[] = $this->downloadToFileAtomic(
|
||||
$agentArtifactUrl,
|
||||
$this->installDir . DIRECTORY_SEPARATOR . 'agent.php',
|
||||
$agentArtifactSha
|
||||
);
|
||||
}
|
||||
|
||||
if ($serviceUnitUrl !== '') {
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_SERVICE', 'Downloading service unit', 65, [
|
||||
'url' => $serviceUnitUrl,
|
||||
], 'downloading-service');
|
||||
$changedFiles[] = $this->downloadToFileAtomic(
|
||||
$serviceUnitUrl,
|
||||
$this->installDir . DIRECTORY_SEPARATOR . 'truckwash-edge-agent.service',
|
||||
$serviceUnitSha
|
||||
);
|
||||
}
|
||||
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_WRITE_CONFIG', 'Writing staged update metadata', 85, [], 'writing-config');
|
||||
$this->config->set('installedVersion', $targetVersion);
|
||||
$this->config->set('targetVersion', $targetVersion);
|
||||
$this->config->set('lastStagedUpdate', [
|
||||
'target_version' => $targetVersion,
|
||||
'staged_at' => date('c'),
|
||||
'changed_files' => $changedFiles,
|
||||
]);
|
||||
$this->config->save();
|
||||
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_STAGED', 'Artifacts staged successfully', 95, [
|
||||
'changed_files' => $changedFiles,
|
||||
], 'staged');
|
||||
|
||||
return [
|
||||
'installed_version' => $targetVersion,
|
||||
'target_version' => $targetVersion,
|
||||
'restart_required' => true,
|
||||
'service_name' => (string)$this->config->get('serviceName', 'truckwash-edge-agent.service'),
|
||||
'changed_files' => $changedFiles,
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
];
|
||||
}
|
||||
|
||||
private function runUninstall(int $gatewayId, int $operationId, array $request): array
|
||||
{
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UNINSTALL_PREPARE', 'Preparing uninstall manifest', 40, [], 'preparing-uninstall');
|
||||
$manifestPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'uninstall-plan.json';
|
||||
$manifest = [
|
||||
'service_name' => (string)($request['service_name'] ?? $this->config->get('serviceName', 'truckwash-edge-agent.service')),
|
||||
'install_dir' => (string)($request['install_dir'] ?? $this->installDir),
|
||||
'generated_at' => date('c'),
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
];
|
||||
file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
file_put_contents($this->runtimeDir . DIRECTORY_SEPARATOR . 'uninstall.flag', date('c') . PHP_EOL);
|
||||
$this->config->set('pendingUninstall', $manifest);
|
||||
$this->config->save();
|
||||
|
||||
$this->emitOperationProgress($gatewayId, $operationId, 'UNINSTALL_READY', 'Gateway marked for controlled uninstall', 90, [
|
||||
'manifest_path' => $manifestPath,
|
||||
], 'uninstall-ready');
|
||||
|
||||
return [
|
||||
'uninstalled' => true,
|
||||
'service_name' => $manifest['service_name'],
|
||||
'install_dir' => $manifest['install_dir'],
|
||||
'manual_cleanup_required' => true,
|
||||
'cleanup_manifest_path' => $manifestPath,
|
||||
];
|
||||
}
|
||||
|
||||
private function readRelayStatus(array $request): array
|
||||
{
|
||||
$localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? '');
|
||||
$channel = (int)($request['channel'] ?? 0);
|
||||
return $this->fetchShellyState($localIp, $channel);
|
||||
}
|
||||
|
||||
private function switchRelay(array $request): array
|
||||
{
|
||||
$localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? '');
|
||||
$channel = (int)($request['channel'] ?? 0);
|
||||
$on = (bool)($request['on'] ?? false);
|
||||
|
||||
$rpcUrl = sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false');
|
||||
try {
|
||||
$this->http->getJson($rpcUrl, 8);
|
||||
} catch (Throwable) {
|
||||
$legacyUrl = sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off');
|
||||
$this->http->getJson($legacyUrl, 8);
|
||||
}
|
||||
|
||||
return $this->fetchShellyState($localIp, $channel);
|
||||
}
|
||||
|
||||
private function downloadToFileAtomic(string $url, string $path, string $expectedSha256 = ''): array
|
||||
{
|
||||
$directory = dirname($path);
|
||||
if (!is_dir($directory)) {
|
||||
@mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
$temporaryPath = $path . '.download';
|
||||
$backupPath = $path . '.bak';
|
||||
$raw = $this->http->download($url, 60);
|
||||
file_put_contents($temporaryPath, $raw);
|
||||
|
||||
if ($expectedSha256 !== '') {
|
||||
$actualSha = hash_file('sha256', $temporaryPath);
|
||||
if (!is_string($actualSha) || !hash_equals(strtolower($expectedSha256), strtolower($actualSha))) {
|
||||
@unlink($temporaryPath);
|
||||
throw new RuntimeException('Artifact checksum mismatch for ' . basename($path));
|
||||
}
|
||||
}
|
||||
|
||||
if (is_file($backupPath)) {
|
||||
@unlink($backupPath);
|
||||
}
|
||||
|
||||
if (is_file($path) && !@rename($path, $backupPath)) {
|
||||
@unlink($temporaryPath);
|
||||
throw new RuntimeException('Could not create backup for ' . $path);
|
||||
}
|
||||
|
||||
if (!@rename($temporaryPath, $path)) {
|
||||
if (is_file($backupPath)) {
|
||||
@rename($backupPath, $path);
|
||||
}
|
||||
@unlink($temporaryPath);
|
||||
throw new RuntimeException('Could not promote downloaded artifact for ' . $path);
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'url' => $url,
|
||||
'backup_path' => is_file($backupPath) ? $backupPath : null,
|
||||
'sha256' => hash_file('sha256', $path),
|
||||
];
|
||||
}
|
||||
|
||||
private function classifyManagementError(Throwable $throwable, string $type): string
|
||||
{
|
||||
$message = strtolower(trim($throwable->getMessage()));
|
||||
if (str_contains($message, 'token') || str_contains($message, 'credential')) {
|
||||
return 'EDGE_GATEWAY_INVALID_TOKEN';
|
||||
}
|
||||
if (str_contains($message, 'timeout') || str_contains($message, 'http 5')) {
|
||||
return 'EDGE_GATEWAY_OPERATION_TIMEOUT';
|
||||
}
|
||||
if ($type === 'UPDATE' && (str_contains($message, 'version') || str_contains($message, 'checksum'))) {
|
||||
return 'EDGE_GATEWAY_UNSUPPORTED_VERSION';
|
||||
}
|
||||
|
||||
return 'EDGE_GATEWAY_VALIDATION_FAILED';
|
||||
}
|
||||
|
||||
private function fetchShellyState(string $localIp, int $channel): array
|
||||
{
|
||||
if ($localIp === '') {
|
||||
throw new RuntimeException('Missing Shelly IP address');
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->http->getJson(sprintf('http://%s/rpc/Switch.GetStatus?id=%d', $localIp, $channel), 8);
|
||||
return [
|
||||
'online' => true,
|
||||
'on' => (bool)($payload['output'] ?? false),
|
||||
'output' => (bool)($payload['output'] ?? false),
|
||||
'raw' => $payload,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
$payload = $this->http->getJson(sprintf('http://%s/relay/%d', $localIp, $channel), 8);
|
||||
return [
|
||||
'online' => true,
|
||||
'on' => (bool)($payload['ison'] ?? false),
|
||||
'output' => (bool)($payload['ison'] ?? false),
|
||||
'raw' => $payload,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureAgentInstanceId(): string
|
||||
{
|
||||
$configured = trim((string)$this->config->get('agentInstanceId', ''));
|
||||
if ($configured !== '') {
|
||||
return substr($configured, 0, 128);
|
||||
}
|
||||
|
||||
$generated = sprintf(
|
||||
'%s-%s',
|
||||
preg_replace('/[^A-Za-z0-9\-]+/', '-', gethostname() ?: 'truckwash-edge') ?: 'truckwash-edge',
|
||||
substr(bin2hex(random_bytes(6)), 0, 12)
|
||||
);
|
||||
$this->config->set('agentInstanceId', $generated);
|
||||
$this->config->save();
|
||||
return $generated;
|
||||
}
|
||||
|
||||
private function recoverPreviousOperationState(): void
|
||||
{
|
||||
$state = $this->readOperationState();
|
||||
if ($state === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->warning(sprintf(
|
||||
'Recovered previous unfinished operation snapshot: operation %s (%s) last stage %s.',
|
||||
(string)($state['operation_id'] ?? 'unknown'),
|
||||
(string)($state['type'] ?? 'unknown'),
|
||||
(string)($state['stage'] ?? 'unknown')
|
||||
));
|
||||
}
|
||||
|
||||
private function persistOperationState(array $state): void
|
||||
{
|
||||
file_put_contents($this->statePath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
}
|
||||
|
||||
private function snapshotOperationState(string $status, array $extra = []): void
|
||||
{
|
||||
$state = $this->readOperationState() ?? [];
|
||||
$state['status'] = $status;
|
||||
$state['finished_at'] = date('c');
|
||||
$state = array_merge($state, $extra);
|
||||
file_put_contents($this->lastOperationSnapshotPath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
}
|
||||
|
||||
private function clearOperationState(): void
|
||||
{
|
||||
if (is_file($this->statePath)) {
|
||||
@unlink($this->statePath);
|
||||
}
|
||||
}
|
||||
|
||||
private function readOperationState(): ?array
|
||||
{
|
||||
if (!is_file($this->statePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)file_get_contents($this->statePath), true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
}
|
||||
|
||||
$configPath = null;
|
||||
foreach ($argv as $index => $argument) {
|
||||
if ($argument === '--config' && isset($argv[$index + 1])) {
|
||||
$configPath = $argv[$index + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_string($configPath) || trim($configPath) === '') {
|
||||
fwrite(STDERR, "Usage: agent.php --config /path/to/config.json\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
(new TruckwashEdgeAgent($configPath))->run();
|
||||
Reference in New Issue
Block a user