Add delivery metadata support, preferred channels, and enhanced agent validation

This commit introduces delivery metadata tracking for gateway commands, updates, and shells. Adds preferred delivery channel handling, refined validation for edge agents, improved relay management logic, and broker presence reporting. Includes schema changes, enhanced shell handling, and test coverage.
This commit is contained in:
Jeppe Bundgaard
2026-04-16 13:44:28 +02:00
parent d4d162d38a
commit d30a006457
16 changed files with 2188 additions and 160 deletions
@@ -19,6 +19,10 @@ it('queues admin commands, exposes agent poll/result handlers, and keeps heartbe
expect($source)->toContain("\$gateway->discovery_status->set('READY');");
expect($source)->toContain("\$gateway->discovery_status->set('FAILED');");
expect($source)->toContain("\$this->buildUpdateCommandPayload(\$targetVersion, \$releaseChannel)");
expect($source)->toContain("'UNINSTALL_AGENT'");
expect($source)->toContain("public function queueUninstall");
expect($source)->toContain("public function deleteGateway");
expect($source)->toContain("private function softDeleteGatewayRelations");
expect($source)->toContain("\$updateJob->status->set('DISPATCHING');");
expect($source)->toContain("\$updateJob->status->set('VERIFYING');");
expect($source)->toContain("\$updateJob->status->set(\$finalStatus ?? (\$ok ? 'COMPLETED' : 'FAILED'));");
@@ -33,6 +37,8 @@ it('defines the dispatchable gateway guard and api-polled shell queue on the loa
expect($reflection->getMethod('requireDispatchableGateway')->isPrivate())->toBeTrue();
expect($reflection->hasMethod('queueDiscovery'))->toBeTrue();
expect($reflection->hasMethod('queueUpdate'))->toBeTrue();
expect($reflection->hasMethod('queueUninstall'))->toBeTrue();
expect($reflection->hasMethod('deleteGateway'))->toBeTrue();
expect($reflection->hasMethod('buildUpdateCommandPayload'))->toBeTrue();
expect($reflection->hasMethod('applyHeartbeatUpdateLifecycle'))->toBeTrue();
expect($reflection->hasMethod('dispatchRelayStatus'))->toBeTrue();
@@ -67,3 +67,75 @@ it('merges incoming heartbeat metadata with existing gateway metadata', function
expect($source)->toContain("\$existingMetadata = (array)(\$gateway->metadata_json->value() ?? []);");
expect($source)->toContain("\$gateway->metadata_json->set(array_merge(\$existingMetadata, (array)(\$payload['metadata'] ?? [])));");
});
it('derives relay fallback and transport health details for degraded hybrid control planes', function (): void {
$gateway = edge_gateway_manager::deriveGatewayRuntimeState([
'status' => edge_gateway_manager::STATUS_ONLINE,
'last_heartbeat_at' => '2026-04-08 10:04:30',
'department_transport_mode' => edge_gateway_manager::TRANSPORT_MODE_GATEWAY,
'metadata' => [
'broker_presence' => [
'connected' => false,
'last_seen_at' => '2026-04-08 10:03:00',
'last_error' => 'broker timeout',
],
],
'operational_snapshot' => [
'command_backlog' => 2,
'shell_backlog' => 1,
'update_backlog' => 1,
],
'bindings' => [
[
'id' => 1,
'relay_id' => 'M-7',
'device_id' => 'shelly-plus-01',
'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL,
],
[
'id' => 2,
'relay_id' => 'M-7-CANARY',
'device_id' => 'missing-device',
'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_LOCAL_ONLY,
],
],
'inventory' => [
[
'device_id' => 'shelly-plus-01',
'online' => true,
'last_seen_at' => '2026-04-08 09:55:00',
],
],
'recent_commands' => [
[
'status' => 'COMPLETED',
'command_type' => 'DISCOVER_SHELLY',
'completed_at' => '2026-04-08 10:02:00',
],
],
'recent_shell_sessions' => [
[
'opened_at' => '2026-04-08 10:03:00',
],
],
], strtotime('2026-04-08 10:05:00'));
expect($gateway['channel_status']['command']['active'])->toBe(edge_gateway_manager::DELIVERY_CHANNEL_API);
expect($gateway['channel_status']['broker']['state'])->toBe(edge_gateway_manager::STATUS_OFFLINE);
expect($gateway['relay_health'][0]['execution_path'])->toBe('cloud');
expect($gateway['relay_health'][0]['reason'])->toBe('device_stale');
expect($gateway['relay_health'][0]['recommended_action'])->toBe('retry_discovery');
expect($gateway['relay_health'][1]['execution_path'])->toBe('local');
expect($gateway['relay_health'][1]['reason'])->toBe('device_missing');
expect($gateway['fallback_summary']['cloud_relays'])->toBe(1);
expect($gateway['fallback_summary']['local_only_relays'])->toBe(1);
expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED);
expect($gateway['transport_health']['recommended_action'])->toBe('retry_discovery');
expect($gateway['last_successful_discovery_at'])->toBe('2026-04-08 10:02:00');
expect($gateway['last_successful_shell_at'])->toBe('2026-04-08 10:03:00');
expect($gateway['backlog_depth'])->toBe([
'commands' => 2,
'shell_actions' => 1,
'updates' => 1,
]);
});
@@ -15,6 +15,7 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
{
$originalServer = $_SERVER;
$originalPublicApiUrl = getenv('EDGE_PUBLIC_API_URL');
$originalPublicBrokerUrl = getenv('EDGE_PUBLIC_BROKER_URL');
$_SERVER = $server;
@@ -27,6 +28,11 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
} else {
putenv('EDGE_PUBLIC_API_URL=' . $originalPublicApiUrl);
}
if ($originalPublicBrokerUrl === false) {
putenv('EDGE_PUBLIC_BROKER_URL');
} else {
putenv('EDGE_PUBLIC_BROKER_URL=' . $originalPublicBrokerUrl);
}
}
}
@@ -52,7 +58,7 @@ it('builds install script urls with forwarded https scheme when proxied', functi
expect($script)->toContain('"commandPollTimeoutSeconds":20');
expect($script)->toContain('"shellActionPollTimeoutSeconds":20');
expect($script)->toContain('"updateVerificationTimeoutSeconds":45');
expect($script)->not->toContain('"brokerUrl"');
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
expect($script)->not->toContain('Undefined variable $INSTALL_DIR');
});
});
@@ -80,7 +86,7 @@ it('infers https for the staging api host when only the https port is present',
expect($manager->getApiBaseUrl())->toBe('https://api.truckwash.io:4433');
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
expect($script)->not->toContain('"brokerUrl"');
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
});
});
@@ -90,11 +96,13 @@ it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
'HTTP_X_FORWARDED_PROTO' => 'http',
], function (): void {
putenv('EDGE_PUBLIC_API_URL=https://edge.example.test/api');
putenv('EDGE_PUBLIC_BROKER_URL=https://broker.edge.example.test');
$manager = new EdgeGatewayManagerUrlHarness();
expect($manager->getApiBaseUrl())->toBe('https://edge.example.test/api');
expect($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1');
expect($manager->buildInstallScript('token-1'))->toContain('"brokerUrl":"https://broker.edge.example.test"');
});
});
@@ -10,12 +10,14 @@ it('registers the edge gateway management REST endpoints', function (): void {
expect($route)->toContain("'/edge-gateways/{id}/discovery'");
expect($route)->toContain("'/edge-gateways/{id}/bindings'");
expect($route)->toContain("'/edge-gateways/{id}/update-jobs'");
expect($route)->toContain("'/edge-gateways/{id}/uninstall'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'");
expect($route)->toContain("'/edge-gateways/{id}/rotate-credentials'");
expect($route)->toContain("'/edge-gateways/{id}'");
expect($route)->toContain("'/departments/{id}/gateway-cutover'");
});
@@ -34,3 +36,15 @@ it('registers public installer, claim, heartbeat, command polling, and shell pol
expect($route)->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'");
expect($route)->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
});
it('registers authenticated internal broker validation and presence endpoints', function (): void {
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/validate'");
expect($route)->toContain("'/edge-agent/internal/shell-sessions/validate'");
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/presence'");
expect($route)->toContain("'/edge-agent/internal/shell-sessions/close'");
expect($route)->toContain('HTTP_X_EDGE_BROKER_SECRET');
expect($route)->toContain('EDGE_BROKER_SHARED_SECRET');
});
@@ -27,4 +27,6 @@ it('stores edge gateway heartbeats, bindings, shell transcripts, and shell polli
expect($bootstrapContent)->toContain('payload_json JSON NULL');
expect($bootstrapContent)->toContain('event_type VARCHAR(32) NOT NULL');
expect($bootstrapContent)->toContain('context_json JSON NULL');
expect(substr_count($bootstrapContent, 'delivery_json JSON NULL'))->toBeGreaterThanOrEqual(3);
expect($bootstrapContent)->toContain('ADD COLUMN delivery_json JSON NULL');
});
@@ -7,7 +7,10 @@ it('rewrites edge gateway shell transport to API polling queues', function (): v
expect($managerSource)->not->toBeFalse();
expect($routeSource)->not->toBeFalse();
expect($managerSource)->toContain("'transport' => 'API_POLLING'");
expect($managerSource)->toContain("'transport' => self::DELIVERY_CHANNEL_API");
expect($managerSource)->toContain("'transport_path' => self::DELIVERY_CHANNEL_API");
expect($managerSource)->toContain("'reconnect_state' => 'PENDING'");
expect($managerSource)->toContain("'preferred_channel' => self::DELIVERY_CHANNEL_API");
expect($managerSource)->toContain('private function createShellActionJob');
expect($managerSource)->toContain('private function claimNextShellActionJob');
expect($managerSource)->toContain('private function appendShellEvent');
@@ -25,6 +28,9 @@ it('rewrites edge gateway shell transport to API polling queues', function (): v
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'");
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
expect($routeSource)->not->toContain("'/edge-agent/internal/agent/auth'");
expect($routeSource)->not->toContain("'/edge-agent/internal/shell/auth'");
expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/validate'");
expect($routeSource)->toContain("'/edge-agent/internal/shell-sessions/validate'");
expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/presence'");
expect($routeSource)->toContain("'/edge-agent/internal/shell-sessions/close'");
expect($routeSource)->toContain('requireBrokerSharedSecret');
});