diff --git a/app/Actions/Site/UpdateSiteWorkerEnvironment.php b/app/Actions/Site/UpdateSiteWorkerEnvironment.php new file mode 100644 index 000000000..8f0e5e6e1 --- /dev/null +++ b/app/Actions/Site/UpdateSiteWorkerEnvironment.php @@ -0,0 +1,38 @@ + $input + * + * @throws SSHError + */ + public function update(Site $site, array $input): WorkerEnvironmentUpdateResult + { + $type = $site->type(); + $worker = $type instanceof AbstractProxiedSiteType ? $type->bootstrapWorker() : null; + + if ($worker !== null) { + return app(UpdateWorkerEnvironment::class)->update($worker, $input); + } + + $validated = Validator::make($input, UpdateWorkerEnvironment::rules())->validate(); + + $site->worker_environment = UpdateWorkerEnvironment::processVariables( + $validated['variables'], + $site->worker_environment, + ); + $site->save(); + + return WorkerEnvironmentUpdateResult::PreFirstDeploy; + } +} diff --git a/app/Actions/Worker/CreateWorker.php b/app/Actions/Worker/CreateWorker.php index 80901d9b0..b37da2ca3 100644 --- a/app/Actions/Worker/CreateWorker.php +++ b/app/Actions/Worker/CreateWorker.php @@ -37,7 +37,9 @@ public function create(Server $server, array $input, ?Site $site = null): Worker 'auto_start' => $input['auto_start'] ? 1 : 0, 'auto_restart' => $input['auto_restart'] ? 1 : 0, 'numprocs' => $input['numprocs'], - 'environment' => $input['environment'] ?? null, + 'environment' => isset($input['environment']) + ? UpdateWorkerEnvironment::processVariables($input['environment'], null) + : null, 'status' => WorkerStatus::CREATING, ]); $worker->save(); @@ -83,6 +85,13 @@ private function validate(Server $server, array $input, ?Site $site = null): voi 'numeric', 'min:1', ], + 'environment' => [ + 'sometimes', + 'nullable', + 'array', + 'max:100', + ], + ...UpdateWorkerEnvironment::nestedRules('environment'), ]; // Add site_id validation if provided in input diff --git a/app/Actions/Worker/RestartAllWorkers.php b/app/Actions/Worker/RestartAllWorkers.php new file mode 100644 index 000000000..118c3d156 --- /dev/null +++ b/app/Actions/Worker/RestartAllWorkers.php @@ -0,0 +1,27 @@ +workers() + ->when($site, fn ($query) => $query->where('site_id', $site->id)) + ->whereNotIn('status', [WorkerStatus::CREATING, WorkerStatus::DELETING]) + ->get() + ->each(function (Worker $worker): void { + $worker->status = WorkerStatus::RESTARTING; + $worker->error = null; + $worker->save(); + }); + + dispatch(new RestartAllJob($server, $site))->onQueue('ssh'); + } +} diff --git a/app/Actions/Worker/SyncWorkerStatuses.php b/app/Actions/Worker/SyncWorkerStatuses.php new file mode 100644 index 000000000..5eeecbcc6 --- /dev/null +++ b/app/Actions/Worker/SyncWorkerStatuses.php @@ -0,0 +1,128 @@ +processManager(); + /** @var ProcessManager $handler */ + $handler = $service->handler(); + + $workers = $server->workers() + ->when($site, fn ($query) => $query->where('site_id', $site->id)) + ->whereNotIn('status', [WorkerStatus::CREATING, WorkerStatus::DELETING]) + ->get(); + + if ($workers->isEmpty()) { + return 0; + } + + $statuses = $handler->statuses(); + + $changed = $workers->filter(fn (Worker $worker): bool => $this->settle($worker, $statuses[$worker->id] ?? [])); + + $changed->loadMissing('site') + ->pluck('site') + ->filter() + ->unique('id') + ->each(fn (Site $workerSite) => app(BroadcastSiteUpdate::class)->broadcast($workerSite)); + + return $changed->count(); + } + + /** + * @param array $processes + */ + private function settle(Worker $worker, array $processes): bool + { + [$status, $error] = $this->target($processes); + + if ($worker->status === $status && $worker->error === $error) { + return false; + } + + if ($status === WorkerStatus::FAILED) { + $this->failWorker($worker, $error, self::LOG_TYPE, (string) $error); + + return true; + } + + $worker->status = $status; + $worker->error = null; + $worker->save(); + + $this->broadcastWorkerUpdate($worker); + + return true; + } + + /** + * @param array $processes + * @return array{0: WorkerStatus, 1: ?string} + */ + private function target(array $processes): array + { + if ($processes === []) { + return [WorkerStatus::FAILED, 'Process not found in supervisor']; + } + + $worst = WorkerStatus::RUNNING; + $errors = []; + + foreach ($processes as $process => $info) { + $status = $this->mapState($info['state']); + + if ($status === WorkerStatus::FAILED) { + $errors[] = trim("{$process}: {$info['state']} {$info['description']}"); + } + + if ($this->severity($status) > $this->severity($worst)) { + $worst = $status; + } + } + + if ($worst === WorkerStatus::FAILED) { + return [WorkerStatus::FAILED, mb_substr(implode("\n", $errors), 0, 500)]; + } + + return [$worst, null]; + } + + private function mapState(string $state): WorkerStatus + { + return match ($state) { + 'RUNNING' => WorkerStatus::RUNNING, + 'STARTING' => WorkerStatus::STARTING, + 'STOPPING' => WorkerStatus::STOPPING, + 'STOPPED', 'EXITED' => WorkerStatus::STOPPED, + default => WorkerStatus::FAILED, + }; + } + + private function severity(WorkerStatus $status): int + { + return match ($status) { + WorkerStatus::FAILED => 4, + WorkerStatus::STOPPED => 3, + WorkerStatus::STOPPING => 2, + WorkerStatus::STARTING => 1, + default => 0, + }; + } +} diff --git a/app/Actions/Worker/UpdateWorkerEnvironment.php b/app/Actions/Worker/UpdateWorkerEnvironment.php new file mode 100644 index 000000000..d97145c42 --- /dev/null +++ b/app/Actions/Worker/UpdateWorkerEnvironment.php @@ -0,0 +1,88 @@ + $input + * + * @throws SSHError + * @throws ValidationException + */ + public function update(Worker $worker, array $input): WorkerEnvironmentUpdateResult + { + $validated = Validator::make($input, [ + ...self::rules(), + 'restart' => ['sometimes', 'boolean'], + ])->validate(); + + $worker->environment = self::processVariables($validated['variables'], $worker->environment); + $worker->save(); + + /** @var ProcessManager $processManager */ + $processManager = $worker->server->processManager()->handler(); + $processManager->writeConfig($worker); + + if ($validated['restart'] ?? false) { + app(ManageWorker::class)->restart($worker); + + return WorkerEnvironmentUpdateResult::Restarting; + } + + return WorkerEnvironmentUpdateResult::PendingRestart; + } + + /** + * @return array> + */ + public static function rules(string $attribute = 'variables'): array + { + return [ + $attribute => ['present', 'array', 'max:100'], + ...self::nestedRules($attribute), + ]; + } + + /** + * @return array> + */ + public static function nestedRules(string $attribute = 'variables'): array + { + return [ + "{$attribute}.*.key" => ['required', 'string', 'max:255', 'regex:/^[A-Za-z_][A-Za-z0-9_]*$/', 'distinct'], + "{$attribute}.*.value" => ['present', 'nullable', 'string', 'max:10000', 'regex:/\A[^\x00-\x1F\x7F"]*\z/'], + "{$attribute}.*.is_secret" => ['required', 'boolean'], + ]; + } + + /** + * @param array> $incoming + * @param ?array $stored + * @return array + */ + public static function processVariables(array $incoming, ?array $stored): array + { + $normalized = array_map(fn (array $variable): array => [ + 'key' => (string) $variable['key'], + 'value' => (string) ($variable['value'] ?? ''), + 'is_secret' => (bool) ($variable['is_secret'] ?? false), + ], $incoming); + + return EnvParser::mergeWithStored($normalized, $stored); + } +} + +enum WorkerEnvironmentUpdateResult +{ + case PreFirstDeploy; + case PendingRestart; + case Restarting; +} diff --git a/app/Http/Controllers/API/WorkerController.php b/app/Http/Controllers/API/WorkerController.php index b3585e5b3..9b7e9bed4 100644 --- a/app/Http/Controllers/API/WorkerController.php +++ b/app/Http/Controllers/API/WorkerController.php @@ -7,6 +7,8 @@ use App\Actions\Worker\EditWorker; use App\Actions\Worker\GetWorkerLogs; use App\Actions\Worker\ManageWorker; +use App\Actions\Worker\RestartAllWorkers; +use App\Actions\Worker\SyncWorkerStatuses; use App\Http\Controllers\Controller; use App\Http\Resources\WorkerResource; use App\Models\Project; @@ -23,6 +25,7 @@ use Spatie\RouteAttributes\Attributes\Post; use Spatie\RouteAttributes\Attributes\Prefix; use Spatie\RouteAttributes\Attributes\Put; +use Spatie\RouteAttributes\Attributes\WhereNumber; #[Prefix('api/projects/{project}/servers/{server}')] #[Middleware(['auth:sanctum', 'can-see-project'])] @@ -76,7 +79,38 @@ public function siteShow(Project $project, Server $server, Site $site, Worker $w return new WorkerResource($worker); } + #[Post('/workers/resync/{site?}', name: 'api.projects.servers.workers.resync', middleware: 'ability:write')] + #[WhereNumber('site')] + public function resync(Project $project, Server $server, ?Site $site = null): JsonResponse + { + $this->authorize('update', [$project, $server]); + + $this->validateRoute($project, $server, $site); + + $count = app(SyncWorkerStatuses::class)->sync($server, $site); + + return response()->json([ + 'synced' => $count, + ]); + } + + #[Post('/workers/restart-all/{site?}', name: 'api.projects.servers.workers.restart-all', middleware: 'ability:write')] + #[WhereNumber('site')] + public function restartAll(Project $project, Server $server, ?Site $site = null): JsonResponse + { + $this->authorize('update', [$project, $server]); + + $this->validateRoute($project, $server, $site); + + app(RestartAllWorkers::class)->restart($server, $site); + + return response()->json([ + 'message' => 'Workers are being restarted.', + ], 202); + } + #[Post('/workers/{site?}', name: 'api.projects.servers.workers.create', middleware: 'ability:write')] + #[WhereNumber('site')] public function create(Request $request, Project $project, Server $server, ?Site $site = null): WorkerResource { $this->authorize('create', [$project, $server, $site]); diff --git a/app/Http/Controllers/SiteSettingController.php b/app/Http/Controllers/SiteSettingController.php index b12bb261e..24f0b41d4 100644 --- a/app/Http/Controllers/SiteSettingController.php +++ b/app/Http/Controllers/SiteSettingController.php @@ -9,6 +9,7 @@ use App\Actions\Site\UpdatePHPVersion; use App\Actions\Site\UpdatePort; use App\Actions\Site\UpdateSiteStats; +use App\Actions\Site\UpdateSiteWorkerEnvironment; use App\Actions\Site\UpdateSourceControl; use App\Actions\Site\UpdateStartCommand; use App\Actions\Site\UpdateVhost; @@ -18,10 +19,13 @@ use App\Actions\Site\WorkerStartCommandUpdateResult; use App\Actions\Webserver\GenerateCaddyConfig; use App\Actions\Webserver\GenerateNginxConfig; +use App\Actions\Worker\WorkerEnvironmentUpdateResult; use App\Exceptions\SSHError; +use App\Helpers\EnvParser; use App\Http\Resources\SourceControlResource; use App\Models\Server; use App\Models\Site; +use App\SiteTypes\AbstractProxiedSiteType; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -136,6 +140,53 @@ public function updateStartCommand(Request $request, Server $server, Site $site) }; } + #[Get('/worker-env', name: 'site-settings.worker-env')] + public function workerEnv(Server $server, Site $site): JsonResponse + { + $this->authorize('view', [$site, $server]); + + $type = $site->type(); + if (! $type instanceof AbstractProxiedSiteType) { + abort(404); + } + + return response()->json([ + 'variables' => EnvParser::maskSecrets( + $type->bootstrapWorker()->environment ?? $site->worker_environment ?? [] + ), + ]); + } + + /** + * @throws SSHError + */ + #[Patch('/worker-env', name: 'site-settings.update-worker-env')] + public function updateWorkerEnv(Request $request, Server $server, Site $site): RedirectResponse + { + $this->authorize('update', [$site, $server]); + + if (! $site->type() instanceof AbstractProxiedSiteType) { + abort(404); + } + + $result = app(UpdateSiteWorkerEnvironment::class)->update($site, $request->input()); + + return match ($result) { + WorkerEnvironmentUpdateResult::PreFirstDeploy => back()->with( + 'info', + 'Environment saved. It will be applied when the application worker is created on the first deploy.', + ), + WorkerEnvironmentUpdateResult::PendingRestart => back()->with( + 'warning', + 'Environment updated. The worker is still running with the previous variables — restart it or deploy to apply.', + ), + WorkerEnvironmentUpdateResult::Restarting => back()->with( + 'info', + 'Environment updated. The worker is restarting to apply the change.', + ), + }; + } + #[Get('/vhost', name: 'site-settings.vhost')] public function vhost(Server $server, Site $site): JsonResponse { diff --git a/app/Http/Controllers/WorkerController.php b/app/Http/Controllers/WorkerController.php index 24b291e7c..22227051c 100644 --- a/app/Http/Controllers/WorkerController.php +++ b/app/Http/Controllers/WorkerController.php @@ -7,6 +7,11 @@ use App\Actions\Worker\EditWorker; use App\Actions\Worker\GetWorkerLogs; use App\Actions\Worker\ManageWorker; +use App\Actions\Worker\RestartAllWorkers; +use App\Actions\Worker\SyncWorkerStatuses; +use App\Actions\Worker\UpdateWorkerEnvironment; +use App\Actions\Worker\WorkerEnvironmentUpdateResult; +use App\Helpers\EnvParser; use App\Http\Resources\WorkerResource; use App\Models\Server; use App\Models\Site; @@ -19,9 +24,11 @@ use Spatie\RouteAttributes\Attributes\Delete; use Spatie\RouteAttributes\Attributes\Get; use Spatie\RouteAttributes\Attributes\Middleware; +use Spatie\RouteAttributes\Attributes\Patch; use Spatie\RouteAttributes\Attributes\Post; use Spatie\RouteAttributes\Attributes\Prefix; use Spatie\RouteAttributes\Attributes\Put; +use Spatie\RouteAttributes\Attributes\WhereNumber; #[Prefix('servers/{server}')] #[Middleware(['auth', 'has-project'])] @@ -59,7 +66,34 @@ public function site(Server $server, Site $site): Response ]); } + #[Post('/workers/resync/{site?}', name: 'workers.resync')] + #[WhereNumber('site')] + public function resync(Server $server, ?Site $site = null): RedirectResponse + { + $this->authorize('manage', [Worker::class, $server, $site]); + + $count = app(SyncWorkerStatuses::class)->sync($server, $site); + + return back() + ->with('success', $count === 0 + ? 'Worker statuses are already up to date.' + : "Updated statuses of {$count} worker(s)."); + } + + #[Post('/workers/restart-all/{site?}', name: 'workers.restart-all')] + #[WhereNumber('site')] + public function restartAll(Server $server, ?Site $site = null): RedirectResponse + { + $this->authorize('manage', [Worker::class, $server, $site]); + + app(RestartAllWorkers::class)->restart($server, $site); + + return back() + ->with('info', 'Workers are being restarted.'); + } + #[Post('/workers/{site?}', name: 'workers.store')] + #[WhereNumber('site')] public function store(Request $request, Server $server, ?Site $site = null): RedirectResponse { $this->authorize('create', [Worker::class, $server, $site]); @@ -114,6 +148,35 @@ public function restart(Server $server, Worker $worker): RedirectResponse ->with('info', 'Worker is being restarted.'); } + #[Get('/workers/{worker}/env', name: 'workers.env')] + public function env(Server $server, Worker $worker): JsonResponse + { + $this->authorize('view', [$worker, $server]); + + return response()->json([ + 'variables' => EnvParser::maskSecrets($worker->environment ?? []), + ]); + } + + #[Patch('/workers/{worker}/env', name: 'workers.update-env')] + public function updateEnv(Request $request, Server $server, Worker $worker): RedirectResponse + { + $this->authorize('update', [$worker, $server]); + + $result = app(UpdateWorkerEnvironment::class)->update($worker, $request->input()); + + return match ($result) { + WorkerEnvironmentUpdateResult::PendingRestart => back()->with( + 'warning', + 'Environment updated. The worker is still running with the previous variables — restart it or deploy to apply.', + ), + default => back()->with( + 'info', + 'Environment updated. The worker is restarting to apply the change.', + ), + }; + } + #[Get('/workers/{worker}/logs', name: 'workers.logs')] public function logs(Server $server, Worker $worker): JsonResponse { diff --git a/app/Jobs/Worker/RestartAllJob.php b/app/Jobs/Worker/RestartAllJob.php new file mode 100644 index 000000000..abdb8044e --- /dev/null +++ b/app/Jobs/Worker/RestartAllJob.php @@ -0,0 +1,57 @@ +run("server-{$this->server->id}", function () { + /** @var Service $service */ + $service = $this->server->processManager(); + /** @var ProcessManager $handler */ + $handler = $service->handler(); + $handler->restartAll($this->site?->id); + + app(SyncWorkerStatuses::class)->sync($this->server, $this->site); + }); + } + + public function failed(Throwable $e): void + { + $failed = $this->server->workers() + ->when($this->site, fn ($query) => $query->where('site_id', $this->site->id)) + ->where('status', WorkerStatus::RESTARTING) + ->get() + ->each(fn (Worker $worker) => $this->markWorkerFailed($worker, $e, 'restart-all-workers-failed')); + + $failed->loadMissing('site') + ->pluck('site') + ->filter() + ->unique('id') + ->each(fn (Site $workerSite) => app(BroadcastSiteUpdate::class)->broadcast($workerSite)); + } +} diff --git a/app/Models/Site.php b/app/Models/Site.php index 5680edecd..401a24ab4 100755 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -40,6 +40,7 @@ * @property string $type * @property array $type_data * @property ?array $env_variables + * @property ?array $worker_environment * @property string $domain * @property array $aliases * @property string $web_directory @@ -122,6 +123,7 @@ class Site extends AbstractModel 'isolated_user_id' => 'integer', 'type_data' => 'json', 'env_variables' => 'encrypted:array', + 'worker_environment' => 'encrypted:array', 'port' => 'integer', 'progress' => 'integer', 'aliases' => 'array', diff --git a/app/Models/Worker.php b/app/Models/Worker.php index 163f6e4ae..1d2fe8c30 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -19,7 +19,7 @@ * @property bool $auto_start * @property bool $auto_restart * @property int $numprocs - * @property ?array $environment + * @property ?array $environment * @property int $redirect_stderr * @property string $stdout_logfile * @property WorkerStatus $status @@ -54,7 +54,7 @@ class Worker extends AbstractModel 'auto_start' => 'boolean', 'auto_restart' => 'boolean', 'numprocs' => 'integer', - 'environment' => 'array', + 'environment' => 'encrypted:array', 'redirect_stderr' => 'boolean', 'status' => WorkerStatus::class, ]; @@ -118,12 +118,26 @@ public function getLogFile(): string return $this->getLogDirectory().'/'.$this->id.'.log'; } + /** + * @return array + */ + public function environmentMap(): array + { + $map = []; + + foreach ($this->environment ?? [] as $variable) { + $map[$variable['key']] = $variable['value']; + } + + return $map; + } + /** * @return array */ public function effectiveEnvironment(): array { - $base = $this->environment ?? []; + $base = $this->environmentMap(); if ($this->site_id && $this->site) { return array_merge($base, SiteShellEnvironment::collect($this->site)); diff --git a/app/Policies/WorkerPolicy.php b/app/Policies/WorkerPolicy.php index 748f92a13..01f4907b8 100644 --- a/app/Policies/WorkerPolicy.php +++ b/app/Policies/WorkerPolicy.php @@ -36,6 +36,13 @@ public function create(User $user, Server $server, ?Site $site = null): bool $server->processManager(); } + public function manage(User $user, Server $server, ?Site $site = null): bool + { + return $this->hasWriteAccess($user, $server->project) && + $server->isReady() && + $server->processManager(); + } + public function update(User $user, Worker $worker, Server $server, ?Site $site = null): bool { return $this->hasWriteAccess($user, $server->project) && diff --git a/app/Services/ProcessManager/ProcessManager.php b/app/Services/ProcessManager/ProcessManager.php index a88b3c875..3591f3f1b 100755 --- a/app/Services/ProcessManager/ProcessManager.php +++ b/app/Services/ProcessManager/ProcessManager.php @@ -26,5 +26,10 @@ public function start(int $id, ?int $siteId = null): void; public function restartAll(?int $siteId = null): void; + /** + * @return array> + */ + public function statuses(): array; + public function getLogs(string $user, string $logPath): string; } diff --git a/app/Services/ProcessManager/Supervisor.php b/app/Services/ProcessManager/Supervisor.php index 3eea5a3fe..523e5009d 100644 --- a/app/Services/ProcessManager/Supervisor.php +++ b/app/Services/ProcessManager/Supervisor.php @@ -3,6 +3,7 @@ namespace App\Services\ProcessManager; use App\DTOs\ServiceLog; +use App\Enums\WorkerStatus; use App\Exceptions\SSHError; use App\Models\Worker; use App\Services\HasLogs; @@ -53,6 +54,21 @@ public function uninstall(): void $this->service->server->os()->cleanup(); } + /** + * @param array $environment + */ + public static function formatEnvironment(array $environment): string + { + return collect($environment) + ->filter(fn (string $value, string $key): bool => preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key) === 1) + ->map(function (string $value, string $key): string { + $sanitized = str_replace(["\r", "\n", '"'], '', $value); + + return $key.'="'.str_replace('%', '%%', $sanitized).'"'; + }) + ->implode(','); + } + /** * @throws SSHError */ @@ -69,7 +85,7 @@ public function writeConfig(Worker $worker): void 'autoRestart' => var_export($worker->auto_restart, true), 'numprocs' => (string) $worker->numprocs, 'logFile' => $worker->getLogFile(), - 'environment' => $worker->effectiveEnvironment(), + 'environment' => self::formatEnvironment($worker->effectiveEnvironment()), ]), 'root' ); @@ -170,13 +186,65 @@ public function restartMany(array $ids, ?int $siteId = null): string ); } + /** + * @throws Throwable + */ public function restartAll(?int $siteId = null): void { + if ($siteId !== null) { + $ids = $this->service->server->workers() + ->where('site_id', $siteId) + ->whereNotIn('status', [WorkerStatus::CREATING, WorkerStatus::DELETING]) + ->pluck('id') + ->all(); + + if ($ids !== []) { + $this->restartMany($ids, $siteId); + } + + return; + } + $this->service->server->ssh()->exec( view('ssh.services.process-manager.supervisor.restart-all-workers'), - 'restart-all-workers', - $siteId + 'restart-all-workers' + ); + } + + /** + * @return array> + * + * @throws Throwable + */ + public function statuses(): array + { + $output = $this->service->server->ssh()->exec( + view('ssh.services.process-manager.supervisor.worker-statuses'), + 'worker-statuses' ); + + $statuses = []; + + foreach (explode("\n", $output) as $line) { + $parts = preg_split('/\s+/', trim($line), 3) ?: []; + + if (count($parts) < 2) { + continue; + } + + $group = explode(':', $parts[0], 2)[0]; + + if (! ctype_digit($group)) { + continue; + } + + $statuses[(int) $group][$parts[0]] = [ + 'state' => strtoupper($parts[1]), + 'description' => $parts[2] ?? '', + ]; + } + + return $statuses; } /** diff --git a/app/SiteTypes/AbstractProxiedSiteType.php b/app/SiteTypes/AbstractProxiedSiteType.php index 32562a692..7db90cb4a 100644 --- a/app/SiteTypes/AbstractProxiedSiteType.php +++ b/app/SiteTypes/AbstractProxiedSiteType.php @@ -137,6 +137,7 @@ public function afterDeploy(Deployment $deployment): void 'auto_start' => true, 'auto_restart' => true, 'numprocs' => 1, + 'environment' => $this->site->worker_environment ?: null, ], $this->site, ); diff --git a/app/Support/Testing/SSHFake.php b/app/Support/Testing/SSHFake.php index 49e8adb25..2a15ae758 100644 --- a/app/Support/Testing/SSHFake.php +++ b/app/Support/Testing/SSHFake.php @@ -121,25 +121,28 @@ public function assertExecutedContains(string $command): void $executed = false; foreach ($this->commands as $executedCommand) { if (str($executedCommand)->contains($command)) { - return; + $executed = true; + break; } } - Assert::fail( + Assert::assertTrue( + $executed, 'The expected command is not executed in the executed commands: '.implode(', ', $this->commands) ); } public function assertNotExecutedContains(string $command, string $message = ''): void { - foreach ($this->commands as $executedCommand) { - $commandStr = (string) $executedCommand; - if (str($commandStr)->contains($command)) { - Assert::fail( - $message ?: "The command '{$command}' should not be executed, but it was found in: {$commandStr}" - ); - } - } + $matches = array_filter( + $this->commands, + fn (string|View $executedCommand): bool => str((string) $executedCommand)->contains($command) + ); + + Assert::assertEmpty( + $matches, + $message ?: "The command '{$command}' should not be executed, but it was found in: ".implode(', ', $matches) + ); } public function assertFileUploaded(string $toPath, ?string $content = null): void diff --git a/database/factories/WorkerFactory.php b/database/factories/WorkerFactory.php index 8b02332e1..6bcf83050 100644 --- a/database/factories/WorkerFactory.php +++ b/database/factories/WorkerFactory.php @@ -35,7 +35,14 @@ public function definition(): array public function withEnvironment(array $environment): static { return $this->state(fn (array $attributes) => [ - 'environment' => $environment, + 'environment' => collect($environment) + ->map(fn (string $value, string $key): array => [ + 'key' => $key, + 'value' => $value, + 'is_secret' => false, + ]) + ->values() + ->all(), ]); } } diff --git a/database/migrations/2026_06_06_120237_add_worker_environment_support.php b/database/migrations/2026_06_06_120237_add_worker_environment_support.php new file mode 100644 index 000000000..312a4f99c --- /dev/null +++ b/database/migrations/2026_06_06_120237_add_worker_environment_support.php @@ -0,0 +1,100 @@ +text('worker_environment')->nullable()->after('env_variables'); + }); + } + + Schema::table('workers', function (Blueprint $table) { + $table->text('environment')->nullable()->change(); + }); + + DB::table('workers')->whereNotNull('environment')->orderBy('id')->chunkById(100, function ($workers) { + foreach ($workers as $worker) { + $decoded = json_decode((string) $worker->environment, true); + + if (! is_array($decoded)) { + continue; + } + + $variables = array_is_list($decoded) + ? $decoded + : $this->convertMapToVariables($decoded); + + DB::table('workers')->where('id', $worker->id)->update([ + 'environment' => Crypt::encryptString(json_encode($variables)), + ]); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sites', function (Blueprint $table) { + $table->dropColumn('worker_environment'); + }); + + DB::table('workers')->whereNotNull('environment')->orderBy('id')->chunkById(100, function ($workers) { + foreach ($workers as $worker) { + try { + $decoded = json_decode(Crypt::decryptString((string) $worker->environment), true); + } catch (Throwable) { + $decoded = null; + } + + $map = []; + if (is_array($decoded)) { + foreach ($decoded as $variable) { + if (is_array($variable) && isset($variable['key'])) { + $map[(string) $variable['key']] = (string) ($variable['value'] ?? ''); + } + } + } + + DB::table('workers')->where('id', $worker->id)->update([ + 'environment' => $map === [] ? null : json_encode($map), + ]); + } + }); + + Schema::table('workers', function (Blueprint $table) { + $table->json('environment')->nullable()->change(); + }); + } + + /** + * @param array $map + * @return array + */ + private function convertMapToVariables(array $map): array + { + $variables = []; + + foreach ($map as $key => $value) { + $variables[] = [ + 'key' => $key, + 'value' => is_scalar($value) ? (string) $value : '', + 'is_secret' => (bool) preg_match('/PASSWORD|SECRET|TOKEN|KEY|PRIVATE/i', (string) $key), + ]; + } + + return $variables; + } +}; diff --git a/public/api-docs/openapi/workers.yaml b/public/api-docs/openapi/workers.yaml index 7b1283b61..4d1e79bf3 100644 --- a/public/api-docs/openapi/workers.yaml +++ b/public/api-docs/openapi/workers.yaml @@ -135,6 +135,33 @@ paths: description: Number of processes default: 1 example: 1 + environment: + type: array + nullable: true + maxItems: 100 + description: Environment variables passed to the worker process. Not returned in responses. + items: + type: object + required: + - key + - value + - is_secret + properties: + key: + type: string + maxLength: 255 + pattern: '^[A-Za-z_][A-Za-z0-9_]*$' + description: Variable name. Must be unique within the list. + example: 'APP_ENV' + value: + type: string + maxLength: 10000 + description: Variable value. Must not contain control characters, newlines, or double quotes. + example: 'production' + is_secret: + type: boolean + description: Whether the value should be masked in the UI + example: false example: name: 'queue-worker' command: 'php artisan queue:work' @@ -175,6 +202,104 @@ paths: schema: $ref: '/api-docs/openapi/schemas/ValidationError.yaml' + /api/projects/{project}/servers/{server}/workers/resync: + post: + summary: Resync worker statuses + description: Fetch the live process statuses from the process manager and update the workers in Vito. Append /{site} to scope the resync to one site's workers. + tags: + - Workers + security: + - bearerAuth: [] + parameters: + - name: project + in: path + required: true + description: Project ID + schema: + type: integer + example: 1 + - name: server + in: path + required: true + description: Server ID + schema: + type: integer + example: 1 + responses: + '200': + description: Worker statuses synced + content: + application/json: + example: + synced: 3 + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '/api-docs/openapi/schemas/ErrorResponse.yaml' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '/api-docs/openapi/schemas/ErrorResponse.yaml' + '404': + description: Project or server not found + content: + application/json: + schema: + $ref: '/api-docs/openapi/schemas/ErrorResponse.yaml' + + /api/projects/{project}/servers/{server}/workers/restart-all: + post: + summary: Restart all workers + description: Restart all workers on the server and resync their statuses afterwards. Append /{site} to scope the restart to one site's workers. + tags: + - Workers + security: + - bearerAuth: [] + parameters: + - name: project + in: path + required: true + description: Project ID + schema: + type: integer + example: 1 + - name: server + in: path + required: true + description: Server ID + schema: + type: integer + example: 1 + responses: + '202': + description: Workers are being restarted + content: + application/json: + example: + message: 'Workers are being restarted.' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '/api-docs/openapi/schemas/ErrorResponse.yaml' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '/api-docs/openapi/schemas/ErrorResponse.yaml' + '404': + description: Project or server not found + content: + application/json: + schema: + $ref: '/api-docs/openapi/schemas/ErrorResponse.yaml' + /api/projects/{project}/servers/{server}/workers/{worker}: get: summary: Get worker diff --git a/resources/js/components/dialogs/registry.ts b/resources/js/components/dialogs/registry.ts index 705cd8a3c..2a5aa3283 100644 --- a/resources/js/components/dialogs/registry.ts +++ b/resources/js/components/dialogs/registry.ts @@ -4,6 +4,7 @@ import ConfirmationDialog from './confirmation-dialog'; import StorageProviderEditDialog from '@/pages/storage-providers/components/edit-dialog'; import PluginLogsDialog from '@/pages/plugins/components/logs-dialog'; import WorkerLogsDialog from '@/pages/workers/components/logs-dialog'; +import WorkerEnvDialog from '@/pages/workers/components/env-dialog'; import CronJobForm from '@/pages/cronjobs/components/form'; import WorkerForm from '@/pages/workers/components/form'; import ActivateServerSslDialog from '@/pages/server-ssls/components/activate-dialog'; @@ -50,6 +51,7 @@ export const dialogs = { storageProviderEdit: StorageProviderEditDialog, pluginLogs: PluginLogsDialog, workerLogs: WorkerLogsDialog, + workerEnv: WorkerEnvDialog, cronjobForm: CronJobForm, workerForm: WorkerForm, activateServerSsl: ActivateServerSslDialog, diff --git a/resources/js/components/radio-card.tsx b/resources/js/components/radio-card.tsx new file mode 100644 index 000000000..dad7a4317 --- /dev/null +++ b/resources/js/components/radio-card.tsx @@ -0,0 +1,32 @@ +import { RadioGroupItem } from '@/components/ui/radio-group'; +import { cn } from '@/lib/utils'; + +export default function RadioCard({ + value, + selected, + title, + description, + onSelect, +}: { + value: string; + selected: boolean; + title: string; + description: string; + onSelect: (value: string) => void; +}) { + return ( + + ); +} diff --git a/resources/js/lib/editor.ts b/resources/js/lib/editor.ts index 6c5bf770b..f56c798d2 100644 --- a/resources/js/lib/editor.ts +++ b/resources/js/lib/editor.ts @@ -201,9 +201,17 @@ export function registerBashLanguage(monaco: monacoType): void { [/'/, { token: 'string.quote', bracket: '@open', next: '@string_single' }], [/\$[a-zA-Z_]\w*/, 'variable'], [/\$\{[^}]+}/, 'variable'], - [/\b(if|then|else|fi|for|while|in|do|done|case|esac|function|select|until|elif|time)\b/, 'keyword'], - [/\b(echo|read|cd|pwd|exit|kill|exec|eval|set|unset|export|source|trap|shift|alias|type|ulimit)\b/, 'type.identifier'], - [/\b\d+\b/, 'number'], + [ + /[a-zA-Z_]\w*/, + { + cases: { + '@keywords': 'keyword', + '@builtins': 'type.identifier', + '@default': '', + }, + }, + ], + [/\d+/, 'number'], [/==|=~|!=|<=|>=|<<|>>|[<>;&|]/, 'operator'], ], string_double: [ diff --git a/resources/js/lib/env.ts b/resources/js/lib/env.ts new file mode 100644 index 000000000..e45e16358 --- /dev/null +++ b/resources/js/lib/env.ts @@ -0,0 +1,11 @@ +export function generateUniqueKey(existingKeys: string[]): string { + let counter = 1; + let newKey = 'NEW_VARIABLE'; + + while (existingKeys.includes(newKey)) { + newKey = `NEW_VARIABLE_${counter}`; + counter++; + } + + return newKey; +} diff --git a/resources/js/pages/application/components/app-with-deployment.tsx b/resources/js/pages/application/components/app-with-deployment.tsx index 3ec20ab8e..61c787711 100644 --- a/resources/js/pages/application/components/app-with-deployment.tsx +++ b/resources/js/pages/application/components/app-with-deployment.tsx @@ -132,7 +132,9 @@ export default function AppWithDeployment() { {site.is_proxied_site_type && ( <> - +
+ +
)} diff --git a/resources/js/pages/application/components/env.tsx b/resources/js/pages/application/components/env.tsx index 6252c9c4a..e1b54d50d 100644 --- a/resources/js/pages/application/components/env.tsx +++ b/resources/js/pages/application/components/env.tsx @@ -14,18 +14,7 @@ import { Input } from '@/components/ui/input'; import { useInputFocus } from '@/stores/useInputFocus'; import { EnvVariable } from '@/types/env'; import EnvVariableRow from './env-variable-row'; - -function generateUniqueKey(existingKeys: string[]): string { - let counter = 1; - let newKey = 'NEW_VARIABLE'; - - while (existingKeys.includes(newKey)) { - newKey = `NEW_VARIABLE_${counter}`; - counter++; - } - - return newKey; -} +import { generateUniqueKey } from '@/lib/env'; export default function Env({ site, children }: { site: Site; children: ReactNode }) { const setFocused = useInputFocus((state) => state.setFocused); diff --git a/resources/js/pages/application/components/proxied-app-card.tsx b/resources/js/pages/application/components/proxied-app-card.tsx index 000cf1788..5eac63397 100644 --- a/resources/js/pages/application/components/proxied-app-card.tsx +++ b/resources/js/pages/application/components/proxied-app-card.tsx @@ -3,17 +3,19 @@ import { router } from '@inertiajs/react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { MoreVerticalIcon } from 'lucide-react'; import Port from '@/pages/site-settings/components/port'; import StartCommand from '@/pages/site-settings/components/start-command'; -import { WorkerAction, WorkerLogs } from '@/pages/workers/components/worker-row-actions'; +import { WorkerAction, WorkerEnvironment, WorkerLogs } from '@/pages/workers/components/worker-row-actions'; import ErrorIndicator from '@/components/error-indicator'; import { useRealtimeRecord, useSocketListener, type SocketEventData } from '@/hooks/use-socket-events'; +import { useDialog } from '@/hooks/use-dialog'; import { Site } from '@/types/site'; import { Worker } from '@/types/worker'; export default function ProxiedAppCard({ site, initialWorker }: { site: Site; initialWorker: Worker | null }) { + const dialog = useDialog(); const worker = useRealtimeRecord(initialWorker, 'worker'); useSocketListener( @@ -63,22 +65,29 @@ export default function ProxiedAppCard({ site, initialWorker }: { site: Site; in {worker?.status ?? 'pending_deploy'} - {worker && ( - - - - - - - - - - - - )} + + + + + + {worker ? ( + <> + + + + + + + ) : ( + dialog.workerEnv.open({ serverId: site.server_id, siteId: site.id })}> + Environment + + )} + + diff --git a/resources/js/pages/site-settings/components/start-command.tsx b/resources/js/pages/site-settings/components/start-command.tsx index 992747a82..64d305282 100644 --- a/resources/js/pages/site-settings/components/start-command.tsx +++ b/resources/js/pages/site-settings/components/start-command.tsx @@ -17,8 +17,8 @@ import InputError from '@/components/ui/input-error'; import { LoaderCircleIcon } from 'lucide-react'; import { Site } from '@/types/site'; import { Input } from '@/components/ui/input'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { cn } from '@/lib/utils'; +import { RadioGroup } from '@/components/ui/radio-group'; +import RadioCard from '@/components/radio-card'; type ApplyChoice = '' | 'config' | 'restart'; @@ -124,33 +124,3 @@ export default function StartCommand({ site, children }: { site: Site; children: ); } - -function RadioCard({ - value, - selected, - title, - description, - onSelect, -}: { - value: string; - selected: boolean; - title: string; - description: string; - onSelect: (value: string) => void; -}) { - return ( - - ); -} diff --git a/resources/js/pages/workers/components/columns.tsx b/resources/js/pages/workers/components/columns.tsx index b3f2c8181..0ed50315f 100644 --- a/resources/js/pages/workers/components/columns.tsx +++ b/resources/js/pages/workers/components/columns.tsx @@ -7,7 +7,7 @@ import { Badge } from '@/components/ui/badge'; import DateTime from '@/components/date-time'; import CopyableBadge from '@/components/copyable-badge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { WorkerAction, WorkerLogs } from '@/pages/workers/components/worker-row-actions'; +import { WorkerAction, WorkerEnvironment, WorkerLogs } from '@/pages/workers/components/worker-row-actions'; import ErrorIndicator from '@/components/error-indicator'; import { useDialog } from '@/hooks/use-dialog'; @@ -71,6 +71,7 @@ function Actions({ worker }: { worker: Worker }) { + {locked ? : } diff --git a/resources/js/pages/workers/components/env-dialog.tsx b/resources/js/pages/workers/components/env-dialog.tsx new file mode 100644 index 000000000..4a1b097ca --- /dev/null +++ b/resources/js/pages/workers/components/env-dialog.tsx @@ -0,0 +1,238 @@ +import { FormEvent, useEffect, useId, useMemo, useState } from 'react'; +import { useForm } from '@inertiajs/react'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Form, FormField } from '@/components/ui/form'; +import { Label } from '@/components/ui/label'; +import { RadioGroup } from '@/components/ui/radio-group'; +import RadioCard from '@/components/radio-card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { AlertCircleIcon, LoaderCircleIcon, PlusIcon, RefreshCwIcon } from 'lucide-react'; +import { useInputFocus } from '@/stores/useInputFocus'; +import { EnvVariable } from '@/types/env'; +import { generateUniqueKey } from '@/lib/env'; +import EnvVariableRow from '@/pages/application/components/env-variable-row'; + +type ApplyChoice = '' | 'config' | 'restart'; + +export default function WorkerEnvDialog({ + open, + onOpenChange, + serverId, + workerId, + siteId, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + serverId: number; + workerId?: number; + siteId?: number; +}) { + const setFocused = useInputFocus((state) => state.setFocused); + const groupLabelId = useId(); + const [variables, setVariables] = useState([]); + const [apply, setApply] = useState(''); + + useEffect(() => { + setFocused(open); + return () => setFocused(false); + }, [open, setFocused]); + + const duplicateKeys = useMemo(() => { + const keyCounts = new Map(); + variables.forEach((v) => { + const key = v.key.trim(); + if (key) { + keyCounts.set(key, (keyCounts.get(key) || 0) + 1); + } + }); + const duplicates = new Set(); + keyCounts.forEach((count, key) => { + if (count > 1) { + duplicates.add(key); + } + }); + return duplicates; + }, [variables]); + + const hasDuplicates = duplicateKeys.size > 0; + const workerMode = workerId !== undefined; + const choiceMissing = workerMode && apply === ''; + + const form = useForm<{ variables: Array<{ key: string; value: string; is_secret: boolean }>; restart?: boolean }>({ + variables: [], + }); + + const query = useQuery({ + queryKey: ['workerEnv', serverId, workerId, siteId], + queryFn: async () => { + const response = await axios.get( + workerMode + ? route('workers.env', { server: serverId, worker: workerId }) + : route('site-settings.worker-env', { server: serverId, site: siteId }), + ); + const parsed = (response.data?.variables ?? []).map((v: { key: string; value: string; is_secret: boolean }) => ({ + key: v.key, + value: v.value, + isSecret: v.is_secret, + isNew: false, + })); + setVariables(parsed); + return response.data; + }, + retry: false, + enabled: open, + refetchOnWindowFocus: false, + }); + + const handleVariableChange = (index: number, updatedVariable: EnvVariable) => { + setVariables((prev) => { + const newVariables = [...prev]; + newVariables[index] = updatedVariable; + return newVariables; + }); + }; + + const handleVariableDelete = (index: number) => { + setVariables((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleAddVariable = () => { + setVariables((prev) => [ + ...prev, + { + key: generateUniqueKey(prev.map((v) => v.key)), + value: '', + isSecret: false, + isNew: true, + }, + ]); + }; + + const submit = (e: FormEvent) => { + e.preventDefault(); + if (choiceMissing || !query.isSuccess) { + return; + } + form.transform(() => ({ + variables: variables.map((v) => ({ + key: v.key, + value: v.value, + is_secret: v.isSecret, + })), + ...(workerMode ? { restart: apply === 'restart' } : {}), + })); + form.patch( + workerMode + ? route('workers.update-env', { server: serverId, worker: workerId }) + : route('site-settings.update-worker-env', { server: serverId, site: siteId }), + { + onSuccess: () => onOpenChange(false), + }, + ); + }; + + return ( + + e.preventDefault()}> + + Worker Environment Variables + Environment variables passed to the worker process via supervisor. + +
+
+ {form.errors && Object.keys(form.errors).length > 0 && ( + + + + {Object.values(form.errors).map((error, i) => ( +
{error}
+ ))} +
+
+ )} + {query.isError ? ( + + + + Failed to load the environment variables. + + + + ) : query.isSuccess ? ( +
+ {variables.map((variable, index) => ( + handleVariableChange(index, updated)} + onDelete={() => handleVariableDelete(index)} + error={duplicateKeys.has(variable.key.trim()) ? 'Duplicate key' : undefined} + /> + ))} + +
+ ) : ( +
+ {[...Array(3)].map((_, i) => ( +
+ + + +
+ ))} +
+ )} +
+
+ {workerMode ? ( + + + setApply(value as ApplyChoice)} aria-labelledby={groupLabelId} className="gap-2"> + setApply(value as ApplyChoice)} + title="Update config only" + description="The worker keeps running with its current variables. The change takes effect on the next restart or deploy." + /> + setApply(value as ApplyChoice)} + title="Update and restart now" + description="Rewrites the config and restarts the worker immediately so the new variables take effect right away." + /> + + + ) : ( +

+ These variables will be applied when the application worker is created on the next deploy. +

+ )} +
+
+ +
+ + + + +
+
+
+
+ ); +} diff --git a/resources/js/pages/workers/components/worker-row-actions.tsx b/resources/js/pages/workers/components/worker-row-actions.tsx index 82d1f1140..a750e0ee0 100644 --- a/resources/js/pages/workers/components/worker-row-actions.tsx +++ b/resources/js/pages/workers/components/worker-row-actions.tsx @@ -29,3 +29,9 @@ export function WorkerLogs({ worker }: { worker: Worker }) { return dialog.workerLogs.open({ serverId: worker.server_id, workerId: worker.id })}>Logs; } + +export function WorkerEnvironment({ worker }: { worker: Worker }) { + const dialog = useDialog(); + + return dialog.workerEnv.open({ serverId: worker.server_id, workerId: worker.id })}>Environment; +} diff --git a/resources/js/pages/workers/index.tsx b/resources/js/pages/workers/index.tsx index 138f56f1f..85f35fb4e 100644 --- a/resources/js/pages/workers/index.tsx +++ b/resources/js/pages/workers/index.tsx @@ -6,7 +6,8 @@ import SiteBanners from '@/components/site-banners'; import HeaderContainer from '@/components/header-container'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; -import { BookOpenIcon, PlusIcon } from 'lucide-react'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { BookOpenIcon, MoreVerticalIcon, PlusIcon, RefreshCwIcon, RotateCwIcon } from 'lucide-react'; import Container from '@/components/container'; import { DataTable } from '@/components/data-table'; import { Worker } from '@/types/worker'; @@ -26,6 +27,9 @@ export default function WorkerIndex() { const [workers] = useRealtime(page.props.workers, 'worker'); + const scope = page.props.site ? { server: page.props.server.id, site: page.props.site.id } : { server: page.props.server.id }; + const scopeLabel = page.props.site ? `${page.props.site.domain}'s workers` : "this server's workers"; + return ( @@ -43,6 +47,43 @@ export default function WorkerIndex() { Docs + + + + + + + dialog.confirm.open({ + title: 'Resync workers', + description: `Fetch the live status of ${scopeLabel} from the process manager and update Vito. Continue?`, + confirmLabel: 'Resync', + method: 'post', + url: route('workers.resync', scope), + }) + } + > + + Resync + + + dialog.confirm.open({ + title: 'Restart all workers', + description: `Are you sure you want to restart ${scopeLabel}?`, + confirmLabel: 'Restart All', + method: 'post', + url: route('workers.restart-all', scope), + }) + } + > + + Restart All + + +