Remove edge_broker_client.php and related unit tests. Add new dependencies and configuration files for edge-agent, including build scripts and package-lock updates.
This commit is contained in:
@@ -10,6 +10,8 @@ use objects\edge_gateway_claim_tokens_o;
|
||||
use objects\edge_gateway_command_jobs_o;
|
||||
use objects\edge_gateway_device_inventory_o;
|
||||
use objects\edge_gateway_relay_bindings_o;
|
||||
use objects\edge_gateway_shell_action_jobs_o;
|
||||
use objects\edge_gateway_shell_events_o;
|
||||
use objects\edge_gateway_shell_sessions_o;
|
||||
use objects\edge_gateway_update_jobs_o;
|
||||
use objects\edge_gateways_o;
|
||||
@@ -32,8 +34,12 @@ class edge_gateway_manager
|
||||
public const COMMAND_POLL_TIMEOUT_SECONDS = 20;
|
||||
public const COMMAND_POLL_INTERVAL_MICROSECONDS = 250000;
|
||||
public const COMMAND_DISPATCH_STALE_AFTER_SECONDS = 30;
|
||||
public const SHELL_ACTION_POLL_TIMEOUT_SECONDS = 20;
|
||||
public const SHELL_ACTION_STALE_AFTER_SECONDS = 30;
|
||||
public const SHELL_EVENT_POLL_TIMEOUT_SECONDS = 5;
|
||||
public const SHELL_EVENT_POLL_LIMIT = 200;
|
||||
|
||||
public function __construct(private readonly ?edge_broker_client $brokerClient = null)
|
||||
public function __construct()
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
}
|
||||
@@ -118,7 +124,6 @@ class edge_gateway_manager
|
||||
return [
|
||||
'gateway' => $this->getGateway($gatewayId),
|
||||
'agent_token' => $agentToken,
|
||||
'broker_url' => $this->getBrokerPublicUrl(),
|
||||
'heartbeat_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/heartbeat',
|
||||
'release_channel' => (string)$gateway->release_channel->value(),
|
||||
];
|
||||
@@ -443,8 +448,23 @@ class edge_gateway_manager
|
||||
*/
|
||||
public function createShellSession(int $gatewayId, string $reason, ?int $userId = null): array
|
||||
{
|
||||
$gateway = $this->requireGateway($gatewayId);
|
||||
return $this->createShellSessionRequest($gatewayId, $reason, [], $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function createShellSessionRequest(int $gatewayId, string $reason, array $options = [], ?int $userId = null): array
|
||||
{
|
||||
$gateway = $this->requireDispatchableGateway($gatewayId);
|
||||
$sessionToken = bin2hex(random_bytes(32));
|
||||
$metadata = [
|
||||
'ttl_seconds' => self::SHELL_SESSION_TTL_SECONDS,
|
||||
'transport' => 'API_POLLING',
|
||||
];
|
||||
|
||||
$cols = isset($options['cols']) ? (int)$options['cols'] : null;
|
||||
$rows = isset($options['rows']) ? (int)$options['rows'] : null;
|
||||
|
||||
$sessionObject = new edge_gateway_shell_sessions_o();
|
||||
$sessionId = $sessionObject->add_object([
|
||||
@@ -456,11 +476,20 @@ class edge_gateway_manager
|
||||
'approved_by' => $userId,
|
||||
'approved_at' => $this->now(),
|
||||
'expires_at' => $this->formatDateTime(time() + self::SHELL_SESSION_TTL_SECONDS),
|
||||
'metadata_json' => [
|
||||
'ttl_seconds' => self::SHELL_SESSION_TTL_SECONDS,
|
||||
],
|
||||
'metadata_json' => $metadata,
|
||||
]);
|
||||
$sessionObject->select($sessionId);
|
||||
$this->createShellActionJob(
|
||||
(int)$sessionObject->id,
|
||||
$gatewayId,
|
||||
'OPEN',
|
||||
array_filter([
|
||||
'reason' => $reason,
|
||||
'cols' => $cols,
|
||||
'rows' => $rows,
|
||||
], static fn(mixed $value): bool => $value !== null && $value !== ''),
|
||||
$userId
|
||||
);
|
||||
|
||||
$this->writeAudit(
|
||||
$gatewayId,
|
||||
@@ -473,7 +502,7 @@ class edge_gateway_manager
|
||||
return [
|
||||
'session' => $sessionObject->asArray(),
|
||||
'session_token' => $sessionToken,
|
||||
'websocket_url' => $this->buildBrowserShellWsUrl($sessionToken),
|
||||
'transport' => 'API_POLLING',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -527,6 +556,225 @@ class edge_gateway_manager
|
||||
return $session->asArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getShellSession(int $gatewayId, int $sessionId): array
|
||||
{
|
||||
return $this->requireShellSessionForGateway($gatewayId, $sessionId)->asArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function pollShellSessionEvents(
|
||||
int $gatewayId,
|
||||
int $sessionId,
|
||||
int $afterId = 0,
|
||||
int $waitSeconds = self::SHELL_EVENT_POLL_TIMEOUT_SECONDS
|
||||
): array {
|
||||
$session = $this->requireShellSessionForGateway($gatewayId, $sessionId);
|
||||
$deadline = microtime(true) + max(0, $waitSeconds);
|
||||
$cursor = max(0, $afterId);
|
||||
|
||||
do {
|
||||
$session = (new edge_gateway_shell_sessions_o())->select($sessionId);
|
||||
$events = $this->listShellEvents($sessionId, $cursor, self::SHELL_EVENT_POLL_LIMIT);
|
||||
if ($events !== [] || $session->closed_at->value() !== null) {
|
||||
return [
|
||||
'session' => $session->asArray(),
|
||||
'events' => $events,
|
||||
];
|
||||
}
|
||||
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
|
||||
usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS);
|
||||
} while (true);
|
||||
|
||||
return [
|
||||
'session' => $session->asArray(),
|
||||
'events' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function enqueueShellInput(int $gatewayId, int $sessionId, string $data, ?int $userId = null): array
|
||||
{
|
||||
$session = $this->requireActiveShellSessionForGateway($gatewayId, $sessionId);
|
||||
$this->createShellActionJob(
|
||||
$sessionId,
|
||||
$gatewayId,
|
||||
'INPUT',
|
||||
['data' => $data],
|
||||
$userId
|
||||
);
|
||||
|
||||
return $session->asArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function enqueueShellResize(int $gatewayId, int $sessionId, int $cols, int $rows, ?int $userId = null): array
|
||||
{
|
||||
$session = $this->requireActiveShellSessionForGateway($gatewayId, $sessionId);
|
||||
$this->createShellActionJob(
|
||||
$sessionId,
|
||||
$gatewayId,
|
||||
'RESIZE',
|
||||
[
|
||||
'cols' => max(1, $cols),
|
||||
'rows' => max(1, $rows),
|
||||
],
|
||||
$userId
|
||||
);
|
||||
|
||||
return $session->asArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function requestShellSessionClose(int $gatewayId, int $sessionId, ?int $userId = null): array
|
||||
{
|
||||
$session = $this->requireShellSessionForGateway($gatewayId, $sessionId);
|
||||
$metadata = (array)($session->metadata_json->value() ?? []);
|
||||
$metadata['close_requested_by'] = $userId;
|
||||
$session->metadata_json->set($metadata);
|
||||
|
||||
$wasClosed = $session->closed_at->value() !== null;
|
||||
if (!$wasClosed) {
|
||||
$session->approval_status->set('CLOSED');
|
||||
$session->closed_at->set($this->now());
|
||||
$this->writeAudit(
|
||||
(int)$session->gateway_id->value(),
|
||||
null,
|
||||
'ROOT_SHELL_CLOSED',
|
||||
$userId,
|
||||
['session_id' => (int)$session->id, 'closed_reason' => 'browser_requested']
|
||||
);
|
||||
}
|
||||
|
||||
$this->cancelPendingShellActionJobs($sessionId);
|
||||
$this->createShellActionJob(
|
||||
$sessionId,
|
||||
$gatewayId,
|
||||
'CLOSE',
|
||||
['reason' => 'browser_requested'],
|
||||
$userId
|
||||
);
|
||||
$this->appendShellEvent(
|
||||
(int)$session->gateway_id->value(),
|
||||
(int)$session->id,
|
||||
'CLOSED',
|
||||
['reason' => 'browser_requested']
|
||||
);
|
||||
|
||||
return $session->asArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function pollShellAction(
|
||||
int $gatewayId,
|
||||
string $plainToken,
|
||||
int $waitSeconds = self::SHELL_ACTION_POLL_TIMEOUT_SECONDS
|
||||
): ?array {
|
||||
$gateway = $this->authenticateGateway($gatewayId, $plainToken);
|
||||
$deadline = microtime(true) + max(0, $waitSeconds);
|
||||
|
||||
do {
|
||||
$job = $this->claimNextShellActionJob($gateway);
|
||||
if ($job !== null) {
|
||||
return $this->formatAgentShellAction($job);
|
||||
}
|
||||
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
|
||||
usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS);
|
||||
} while (true);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function submitShellActionResult(
|
||||
int $gatewayId,
|
||||
int $actionId,
|
||||
string $plainToken,
|
||||
bool $ok,
|
||||
?string $error = null
|
||||
): array {
|
||||
$gateway = $this->authenticateGateway($gatewayId, $plainToken);
|
||||
$job = (new edge_gateway_shell_action_jobs_o())->select($actionId);
|
||||
if (!$job->exists()) {
|
||||
throw new Exception('Edge gateway shell action job not found');
|
||||
}
|
||||
if ((int)$job->gateway_id->value() !== (int)$gateway->id) {
|
||||
throw new Exception('Edge gateway shell action job does not belong to this gateway');
|
||||
}
|
||||
|
||||
$status = (string)$job->status->value();
|
||||
if (in_array($status, ['COMPLETED', 'FAILED'], true)) {
|
||||
return [
|
||||
'acknowledged' => true,
|
||||
'job' => $job->asArray(),
|
||||
];
|
||||
}
|
||||
|
||||
$job->status->set($ok ? 'COMPLETED' : 'FAILED');
|
||||
$job->completed_at->set($this->now());
|
||||
$job->error_message->set($ok ? null : (trim((string)$error) !== '' ? trim((string)$error) : 'Edge gateway shell action failed'));
|
||||
|
||||
return [
|
||||
'acknowledged' => true,
|
||||
'job' => $job->asArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $events
|
||||
* @return array<string,mixed>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function recordShellSessionEvents(
|
||||
int $gatewayId,
|
||||
int $sessionId,
|
||||
string $plainToken,
|
||||
array $events
|
||||
): array {
|
||||
$gateway = $this->authenticateGateway($gatewayId, $plainToken);
|
||||
$session = $this->requireShellSessionForGateway((int)$gateway->id, $sessionId);
|
||||
$recordedEvents = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
if (!is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recorded = $this->applyShellSessionEvent($session, $event);
|
||||
if ($recorded !== null) {
|
||||
$recordedEvents[] = $recorded;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'session' => $session->asArray(),
|
||||
'events' => $recordedEvents,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -561,8 +809,6 @@ class edge_gateway_manager
|
||||
'channel' => (int)$binding['channel'],
|
||||
], null);
|
||||
|
||||
$this->tryImmediateBrokerDispatch($job, $gateway);
|
||||
|
||||
return $this->waitForCommandResult((int)$job->id);
|
||||
}
|
||||
|
||||
@@ -581,8 +827,6 @@ class edge_gateway_manager
|
||||
'on' => $on,
|
||||
], null);
|
||||
|
||||
$this->tryImmediateBrokerDispatch($job, $gateway);
|
||||
|
||||
return $this->waitForCommandResult((int)$job->id);
|
||||
}
|
||||
|
||||
@@ -627,11 +871,12 @@ class edge_gateway_manager
|
||||
{
|
||||
$configJson = json_encode([
|
||||
'apiUrl' => $this->getApiBaseUrl(),
|
||||
'brokerUrl' => $this->getBrokerPublicUrl(),
|
||||
'installToken' => $plainToken,
|
||||
'gatewayId' => null,
|
||||
'agentToken' => null,
|
||||
'heartbeatIntervalSeconds' => 15,
|
||||
'commandPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS,
|
||||
'shellActionPollTimeoutSeconds' => self::SHELL_ACTION_POLL_TIMEOUT_SECONDS,
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$script = <<<'BASH'
|
||||
@@ -698,38 +943,6 @@ BASH;
|
||||
];
|
||||
}
|
||||
|
||||
public function buildBrowserShellWsUrl(string $sessionToken): ?string
|
||||
{
|
||||
$brokerUrl = $this->getBrokerPublicUrl();
|
||||
if ($brokerUrl === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parsed = parse_url($brokerUrl);
|
||||
if (!is_array($parsed) || !isset($parsed['host'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scheme = (($parsed['scheme'] ?? 'http') === 'https') ? 'wss' : 'ws';
|
||||
$url = $scheme . '://' . $parsed['host'];
|
||||
if (isset($parsed['port'])) {
|
||||
$url .= ':' . $parsed['port'];
|
||||
}
|
||||
$url .= '/ws/browser-shell?token=' . urlencode($sessionToken);
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
public function getBrokerPublicUrl(): ?string
|
||||
{
|
||||
$configured = trim((string)(getenv('EDGE_BROKER_PUBLIC_URL') ?: ''));
|
||||
if ($configured !== '') {
|
||||
return $this->normalizeBrokerPublicUrl($configured);
|
||||
}
|
||||
|
||||
return $this->buildNormalizedBrokerOrigin($this->getApiBaseUrl());
|
||||
}
|
||||
|
||||
public function getApiBaseUrl(): string
|
||||
{
|
||||
$configured = trim((string)(getenv('EDGE_PUBLIC_API_URL') ?: ''));
|
||||
@@ -802,55 +1015,6 @@ BASH;
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeBrokerPublicUrl(string $url): string
|
||||
{
|
||||
$normalized = $this->buildNormalizedBrokerOrigin($url);
|
||||
return $normalized ?? trim($url);
|
||||
}
|
||||
|
||||
private function buildNormalizedBrokerOrigin(string $url): ?string
|
||||
{
|
||||
$parsed = parse_url(trim($url));
|
||||
if (!is_array($parsed) || !isset($parsed['host'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = (string)$parsed['host'];
|
||||
$scheme = strtolower((string)($parsed['scheme'] ?? ''));
|
||||
if ($scheme === '' || ($scheme === 'http' && $this->shouldUseSecureBrokerScheme($host))) {
|
||||
$scheme = $this->shouldUseSecureBrokerScheme($host) ? 'https' : 'http';
|
||||
}
|
||||
|
||||
$normalized = $scheme . '://' . $host;
|
||||
if (isset($parsed['port'])) {
|
||||
$normalized .= ':' . $parsed['port'];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function shouldUseSecureBrokerScheme(string $host): bool
|
||||
{
|
||||
$normalized = strtolower(trim($host, '[]'));
|
||||
if ($normalized === '' || $normalized === 'localhost' || $normalized === 'edge-broker') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_ends_with($normalized, '.localhost')
|
||||
|| str_ends_with($normalized, '.local')
|
||||
|| str_ends_with($normalized, '.lan')
|
||||
|| str_ends_with($normalized, '.internal')
|
||||
|| str_ends_with($normalized, '.home.arpa')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter_var($normalized, FILTER_VALIDATE_IP) !== false) {
|
||||
return filter_var($normalized, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
|
||||
}
|
||||
|
||||
return str_contains($normalized, '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -982,38 +1146,262 @@ BASH;
|
||||
return $jobObject->select($jobId);
|
||||
}
|
||||
|
||||
private function createShellActionJob(
|
||||
int $sessionId,
|
||||
int $gatewayId,
|
||||
string $actionType,
|
||||
array $payload,
|
||||
?int $userId
|
||||
): edge_gateway_shell_action_jobs_o {
|
||||
$jobObject = new edge_gateway_shell_action_jobs_o();
|
||||
$jobId = $jobObject->add_object([
|
||||
'gateway_id' => $gatewayId,
|
||||
'session_id' => $sessionId,
|
||||
'action_type' => strtoupper($actionType),
|
||||
'status' => 'PENDING',
|
||||
'payload_json' => $payload,
|
||||
'requested_by' => $userId,
|
||||
'requested_at' => $this->now(),
|
||||
]);
|
||||
|
||||
return $jobObject->select($jobId);
|
||||
}
|
||||
|
||||
private function claimNextShellActionJob(edge_gateways_o $gateway): ?edge_gateway_shell_action_jobs_o
|
||||
{
|
||||
$pdo = db::getPDO();
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$statement = $pdo->prepare(
|
||||
'SELECT id
|
||||
FROM edge_gateway_shell_action_jobs
|
||||
WHERE gateway_id = :gateway_id
|
||||
AND deleted_at IS NULL
|
||||
AND (
|
||||
status = :pending_status_match
|
||||
OR (
|
||||
status = :dispatching_status_match
|
||||
AND COALESCE(updated_at, created_at, requested_at) <= :stale_before
|
||||
)
|
||||
)
|
||||
ORDER BY CASE WHEN status = :pending_status_order THEN 0 ELSE 1 END, requested_at ASC, id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE'
|
||||
);
|
||||
$statement->execute([
|
||||
':gateway_id' => (int)$gateway->id,
|
||||
':pending_status_match' => 'PENDING',
|
||||
':dispatching_status_match' => 'DISPATCHING',
|
||||
':pending_status_order' => 'PENDING',
|
||||
':stale_before' => $this->formatDateTime(time() - self::SHELL_ACTION_STALE_AFTER_SECONDS),
|
||||
]);
|
||||
|
||||
$row = $statement->fetch();
|
||||
if (!is_array($row) || !isset($row['id'])) {
|
||||
$pdo->commit();
|
||||
return null;
|
||||
}
|
||||
|
||||
$update = $pdo->prepare(
|
||||
'UPDATE edge_gateway_shell_action_jobs
|
||||
SET status = :status,
|
||||
error_message = NULL,
|
||||
completed_at = NULL
|
||||
WHERE id = :id'
|
||||
);
|
||||
$update->execute([
|
||||
':status' => 'DISPATCHING',
|
||||
':id' => (int)$row['id'],
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
return (new edge_gateway_shell_action_jobs_o())->select((int)$row['id']);
|
||||
}
|
||||
|
||||
private function formatAgentShellAction(edge_gateway_shell_action_jobs_o $job): array
|
||||
{
|
||||
$session = $this->requireShellSessionForGateway((int)$job->gateway_id->value(), (int)$job->session_id->value());
|
||||
|
||||
return [
|
||||
'id' => (int)$job->id,
|
||||
'gateway_id' => (int)$job->gateway_id->value(),
|
||||
'session_id' => (int)$job->session_id->value(),
|
||||
'action_type' => (string)$job->action_type->value(),
|
||||
'actionType' => (string)$job->action_type->value(),
|
||||
'payload' => array_merge([
|
||||
'sessionId' => (int)$session->id,
|
||||
'reason' => (string)$session->reason->value(),
|
||||
'expiresAt' => (string)$session->expires_at->value(),
|
||||
], (array)($job->payload_json->value() ?? [])),
|
||||
'requested_at' => (string)$job->requested_at->value(),
|
||||
];
|
||||
}
|
||||
|
||||
private function applyShellSessionEvent(edge_gateway_shell_sessions_o $session, array $event): ?array
|
||||
{
|
||||
$type = strtoupper(trim((string)($event['type'] ?? $event['event_type'] ?? '')));
|
||||
if ($type === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = isset($event['payload']) && is_array($event['payload']) ? (array)$event['payload'] : [];
|
||||
if (array_key_exists('data', $event) && !array_key_exists('data', $payload)) {
|
||||
$payload['data'] = (string)$event['data'];
|
||||
}
|
||||
if (array_key_exists('code', $event) && !array_key_exists('code', $payload)) {
|
||||
$payload['code'] = (int)$event['code'];
|
||||
}
|
||||
if (array_key_exists('reason', $event) && !array_key_exists('reason', $payload)) {
|
||||
$payload['reason'] = (string)$event['reason'];
|
||||
}
|
||||
|
||||
if ($type === 'OPENED') {
|
||||
if ($session->opened_at->value() === null) {
|
||||
$session->opened_at->set($this->now());
|
||||
}
|
||||
$session->approval_status->set('OPEN');
|
||||
return $this->appendShellEvent((int)$session->gateway_id->value(), (int)$session->id, $type, $payload);
|
||||
}
|
||||
|
||||
if ($type === 'OUTPUT') {
|
||||
$data = (string)($payload['data'] ?? '');
|
||||
if ($data === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$session->transcript_text->set((string)($session->transcript_text->value() ?? '') . $data);
|
||||
return $this->appendShellEvent(
|
||||
(int)$session->gateway_id->value(),
|
||||
(int)$session->id,
|
||||
$type,
|
||||
['data' => $data]
|
||||
);
|
||||
}
|
||||
|
||||
if ($type === 'CLOSED') {
|
||||
$metadata = (array)($session->metadata_json->value() ?? []);
|
||||
$closedReason = trim((string)($payload['reason'] ?? 'agent_closed'));
|
||||
$metadata['closed_reason'] = $closedReason !== '' ? $closedReason : 'agent_closed';
|
||||
if (array_key_exists('code', $payload)) {
|
||||
$metadata['exit_code'] = (int)$payload['code'];
|
||||
}
|
||||
$session->metadata_json->set($metadata);
|
||||
|
||||
$wasClosed = $session->closed_at->value() !== null;
|
||||
$session->approval_status->set('CLOSED');
|
||||
if (!$wasClosed) {
|
||||
$session->closed_at->set($this->now());
|
||||
$this->writeAudit(
|
||||
(int)$session->gateway_id->value(),
|
||||
null,
|
||||
'ROOT_SHELL_CLOSED',
|
||||
null,
|
||||
['session_id' => (int)$session->id, 'closed_reason' => $metadata['closed_reason']]
|
||||
);
|
||||
}
|
||||
|
||||
$this->cancelPendingShellActionJobs((int)$session->id);
|
||||
return $this->appendShellEvent((int)$session->gateway_id->value(), (int)$session->id, $type, $payload);
|
||||
}
|
||||
|
||||
return $this->appendShellEvent((int)$session->gateway_id->value(), (int)$session->id, $type, $payload);
|
||||
}
|
||||
|
||||
private function appendShellEvent(int $gatewayId, int $sessionId, string $eventType, array $payload): array
|
||||
{
|
||||
$eventObject = new edge_gateway_shell_events_o();
|
||||
$eventId = $eventObject->add_object([
|
||||
'gateway_id' => $gatewayId,
|
||||
'session_id' => $sessionId,
|
||||
'event_type' => strtoupper($eventType),
|
||||
'payload_json' => $payload,
|
||||
]);
|
||||
|
||||
return $eventObject->select($eventId)->asArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function listShellEvents(int $sessionId, int $afterId = 0, int $limit = self::SHELL_EVENT_POLL_LIMIT): array
|
||||
{
|
||||
$safeAfterId = max(0, $afterId);
|
||||
$safeLimit = max(1, min(self::SHELL_EVENT_POLL_LIMIT, $limit));
|
||||
$statement = db::getPDO()->prepare(
|
||||
'SELECT id
|
||||
FROM edge_gateway_shell_events
|
||||
WHERE session_id = :session_id
|
||||
AND deleted_at IS NULL
|
||||
AND id > :after_id
|
||||
ORDER BY id ASC
|
||||
LIMIT ' . $safeLimit
|
||||
);
|
||||
$statement->execute([
|
||||
':session_id' => $sessionId,
|
||||
':after_id' => $safeAfterId,
|
||||
]);
|
||||
|
||||
$events = [];
|
||||
while (($row = $statement->fetch()) && is_array($row) && isset($row['id'])) {
|
||||
$events[] = (new edge_gateway_shell_events_o())->select((int)$row['id'])->asArray();
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function tryImmediateBrokerDispatch(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): void
|
||||
private function requireShellSessionForGateway(int $gatewayId, int $sessionId): edge_gateway_shell_sessions_o
|
||||
{
|
||||
$this->markCommandJobDispatching($job);
|
||||
|
||||
try {
|
||||
$response = $this->broker()->dispatchCommand(
|
||||
(int)$gateway->id,
|
||||
(string)$job->command_type->value(),
|
||||
$this->buildCommandExecutionPayload($job, $gateway)
|
||||
);
|
||||
|
||||
$ok = (bool)($response['ok'] ?? false);
|
||||
$payload = (array)($response['payload'] ?? []);
|
||||
$errorMessage = $ok ? null : trim((string)($response['error'] ?? 'Edge broker command failed'));
|
||||
|
||||
$this->finalizeCommandJob($job, $ok, $payload, $errorMessage, $gateway);
|
||||
|
||||
if (!$ok) {
|
||||
throw new Exception($errorMessage ?: 'Edge broker command failed');
|
||||
}
|
||||
} catch (\Throwable $throwable) {
|
||||
if ($this->shouldFallbackToQueuedDelivery($throwable)) {
|
||||
$this->releaseCommandJobToQueue($job);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->finalizeCommandJob($job, false, [], $throwable->getMessage(), $gateway);
|
||||
throw $throwable;
|
||||
$session = (new edge_gateway_shell_sessions_o())->select($sessionId);
|
||||
if (!$session->exists() || $session->deleted_at->value() !== null) {
|
||||
throw new Exception('Edge gateway shell session not found');
|
||||
}
|
||||
if ((int)$session->gateway_id->value() !== $gatewayId) {
|
||||
throw new Exception('Edge gateway shell session does not belong to this gateway');
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function requireActiveShellSessionForGateway(int $gatewayId, int $sessionId): edge_gateway_shell_sessions_o
|
||||
{
|
||||
$session = $this->requireShellSessionForGateway($gatewayId, $sessionId);
|
||||
if ($session->closed_at->value() !== null) {
|
||||
throw new Exception('Edge gateway shell session is already closed');
|
||||
}
|
||||
if (strtotime((string)$session->expires_at->value()) < time()) {
|
||||
throw new Exception('Edge gateway shell session has expired');
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
private function cancelPendingShellActionJobs(int $sessionId): void
|
||||
{
|
||||
$statement = db::getPDO()->prepare(
|
||||
'UPDATE edge_gateway_shell_action_jobs
|
||||
SET deleted_at = :deleted_at
|
||||
WHERE session_id = :session_id
|
||||
AND deleted_at IS NULL
|
||||
AND status IN (\'PENDING\', \'DISPATCHING\')'
|
||||
);
|
||||
$statement->execute([
|
||||
':deleted_at' => $this->now(),
|
||||
':session_id' => $sessionId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1186,39 +1574,6 @@ BASH;
|
||||
}
|
||||
}
|
||||
|
||||
private function releaseCommandJobToQueue(edge_gateway_command_jobs_o $job): void
|
||||
{
|
||||
$job->response_json->set([]);
|
||||
$job->error_message->set(null);
|
||||
$job->completed_at->set(null);
|
||||
$job->status->set('PENDING');
|
||||
}
|
||||
|
||||
private function markCommandJobDispatching(edge_gateway_command_jobs_o $job): void
|
||||
{
|
||||
$job->response_json->set([]);
|
||||
$job->error_message->set(null);
|
||||
$job->completed_at->set(null);
|
||||
$job->status->set('DISPATCHING');
|
||||
}
|
||||
|
||||
private function shouldFallbackToQueuedDelivery(\Throwable $throwable): bool
|
||||
{
|
||||
if ($throwable instanceof edge_broker_transport_exception) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($throwable instanceof edge_broker_http_exception) {
|
||||
return $throwable->statusCode() === 503;
|
||||
}
|
||||
|
||||
$message = $throwable->getMessage();
|
||||
return str_contains($message, 'Gateway agent is offline')
|
||||
|| str_contains($message, 'Could not resolve host:')
|
||||
|| str_contains($message, 'Failed to connect')
|
||||
|| str_contains($message, 'Connection refused');
|
||||
}
|
||||
|
||||
private function markLinkedUpdateJobStarted(int $commandJobId): void
|
||||
{
|
||||
$updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId);
|
||||
@@ -1443,11 +1798,6 @@ BASH;
|
||||
]);
|
||||
}
|
||||
|
||||
private function broker(): edge_broker_client
|
||||
{
|
||||
return $this->brokerClient ?? new edge_broker_client();
|
||||
}
|
||||
|
||||
private function hashToken(string $plainToken): string
|
||||
{
|
||||
return hash('sha256', $plainToken);
|
||||
|
||||
Reference in New Issue
Block a user