Files
ladill-hosting/app/Services/Hosting/ServerAgentBootstrapService.php
T
isaaccladandCursor e251a4cf60
Deploy Ladill Hosting / deploy (push) Failing after 17s
Initial Ladill Hosting app with Gitea deploy pipeline.
Shared web hosting extracted from the platform monolith, with CI deploy
to /var/www/ladill-hosting matching Bird/Domains/Email.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 16:24:20 +00:00

203 lines
7.7 KiB
PHP

<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\ServerAgent;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class ServerAgentBootstrapService
{
/**
* @return array{agent: ServerAgent, bootstrap_token: string, registration_url: string, heartbeat_url: string}
*/
public function ensureBootstrap(CustomerHostingOrder $order): array
{
$order->loadMissing('serverAgent');
$meta = (array) ($order->meta ?? []);
$agentMeta = (array) ($meta['server_agent'] ?? []);
$bootstrapToken = $this->decryptBootstrapToken((string) ($agentMeta['bootstrap_token_encrypted'] ?? ''));
if ($bootstrapToken === null) {
$bootstrapToken = Str::random(64);
}
$agent = $order->serverAgent ?: new ServerAgent([
'customer_hosting_order_id' => $order->id,
'uuid' => (string) Str::uuid(),
'status' => ServerAgent::STATUS_PENDING_REGISTRATION,
]);
$agent->fill([
'bootstrap_token_hash' => hash('sha256', $bootstrapToken),
]);
$agent->save();
$agentMeta = array_merge($agentMeta, [
'agent_id' => $agent->id,
'agent_uuid' => $agent->uuid,
'bootstrap_token_encrypted' => encrypt($bootstrapToken),
'registration_url' => route('api.server-agents.register'),
'heartbeat_url' => route('api.server-agents.heartbeat'),
'task_progress_url_template' => url('/api/server-agents/tasks/{task}/progress'),
'task_complete_url_template' => url('/api/server-agents/tasks/{task}/complete'),
'task_fail_url_template' => url('/api/server-agents/tasks/{task}/fail'),
]);
$meta['server_agent'] = $agentMeta;
$meta['server_panel_runtime'] = $this->withRuntimeAgentState(
(array) ($meta['server_panel_runtime'] ?? []),
$agent
);
$order->update(['meta' => $meta]);
return [
'agent' => $agent->fresh(),
'bootstrap_token' => $bootstrapToken,
'registration_url' => $agentMeta['registration_url'],
'heartbeat_url' => $agentMeta['heartbeat_url'],
];
}
/**
* @param array<string, mixed> $attributes
* @return array{agent: ServerAgent, access_token: string}
*/
public function register(CustomerHostingOrder $order, string $bootstrapToken, array $attributes): array
{
$bootstrap = $this->ensureBootstrap($order);
$agent = $bootstrap['agent'];
if (! hash_equals((string) $agent->bootstrap_token_hash, hash('sha256', $bootstrapToken))) {
throw ValidationException::withMessages([
'bootstrap_token' => 'The bootstrap token is invalid.',
]);
}
$accessToken = Str::random(80);
$agent->fill([
'status' => ServerAgent::STATUS_ONLINE,
'hostname' => (string) ($attributes['hostname'] ?? $agent->hostname),
'agent_version' => (string) ($attributes['agent_version'] ?? $agent->agent_version),
'platform' => (string) ($attributes['platform'] ?? $agent->platform),
'capabilities' => array_values(array_unique((array) ($attributes['capabilities'] ?? []))),
'metadata' => (array) ($attributes['metadata'] ?? $agent->metadata ?? []),
'access_token_hash' => hash('sha256', $accessToken),
'access_token_issued_at' => now(),
'registered_at' => $agent->registered_at ?? now(),
'last_heartbeat_at' => now(),
'last_seen_ip' => (string) ($attributes['last_seen_ip'] ?? $agent->last_seen_ip),
]);
$agent->save();
$this->syncOrderRuntime($order->fresh(), $agent->fresh());
return [
'agent' => $agent->fresh(),
'access_token' => $accessToken,
];
}
/**
* @param array<string, mixed> $attributes
*/
public function recordHeartbeat(ServerAgent $agent, array $attributes = []): ServerAgent
{
$agent->fill([
'status' => ServerAgent::STATUS_ONLINE,
'hostname' => (string) ($attributes['hostname'] ?? $agent->hostname),
'agent_version' => (string) ($attributes['agent_version'] ?? $agent->agent_version),
'platform' => (string) ($attributes['platform'] ?? $agent->platform),
'capabilities' => array_values(array_unique((array) ($attributes['capabilities'] ?? $agent->capabilities ?? []))),
'metadata' => array_replace((array) ($agent->metadata ?? []), (array) ($attributes['metadata'] ?? [])),
'last_heartbeat_at' => now(),
'last_seen_ip' => (string) ($attributes['last_seen_ip'] ?? $agent->last_seen_ip),
]);
$agent->save();
if ($agent->relationLoaded('order')) {
$this->syncOrderRuntime($agent->order, $agent);
} else {
$this->syncOrderRuntime($agent->order()->firstOrFail(), $agent);
}
return $agent->fresh();
}
public function syncOrderRuntime(CustomerHostingOrder $order, ServerAgent $agent): void
{
$meta = (array) ($order->meta ?? []);
$meta['server_agent'] = array_merge((array) ($meta['server_agent'] ?? []), [
'agent_id' => $agent->id,
'agent_uuid' => $agent->uuid,
'status' => $agent->status,
'hostname' => $agent->hostname,
'agent_version' => $agent->agent_version,
'platform' => $agent->platform,
'capabilities' => (array) ($agent->capabilities ?? []),
'registered_at' => optional($agent->registered_at)->toIso8601String(),
'last_heartbeat_at' => optional($agent->last_heartbeat_at)->toIso8601String(),
]);
$meta['server_panel_runtime'] = $this->withRuntimeAgentState(
(array) ($meta['server_panel_runtime'] ?? []),
$agent
);
$order->update(['meta' => $meta]);
}
/**
* @param array<string, mixed> $runtime
* @return array<string, mixed>
*/
private function withRuntimeAgentState(array $runtime, ServerAgent $agent): array
{
$current = array_values(array_unique(array_merge(
(array) ($runtime['current_capabilities'] ?? []),
['Agent registration', 'Async task dispatch']
)));
if ($agent->status === ServerAgent::STATUS_ONLINE) {
$current = array_values(array_unique(array_merge($current, [
'Files',
'Databases',
'Domains',
'SSL',
'PHP',
'Cron',
'Logs',
'Site management',
])));
}
$runtime['hosting_panel_ready'] = $agent->registered_at !== null;
$runtime['future_hosting_panel_status'] = $agent->registered_at !== null ? 'agent_registered' : ($runtime['future_hosting_panel_status'] ?? 'planned');
$runtime['agent_status'] = $agent->status;
$runtime['current_capabilities'] = $current;
$runtime['secondary_message'] = $agent->registered_at !== null
? 'The server agent is registered. Managed tasks for files, databases, domains, SSL, PHP, cron, logs, and site actions are now queued through Ladill.'
: ($runtime['secondary_message'] ?? null);
return $runtime;
}
private function decryptBootstrapToken(string $encryptedToken): ?string
{
if ($encryptedToken === '') {
return null;
}
try {
$token = decrypt($encryptedToken);
} catch (\Throwable) {
return null;
}
return is_string($token) && $token !== '' ? $token : null;
}
}