From c5961a61d52155830d5e0ac5ba1e255a59a6ed8b Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sat, 17 Oct 2026 19:15:35 +0200 Subject: [PATCH 01/30] phase 1 --- .gitignore | 3 +- .../HostedDomain/AssertHostAvailable.php | 33 +++ .../HostedDomain/CreateHostedDomain.php | 11 +- .../HostedDomain/UpdateHostedDomain.php | 15 +- .../IsolatedUser/EnsureIsolatedUser.php | 26 ++ app/Actions/PHP/EnsureFpmPool.php | 37 +++ app/Actions/Site/CreateSite.php | 14 +- app/Actions/Site/Tooling/GetSiteTooling.php | 3 +- .../Site/Tooling/UninstallSiteTooling.php | 3 +- app/Actions/SourceControl/SetupDeployKey.php | 50 ++++ app/DTOs/GitCloneDTO.php | 25 ++ app/DTOs/ToolingTargetDTO.php | 29 ++ app/Helpers/SiteShellEnvironment.php | 13 +- app/Http/Controllers/API/SiteController.php | 3 +- app/Jobs/Site/DeployJob.php | 4 +- app/Jobs/Site/RollbackJob.php | 4 +- .../Site/Tooling/InstallSiteToolingJob.php | 3 +- .../Site/Tooling/UninstallSiteToolingJob.php | 3 +- app/Models/Site.php | 12 +- app/Models/SourceControl.php | 16 + app/Models/Worker.php | 3 +- app/SSH/Mise/Mise.php | 14 +- app/SSH/OS/Composer.php | 3 +- app/SSH/OS/Git.php | 53 +++- app/SSH/OS/OS.php | 18 +- app/SiteTypes/AbstractSiteType.php | 89 ++---- app/Tooling/AbstractMiseTooling.php | 16 +- app/Tooling/AbstractTooling.php | 8 +- app/Tooling/ComposerTooling.php | 20 +- app/Tooling/ToolingInterface.php | 10 +- .../ssh/modern-deployment/release.blade.php | 2 +- tests/Feature/API/SitesTest.php | 64 ++++ tests/Feature/HostedDomainsTest.php | 24 ++ tests/Feature/SiteGroundworkTest.php | 277 ++++++++++++++++++ tests/Feature/SitesTest.php | 49 ++++ tests/Unit/SiteShellEnvironmentTest.php | 11 +- 36 files changed, 794 insertions(+), 174 deletions(-) create mode 100644 app/Actions/HostedDomain/AssertHostAvailable.php create mode 100644 app/Actions/IsolatedUser/EnsureIsolatedUser.php create mode 100644 app/Actions/PHP/EnsureFpmPool.php create mode 100644 app/Actions/SourceControl/SetupDeployKey.php create mode 100644 app/DTOs/GitCloneDTO.php create mode 100644 app/DTOs/ToolingTargetDTO.php create mode 100644 tests/Feature/SiteGroundworkTest.php diff --git a/.gitignore b/.gitignore index 14ee471b9..e30fba3db 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ opencode.json !.claude/commands/ .opencode .agents/ -skills-lock.json \ No newline at end of file +skills-lock.json +.plans/ \ No newline at end of file diff --git a/app/Actions/HostedDomain/AssertHostAvailable.php b/app/Actions/HostedDomain/AssertHostAvailable.php new file mode 100644 index 000000000..2e3bfc713 --- /dev/null +++ b/app/Actions/HostedDomain/AssertHostAvailable.php @@ -0,0 +1,33 @@ +where('server_id', $server->id) + ->where('domain', $host) + ->when($ignore?->type === HostedDomainType::PRIMARY, fn ($query) => $query->whereKeyNot($ignore->site_id)) + ->exists(); + + $hostedDomainExists = HostedDomain::query() + ->where('domain', $host) + ->whereHas('site', fn ($query) => $query->where('server_id', $server->id)) + ->when($ignore, fn ($query) => $query->whereKeyNot($ignore->id)) + ->exists(); + + if ($siteExists || $hostedDomainExists) { + throw ValidationException::withMessages([ + $field => 'This domain is already in use on this server.', + ]); + } + } +} diff --git a/app/Actions/HostedDomain/CreateHostedDomain.php b/app/Actions/HostedDomain/CreateHostedDomain.php index eaa64cbf7..67967f22c 100644 --- a/app/Actions/HostedDomain/CreateHostedDomain.php +++ b/app/Actions/HostedDomain/CreateHostedDomain.php @@ -20,6 +20,7 @@ class CreateHostedDomain public function create(Site $site, array $input): HostedDomain { $validated = $this->validate($site, $input); + app(AssertHostAvailable::class)->assert($site->server, $validated['domain'], 'domain'); $hostedDomain = new HostedDomain; $hostedDomain->site_id = $site->id; @@ -48,16 +49,6 @@ private function validate(Site $site, array $input): array 'string', 'max:255', 'regex:/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/', - function (string $attribute, mixed $value, \Closure $fail) use ($site): void { - $exists = HostedDomain::query() - ->where('domain', $value) - ->whereHas('site', fn ($q) => $q->where('server_id', $site->server_id)) - ->exists(); - - if ($exists) { - $fail('This domain is already in use on this server.'); - } - }, ], 'type' => [ 'required', diff --git a/app/Actions/HostedDomain/UpdateHostedDomain.php b/app/Actions/HostedDomain/UpdateHostedDomain.php index 4a299a1d8..9b230b7b5 100644 --- a/app/Actions/HostedDomain/UpdateHostedDomain.php +++ b/app/Actions/HostedDomain/UpdateHostedDomain.php @@ -29,6 +29,10 @@ public function update(HostedDomain $hostedDomain, Site $site, array $input): Ho $validated = $this->validate($hostedDomain, $site, $input); $isPrimary = $hostedDomain->type === HostedDomainType::PRIMARY; + if (! $isPrimary) { + app(AssertHostAvailable::class)->assert($site->server, $validated['domain'], 'domain', $hostedDomain); + } + $domainChanged = ! $isPrimary && $hostedDomain->domain !== $validated['domain']; $sslMethodChangedToLE = SslMethod::from($validated['ssl_method']) === SslMethod::LETSENCRYPT && $hostedDomain->ssl_method !== SslMethod::LETSENCRYPT; @@ -94,17 +98,6 @@ private function validate(HostedDomain $hostedDomain, Site $site, array $input): 'string', 'max:255', 'regex:/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/', - function (string $attribute, mixed $value, \Closure $fail) use ($site, $hostedDomain): void { - $exists = HostedDomain::query() - ->where('domain', $value) - ->where('id', '!=', $hostedDomain->id) - ->whereHas('site', fn ($q) => $q->where('server_id', $site->server_id)) - ->exists(); - - if ($exists) { - $fail('This domain is already in use on this server.'); - } - }, ]; $rules['type'] = [ 'required', diff --git a/app/Actions/IsolatedUser/EnsureIsolatedUser.php b/app/Actions/IsolatedUser/EnsureIsolatedUser.php new file mode 100644 index 000000000..ece995615 --- /dev/null +++ b/app/Actions/IsolatedUser/EnsureIsolatedUser.php @@ -0,0 +1,26 @@ +isolatedUserLock($username)->block(30, function () use ($server, $username, $siteId): void { + $server->os()->createIsolatedUser($username, Str::random(15), $siteId); + }); + } catch (LockTimeoutException) { + throw new RuntimeException("Could not acquire isolated-user lock for '{$username}' on server {$server->id} within 30s."); + } + } +} diff --git a/app/Actions/PHP/EnsureFpmPool.php b/app/Actions/PHP/EnsureFpmPool.php new file mode 100644 index 000000000..8d7decac0 --- /dev/null +++ b/app/Actions/PHP/EnsureFpmPool.php @@ -0,0 +1,37 @@ +php($phpVersion); + if (! $service instanceof Service) { + throw new RuntimeException('PHP service not found'); + } + + $server->isolatedUserLock($user)->block(30, function () use ($server, $user, $phpVersion, $service): void { + try { + $server->ssh()->exec(view('ssh.site.check-fpm-pool-exists', [ + 'user' => $user, + 'version' => $phpVersion, + ])); + } catch (SSHCommandError) { + /** @var PHP $php */ + $php = $service->handler(); + $php->createFpmPool($user, $phpVersion); + } + }); + } +} diff --git a/app/Actions/Site/CreateSite.php b/app/Actions/Site/CreateSite.php index e8aedeb92..d9823db37 100755 --- a/app/Actions/Site/CreateSite.php +++ b/app/Actions/Site/CreateSite.php @@ -2,6 +2,7 @@ namespace App\Actions\Site; +use App\Actions\HostedDomain\AssertHostAvailable; use App\Enums\HostedDomainStatus; use App\Enums\HostedDomainType; use App\Enums\SiteStatus; @@ -153,9 +154,15 @@ private function validate(Server $server, array $input): void 'domain' => [ 'required', new DomainRule, - Rule::unique('sites', 'domain')->where(fn ($query) => $query->where('server_id', $server->id)), + ], + 'aliases' => [ + 'nullable', + 'array', ], 'aliases.*' => [ + 'bail', + 'required', + 'string', new DomainRule, ], 'user' => [ @@ -171,6 +178,11 @@ private function validate(Server $server, array $input): void ]; Validator::make($input, array_merge($rules, $this->typeRules($server, $input)))->validate(); + + app(AssertHostAvailable::class)->assert($server, $input['domain'], 'domain'); + foreach ($input['aliases'] ?? [] as $index => $alias) { + app(AssertHostAvailable::class)->assert($server, $alias, 'aliases.'.$index); + } } /** diff --git a/app/Actions/Site/Tooling/GetSiteTooling.php b/app/Actions/Site/Tooling/GetSiteTooling.php index af63c666d..3402dff10 100644 --- a/app/Actions/Site/Tooling/GetSiteTooling.php +++ b/app/Actions/Site/Tooling/GetSiteTooling.php @@ -2,6 +2,7 @@ namespace App\Actions\Site\Tooling; +use App\DTOs\ToolingTargetDTO; use App\Models\Site; use App\Tooling\SiteToolingState; use App\Tooling\ToolingRegistry; @@ -35,7 +36,7 @@ public function get(Site $site): array $installed = []; $statuses = []; foreach (ToolingRegistry::all() as $id => $tool) { - $installed[$id] = $tool->installedVersion($site); + $installed[$id] = $tool->installedVersion(ToolingTargetDTO::forSite($site)); $statuses[$id] = SiteToolingState::currentStatus($site, $id); } diff --git a/app/Actions/Site/Tooling/UninstallSiteTooling.php b/app/Actions/Site/Tooling/UninstallSiteTooling.php index b8b452d46..71d58fe53 100644 --- a/app/Actions/Site/Tooling/UninstallSiteTooling.php +++ b/app/Actions/Site/Tooling/UninstallSiteTooling.php @@ -2,6 +2,7 @@ namespace App\Actions\Site\Tooling; +use App\DTOs\ToolingTargetDTO; use App\Jobs\Site\Tooling\UninstallSiteToolingJob; use App\Models\IsolatedUser; use App\Models\Site; @@ -27,7 +28,7 @@ public function uninstall(Site $site, string $toolId): void ]); } - if ($tool->installedVersion($site) === null) { + if ($tool->installedVersion(ToolingTargetDTO::forSite($site)) === null) { throw ValidationException::withMessages([ 'tool' => "{$tool::label()} is not currently installed for this isolated user.", ]); diff --git a/app/Actions/SourceControl/SetupDeployKey.php b/app/Actions/SourceControl/SetupDeployKey.php new file mode 100644 index 000000000..9da2bfbcb --- /dev/null +++ b/app/Actions/SourceControl/SetupDeployKey.php @@ -0,0 +1,50 @@ +isGithubApp()) { + return ['public_key' => null, 'deploy_key_id' => null]; + } + + $publicKey ??= $this->generateKey($server, $user, $keyName, $siteId); + + return [ + 'public_key' => $publicKey, + 'deploy_key_id' => $sourceControl->provider()->deployKey($title, $repository, $publicKey), + ]; + } + + /** + * @throws SSHError + */ + public function generateKey(Server $server, string $user, string $keyName, ?int $siteId = null): string + { + $os = $server->os(); + $os->generateSSHKey($keyName, $user, $siteId); + + return $os->readSSHKey($keyName, $user, $siteId); + } +} diff --git a/app/DTOs/GitCloneDTO.php b/app/DTOs/GitCloneDTO.php new file mode 100644 index 000000000..7946e5f38 --- /dev/null +++ b/app/DTOs/GitCloneDTO.php @@ -0,0 +1,25 @@ + $variables + */ + public function __construct( + public Server $server, + public string $user, + public string $repoUrl, + public string $branch, + public string $path, + public string $keyName, + public int $port = 22, + public bool $usesToken = false, + public array $variables = [], + public ?string $commit = null, + public ?int $siteId = null, + ) {} +} diff --git a/app/DTOs/ToolingTargetDTO.php b/app/DTOs/ToolingTargetDTO.php new file mode 100644 index 000000000..a0e41ced8 --- /dev/null +++ b/app/DTOs/ToolingTargetDTO.php @@ -0,0 +1,29 @@ +server, + user: $site->user ?? '', + isolatedUser: $site->isolatedUser, + phpVersion: $site->php_version, + siteId: $site->id, + ); + } +} diff --git a/app/Helpers/SiteShellEnvironment.php b/app/Helpers/SiteShellEnvironment.php index 52e14bf29..5a32f9d01 100644 --- a/app/Helpers/SiteShellEnvironment.php +++ b/app/Helpers/SiteShellEnvironment.php @@ -2,6 +2,7 @@ namespace App\Helpers; +use App\DTOs\ToolingTargetDTO; use App\Models\Site; use App\Tooling\ToolingRegistry; @@ -10,18 +11,18 @@ final class SiteShellEnvironment /** * @return array */ - public static function collect(Site $site): array + public static function collect(ToolingTargetDTO $target): array { - if (! $site->isolatedUser || $site->user === '' || $site->user === null) { + if (! $target->isolatedUser || $target->user === '') { return []; } $paths = []; foreach (ToolingRegistry::all() as $tool) { - if ($tool->installedVersion($site) === null) { + if ($tool->installedVersion($target) === null) { continue; } - foreach ($tool->pathContributions($site) as $entry) { + foreach ($tool->pathContributions($target) as $entry) { if ($entry !== '' && ! in_array($entry, $paths, true)) { $paths[] = $entry; } @@ -32,7 +33,7 @@ public static function collect(Site $site): array return []; } - $base = "/usr/local/bin:/usr/bin:/bin:/home/{$site->user}/.local/bin"; + $base = "/usr/local/bin:/usr/bin:/bin:/home/{$target->user}/.local/bin"; return ['PATH' => implode(':', $paths).':'.$base]; } @@ -40,7 +41,7 @@ public static function collect(Site $site): array public static function wrap(Site $site, string $command, bool $cdToSitePath = false): string { $exports = ''; - foreach (self::collect($site) as $key => $value) { + foreach (self::collect(ToolingTargetDTO::forSite($site)) as $key => $value) { $exports .= sprintf('export %s=%s && ', $key, escapeshellarg($value)); } diff --git a/app/Http/Controllers/API/SiteController.php b/app/Http/Controllers/API/SiteController.php index 1cb924abb..2e5ca4142 100644 --- a/app/Http/Controllers/API/SiteController.php +++ b/app/Http/Controllers/API/SiteController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\API; use App\Actions\Site\CreateSite; +use App\Actions\Site\DeleteSite; use App\Actions\Site\Deploy; use App\Actions\Site\DisableSsl; use App\Actions\Site\EnableSsl; @@ -75,7 +76,7 @@ public function delete(Project $project, Server $server, Site $site): Response $this->validateRoute($project, $server, $site); - $site->delete(); + app(DeleteSite::class)->delete($site, ['domain' => $site->domain]); return response()->noContent(); } diff --git a/app/Jobs/Site/DeployJob.php b/app/Jobs/Site/DeployJob.php index 80083a267..9c9233a2c 100644 --- a/app/Jobs/Site/DeployJob.php +++ b/app/Jobs/Site/DeployJob.php @@ -68,7 +68,7 @@ public function failed(Exception $e): void if ($this->isModern && $current) { $this->deployment->site->server->ssh($site->user)->exec( view('ssh.modern-deployment.release', [ - 'site' => $site, + 'basePath' => $site->basePath(), 'releasePath' => $current->path(), ]), 'release', @@ -153,7 +153,7 @@ private function handleModernDeployment($site, $log): void // release $site->server->ssh($site->user)->exec( view('ssh.modern-deployment.release', [ - 'site' => $site, + 'basePath' => $site->basePath(), 'releasePath' => $this->deployment->path(), ]), 'release', diff --git a/app/Jobs/Site/RollbackJob.php b/app/Jobs/Site/RollbackJob.php index 78aaa434b..47451eb1b 100644 --- a/app/Jobs/Site/RollbackJob.php +++ b/app/Jobs/Site/RollbackJob.php @@ -35,7 +35,7 @@ public function handle(): void $this->run("site-{$site->id}", function () use ($site) { $this->deployment->site->server->ssh($site->user)->exec( view('ssh.modern-deployment.release', [ - 'site' => $site, + 'basePath' => $site->basePath(), 'releasePath' => $this->deployment->path(), ]), 'release', @@ -61,7 +61,7 @@ public function failed(Exception $e): void if ($this->current) { $this->deployment->site->server->ssh($site->user)->exec( view('ssh.modern-deployment.release', [ - 'site' => $site, + 'basePath' => $site->basePath(), 'releasePath' => $this->current->path(), ]), 'release', diff --git a/app/Jobs/Site/Tooling/InstallSiteToolingJob.php b/app/Jobs/Site/Tooling/InstallSiteToolingJob.php index c8fa43a99..a1c259787 100644 --- a/app/Jobs/Site/Tooling/InstallSiteToolingJob.php +++ b/app/Jobs/Site/Tooling/InstallSiteToolingJob.php @@ -2,6 +2,7 @@ namespace App\Jobs\Site\Tooling; +use App\DTOs\ToolingTargetDTO; use App\Jobs\Worker\RefreshSiteWorkerConfigsJob; use App\Models\ServerLog; use App\Models\Site; @@ -43,7 +44,7 @@ public function handle(): void throw new RuntimeException("Tooling '{$this->toolId}' is not registered; it may have been removed or the worker is out of sync."); } - $tool->install($this->site, $this->version); + $tool->install(ToolingTargetDTO::forSite($this->site), $this->version); SiteToolingState::completeInstall($this->site, $this->toolId, $this->version); diff --git a/app/Jobs/Site/Tooling/UninstallSiteToolingJob.php b/app/Jobs/Site/Tooling/UninstallSiteToolingJob.php index 6bdfaf6ab..2ade2a4b9 100644 --- a/app/Jobs/Site/Tooling/UninstallSiteToolingJob.php +++ b/app/Jobs/Site/Tooling/UninstallSiteToolingJob.php @@ -2,6 +2,7 @@ namespace App\Jobs\Site\Tooling; +use App\DTOs\ToolingTargetDTO; use App\Jobs\Worker\RefreshSiteWorkerConfigsJob; use App\Models\ServerLog; use App\Models\Site; @@ -42,7 +43,7 @@ public function handle(): void throw new RuntimeException("Tooling '{$this->toolId}' is not registered; it may have been removed or the worker is out of sync."); } - $tool->uninstall($this->site); + $tool->uninstall(ToolingTargetDTO::forSite($this->site)); SiteToolingState::completeUninstall($this->site, $this->toolId); diff --git a/app/Models/Site.php b/app/Models/Site.php index d812b7c21..a46a2cbfe 100755 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\DTOs\ToolingTargetDTO; use App\Enums\DeploymentStatus; use App\Enums\HostedDomainStatus; use App\Enums\HostedDomainType; @@ -21,7 +22,6 @@ use App\SiteTypes\BunSite; use App\SiteTypes\NodeSite; use App\SiteTypes\SiteType; -use App\SourceControlProviders\GithubApp; use App\Tooling\ToolingRegistry; use App\Traits\HasProjectThroughServer; use Database\Factories\SiteFactory; @@ -165,7 +165,7 @@ public function isReady(): bool public function ssh(): SSH { return $this->server->ssh($this->user)->variables( - SiteShellEnvironment::collect($this) + SiteShellEnvironment::collect(ToolingTargetDTO::forSite($this)) ); } @@ -672,13 +672,7 @@ public function environmentVariables(?Deployment $deployment = null): array 'PHP_PATH' => '/usr/bin/php'.$this->php_version, ]; - if ($this->sourceControl?->isGithubApp()) { - /** @var GithubApp $provider */ - $provider = $this->sourceControl->provider(); - $variables['GIT_HTTP_TOKEN'] = $provider->installationAccessToken(); - } - - return $variables; + return array_merge($variables, $this->sourceControl?->cloneVariables() ?? []); } /** diff --git a/app/Models/SourceControl.php b/app/Models/SourceControl.php index 549525221..4c6dadbbd 100755 --- a/app/Models/SourceControl.php +++ b/app/Models/SourceControl.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\SourceControlProviders\GithubApp; use App\SourceControlProviders\SourceControlProvider; use App\Traits\HasProjectScopedQueries; use Database\Factories\SourceControlFactory; @@ -56,6 +57,21 @@ public function isGithubApp(): bool return $this->provider === self::PROVIDER_GITHUB_APP; } + /** + * @return array + */ + public function cloneVariables(): array + { + if (! $this->isGithubApp()) { + return []; + } + + /** @var GithubApp $provider */ + $provider = $this->provider(); + + return ['GIT_HTTP_TOKEN' => $provider->installationAccessToken()]; + } + /** * @return array */ diff --git a/app/Models/Worker.php b/app/Models/Worker.php index 1d2fe8c30..8bdfcde74 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\DTOs\ToolingTargetDTO; use App\Enums\WorkerStatus; use App\Helpers\SiteShellEnvironment; use App\Services\ProcessManager\ProcessManager; @@ -140,7 +141,7 @@ public function effectiveEnvironment(): array $base = $this->environmentMap(); if ($this->site_id && $this->site) { - return array_merge($base, SiteShellEnvironment::collect($this->site)); + return array_merge($base, SiteShellEnvironment::collect(ToolingTargetDTO::forSite($this->site))); } return $base; diff --git a/app/SSH/Mise/Mise.php b/app/SSH/Mise/Mise.php index bbaf64c89..02a4ea5be 100644 --- a/app/SSH/Mise/Mise.php +++ b/app/SSH/Mise/Mise.php @@ -2,9 +2,9 @@ namespace App\SSH\Mise; +use App\DTOs\ToolingTargetDTO; use App\Exceptions\SSHError; use App\Models\Server; -use App\Models\Site; class Mise { @@ -34,29 +34,29 @@ public function isInstalled(): bool /** * @throws SSHError */ - public function installRuntime(Site $site, string $runtime, string $version): void + public function installRuntime(ToolingTargetDTO $target, string $runtime, string $version): void { - $this->server->ssh($site->user)->exec( + $this->server->ssh($target->user)->exec( view('ssh.mise.install-runtime', [ 'runtime' => $runtime, 'version' => $version, ]), 'mise-install-'.$runtime.'-'.$version, - $site->id + $target->siteId ); } /** * @throws SSHError */ - public function uninstallRuntime(Site $site, string $runtime): void + public function uninstallRuntime(ToolingTargetDTO $target, string $runtime): void { - $this->server->ssh($site->user)->exec( + $this->server->ssh($target->user)->exec( view('ssh.mise.uninstall-runtime', [ 'runtime' => $runtime, ]), 'mise-uninstall-'.$runtime, - $site->id + $target->siteId ); } } diff --git a/app/SSH/OS/Composer.php b/app/SSH/OS/Composer.php index 1030ffc39..4e16e076a 100644 --- a/app/SSH/OS/Composer.php +++ b/app/SSH/OS/Composer.php @@ -2,6 +2,7 @@ namespace App\SSH\OS; +use App\DTOs\ToolingTargetDTO; use App\Exceptions\SSHError; use App\Helpers\SiteShellEnvironment; use App\Models\Site; @@ -14,7 +15,7 @@ class Composer public function installDependencies(Site $site): void { $site->server->ssh($site->user) - ->variables(SiteShellEnvironment::collect($site)) + ->variables(SiteShellEnvironment::collect(ToolingTargetDTO::forSite($site))) ->exec( view('ssh.composer.composer-install', [ 'path' => $site->path, diff --git a/app/SSH/OS/Git.php b/app/SSH/OS/Git.php index 5e12208a8..6c28d6626 100644 --- a/app/SSH/OS/Git.php +++ b/app/SSH/OS/Git.php @@ -2,6 +2,7 @@ namespace App\SSH\OS; +use App\DTOs\GitCloneDTO; use App\Exceptions\SSHError; use App\Models\Site; @@ -13,26 +14,50 @@ class Git public function clone(Site $site, ?string $path = null): void { $usesToken = $site->sourceControl?->isGithubApp() ?? false; - $ssh = $site->server->ssh($site->user); - if ($usesToken) { - $ssh = $ssh->variables($site->environmentVariables()); - } + $this->cloneRepository(new GitCloneDTO( + server: $site->server, + user: $site->user ?? $site->server->getSshUser(), + repoUrl: (string) $site->getFullRepositoryUrl(), + branch: (string) $site->branch, + path: $path ?? $site->path, + keyName: $site->getSshKeyName(), + port: $site->sourceControl?->provider()->getSshPort() ?? 22, + usesToken: $usesToken, + variables: $usesToken ? $site->environmentVariables() : [], + siteId: $site->id, + )); + } - $repoUrl = (string) $site->getFullRepositoryUrl(); - $ssh->exec( + /** + * @throws SSHError + */ + public function cloneRepository(GitCloneDTO $target): void + { + $target->server->ssh($target->user)->variables($target->variables)->exec( view('ssh.git.clone', [ - 'host' => $usesToken ? '' : str($repoUrl)->after('@')->before('-'), - 'repo' => escapeshellarg($repoUrl), - 'path' => escapeshellarg((string) ($path ?? $site->path)), - 'branch' => escapeshellarg((string) $site->branch), - 'key' => $site->getSshKeyName(), - 'port' => $site->sourceControl?->provider()?->getSshPort() ?? 22, - 'usesToken' => $usesToken, + 'host' => $target->usesToken ? '' : str($target->repoUrl)->after('@')->before('-'), + 'repo' => escapeshellarg($target->repoUrl), + 'path' => escapeshellarg($target->path), + 'branch' => escapeshellarg($target->branch), + 'key' => $target->keyName, + 'port' => $target->port, + 'usesToken' => $target->usesToken, ]), 'clone-repository', - $site->id + $target->siteId ); + + if ($target->commit !== null) { + $target->server->ssh($target->user)->exec( + view('ssh.git.checkout', [ + 'path' => escapeshellarg($target->path), + 'branch' => escapeshellarg($target->commit), + ]), + 'checkout-commit', + $target->siteId + ); + } } /** diff --git a/app/SSH/OS/OS.php b/app/SSH/OS/OS.php index 80cb38792..709bc90f6 100644 --- a/app/SSH/OS/OS.php +++ b/app/SSH/OS/OS.php @@ -6,7 +6,6 @@ use App\Exceptions\SSHError; use App\Models\Server; use App\Models\ServerLog; -use App\Models\Site; use RuntimeException; class OS @@ -117,7 +116,7 @@ public function createUser(string $user, string $password, string $key, bool $cl /** * @throws SSHError */ - public function createIsolatedUser(string $user, string $password, int $site_id): void + public function createIsolatedUser(string $user, string $password, ?int $siteId = null): void { $this->server->ssh()->exec( view('ssh.os.create-isolated-user', [ @@ -127,7 +126,7 @@ public function createIsolatedUser(string $user, string $password, int $site_id) 'key' => escapeshellarg(trim($this->server->sshKey()['public_key'])), ]), 'create-isolated-user', - $site_id + $siteId ); } @@ -193,27 +192,28 @@ public function deleteSSHKey(string $key, string $user): void /** * @throws SSHError */ - public function generateSSHKey(string $name, ?Site $site = null): void + public function generateSSHKey(string $name, ?string $user = null, ?int $siteId = null): void { - $this->server->ssh($site?->user)->exec( + $this->server->ssh($user)->exec( view('ssh.os.generate-ssh-key', [ 'name' => $name, ]), 'generate-ssh-key', - $site?->id + $siteId ); } /** * @throws SSHError */ - public function readSSHKey(string $name, ?Site $site = null): string + public function readSSHKey(string $name, ?string $user = null, ?int $siteId = null): string { - return $this->server->ssh($site?->user)->exec( + return $this->server->ssh($user)->exec( view('ssh.os.read-ssh-key', [ 'name' => $name, ]), - 'read-ssh-key' + 'read-ssh-key', + $siteId ); } diff --git a/app/SiteTypes/AbstractSiteType.php b/app/SiteTypes/AbstractSiteType.php index eeb5f151c..a5759c208 100755 --- a/app/SiteTypes/AbstractSiteType.php +++ b/app/SiteTypes/AbstractSiteType.php @@ -2,7 +2,11 @@ namespace App\SiteTypes; +use App\Actions\IsolatedUser\EnsureIsolatedUser; +use App\Actions\PHP\EnsureFpmPool; +use App\Actions\SourceControl\SetupDeployKey; use App\DTOs\SocketEventDTO; +use App\DTOs\ToolingTargetDTO; use App\Events\SocketEvent; use App\Exceptions\FailedToDeployGitKey; use App\Exceptions\SSHCommandError; @@ -10,16 +14,11 @@ use App\Helpers\SiteShellEnvironment; use App\Http\Resources\SiteResource; use App\Models\Deployment; -use App\Models\Service; use App\Models\Site; -use App\Services\PHP\PHP; use App\SSH\OS\Git; use App\Tooling\SiteToolingState; use App\Tooling\ToolingRegistry; -use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Support\Facades\File; -use Illuminate\Support\Str; -use RuntimeException; abstract class AbstractSiteType implements SiteType { @@ -78,7 +77,7 @@ public function vhostData(): array */ public function deploymentEnvironment(): array { - return SiteShellEnvironment::collect($this->site); + return SiteShellEnvironment::collect(ToolingTargetDTO::forSite($this->site)); } public function afterDeploy(Deployment $deployment): void @@ -138,12 +137,17 @@ protected function deployKey(): void return; } - $os = $this->site->server->os(); + $setupDeployKey = app(SetupDeployKey::class); + $keyName = $this->site->getSshKeyName(); + $user = $this->site->user ?? $this->site->server->getSshUser(); if (! $this->site->ssh_key) { - $keyName = $this->site->getSshKeyName(); - $os->generateSSHKey($keyName, $this->site); - $publicKey = $os->readSSHKey($keyName, $this->site); + $publicKey = $setupDeployKey->generateKey( + $this->site->server, + $user, + $keyName, + $this->site->id, + ); if (str_starts_with($keyName, 'iuser_') && $this->site->isolatedUser) { $this->site->isolatedUser->ssh_key = $publicKey; @@ -156,12 +160,17 @@ protected function deployKey(): void } if (empty($this->site->type_data['deploy_key_id'])) { - $keyId = $this->site->sourceControl?->provider()?->deployKey( - $this->site->getDeployKeyName(), + $key = $setupDeployKey->setup( + $this->site->server, + $user, + $keyName, + $this->site->sourceControl, $this->site->repository, - $this->site->ssh_key + $this->site->getDeployKeyName(), + publicKey: $this->site->ssh_key, + siteId: $this->site->id, ); - $this->site->jsonUpdate('type_data', 'deploy_key_id', $keyId); + $this->site->jsonUpdate('type_data', 'deploy_key_id', $key['deploy_key_id']); } } @@ -174,37 +183,10 @@ protected function isolate(): void return; } - $lock = $this->site->isolatedUser?->lock() ?? $this->site->server->isolatedUserLock($this->site->user); - - try { - $lock->block(30); - } catch (LockTimeoutException) { - throw new RuntimeException("Could not acquire isolated-user lock for '{$this->site->user}' on server {$this->site->server_id} within 30s."); - } - - try { - $this->site->server->os()->createIsolatedUser( - $this->site->user, - Str::random(15), - $this->site->id - ); + app(EnsureIsolatedUser::class)->ensure($this->site->server, $this->site->user, $this->site->id); - if ($this->site->php_version) { - $service = $this->site->php(); - if (! $service instanceof Service) { - throw new RuntimeException('PHP service not found'); - } - if (! $this->site->fpmPoolSharedWithSiblings() && ! $this->fpmPoolExists($this->site->user, $this->site->php_version)) { - /** @var PHP $php */ - $php = $service->handler(); - $php->createFpmPool( - $this->site->user, - $this->site->php_version - ); - } - } - } finally { - $lock->release(); + if ($this->site->php_version && ! $this->site->fpmPoolSharedWithSiblings()) { + app(EnsureFpmPool::class)->ensure($this->site->server, $this->site->user, $this->site->php_version); } } @@ -239,23 +221,6 @@ protected function repositoryAlreadyCloned(): bool } } - /** - * @throws SSHError - */ - protected function fpmPoolExists(string $user, string $version): bool - { - try { - $this->site->server->ssh()->exec(view('ssh.site.check-fpm-pool-exists', [ - 'user' => $user, - 'version' => $version, - ])); - - return true; - } catch (SSHCommandError) { - return false; - } - } - /** * @throws SSHError */ @@ -281,7 +246,7 @@ protected function setupRequestedTooling(): void continue; } - $tool->install($this->site, $version); + $tool->install(ToolingTargetDTO::forSite($this->site), $version); SiteToolingState::completeInstall($this->site, $toolId, $version); diff --git a/app/Tooling/AbstractMiseTooling.php b/app/Tooling/AbstractMiseTooling.php index 36b57a40f..dc3fe3aa2 100644 --- a/app/Tooling/AbstractMiseTooling.php +++ b/app/Tooling/AbstractMiseTooling.php @@ -2,8 +2,8 @@ namespace App\Tooling; +use App\DTOs\ToolingTargetDTO; use App\Exceptions\SSHError; -use App\Models\Site; use App\SSH\Mise\Mise; abstract class AbstractMiseTooling extends AbstractTooling @@ -11,23 +11,23 @@ abstract class AbstractMiseTooling extends AbstractTooling /** * @throws SSHError */ - public function install(Site $site, string $version): void + public function install(ToolingTargetDTO $target, string $version): void { - $mise = new Mise($site->server); + $mise = new Mise($target->server); $mise->ensureInstalled(); - $mise->installRuntime($site, static::id(), $version); + $mise->installRuntime($target, static::id(), $version); } /** * @throws SSHError */ - public function uninstall(Site $site): void + public function uninstall(ToolingTargetDTO $target): void { - new Mise($site->server)->uninstallRuntime($site, static::id()); + new Mise($target->server)->uninstallRuntime($target, static::id()); } - public function pathContributions(Site $site): array + public function pathContributions(ToolingTargetDTO $target): array { - return ['/home/'.$site->user.'/.local/share/mise/shims']; + return ['/home/'.$target->user.'/.local/share/mise/shims']; } } diff --git a/app/Tooling/AbstractTooling.php b/app/Tooling/AbstractTooling.php index d6192999f..bc4c837a9 100644 --- a/app/Tooling/AbstractTooling.php +++ b/app/Tooling/AbstractTooling.php @@ -2,7 +2,7 @@ namespace App\Tooling; -use App\Models\Site; +use App\DTOs\ToolingTargetDTO; abstract class AbstractTooling implements ToolingInterface { @@ -19,14 +19,14 @@ public static function supportedVersionsWithNone(): array return array_merge(['none'], static::supportedVersions()); } - public function installedVersion(Site $site): ?string + public function installedVersion(ToolingTargetDTO $target): ?string { - $version = $site->isolatedUser?->toolingVersion(static::id()); + $version = $target->isolatedUser?->toolingVersion(static::id()); return $version === 'none' ? null : $version; } - public function pathContributions(Site $site): array + public function pathContributions(ToolingTargetDTO $target): array { return []; } diff --git a/app/Tooling/ComposerTooling.php b/app/Tooling/ComposerTooling.php index 7f6dd4f5d..d0a2159f1 100644 --- a/app/Tooling/ComposerTooling.php +++ b/app/Tooling/ComposerTooling.php @@ -2,8 +2,8 @@ namespace App\Tooling; +use App\DTOs\ToolingTargetDTO; use App\Exceptions\SSHError; -use App\Models\Site; class ComposerTooling extends AbstractTooling { @@ -37,35 +37,35 @@ public static function commands(): array /** * @throws SSHError */ - public function install(Site $site, string $version): void + public function install(ToolingTargetDTO $target, string $version): void { - $site->server->ssh($site->user)->exec( + $target->server->ssh($target->user)->exec( view('ssh.composer.install', [ - 'phpVersion' => $site->php_version, + 'phpVersion' => $target->phpVersion, 'majorVersion' => $version, 'installDir' => self::INSTALL_DIR, ]), 'install-composer', - $site->id + $target->siteId ); } /** * @throws SSHError */ - public function uninstall(Site $site): void + public function uninstall(ToolingTargetDTO $target): void { - $site->server->ssh($site->user)->exec( + $target->server->ssh($target->user)->exec( view('ssh.composer.uninstall', [ 'installDir' => self::INSTALL_DIR, ]), 'uninstall-composer', - $site->id + $target->siteId ); } - public function pathContributions(Site $site): array + public function pathContributions(ToolingTargetDTO $target): array { - return ['/home/'.$site->user.'/'.self::INSTALL_DIR]; + return ['/home/'.$target->user.'/'.self::INSTALL_DIR]; } } diff --git a/app/Tooling/ToolingInterface.php b/app/Tooling/ToolingInterface.php index b8cb27194..edb60ee9c 100644 --- a/app/Tooling/ToolingInterface.php +++ b/app/Tooling/ToolingInterface.php @@ -2,8 +2,8 @@ namespace App\Tooling; +use App\DTOs\ToolingTargetDTO; use App\Exceptions\SSHError; -use App\Models\Site; interface ToolingInterface { @@ -28,19 +28,19 @@ public static function typeDataKey(): string; /** * @throws SSHError */ - public function install(Site $site, string $version): void; + public function install(ToolingTargetDTO $target, string $version): void; /** * @throws SSHError */ - public function uninstall(Site $site): void; + public function uninstall(ToolingTargetDTO $target): void; - public function installedVersion(Site $site): ?string; + public function installedVersion(ToolingTargetDTO $target): ?string; /** * @return array */ - public function pathContributions(Site $site): array; + public function pathContributions(ToolingTargetDTO $target): array; /** * @return array diff --git a/resources/views/ssh/modern-deployment/release.blade.php b/resources/views/ssh/modern-deployment/release.blade.php index d4f40c280..4436232a9 100644 --- a/resources/views/ssh/modern-deployment/release.blade.php +++ b/resources/views/ssh/modern-deployment/release.blade.php @@ -1,3 +1,3 @@ -ln -sfn {{ $releasePath }} {{ $site->basePath() }}/current +ln -sfn {{ $releasePath }} {{ $basePath }}/current echo "Version {{ basename($releasePath) }} activated! 🎉" diff --git a/tests/Feature/API/SitesTest.php b/tests/Feature/API/SitesTest.php index 4270a616f..fa51322cf 100644 --- a/tests/Feature/API/SitesTest.php +++ b/tests/Feature/API/SitesTest.php @@ -64,6 +64,34 @@ Http::assertNothingSent(); })->with([false, null]); +test('api site creation rejects malformed alias input before checking hosts', function (mixed $aliases, string $field) { + SSH::fake(); + Http::fake(); + Queue::fake(); + $token = $this->user->createToken('site-create', ['write', 'project:'.$this->server->project_id]); + + $this->withToken($token->plainTextToken)->postJson(route('api.projects.servers.sites.create', [ + 'project' => $this->server->project_id, + 'server' => $this->server, + ]), [ + 'type' => Blank::id(), + 'domain' => 'invalid-alias.example.com', + 'aliases' => $aliases, + 'user' => 'newruntime', + 'port' => 3000, + ])->assertUnprocessable()->assertJsonValidationErrors($field); + + $this->assertDatabaseMissing('sites', ['domain' => 'invalid-alias.example.com']); + Queue::assertNothingPushed(); + expect(SSH::getExecutedCommands())->toBe([]); +})->with([ + 'null entry' => [[null], 'aliases.0'], + 'empty entry' => [[''], 'aliases.0'], + 'non-string entry' => [[123], 'aliases.0'], + 'nested entry' => [[['alias.example.com']], 'aliases.0'], + 'scalar list' => ['alias.example.com', 'aliases'], +]); + test('api site source selection uses the token actor rather than the server creator', function (string $scope) { SSH::fake(); Http::fake(); @@ -240,6 +268,42 @@ ->assertNoContent(); }); +test('api site deletion tears down only unshared isolated resources', function (bool $shared) { + SSH::fake(); + Http::fake(); + $site = Site::factory()->create([ + 'server_id' => $this->server->id, + 'user' => 'cleanupuser', + 'php_version' => '8.2', + 'source_control_id' => null, + ]); + if ($shared) { + Site::factory()->create([ + 'server_id' => $this->server->id, + 'user' => 'cleanupuser', + 'php_version' => '8.2', + ]); + } + $token = $this->user->createToken('delete-site', ['write', 'project:'.$this->server->project_id]); + + $this->withToken($token->plainTextToken)->deleteJson(route('api.projects.servers.sites.delete', [ + 'project' => $this->server->project_id, + 'server' => $this->server, + 'site' => $site, + ]))->assertNoContent(); + + $this->assertDatabaseMissing('sites', ['id' => $site->id]); + if ($shared) { + $this->assertDatabaseHas('isolated_users', ['id' => $site->isolated_user_id]); + SSH::assertNotExecutedContains('rm -f /etc/php/8.2/fpm/pool.d/cleanupuser.conf'); + SSH::assertNotExecutedContains('userdel'); + } else { + $this->assertDatabaseMissing('isolated_users', ['id' => $site->isolated_user_id]); + SSH::assertExecutedContains('rm -f /etc/php/8.2/fpm/pool.d/cleanupuser.conf'); + SSH::assertExecutedContains('userdel'); + } +})->with([false, true]); + test('update web directory', function () { SSH::fake(); diff --git a/tests/Feature/HostedDomainsTest.php b/tests/Feature/HostedDomainsTest.php index 7657d6c60..4dad72f57 100644 --- a/tests/Feature/HostedDomainsTest.php +++ b/tests/Feature/HostedDomainsTest.php @@ -141,6 +141,30 @@ ->assertSessionHasErrors('domain'); }); +test('hosted domains cannot claim a site domain without a hosted domain row', function (bool $updating) { + SSH::fake(); + Bus::fake(); + $this->actingAs($this->user); + $input = [ + 'domain' => $this->site->domain, + 'type' => HostedDomainType::ALIAS->value, + 'ssl_method' => SslMethod::NONE->value, + ]; + $params = ['server' => $this->server, 'site' => $this->site]; + + if ($updating) { + $domain = HostedDomain::factory()->create(['site_id' => $this->site->id, 'domain' => 'original.example.com']); + $this->put(route('hosted-domains.update', $params + ['hostedDomain' => $domain]), $input) + ->assertSessionHasErrors('domain'); + $this->assertDatabaseHas('hosted_domains', ['id' => $domain->id, 'domain' => 'original.example.com']); + } else { + $this->post(route('hosted-domains.store', $params), $input)->assertRedirect()->assertSessionHasErrors('domain'); + } + + Bus::assertNotDispatched(CheckDomainJob::class); + expect(SSH::getExecutedCommands())->toBe([]); +})->with([false, true]); + test('create domain with custom ssl', function () { SSH::fake(); diff --git a/tests/Feature/SiteGroundworkTest.php b/tests/Feature/SiteGroundworkTest.php new file mode 100644 index 000000000..d1594ffbf --- /dev/null +++ b/tests/Feature/SiteGroundworkTest.php @@ -0,0 +1,277 @@ + Http::response(['id' => 123], 201)]); + + $key = app(SetupDeployKey::class)->setup( + $this->server, 'runtime', 'runtime_key', $this->site->sourceControl, 'owner/repo', 'Runtime', + siteId: $this->site->id, + ); + + expect($key)->toBe(['public_key' => 'ssh-rsa test-public-key', 'deploy_key_id' => '123']); + SSH::assertExecutedContains('ssh-keygen -t rsa -b 4096 -N "" -f ~/.ssh/runtime_key'); + SSH::assertExecutedContains('cat ~/.ssh/runtime_key.pub'); + Http::assertSent(fn ($request) => $request->url() === 'https://api.github.com/repos/owner/repo/keys' + && $request['title'] === 'Runtime' && $request['key'] === $key['public_key']); + $this->assertDatabaseHas('server_logs', ['site_id' => $this->site->id, 'type' => 'generate-ssh-key']); +}); + +test('deploy key setup reuses a supplied public key without generating another', function () { + SSH::fake(); + Http::fake(['api.github.com/repos/owner/repo/keys' => Http::response(['id' => 123], 201)]); + + $key = app(SetupDeployKey::class)->setup( + $this->server, 'runtime', 'shared_key', $this->site->sourceControl, 'owner/repo', 'Runtime', + publicKey: 'ssh-rsa shared-public-key', + ); + + expect($key['public_key'])->toBe('ssh-rsa shared-public-key'); + expect(SSH::getExecutedCommands())->toBe([]); + Http::assertSent(fn ($request) => $request['key'] === 'ssh-rsa shared-public-key'); +}); + +test('deploy key setup skips github app authentication', function () { + SSH::fake(); + Http::fake(); + + $sourceControl = SourceControl::factory()->githubApp()->create(); + $key = app(SetupDeployKey::class)->setup($this->server, 'runtime', 'unused', $sourceControl, 'owner/repo', 'Runtime'); + + expect($key)->toBe(['public_key' => null, 'deploy_key_id' => null]); + expect(SSH::getExecutedCommands())->toBe([]); + Http::assertNothingSent(); +}); + +test('site deploy key retries preserve generated keys and skip completed registration', function (bool $isolated) { + SSH::fake('ssh-rsa retry-public-key'); + Http::fake(['api.github.com/repos/*/keys' => Http::sequence()->push([], 403)->push(['id' => 123], 201)]); + $site = $isolated ? Site::factory()->create([ + 'server_id' => $this->server->id, + 'source_control_id' => $this->site->source_control_id, + 'repository' => 'owner/repo', + 'user' => 'shareduser', + 'ssh_key' => null, + ]) : $this->site; + $site->update(['ssh_key' => null, 'type_data' => []]); + $type = $site->type(); + $method = new ReflectionMethod($type, 'deployKey'); + + expect(fn () => $method->invoke($type))->toThrow(FailedToDeployGitKey::class); + expect($site->fresh()->ssh_key)->toBe('ssh-rsa retry-public-key'); + if ($isolated) { + expect($site->isolatedUser->fresh()->ssh_key)->toBe('ssh-rsa retry-public-key'); + } + + SSH::fake(); + $method->invoke($type); + $method->invoke($type); + + expect(SSH::getExecutedCommands())->toBe([]); + Http::assertSentCount(2); + $this->assertDatabaseHas('sites', ['id' => $site->id, 'type_data->deploy_key_id' => '123']); +})->with([false, true]); + +test('isolated user setup is repeatable and releases the shared lock', function () { + SSH::fake(); + $action = app(EnsureIsolatedUser::class); + + $action->ensure($this->server, 'runtime', $this->site->id); + $action->ensure($this->server, 'runtime'); + + SSH::assertExecutedContains('if id -u runtime'); + SSH::assertExecutedContains('User runtime already exists'); + expect(SSH::getExecutedCommands())->toHaveCount(2); + $this->assertDatabaseHas('server_logs', ['site_id' => $this->site->id, 'type' => 'create-isolated-user']); + expect($this->server->isolatedUserLock('runtime')->get(fn () => true))->toBeTrue(); +}); + +test('isolated user failures bubble up and release the lock', function () { + SSH::fake()->execWillFail(); + + expect(fn () => app(EnsureIsolatedUser::class)->ensure($this->server, 'runtime'))->toThrow(SSHCommandError::class); + expect($this->server->isolatedUserLock('runtime')->get(fn () => true))->toBeTrue(); +}); + +test('fpm pool setup creates only a missing pool', function () { + $ssh = Mockery::mock(SSHFake::class, [null])->makePartial(); + SSH::swap($ssh); + $checks = 0; + $ssh->shouldReceive('exec') + ->withArgs(fn ($command) => str_contains((string) $command, 'test -f /etc/php/8.2/fpm/pool.d/runtime.conf')) + ->twice() + ->andReturnUsing(function () use (&$checks): string { + if ($checks++ === 0) { + throw new SSHCommandError; + } + + return ''; + }); + + app(EnsureFpmPool::class)->ensure($this->server, 'runtime', '8.2'); + app(EnsureFpmPool::class)->ensure($this->server, 'runtime', '8.2'); + + expect($ssh->getUploadedContent())->toContain('[runtime]'); + $restarts = array_filter(SSH::getExecutedCommands(), fn ($command) => str_contains($command, 'restart php8.2-fpm')); + expect($restarts)->toHaveCount(1); + expect($this->server->isolatedUserLock('runtime')->get(fn () => true))->toBeTrue(); +}); + +test('fpm pool setup does not treat connection failures as missing pools', function () { + $ssh = Mockery::mock(SSHFake::class, [null])->makePartial(); + SSH::swap($ssh); + $ssh->shouldReceive('exec')->once()->andThrow(new SSHConnectionError('Connection failed')); + + expect(fn () => app(EnsureFpmPool::class)->ensure($this->server, 'runtime', '8.2'))->toThrow(SSHConnectionError::class); + expect($ssh->getUploadedContent())->toBe(''); + expect($this->server->isolatedUserLock('runtime')->get(fn () => true))->toBeTrue(); +}); + +test('fpm pool setup requires the requested php service', function () { + SSH::fake(); + + expect(fn () => app(EnsureFpmPool::class)->ensure($this->server, 'runtime', '0.0'))->toThrow(RuntimeException::class, 'PHP service not found'); + expect(SSH::getExecutedCommands())->toBe([]); +}); + +test('repository clone supports token and ssh authentication with optional commit pinning', function (bool $usesToken, ?string $commit) { + SSH::fake(); + $path = "/home/runtime/release's path"; + $url = $usesToken ? 'https://github.com/owner/repo.git' : 'git@github.com-runtime_key:owner/repo.git'; + $target = new GitCloneDTO( + server: $this->server, + user: 'runtime', + repoUrl: $url, + branch: 'main', + path: $path, + keyName: 'runtime_key', + port: 2222, + usesToken: $usesToken, + variables: $usesToken ? ['GIT_HTTP_TOKEN' => 'test-token'] : [], + commit: $commit, + siteId: $this->site->id, + ); + + app(Git::class)->cloneRepository($target); + + $commands = SSH::getExecutedCommands(); + expect($commands)->toHaveCount($commit === null ? 1 : 2); + expect($commands[0])->toContain('clone -b '.escapeshellarg('main').' '.escapeshellarg($url).' '.escapeshellarg($path)); + expect($commands[0])->not->toContain('--depth', 'test-token'); + if ($usesToken) { + expect($commands[0])->toContain('$GIT_HTTP_TOKEN')->not->toContain('ssh-keyscan'); + } else { + expect($commands[0])->toContain('Port 2222', 'IdentityFile=~/.ssh/runtime_key'); + } + if ($commit !== null) { + expect($commands[1])->toContain('cd '.escapeshellarg($path), 'git checkout -f '.escapeshellarg($commit)); + $this->assertDatabaseHas('server_logs', ['site_id' => $this->site->id, 'type' => 'checkout-commit']); + } +})->with([false, true])->with([null, str_repeat('a', 40)]); + +test('source control clone variables expose only github app authentication', function () { + Http::fake(); + $sourceControl = SourceControl::factory()->githubApp()->create(['external_identifier' => '12345']); + Cache::put('github_app_token_12345', 'test-token', 60); + + expect($sourceControl->cloneVariables())->toBe(['GIT_HTTP_TOKEN' => 'test-token']); + expect($this->site->sourceControl->cloneVariables())->toBe([]); + Http::assertNothingSent(); +}); + +test('tooling can install and collect paths without a site', function () { + SSH::fake(); + $user = IsolatedUser::factory()->create(['server_id' => $this->server->id, 'username' => 'runtime']); + $user->setToolingVersion('node', '22'); + $user->setToolingVersion('composer', '2'); + $target = new ToolingTargetDTO($this->server, 'runtime', $user, '8.2'); + + new NodeTooling()->install($target, '22'); + new ComposerTooling()->install($target, '2'); + + SSH::assertExecutedContains('mise use -g node@22'); + SSH::assertExecutedContains('php8.2 composer-setup.php --2'); + expect(SiteShellEnvironment::collect($target)['PATH'])->toContain('/home/runtime/.local/share/mise/shims', '/home/runtime/.local/vito/bin'); + + new NodeTooling()->uninstall($target); + new ComposerTooling()->uninstall($target); + SSH::assertExecutedContains('mise uninstall node --all'); + SSH::assertExecutedContains('rm -f'); +}); + +test('host availability rejects site domains and hosted aliases using the supplied field', function (bool $alias) { + $host = $this->site->domain; + if ($alias) { + $host = HostedDomain::factory()->create(['site_id' => $this->site->id, 'domain' => 'alias.example.com'])->domain; + } + + try { + app(AssertHostAvailable::class)->assert($this->server, $host, 'aliases.0'); + $this->fail('Expected a host collision.'); + } catch (ValidationException $e) { + expect($e->errors())->toHaveKey('aliases.0'); + } +})->with([false, true]); + +test('host availability ignores only the requested hosted domain', function (HostedDomainType $type) { + $host = $type === HostedDomainType::PRIMARY ? $this->site->domain : 'alias.example.com'; + $domain = HostedDomain::factory()->create(['site_id' => $this->site->id, 'domain' => $host, 'type' => $type]); + $action = app(AssertHostAvailable::class); + + $action->assert($this->server, $host, 'domain', $domain); + Site::factory()->create(['server_id' => $this->server->id, 'domain' => $host]); + + expect(fn () => $action->assert($this->server, $host, 'domain', $domain))->toThrow(ValidationException::class); +})->with([HostedDomainType::PRIMARY, HostedDomainType::ALIAS]); + +test('host availability is scoped to the server', function () { + HostedDomain::factory()->create(['site_id' => $this->site->id, 'domain' => 'alias.example.com']); + $otherServer = Server::factory()->create(); + + expect(fn () => app(AssertHostAvailable::class)->assert($otherServer, $this->site->domain, 'domain'))->not->toThrow(ValidationException::class); + expect(fn () => app(AssertHostAvailable::class)->assert($otherServer, 'alias.example.com', 'domain'))->not->toThrow(ValidationException::class); +}); + +test('release template accepts paths without a site', function () { + $script = view('ssh.modern-deployment.release', [ + 'basePath' => '/home/runtime/app', + 'releasePath' => '/home/runtime/app/releases/123', + ])->render(); + + expect($script)->toContain('ln -sfn /home/runtime/app/releases/123 /home/runtime/app/current', 'Version 123 activated!'); +}); diff --git a/tests/Feature/SitesTest.php b/tests/Feature/SitesTest.php index 46518faea..a9156eae3 100644 --- a/tests/Feature/SitesTest.php +++ b/tests/Feature/SitesTest.php @@ -1,5 +1,7 @@ site->domain; + if ($hostedAlias) { + $host = $this->site->hostedDomains()->create([ + 'domain' => 'occupied.example.com', + 'type' => HostedDomainType::ALIAS, + 'status' => HostedDomainStatus::ACTIVE, + ])->domain; + } + $input = [ + 'type' => Blank::id(), + 'domain' => $field === 'domain' ? $host : 'new.example.com', + 'aliases' => $field === 'domain' ? [] : [$host], + 'user' => 'newruntime', + 'port' => 3000, + ]; + + $this->actingAs($this->user)->post(route('sites.store', ['server' => $this->server]), $input) + ->assertRedirect()->assertSessionHasErrors($field); + + $this->assertDatabaseMissing('sites', ['user' => 'newruntime']); + Queue::assertNothingPushed(); + Http::assertNothingSent(); + expect(SSH::getExecutedCommands())->toBe([]); +})->with(['domain', 'aliases.0'])->with([false, true]); + +test('site creation rejects null aliases before checking hosts', function () { + SSH::fake(); + Http::fake(); + Queue::fake(); + + $this->actingAs($this->user)->post(route('sites.store', ['server' => $this->server]), [ + 'type' => Blank::id(), + 'domain' => 'invalid-alias.example.com', + 'aliases' => [null], + 'user' => 'newruntime', + 'port' => 3000, + ])->assertRedirect()->assertSessionHasErrors('aliases.0'); + + $this->assertDatabaseMissing('sites', ['domain' => 'invalid-alias.example.com']); + Queue::assertNothingPushed(); + expect(SSH::getExecutedCommands())->toBe([]); +}); + test('site settings rejects outsiders and mismatched servers', function (bool $outsider) { if ($outsider) { $this->server->project->users()->where('user_id', $this->user->id)->delete(); diff --git a/tests/Unit/SiteShellEnvironmentTest.php b/tests/Unit/SiteShellEnvironmentTest.php index c310f0c17..bc7670cc6 100644 --- a/tests/Unit/SiteShellEnvironmentTest.php +++ b/tests/Unit/SiteShellEnvironmentTest.php @@ -1,5 +1,6 @@ [], ]); - expect(SiteShellEnvironment::collect($site))->toBe([]); + expect(SiteShellEnvironment::collect(ToolingTargetDTO::forSite($site)))->toBe([]); }); test('collect returns mise shims on path when a tool is installed', function () { @@ -29,7 +30,7 @@ ]); $site->isolatedUser->setToolingVersion('node', '22'); - $env = SiteShellEnvironment::collect($site->refresh()); + $env = SiteShellEnvironment::collect(ToolingTargetDTO::forSite($site->refresh())); expect($env)->toHaveKey('PATH'); expect($env['PATH'])->toStartWith('/home/isolated-node/.local/share/mise/shims:'); @@ -49,7 +50,7 @@ $site->isolatedUser->setToolingVersion('bun', '1.2'); $site->isolatedUser->setToolingVersion('pnpm', '9'); - $env = SiteShellEnvironment::collect($site->refresh()); + $env = SiteShellEnvironment::collect(ToolingTargetDTO::forSite($site->refresh())); expect($env)->toHaveKey('PATH'); $occurrences = substr_count($env['PATH'], '/home/isolated-multi/.local/share/mise/shims'); @@ -57,9 +58,9 @@ }); test('collect returns empty when site user is empty', function () { - $site = new Site(['user' => '']); + $site = new Site(['server_id' => $this->server->id, 'user' => '']); - expect(SiteShellEnvironment::collect($site))->toBe([]); + expect(SiteShellEnvironment::collect(ToolingTargetDTO::forSite($site)))->toBe([]); }); test('wrap builds bash invocation with env and optional cd', function () { From 8cc9fc234f32e9eb09ac11459770198fb92d28b3 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sat, 17 Oct 2026 20:02:16 +0200 Subject: [PATCH 02/30] phase 2 --- .../instructions/php-laravel.instructions.md | 1 + CLAUDE.md | 1 + app/Enums/ComponentStatus.php | 32 ++ app/Enums/ComponentType.php | 24 ++ app/Enums/EnvironmentStatus.php | 26 ++ app/Models/Application.php | 62 ++++ app/Models/Component.php | 154 ++++++++++ app/Models/ComponentCapability.php | 41 +++ app/Models/CronJob.php | 28 ++ app/Models/Deployment.php | 40 ++- app/Models/DeploymentScript.php | 44 ++- app/Models/Environment.php | 76 +++++ app/Models/GitHook.php | 16 + app/Models/IsolatedUser.php | 9 + app/Models/Project.php | 9 + app/Models/Server.php | 9 + app/Models/Ssl.php | 16 + app/Models/Worker.php | 30 +- app/Policies/ApplicationPolicy.php | 38 +++ app/Policies/ComponentPolicy.php | 47 +++ app/Policies/EnvironmentPolicy.php | 41 +++ app/Traits/HasExclusiveOwner.php | 37 +++ database/factories/ApplicationFactory.php | 26 ++ .../factories/ComponentCapabilityFactory.php | 26 ++ database/factories/ComponentFactory.php | 40 +++ database/factories/EnvironmentFactory.php | 29 ++ ...0_17_171647_create_applications_tables.php | 128 ++++++++ tests/Arch/ModelsTest.php | 18 ++ tests/Feature/ApplicationDataModelTest.php | 282 ++++++++++++++++++ tests/Feature/ApplicationPoliciesTest.php | 103 +++++++ tests/Feature/ExclusiveOwnerTest.php | 144 +++++++++ 31 files changed, 1570 insertions(+), 7 deletions(-) create mode 100644 app/Enums/ComponentStatus.php create mode 100644 app/Enums/ComponentType.php create mode 100644 app/Enums/EnvironmentStatus.php create mode 100644 app/Models/Application.php create mode 100644 app/Models/Component.php create mode 100644 app/Models/ComponentCapability.php create mode 100644 app/Models/Environment.php create mode 100644 app/Policies/ApplicationPolicy.php create mode 100644 app/Policies/ComponentPolicy.php create mode 100644 app/Policies/EnvironmentPolicy.php create mode 100644 app/Traits/HasExclusiveOwner.php create mode 100644 database/factories/ApplicationFactory.php create mode 100644 database/factories/ComponentCapabilityFactory.php create mode 100644 database/factories/ComponentFactory.php create mode 100644 database/factories/EnvironmentFactory.php create mode 100644 database/migrations/2026_10_17_171647_create_applications_tables.php create mode 100644 tests/Feature/ApplicationDataModelTest.php create mode 100644 tests/Feature/ApplicationPoliciesTest.php create mode 100644 tests/Feature/ExclusiveOwnerTest.php diff --git a/.github/instructions/php-laravel.instructions.md b/.github/instructions/php-laravel.instructions.md index b0793f151..cf2027a45 100644 --- a/.github/instructions/php-laravel.instructions.md +++ b/.github/instructions/php-laravel.instructions.md @@ -20,6 +20,7 @@ description: "PHP and Laravel coding standards, Actions pattern, Eloquent conven - Cast enum properties to their enum class (e.g., `'status' => ServerStatus::class`). - Use `'encrypted:json'` cast for sensitive data like credentials. - Use PHPDoc `@property` annotations for relationship collections. +- Shared owner models (`Deployment`, `DeploymentScript`, `Worker`, `CronJob`, `GitHook`, `Ssl`) must use `HasExclusiveOwner`. Declare `ownerColumns(): list` and `requiresOwner(): bool`: saving rejects multiple non-null owners, and missing owners when required. Deployments, scripts and hooks require one; workers, cron jobs and SSLs may be unowned. Extend the owner list when adding a new owner column; never bypass this guard with bulk owner updates or quiet saves. ## Eloquent & Database diff --git a/CLAUDE.md b/CLAUDE.md index c4b8b5be9..a1aace0ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ Laravel 13 (L10 structure), PHP 8.4, Inertia v2, React 19, Tailwind v4, Pest 5 / Vito has a specific architecture. Match these patterns exactly: - **Models** extend `app/Models/AbstractModel.php`, NOT `Illuminate\Database\Eloquent\Model`. `AbstractModel` includes `HasTimezoneTimestamps` and `jsonUpdate()`. +- **Shared owners**: `Deployment`, `DeploymentScript`, `Worker`, `CronJob`, `GitHook`, `Ssl` must use `HasExclusiveOwner`; extend `ownerColumns()` for new owners and never bypass its saving guard. - **Enums** implement `app/Contracts/VitoEnum.php` — requires `getColor(): string` and `getText(): string`. Cast enum properties to their enum class on the model. - **Actions** in `app/Actions/` hold ALL business logic. Methods named `create()` / `update()` / `handle()` / `run()` / `install()` etc. Validate inside the Action with `Validator::make()`; extract to a private `validate()` when rules are complex. Compose Actions instead of building monoliths. - **Controllers** are thin: receive request → call Action → return response. Use `Spatie\RouteAttributes` (`#[Get]`, `#[Post]`, `#[Prefix]`, `#[Middleware]`) and ALWAYS name routes (`#[Get('/', name: 'servers.index')]`). Return `Inertia::render()` for web, `JsonResource` for API. Call `$this->authorize()` for policy checks. diff --git a/app/Enums/ComponentStatus.php b/app/Enums/ComponentStatus.php new file mode 100644 index 000000000..efe6240db --- /dev/null +++ b/app/Enums/ComponentStatus.php @@ -0,0 +1,32 @@ + 'gray', + self::INSTALLING => 'warning', + self::READY => 'success', + self::INSTALLATION_FAILED, + self::DELETING, + self::DELETION_FAILED => 'danger', + }; + } + + public function getText(): string + { + return $this->value; + } +} diff --git a/app/Enums/ComponentType.php b/app/Enums/ComponentType.php new file mode 100644 index 000000000..0ce716c1b --- /dev/null +++ b/app/Enums/ComponentType.php @@ -0,0 +1,24 @@ + 'info', + self::LOAD_BALANCER => 'warning', + }; + } + + public function getText(): string + { + return $this->value; + } +} diff --git a/app/Enums/EnvironmentStatus.php b/app/Enums/EnvironmentStatus.php new file mode 100644 index 000000000..bbc521df0 --- /dev/null +++ b/app/Enums/EnvironmentStatus.php @@ -0,0 +1,26 @@ + 'success', + self::DELETING, + self::DELETION_FAILED => 'danger', + }; + } + + public function getText(): string + { + return $this->value; + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php new file mode 100644 index 000000000..0ce17469a --- /dev/null +++ b/app/Models/Application.php @@ -0,0 +1,62 @@ + $environments + */ +class Application extends AbstractModel +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'project_id', + 'source_control_id', + 'name', + 'repository', + 'runtime', + ]; + + protected $casts = [ + 'project_id' => 'integer', + 'source_control_id' => 'integer', + ]; + + /** + * @return BelongsTo + */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + /** + * @return BelongsTo + */ + public function sourceControl(): BelongsTo + { + return $this->belongsTo(SourceControl::class); + } + + /** + * @return HasMany + */ + public function environments(): HasMany + { + return $this->hasMany(Environment::class); + } +} diff --git a/app/Models/Component.php b/app/Models/Component.php new file mode 100644 index 000000000..f3c249edc --- /dev/null +++ b/app/Models/Component.php @@ -0,0 +1,154 @@ + $capabilities + * @property Collection $workers + * @property Collection $cronJobs + * @property Collection $deployments + * @property Collection $deploymentScripts + */ +class Component extends AbstractModel +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'environment_id', + 'server_id', + 'isolated_user_id', + 'user', + 'name', + 'type', + ]; + + protected $casts = [ + 'environment_id' => 'integer', + 'server_id' => 'integer', + 'isolated_user_id' => 'integer', + 'type' => ComponentType::class, + 'status' => ComponentStatus::class, + 'needs_redeploy' => 'boolean', + ]; + + /** + * @return BelongsTo + */ + public function environment(): BelongsTo + { + return $this->belongsTo(Environment::class); + } + + /** + * @return BelongsTo + */ + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + /** + * @return BelongsTo + */ + public function isolatedUser(): BelongsTo + { + return $this->belongsTo(IsolatedUser::class); + } + + /** + * @return HasMany + */ + public function capabilities(): HasMany + { + return $this->hasMany(ComponentCapability::class); + } + + /** + * @return HasMany + */ + public function workers(): HasMany + { + return $this->hasMany(Worker::class); + } + + /** + * @return HasMany + */ + public function cronJobs(): HasMany + { + return $this->hasMany(CronJob::class); + } + + /** + * @return HasMany + */ + public function deployments(): HasMany + { + return $this->hasMany(Deployment::class); + } + + /** + * @return HasMany + */ + public function deploymentScripts(): HasMany + { + return $this->hasMany(DeploymentScript::class); + } + + public function basePath(): string + { + return $this->path; + } + + public function currentPath(): string + { + return $this->basePath().'/current'; + } + + public function releasesPath(): string + { + return $this->basePath().'/releases'; + } + + public function sharedPath(): string + { + return $this->basePath().'/shared'; + } + + public function envPath(): string + { + return $this->sharedPath().'/.env'; + } + + public function hasCapability(string $capability): bool + { + return $this->capabilities->contains('capability', $capability); + } + + public function project(): Project + { + return $this->environment->application->project; + } +} diff --git a/app/Models/ComponentCapability.php b/app/Models/ComponentCapability.php new file mode 100644 index 000000000..9ce3e92ba --- /dev/null +++ b/app/Models/ComponentCapability.php @@ -0,0 +1,41 @@ + $config + * @property ComponentStatus $status + * @property Component $component + */ +class ComponentCapability extends AbstractModel +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'component_id', + 'capability', + 'config', + ]; + + protected $casts = [ + 'component_id' => 'integer', + 'config' => 'json', + 'status' => ComponentStatus::class, + ]; + + /** + * @return BelongsTo + */ + public function component(): BelongsTo + { + return $this->belongsTo(Component::class); + } +} diff --git a/app/Models/CronJob.php b/app/Models/CronJob.php index 93725e462..ecf4e01e0 100755 --- a/app/Models/CronJob.php +++ b/app/Models/CronJob.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\CronjobStatus; +use App\Traits\HasExclusiveOwner; use Database\Factories\CronJobFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -10,6 +11,7 @@ /** * @property int $server_id * @property ?int $site_id + * @property ?int $component_id * @property string $command * @property string $user * @property string $frequency @@ -18,16 +20,20 @@ * @property CronjobStatus $status * @property string $crontab * @property Server $server + * @property ?Component $component * @property ?Site $site */ class CronJob extends AbstractModel { + use HasExclusiveOwner; + /** @use HasFactory */ use HasFactory; protected $fillable = [ 'server_id', 'site_id', + 'component_id', 'command', 'user', 'frequency', @@ -38,10 +44,24 @@ class CronJob extends AbstractModel protected $casts = [ 'server_id' => 'integer', 'site_id' => 'integer', + 'component_id' => 'integer', 'hidden' => 'boolean', 'status' => CronjobStatus::class, ]; + /** + * @return list + */ + public function ownerColumns(): array + { + return ['site_id', 'component_id']; + } + + public function requiresOwner(): bool + { + return false; + } + /** * @return BelongsTo */ @@ -50,6 +70,14 @@ public function server(): BelongsTo return $this->belongsTo(Server::class); } + /** + * @return BelongsTo + */ + public function component(): BelongsTo + { + return $this->belongsTo(Component::class); + } + /** * @return BelongsTo */ diff --git a/app/Models/Deployment.php b/app/Models/Deployment.php index 66f730ae7..0afbf21fa 100755 --- a/app/Models/Deployment.php +++ b/app/Models/Deployment.php @@ -3,13 +3,15 @@ namespace App\Models; use App\Enums\DeploymentStatus; +use App\Traits\HasExclusiveOwner; use Database\Factories\DeploymentFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** - * @property int $site_id - * @property int $deployment_script_id + * @property ?int $site_id + * @property ?int $component_id + * @property ?int $deployment_script_id * @property int $log_id * @property string $commit_id * @property string $commit_id_short @@ -17,17 +19,21 @@ * @property DeploymentStatus $status * @property ?string $release * @property bool $active - * @property Site $site - * @property DeploymentScript $deploymentScript + * @property ?Component $component + * @property ?Site $site + * @property ?DeploymentScript $deploymentScript * @property ?ServerLog $log */ class Deployment extends AbstractModel { + use HasExclusiveOwner; + /** @use HasFactory */ use HasFactory; protected $fillable = [ 'site_id', + 'component_id', 'deployment_script_id', 'log_id', 'commit_id', @@ -39,6 +45,7 @@ class Deployment extends AbstractModel protected $casts = [ 'site_id' => 'integer', + 'component_id' => 'integer', 'deployment_script_id' => 'integer', 'log_id' => 'integer', 'commit_data' => 'json', @@ -46,10 +53,27 @@ class Deployment extends AbstractModel 'status' => DeploymentStatus::class, ]; + /** + * @return list + */ + public function ownerColumns(): array + { + return ['site_id', 'component_id']; + } + + public function requiresOwner(): bool + { + return true; + } + protected static function booted(): void { static::created(function (Deployment $deployment): void { $site = $deployment->site; + if (! $site) { + return; + } + $keep = $site->type_data['modern_deployment_history'] ?? 10; if ($site->deployments()->whereNotNull('release')->count() > $keep) { /** @var ?Deployment $lastDeploymentToKeep */ @@ -67,6 +91,14 @@ protected static function booted(): void }); } + /** + * @return BelongsTo + */ + public function component(): BelongsTo + { + return $this->belongsTo(Component::class); + } + /** * @return BelongsTo */ diff --git a/app/Models/DeploymentScript.php b/app/Models/DeploymentScript.php index 90dea079d..00a425db0 100644 --- a/app/Models/DeploymentScript.php +++ b/app/Models/DeploymentScript.php @@ -2,18 +2,25 @@ namespace App\Models; +use App\Traits\HasExclusiveOwner; use Database\Factories\DeploymentScriptFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** - * @property int $site_id + * @property ?int $site_id + * @property ?int $component_id + * @property ?int $environment_id * @property string $name * @property string $content - * @property Site $site + * @property ?Environment $environment + * @property ?Component $component + * @property ?Site $site */ class DeploymentScript extends AbstractModel { + use HasExclusiveOwner; + /** @use HasFactory */ use HasFactory; @@ -28,6 +35,8 @@ protected static function boot(): void protected $fillable = [ 'site_id', + 'component_id', + 'environment_id', 'name', 'content', 'configs', @@ -35,9 +44,40 @@ protected static function boot(): void protected $casts = [ 'site_id' => 'integer', + 'component_id' => 'integer', + 'environment_id' => 'integer', 'configs' => 'array', ]; + /** + * @return list + */ + public function ownerColumns(): array + { + return ['site_id', 'environment_id', 'component_id']; + } + + public function requiresOwner(): bool + { + return true; + } + + /** + * @return BelongsTo + */ + public function environment(): BelongsTo + { + return $this->belongsTo(Environment::class); + } + + /** + * @return BelongsTo + */ + public function component(): BelongsTo + { + return $this->belongsTo(Component::class); + } + /** * @return BelongsTo */ diff --git a/app/Models/Environment.php b/app/Models/Environment.php new file mode 100644 index 000000000..dea6ddff4 --- /dev/null +++ b/app/Models/Environment.php @@ -0,0 +1,76 @@ + $components + * @property Collection $deploymentScripts + */ +class Environment extends AbstractModel +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'application_id', + 'name', + 'slug', + 'branch', + 'runtime_version', + 'primary_component_id', + ]; + + protected $casts = [ + 'application_id' => 'integer', + 'primary_component_id' => 'integer', + 'status' => EnvironmentStatus::class, + ]; + + /** + * @return BelongsTo + */ + public function application(): BelongsTo + { + return $this->belongsTo(Application::class); + } + + /** + * @return HasMany + */ + public function components(): HasMany + { + return $this->hasMany(Component::class); + } + + /** + * @return BelongsTo + */ + public function primaryComponent(): BelongsTo + { + return $this->belongsTo(Component::class, 'primary_component_id'); + } + + /** + * @return HasMany + */ + public function deploymentScripts(): HasMany + { + return $this->hasMany(DeploymentScript::class); + } +} diff --git a/app/Models/GitHook.php b/app/Models/GitHook.php index a3b26c5bc..78b9515bb 100755 --- a/app/Models/GitHook.php +++ b/app/Models/GitHook.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Exceptions\FailedToDestroyGitHook; +use App\Traits\HasExclusiveOwner; use Database\Factories\GitHookFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -21,6 +22,8 @@ */ class GitHook extends AbstractModel { + use HasExclusiveOwner; + /** @use HasFactory */ use HasFactory; @@ -42,6 +45,19 @@ class GitHook extends AbstractModel 'hook_response' => 'json', ]; + /** + * @return list + */ + public function ownerColumns(): array + { + return ['site_id']; + } + + public function requiresOwner(): bool + { + return true; + } + /** * @return BelongsTo */ diff --git a/app/Models/IsolatedUser.php b/app/Models/IsolatedUser.php index 820c751d1..d37c88d5d 100644 --- a/app/Models/IsolatedUser.php +++ b/app/Models/IsolatedUser.php @@ -19,6 +19,7 @@ * @property ?array $installed_tooling * @property Server $server * @property Collection $sites + * @property Collection $components */ class IsolatedUser extends AbstractModel { @@ -46,6 +47,14 @@ public function server(): BelongsTo return $this->belongsTo(Server::class); } + /** + * @return HasMany + */ + public function components(): HasMany + { + return $this->hasMany(Component::class); + } + /** * @return HasMany */ diff --git a/app/Models/Project.php b/app/Models/Project.php index ff623e363..e169bf539 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -20,6 +20,7 @@ * @property User $user * @property Collection $servers * @property Collection $sites + * @property Collection $applications * @property Collection $backups * @property Collection $users * @property Collection $notificationChannels @@ -52,6 +53,14 @@ public static function boot(): void }); } + /** + * @return HasMany + */ + public function applications(): HasMany + { + return $this->hasMany(Application::class); + } + /** * @return HasMany */ diff --git a/app/Models/Server.php b/app/Models/Server.php index fd5660f94..c70ce1e9c 100755 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -59,6 +59,7 @@ * @property ServerProvider $serverProvider * @property Collection $logs * @property Collection $sites + * @property Collection $components * @property Collection $services * @property Collection $databases * @property Collection $databaseUsers @@ -227,6 +228,14 @@ public function serverProvider(): BelongsTo return $this->belongsTo(ServerProvider::class, 'provider_id'); } + /** + * @return HasMany + */ + public function components(): HasMany + { + return $this->hasMany(Component::class); + } + /** * @return HasMany */ diff --git a/app/Models/Ssl.php b/app/Models/Ssl.php index fb4f47481..5bc75fdc8 100644 --- a/app/Models/Ssl.php +++ b/app/Models/Ssl.php @@ -4,6 +4,7 @@ use App\Enums\SslStatus; use App\Enums\SslType; +use App\Traits\HasExclusiveOwner; use Carbon\Carbon; use Database\Factories\SslFactory; use Illuminate\Database\Eloquent\Builder; @@ -40,6 +41,8 @@ */ class Ssl extends AbstractModel { + use HasExclusiveOwner; + /** @use HasFactory */ use HasFactory; @@ -83,6 +86,19 @@ class Ssl extends AbstractModel 'status' => SslStatus::class, ]; + /** + * @return list + */ + public function ownerColumns(): array + { + return ['site_id']; + } + + public function requiresOwner(): bool + { + return false; + } + /** * @return BelongsTo */ diff --git a/app/Models/Worker.php b/app/Models/Worker.php index 8bdfcde74..691d1107d 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -6,6 +6,7 @@ use App\Enums\WorkerStatus; use App\Helpers\SiteShellEnvironment; use App\Services\ProcessManager\ProcessManager; +use App\Traits\HasExclusiveOwner; use Database\Factories\WorkerFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -14,7 +15,8 @@ /** * @property int $server_id - * @property int $site_id + * @property ?int $site_id + * @property ?int $component_id * @property string $command * @property string $user * @property bool $auto_start @@ -27,16 +29,20 @@ * @property ?string $error * @property string $name * @property Server $server + * @property ?Component $component * @property ?Site $site */ class Worker extends AbstractModel { + use HasExclusiveOwner; + /** @use HasFactory */ use HasFactory; protected $fillable = [ 'server_id', 'site_id', + 'component_id', 'command', 'user', 'auto_start', @@ -52,6 +58,7 @@ class Worker extends AbstractModel protected $casts = [ 'server_id' => 'integer', 'site_id' => 'integer', + 'component_id' => 'integer', 'auto_start' => 'boolean', 'auto_restart' => 'boolean', 'numprocs' => 'integer', @@ -60,6 +67,19 @@ class Worker extends AbstractModel 'status' => WorkerStatus::class, ]; + /** + * @return list + */ + public function ownerColumns(): array + { + return ['site_id', 'component_id']; + } + + public function requiresOwner(): bool + { + return false; + } + public static function boot(): void { parent::boot(); @@ -97,6 +117,14 @@ public function server(): BelongsTo return $this->belongsTo(Server::class); } + /** + * @return BelongsTo + */ + public function component(): BelongsTo + { + return $this->belongsTo(Component::class); + } + /** * @return BelongsTo */ diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php new file mode 100644 index 000000000..7aded073b --- /dev/null +++ b/app/Policies/ApplicationPolicy.php @@ -0,0 +1,38 @@ +hasReadAccess($user, $project); + } + + public function view(User $user, Application $application): bool + { + return $this->hasReadAccess($user, $application->project); + } + + public function create(User $user, Project $project): bool + { + return $this->hasWriteAccess($user, $project); + } + + public function update(User $user, Application $application): bool + { + return $this->hasWriteAccess($user, $application->project); + } + + public function delete(User $user, Application $application): bool + { + return $this->hasWriteAccess($user, $application->project); + } +} diff --git a/app/Policies/ComponentPolicy.php b/app/Policies/ComponentPolicy.php new file mode 100644 index 000000000..f65c94f3b --- /dev/null +++ b/app/Policies/ComponentPolicy.php @@ -0,0 +1,47 @@ +application_id === $application->id + && $this->hasReadAccess($user, $application->project); + } + + public function view(User $user, Component $component, Environment $environment, Application $application): bool + { + return $component->environment_id === $environment->id + && $environment->application_id === $application->id + && $this->hasReadAccess($user, $application->project); + } + + public function create(User $user, Environment $environment, Application $application): bool + { + return $environment->application_id === $application->id + && $this->hasWriteAccess($user, $application->project); + } + + public function update(User $user, Component $component, Environment $environment, Application $application): bool + { + return $component->environment_id === $environment->id + && $environment->application_id === $application->id + && $this->hasWriteAccess($user, $application->project); + } + + public function delete(User $user, Component $component, Environment $environment, Application $application): bool + { + return $component->environment_id === $environment->id + && $environment->application_id === $application->id + && $this->hasWriteAccess($user, $application->project); + } +} diff --git a/app/Policies/EnvironmentPolicy.php b/app/Policies/EnvironmentPolicy.php new file mode 100644 index 000000000..9263676db --- /dev/null +++ b/app/Policies/EnvironmentPolicy.php @@ -0,0 +1,41 @@ +hasReadAccess($user, $application->project); + } + + public function view(User $user, Environment $environment, Application $application): bool + { + return $environment->application_id === $application->id + && $this->hasReadAccess($user, $application->project); + } + + public function create(User $user, Application $application): bool + { + return $this->hasWriteAccess($user, $application->project); + } + + public function update(User $user, Environment $environment, Application $application): bool + { + return $environment->application_id === $application->id + && $this->hasWriteAccess($user, $application->project); + } + + public function delete(User $user, Environment $environment, Application $application): bool + { + return $environment->application_id === $application->id + && $this->hasWriteAccess($user, $application->project); + } +} diff --git a/app/Traits/HasExclusiveOwner.php b/app/Traits/HasExclusiveOwner.php new file mode 100644 index 000000000..366e01b93 --- /dev/null +++ b/app/Traits/HasExclusiveOwner.php @@ -0,0 +1,37 @@ + + */ + abstract public function ownerColumns(): array; + + abstract public function requiresOwner(): bool; + + public static function bootHasExclusiveOwner(): void + { + static::saving(function (self $model): void { + $attributes = $model->getAttributes(); + $columns = $model->ownerColumns(); + $missing = array_diff($columns, array_keys($attributes)); + + if ($model->exists && $missing !== []) { + $attributes += $model->newModelQuery()->whereKey($model->getKey())->firstOrFail($missing)->getAttributes(); + } + + $owners = array_filter( + $columns, + fn (string $column): bool => ($attributes[$column] ?? null) !== null, + ); + + if (count($owners) > 1 || ($model->requiresOwner() && count($owners) === 0)) { + throw new LogicException('Invalid owner for '.static::class.'.'); + } + }); + } +} diff --git a/database/factories/ApplicationFactory.php b/database/factories/ApplicationFactory.php new file mode 100644 index 000000000..8dc126181 --- /dev/null +++ b/database/factories/ApplicationFactory.php @@ -0,0 +1,26 @@ + + */ +class ApplicationFactory extends Factory +{ + protected $model = Application::class; + + public function definition(): array + { + return [ + 'project_id' => Project::factory(), + 'source_control_id' => null, + 'name' => $this->faker->unique()->slug(2), + 'repository' => 'organization/repository', + 'runtime' => 'node', + ]; + } +} diff --git a/database/factories/ComponentCapabilityFactory.php b/database/factories/ComponentCapabilityFactory.php new file mode 100644 index 000000000..96f944501 --- /dev/null +++ b/database/factories/ComponentCapabilityFactory.php @@ -0,0 +1,26 @@ + + */ +class ComponentCapabilityFactory extends Factory +{ + protected $model = ComponentCapability::class; + + public function definition(): array + { + return [ + 'component_id' => Component::factory(), + 'capability' => 'web', + 'config' => null, + 'status' => ComponentStatus::PENDING, + ]; + } +} diff --git a/database/factories/ComponentFactory.php b/database/factories/ComponentFactory.php new file mode 100644 index 000000000..383b554f1 --- /dev/null +++ b/database/factories/ComponentFactory.php @@ -0,0 +1,40 @@ + + */ +class ComponentFactory extends Factory +{ + protected $model = Component::class; + + public function definition(): array + { + return [ + 'environment_id' => Environment::factory(), + 'server_id' => fn (array $attributes): int => Server::factory()->create([ + 'project_id' => Environment::query()->findOrFail($attributes['environment_id'])->application->project_id, + ])->id, + 'isolated_user_id' => null, + 'user' => fn (array $attributes): string => Server::query()->findOrFail($attributes['server_id'])->ssh_user, + 'name' => $this->faker->unique()->slug(2), + 'type' => ComponentType::APP, + 'path' => function (array $attributes): string { + $environment = Environment::query()->findOrFail($attributes['environment_id']); + + return '/home/'.$attributes['user'].'/app-'.$environment->application_id.'-'.$environment->slug; + }, + 'status' => ComponentStatus::PENDING, + 'last_error' => null, + 'needs_redeploy' => false, + ]; + } +} diff --git a/database/factories/EnvironmentFactory.php b/database/factories/EnvironmentFactory.php new file mode 100644 index 000000000..62953df3a --- /dev/null +++ b/database/factories/EnvironmentFactory.php @@ -0,0 +1,29 @@ + + */ +class EnvironmentFactory extends Factory +{ + protected $model = Environment::class; + + public function definition(): array + { + return [ + 'application_id' => Application::factory(), + 'name' => $this->faker->unique()->slug(2), + 'slug' => fn (array $attributes): string => $attributes['name'], + 'branch' => 'main', + 'runtime_version' => null, + 'primary_component_id' => null, + 'status' => EnvironmentStatus::READY, + ]; + } +} diff --git a/database/migrations/2026_10_17_171647_create_applications_tables.php b/database/migrations/2026_10_17_171647_create_applications_tables.php new file mode 100644 index 000000000..d946248f6 --- /dev/null +++ b/database/migrations/2026_10_17_171647_create_applications_tables.php @@ -0,0 +1,128 @@ +id(); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $table->foreignId('source_control_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name'); + $table->string('repository'); + $table->string('runtime'); + $table->timestamps(); + + $table->unique(['project_id', 'name']); + }); + + Schema::create('environments', function (Blueprint $table): void { + $table->id(); + $table->foreignId('application_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('slug'); + $table->string('branch'); + $table->string('runtime_version')->nullable(); + $table->string('status')->default(EnvironmentStatus::READY->value); + $table->timestamps(); + + $table->unique(['application_id', 'name']); + $table->unique(['application_id', 'slug']); + }); + + Schema::create('components', function (Blueprint $table): void { + $table->id(); + $table->foreignId('environment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('server_id')->constrained()->restrictOnDelete(); + $table->foreignId('isolated_user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('user'); + $table->string('name'); + $table->string('type'); + $table->string('path'); + $table->string('status')->default(ComponentStatus::PENDING->value); + $table->text('last_error')->nullable(); + $table->boolean('needs_redeploy')->default(false); + $table->timestamps(); + + $table->unique(['environment_id', 'name']); + $table->unique(['environment_id', 'server_id', 'user']); + }); + + Schema::table('environments', function (Blueprint $table): void { + $table->foreignId('primary_component_id')->nullable()->constrained('components')->nullOnDelete(); + }); + + Schema::create('component_capabilities', function (Blueprint $table): void { + $table->id(); + $table->foreignId('component_id')->constrained()->cascadeOnDelete(); + $table->string('capability'); + $table->json('config')->nullable(); + $table->string('status')->default(ComponentStatus::PENDING->value); + $table->timestamps(); + + $table->unique(['component_id', 'capability']); + }); + + Schema::table('deployments', function (Blueprint $table): void { + $table->unsignedBigInteger('site_id')->nullable()->change(); + $table->unsignedBigInteger('deployment_script_id')->nullable()->change(); + $table->foreignId('component_id')->nullable()->constrained()->cascadeOnDelete(); + }); + + Schema::table('deployment_scripts', function (Blueprint $table): void { + $table->unsignedBigInteger('site_id')->nullable()->change(); + $table->foreignId('environment_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('component_id')->nullable()->constrained()->cascadeOnDelete(); + }); + + Schema::table('workers', function (Blueprint $table): void { + $table->foreignId('component_id')->nullable()->constrained()->cascadeOnDelete(); + }); + + Schema::table('cron_jobs', function (Blueprint $table): void { + $table->foreignId('component_id')->nullable()->constrained()->cascadeOnDelete(); + }); + } + + public function down(): void + { + DB::table('applications')->delete(); + + Schema::table('cron_jobs', function (Blueprint $table): void { + $table->dropConstrainedForeignId('component_id'); + }); + + Schema::table('workers', function (Blueprint $table): void { + $table->dropConstrainedForeignId('component_id'); + }); + + Schema::table('deployment_scripts', function (Blueprint $table): void { + $table->dropConstrainedForeignId('component_id'); + $table->dropConstrainedForeignId('environment_id'); + $table->unsignedBigInteger('site_id')->nullable(false)->change(); + }); + + Schema::table('deployments', function (Blueprint $table): void { + $table->dropConstrainedForeignId('component_id'); + $table->unsignedBigInteger('site_id')->nullable(false)->change(); + $table->unsignedBigInteger('deployment_script_id')->nullable(false)->change(); + }); + + Schema::dropIfExists('component_capabilities'); + + Schema::table('environments', function (Blueprint $table): void { + $table->dropConstrainedForeignId('primary_component_id'); + }); + + Schema::dropIfExists('components'); + Schema::dropIfExists('environments'); + Schema::dropIfExists('applications'); + } +}; diff --git a/tests/Arch/ModelsTest.php b/tests/Arch/ModelsTest.php index 1813b0c70..06d305c39 100644 --- a/tests/Arch/ModelsTest.php +++ b/tests/Arch/ModelsTest.php @@ -2,6 +2,13 @@ use App\Contracts\VitoEnum; use App\Models\AbstractModel; +use App\Models\CronJob; +use App\Models\Deployment; +use App\Models\DeploymentScript; +use App\Models\GitHook; +use App\Models\Ssl; +use App\Models\Worker; +use App\Traits\HasExclusiveOwner; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; use Tests\ArchTestCase; @@ -16,6 +23,17 @@ ...ArchTestCase::except('models.not-on-abstract-model'), ]); +arch('shared owner models enforce exclusive ownership') + ->expect([ + Deployment::class, + DeploymentScript::class, + Worker::class, + CronJob::class, + GitHook::class, + Ssl::class, + ]) + ->toUseTrait(HasExclusiveOwner::class); + arch('eloquent models only live in the models namespace') ->expect('App') ->not->toExtend(Model::class) diff --git a/tests/Feature/ApplicationDataModelTest.php b/tests/Feature/ApplicationDataModelTest.php new file mode 100644 index 000000000..681131e23 --- /dev/null +++ b/tests/Feature/ApplicationDataModelTest.php @@ -0,0 +1,282 @@ +create([ + 'project_id' => $this->server->project_id, + 'source_control_id' => $this->site->source_control_id, + ]); + $environment = Environment::factory()->for($application)->create(); + $isolatedUser = IsolatedUser::factory()->create(['server_id' => $this->server->id]); + $component = Component::factory()->for($environment)->create([ + 'server_id' => $this->server->id, + 'isolated_user_id' => $isolatedUser->id, + 'user' => $isolatedUser->username, + 'needs_redeploy' => 1, + ]); + $capability = ComponentCapability::factory()->for($component)->create([ + 'config' => ['port' => 3000, 'enabled' => true], + ]); + $environment->update(['primary_component_id' => $component->id]); + $environmentScript = DeploymentScript::factory()->create(['site_id' => null, 'environment_id' => $environment->id]); + $componentScript = DeploymentScript::factory()->create(['site_id' => null, 'component_id' => $component->id]); + $worker = Worker::factory()->create(['server_id' => $this->server->id, 'component_id' => $component->id]); + $cron = CronJob::factory()->create(['server_id' => $this->server->id, 'component_id' => $component->id]); + + $component->refresh()->load('environment.application.project', 'capabilities'); + expect($this->server->project->applications->sole()->is($application))->toBeTrue() + ->and($application->sourceControl->is($this->site->sourceControl))->toBeTrue() + ->and($application->environments->sole()->is($environment))->toBeTrue() + ->and($environment->components->sole()->is($component))->toBeTrue() + ->and($environment->primaryComponent->is($component))->toBeTrue() + ->and($environment->status)->toBe(EnvironmentStatus::READY) + ->and($environment->deploymentScripts->sole()->is($environmentScript))->toBeTrue() + ->and($component->server->is($this->server))->toBeTrue() + ->and($component->isolatedUser->is($isolatedUser))->toBeTrue() + ->and($component->type)->toBe(ComponentType::APP) + ->and($component->status)->toBe(ComponentStatus::PENDING) + ->and($component->needs_redeploy)->toBeTrue() + ->and($component->project()->is($application->project))->toBeTrue() + ->and($component->deploymentScripts->sole()->is($componentScript))->toBeTrue() + ->and($component->workers->sole()->is($worker))->toBeTrue() + ->and($component->cronJobs->sole()->is($cron))->toBeTrue() + ->and($this->server->components->sole()->is($component))->toBeTrue() + ->and($isolatedUser->components->sole()->is($component))->toBeTrue() + ->and($capability->component->is($component))->toBeTrue() + ->and($capability->refresh()->config)->toBe(['port' => 3000, 'enabled' => true]) + ->and($capability->status)->toBe(ComponentStatus::PENDING) + ->and($component->hasCapability('web'))->toBeTrue() + ->and($component->hasCapability('worker'))->toBeFalse(); +}); + +test('component paths stay stored when parent names and users change', function () { + $component = Component::factory()->create(); + $environment = $component->environment; + $path = '/home/'.$component->server->ssh_user.'/app-'.$environment->application_id.'-'.$environment->slug; + + expect($component->user)->toBe($component->server->ssh_user) + ->and($component->server->project_id)->toBe($component->project()->id) + ->and($component->basePath())->toBe($path); + $component->update(['user' => 'different-user']); + $environment->update(['name' => 'Renamed', 'slug' => 'renamed']); + $environment->application->update(['name' => 'Renamed application']); + $component->refresh(); + + expect($component->basePath())->toBe($path) + ->and($component->currentPath())->toBe($path.'/current') + ->and($component->releasesPath())->toBe($path.'/releases') + ->and($component->sharedPath())->toBe($path.'/shared') + ->and($component->envPath())->toBe($path.'/shared/.env'); + $this->assertDatabaseHas('components', ['id' => $component->id, 'path' => $path]); +}); + +test('component deployments do not invoke site release pruning', function () { + SSH::fake(); + $component = Component::factory()->create(['server_id' => $this->server->id]); + $this->site->jsonUpdate('type_data', 'modern_deployment_history', 0); + $deployment = Deployment::factory()->create([ + 'site_id' => null, + 'component_id' => $component->id, + 'deployment_script_id' => null, + 'log_id' => null, + 'release' => 'release-one', + ]); + + expect($component->deployments->sole()->is($deployment))->toBeTrue() + ->and($deployment->component->is($component))->toBeTrue() + ->and($deployment->site)->toBeNull() + ->and($deployment->deploymentScript)->toBeNull() + ->and(SSH::getExecutedCommands())->toBe([]); + $this->assertDatabaseHas('deployments', ['id' => $deployment->id, 'release' => 'release-one']); +}); + +test('application schema enforces scoped unique keys', function (string $key) { + $component = Component::factory()->create(); + $environment = $component->environment; + $application = $environment->application; + $capability = ComponentCapability::factory()->for($component)->create(); + + $duplicate = match ($key) { + 'application name' => $application->replicate(), + 'environment name' => $environment->replicate()->fill(['slug' => 'different']), + 'environment slug' => $environment->replicate()->fill(['name' => 'Different']), + 'component name' => $component->replicate()->fill(['user' => 'different']), + 'component placement' => $component->replicate()->fill(['name' => 'different']), + 'capability' => $capability->replicate(), + }; + + expect(fn () => $duplicate->save())->toThrow(QueryException::class); +})->with(['application name', 'environment name', 'environment slug', 'component name', 'component placement', 'capability']); + +test('application unique keys permit reuse outside their parent scope', function () { + $component = Component::factory()->create(); + $application = $component->environment->application; + $otherApplication = $application->replicate()->fill(['project_id' => $this->server->project_id]); + $otherApplication->save(); + $environment = $component->environment->replicate()->fill(['application_id' => $otherApplication->id]); + $environment->save(); + $otherComponent = $component->replicate()->fill(['environment_id' => $environment->id]); + $otherComponent->save(); + + ComponentCapability::factory()->for($component)->create(); + $capability = ComponentCapability::factory()->for($otherComponent)->create(); + + $this->assertDatabaseHas('component_capabilities', ['id' => $capability->id, 'component_id' => $otherComponent->id]); +}); + +test('application nullable foreign keys clear when their targets are deleted', function () { + $application = Application::factory()->create(['source_control_id' => $this->site->source_control_id]); + $environment = Environment::factory()->for($application)->create(); + $isolatedUser = IsolatedUser::factory()->create(['server_id' => $this->server->id]); + $component = Component::factory()->for($environment)->create([ + 'server_id' => $this->server->id, + 'isolated_user_id' => $isolatedUser->id, + ]); + $environment->update(['primary_component_id' => $component->id]); + + $application->sourceControl->forceDelete(); + $isolatedUser->delete(); + expect($application->refresh()->source_control_id)->toBeNull() + ->and($application->sourceControl)->toBeNull() + ->and($component->refresh()->isolated_user_id)->toBeNull() + ->and($component->isolatedUser)->toBeNull(); + + $component->delete(); + expect($environment->refresh()->primary_component_id)->toBeNull() + ->and($environment->primaryComponent)->toBeNull(); +}); + +test('components restrict raw server deletion', function () { + $component = Component::factory()->create(); + + expect(fn () => Server::query()->whereKey($component->server_id)->delete())->toThrow(QueryException::class); +}); + +test('application ownership cascades without invoking remote model events', function (string $parent) { + SSH::fake(); + $component = Component::factory()->create(); + $environment = $component->environment; + $environment->update(['primary_component_id' => $component->id]); + $capability = ComponentCapability::factory()->for($component)->create(); + $worker = Worker::factory()->create(['server_id' => $component->server_id, 'component_id' => $component->id]); + $cron = CronJob::factory()->create(['server_id' => $component->server_id, 'component_id' => $component->id]); + $deployment = Deployment::factory()->create(['site_id' => null, 'component_id' => $component->id, 'deployment_script_id' => null]); + $script = DeploymentScript::factory()->create(['site_id' => null, 'component_id' => $component->id]); + $environmentScript = DeploymentScript::factory()->create(['site_id' => null, 'environment_id' => $environment->id]); + $owner = match ($parent) { + 'component' => $component, + 'environment' => $environment, + 'application' => $environment->application, + 'project' => $environment->application->project, + }; + + $owner->newQuery()->whereKey($owner->id)->delete(); + + foreach ([$component, $capability, $worker, $cron, $deployment, $script] as $deleted) { + $this->assertDatabaseMissing($deleted->getTable(), ['id' => $deleted->id]); + } + if ($parent !== 'component') { + $this->assertDatabaseMissing('environments', ['id' => $environment->id]); + $this->assertDatabaseMissing('deployment_scripts', ['id' => $environmentScript->id]); + } + expect(SSH::getExecutedCommands())->toBe([]); +})->with(['component', 'environment', 'application', 'project']); + +test('application migration round trips while preserving site data', function () { + SSH::fake(); + $siteDeployment = Deployment::factory()->create(['site_id' => $this->site->id]); + $siteScript = $this->site->deploymentScript; + $siteWorker = Worker::factory()->create(['server_id' => $this->server->id, 'site_id' => $this->site->id]); + $siteCron = CronJob::factory()->create(['server_id' => $this->server->id, 'site_id' => $this->site->id]); + $component = Component::factory()->create(); + $component->environment->update(['primary_component_id' => $component->id]); + ComponentCapability::factory()->for($component)->create(); + Deployment::factory()->create(['site_id' => null, 'component_id' => $component->id, 'deployment_script_id' => null]); + DeploymentScript::factory()->create(['site_id' => null, 'environment_id' => $component->environment_id]); + DeploymentScript::factory()->create(['site_id' => null, 'component_id' => $component->id]); + Worker::factory()->create(['server_id' => $component->server_id, 'component_id' => $component->id]); + CronJob::factory()->create(['server_id' => $component->server_id, 'component_id' => $component->id]); + $migration = require database_path('migrations/2026_10_17_171647_create_applications_tables.php'); + + $migration->down(); + expect(Schema::hasTable('applications'))->toBeFalse() + ->and(Schema::hasTable('environments'))->toBeFalse() + ->and(Schema::hasTable('components'))->toBeFalse() + ->and(Schema::hasTable('component_capabilities'))->toBeFalse(); + foreach (['deployments', 'deployment_scripts', 'workers', 'cron_jobs'] as $table) { + expect(Schema::hasColumn($table, 'component_id'))->toBeFalse(); + } + foreach (['deployments', 'deployment_scripts'] as $table) { + $column = collect(Schema::getColumns($table))->firstWhere('name', 'site_id'); + expect($column['nullable'])->toBeFalse(); + } + $migration->up(); + + foreach ([$siteDeployment, $siteScript, $siteWorker, $siteCron] as $preserved) { + $this->assertDatabaseHas($preserved->getTable(), ['id' => $preserved->id, 'site_id' => $this->site->id]); + } + expect(Component::query()->count())->toBe(0) + ->and(SSH::getExecutedCommands())->toBe([]); +}); + +test('application lifecycle state and generated paths cannot be mass assigned', function () { + $component = Component::factory()->create(); + $path = $component->path; + $component->fill(['path' => '/different', 'status' => ComponentStatus::READY]); + expect($component->path)->toBe($path)->and($component->status)->toBe(ComponentStatus::PENDING); + + $environment = $component->environment; + $environment->fill(['status' => EnvironmentStatus::DELETING]); + expect($environment->status)->toBe(EnvironmentStatus::READY); + + $capability = ComponentCapability::factory()->for($component)->create(); + $capability->fill(['status' => ComponentStatus::READY]); + expect($capability->status)->toBe(ComponentStatus::PENDING); + + $component->status = ComponentStatus::READY; + $component->save(); + $environment->status = EnvironmentStatus::DELETING; + $environment->save(); + $capability->status = ComponentStatus::READY; + $capability->save(); + + $this->assertDatabaseHas('components', ['id' => $component->id, 'path' => $path, 'status' => ComponentStatus::READY]); + $this->assertDatabaseHas('environments', ['id' => $environment->id, 'status' => EnvironmentStatus::DELETING]); + $this->assertDatabaseHas('component_capabilities', ['id' => $capability->id, 'status' => ComponentStatus::READY]); +}); + +test('application enums expose their display contract', function (VitoEnum $enum, string $text, string $color) { + expect($enum->getText())->toBe($text)->and($enum->getColor())->toBe($color); +})->with([ + [ComponentType::APP, 'app', 'info'], + [ComponentType::LOAD_BALANCER, 'load_balancer', 'warning'], + [ComponentStatus::PENDING, 'pending', 'gray'], + [ComponentStatus::INSTALLING, 'installing', 'warning'], + [ComponentStatus::READY, 'ready', 'success'], + [ComponentStatus::INSTALLATION_FAILED, 'installation_failed', 'danger'], + [ComponentStatus::DELETING, 'deleting', 'danger'], + [ComponentStatus::DELETION_FAILED, 'deletion_failed', 'danger'], + [EnvironmentStatus::READY, 'ready', 'success'], + [EnvironmentStatus::DELETING, 'deleting', 'danger'], + [EnvironmentStatus::DELETION_FAILED, 'deletion_failed', 'danger'], +]); diff --git a/tests/Feature/ApplicationPoliciesTest.php b/tests/Feature/ApplicationPoliciesTest.php new file mode 100644 index 000000000..3b246fdb4 --- /dev/null +++ b/tests/Feature/ApplicationPoliciesTest.php @@ -0,0 +1,103 @@ +application = Application::factory()->create(['project_id' => $this->server->project_id]); + $this->environment = Environment::factory()->for($this->application)->create(); + $this->component = Component::factory()->for($this->environment)->create(['server_id' => $this->server->id]); +}); + +test('application policies respect project roles', function (?UserRole $role, bool $read, bool $write) { + $membership = $this->application->project->users()->where('user_id', $this->user->id); + if ($role === null) { + $membership->delete(); + } else { + $membership->update(['role' => $role]); + } + $gate = Gate::forUser($this->user); + + $parents = [ + [Application::class, $this->application->project], + [Environment::class, $this->application], + [Component::class, $this->environment, $this->application], + ]; + $models = [ + [$this->application], + [$this->environment, $this->application], + [$this->component, $this->environment, $this->application], + ]; + + foreach ($parents as $arguments) { + expect($gate->allows('viewAny', $arguments))->toBe($read) + ->and($gate->allows('create', $arguments))->toBe($write); + } + foreach ($models as $arguments) { + expect($gate->allows('view', $arguments))->toBe($read) + ->and($gate->allows('update', $arguments))->toBe($write) + ->and($gate->allows('delete', $arguments))->toBe($write); + } +})->with([ + 'owner' => [UserRole::OWNER, true, true], + 'admin' => [UserRole::ADMIN, true, true], + 'read only' => [UserRole::USER, true, false], + 'non member' => [null, false, false], +]); + +test('application policies reject resources in other projects', function () { + $other = Application::factory()->create(); + $environment = Environment::factory()->for($other)->create(); + $component = Component::factory()->for($environment)->create(); + $gate = Gate::forUser($this->user); + + foreach (['viewAny', 'create'] as $ability) { + expect($gate->allows($ability, [Application::class, $other->project]))->toBeFalse() + ->and($gate->allows($ability, [Environment::class, $other]))->toBeFalse() + ->and($gate->allows($ability, [Component::class, $environment, $other]))->toBeFalse(); + } + foreach (['view', 'update', 'delete'] as $ability) { + expect($gate->allows($ability, $other))->toBeFalse() + ->and($gate->allows($ability, [$environment, $other]))->toBeFalse() + ->and($gate->allows($ability, [$component, $environment, $other]))->toBeFalse(); + } +}); + +test('child policies reject mismatched parents even within an authorized project', function () { + $other = Application::factory()->create(['project_id' => $this->application->project_id]); + $otherEnvironment = Environment::factory()->for($this->application)->create(); + $gate = Gate::forUser($this->user); + + foreach (['view', 'update', 'delete'] as $ability) { + expect($gate->allows($ability, [$this->environment, $other]))->toBeFalse() + ->and($gate->allows($ability, [$this->component, $this->environment, $other]))->toBeFalse() + ->and($gate->allows($ability, [$this->component, $otherEnvironment, $this->application]))->toBeFalse(); + } + foreach (['viewAny', 'create'] as $ability) { + expect($gate->allows($ability, [Component::class, $this->environment, $other]))->toBeFalse(); + } +}); + +test('application policies honor api token project scopes', function (bool $sameProject) { + $project = $sameProject ? $this->application->project : Project::factory()->create(); + $abilities = ['read', 'write', 'project:'.$project->id]; + $this->user->withAccessToken($this->user->createToken('applications', $abilities)->accessToken); + $gate = Gate::forUser($this->user); + + foreach ([ + [$this->application], + [$this->environment, $this->application], + [$this->component, $this->environment, $this->application], + ] as $arguments) { + expect($gate->allows('view', $arguments))->toBe($sameProject) + ->and($gate->allows('update', $arguments))->toBe($sameProject) + ->and($gate->allows('delete', $arguments))->toBe($sameProject); + } +})->with([true, false]); diff --git a/tests/Feature/ExclusiveOwnerTest.php b/tests/Feature/ExclusiveOwnerTest.php new file mode 100644 index 000000000..824a5eb64 --- /dev/null +++ b/tests/Feature/ExclusiveOwnerTest.php @@ -0,0 +1,144 @@ + [Deployment::class, ['site_id', 'component_id'], true], + 'script' => [DeploymentScript::class, ['site_id', 'environment_id', 'component_id'], true], + 'worker' => [Worker::class, ['site_id', 'component_id'], false], + 'cron job' => [CronJob::class, ['site_id', 'component_id'], false], + 'git hook' => [GitHook::class, ['site_id'], true], + 'ssl' => [Ssl::class, ['site_id'], false], +]); + +test('shared models enforce their zero owner contract', function (string $class, array $columns, bool $required) { + $model = $class::factory()->make(array_fill_keys($columns, null)); + if (in_array($class, [Worker::class, CronJob::class, Ssl::class])) { + $model->server_id = $this->server->id; + } + + expect($model->ownerColumns())->toBe($columns) + ->and($model->requiresOwner())->toBe($required); + + if ($required) { + expect(fn () => $model->save())->toThrow(LogicException::class); + + return; + } + + $model->save(); + $this->assertDatabaseHas($model->getTable(), array_merge(['id' => $model->id], array_fill_keys($columns, null))); +})->with('exclusive owner models'); + +test('shared models accept each single owner', function (string $class, array $columns) { + $component = Component::factory()->create(['server_id' => $this->server->id]); + $ids = [ + 'site_id' => $this->site->id, + 'environment_id' => $component->environment_id, + 'component_id' => $component->id, + ]; + + foreach ($columns as $column) { + $model = $class::factory()->make(array_merge(array_fill_keys($columns, null), [$column => $ids[$column]])); + if (in_array($class, [Worker::class, CronJob::class, Ssl::class])) { + $model->server_id = $this->server->id; + } + $model->save(); + $this->assertDatabaseHas($model->getTable(), ['id' => $model->id, $column => $ids[$column]]); + + if ($column === 'site_id') { + expect($model->site->is($this->site))->toBeTrue(); + } elseif ($column === 'environment_id') { + expect($model->environment->is($component->environment))->toBeTrue(); + } else { + expect($model->component->is($component))->toBeTrue(); + } + } +})->with('exclusive owner models'); + +test('shared models reject every pair of owners on create and update', function (string $class, array $columns) { + $component = Component::factory()->create(['server_id' => $this->server->id]); + $ids = [ + 'site_id' => $this->site->id, + 'environment_id' => $component->environment_id, + 'component_id' => $component->id, + ]; + + foreach ($columns as $index => $first) { + foreach (array_slice($columns, $index + 1) as $second) { + $model = $class::factory()->make(array_merge( + array_fill_keys($columns, null), + [$first => $ids[$first], $second => $ids[$second]], + )); + if (in_array($class, [Worker::class, CronJob::class])) { + $model->server_id = $this->server->id; + } + expect(fn () => $model->save())->toThrow(LogicException::class); + + $model->{$second} = null; + $model->save(); + $model->{$second} = $ids[$second]; + expect(fn () => $model->save())->toThrow(LogicException::class); + $this->assertDatabaseHas($model->getTable(), ['id' => $model->id, $first => $ids[$first], $second => null]); + } + } +})->with([ + [Deployment::class, ['site_id', 'component_id']], + [DeploymentScript::class, ['site_id', 'environment_id', 'component_id']], + [Worker::class, ['site_id', 'component_id']], + [CronJob::class, ['site_id', 'component_id']], +]); + +test('required owners cannot be cleared on existing models', function (string $class) { + $model = $class::factory()->create(['site_id' => $this->site->id]); + $model->site_id = null; + + expect(fn () => $model->save())->toThrow(LogicException::class); + $this->assertDatabaseHas($model->getTable(), ['id' => $model->id, 'site_id' => $this->site->id]); +})->with([Deployment::class, DeploymentScript::class, GitHook::class]); + +test('partial selects cannot add a second owner', function (string $class) { + $component = Component::factory()->create(['server_id' => $this->server->id]); + $model = $class::factory()->create(['site_id' => $this->site->id]); + $partial = $class::query()->select('id', 'component_id')->findOrFail($model->id); + $partial->component_id = $component->id; + + expect(fn () => $partial->save())->toThrow(LogicException::class); + $this->assertDatabaseHas($model->getTable(), ['id' => $model->id, 'site_id' => $this->site->id, 'component_id' => null]); +})->with([Deployment::class, DeploymentScript::class, Worker::class, CronJob::class]); + +test('partial selects can save unrelated changes without losing their required owner', function (string $class) { + $model = $class::factory()->create(['site_id' => $this->site->id]); + $columns = $class === DeploymentScript::class ? ['id', 'content'] : ['id']; + $partial = $class::query()->select($columns)->findOrFail($model->id); + $partial->updated_at = now()->addMinute(); + $partial->save(); + + $this->assertDatabaseHas($model->getTable(), ['id' => $model->id, 'site_id' => $this->site->id]); +})->with([Deployment::class, DeploymentScript::class, GitHook::class]); + +test('partial selects preserve explicit owner changes', function () { + $component = Component::factory()->create(['server_id' => $this->server->id]); + $script = DeploymentScript::factory()->create(['site_id' => $this->site->id]); + $partial = DeploymentScript::query()->select('id', 'content')->findOrFail($script->id); + $partial->site_id = null; + $partial->component_id = $component->id; + $partial->save(); + + $this->assertDatabaseHas('deployment_scripts', ['id' => $script->id, 'site_id' => null, 'component_id' => $component->id]); +}); + +test('owner checks count non-null values rather than truthy ids', function () { + $worker = Worker::factory()->make(['site_id' => 0, 'component_id' => 1]); + + expect(fn () => $worker->save())->toThrow(LogicException::class); +}); From ebc5d0bd987f6024d1d4a779c100ad179265ce23 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sat, 17 Oct 2026 20:51:11 +0200 Subject: [PATCH 03/30] phase 3 --- .../instructions/php-laravel.instructions.md | 9 + app/Actions/Bootstrap/GetBootstrap.php | 47 +++ .../ValidateCapabilityServices.php | 44 +++ .../ValidateSchedulerCapability.php | 37 ++ .../ValidateWebCapability.php | 88 +++++ .../AbstractComponentCapability.php | 54 +++ app/ComponentCapabilities/Scheduler.php | 50 +++ app/ComponentCapabilities/Web.php | 75 ++++ app/ComponentCapabilities/Worker.php | 33 ++ app/Enums/WebMode.php | 26 ++ app/Models/Application.php | 13 + app/Models/ComponentCapability.php | 13 + app/Plugins/RegisterComponentCapability.php | 63 ++++ app/Plugins/RegisterRuntimePreset.php | 64 ++++ app/Providers/ComponentServiceProvider.php | 36 ++ app/RuntimePresets/AbstractRuntimePreset.php | 80 +++++ app/RuntimePresets/Bun.php | 39 +++ app/RuntimePresets/Custom.php | 23 ++ app/RuntimePresets/Laravel.php | 31 ++ app/RuntimePresets/NodeJS.php | 39 +++ app/RuntimePresets/PHP.php | 33 ++ app/RuntimePresets/StaticSite.php | 23 ++ config/app.php | 2 + config/component.php | 11 + docs/4.x/plugins.md | 115 +++++++ .../deployment-scripts/presets/bun/build.sh | 2 + .../presets/laravel/build.sh | 1 + .../presets/laravel/post-release.sh | 1 + .../presets/laravel/pre-release.sh | 1 + .../deployment-scripts/presets/node/build.sh | 2 + .../deployment-scripts/presets/php/build.sh | 1 + resources/js/types/index.d.ts | 15 + tests/Arch/ExtensionPointsTest.php | 12 + tests/Feature/ComponentCapabilityTest.php | 320 ++++++++++++++++++ tests/Feature/RuntimePresetTest.php | 219 ++++++++++++ 35 files changed, 1622 insertions(+) create mode 100644 app/Actions/ComponentCapability/ValidateCapabilityServices.php create mode 100644 app/Actions/ComponentCapability/ValidateSchedulerCapability.php create mode 100644 app/Actions/ComponentCapability/ValidateWebCapability.php create mode 100644 app/ComponentCapabilities/AbstractComponentCapability.php create mode 100644 app/ComponentCapabilities/Scheduler.php create mode 100644 app/ComponentCapabilities/Web.php create mode 100644 app/ComponentCapabilities/Worker.php create mode 100644 app/Enums/WebMode.php create mode 100644 app/Plugins/RegisterComponentCapability.php create mode 100644 app/Plugins/RegisterRuntimePreset.php create mode 100644 app/Providers/ComponentServiceProvider.php create mode 100644 app/RuntimePresets/AbstractRuntimePreset.php create mode 100644 app/RuntimePresets/Bun.php create mode 100644 app/RuntimePresets/Custom.php create mode 100644 app/RuntimePresets/Laravel.php create mode 100644 app/RuntimePresets/NodeJS.php create mode 100644 app/RuntimePresets/PHP.php create mode 100644 app/RuntimePresets/StaticSite.php create mode 100644 config/component.php create mode 100644 resources/deployment-scripts/presets/bun/build.sh create mode 100644 resources/deployment-scripts/presets/laravel/build.sh create mode 100644 resources/deployment-scripts/presets/laravel/post-release.sh create mode 100644 resources/deployment-scripts/presets/laravel/pre-release.sh create mode 100644 resources/deployment-scripts/presets/node/build.sh create mode 100644 resources/deployment-scripts/presets/php/build.sh create mode 100644 tests/Feature/ComponentCapabilityTest.php create mode 100644 tests/Feature/RuntimePresetTest.php diff --git a/.github/instructions/php-laravel.instructions.md b/.github/instructions/php-laravel.instructions.md index cf2027a45..85105f377 100644 --- a/.github/instructions/php-laravel.instructions.md +++ b/.github/instructions/php-laravel.instructions.md @@ -79,6 +79,15 @@ All business logic belongs in Action classes under `app/Actions/`. - Implement `static id(): string` and `static type(): string`. - SSH scripts for services live in `resources/views/ssh/services/[service-name]/`. +## Application Runtime Presets & Component Capabilities + +- Runtime presets under `app/RuntimePresets/` extend `AbstractRuntimePreset`; component capabilities under `app/ComponentCapabilities/` extend `AbstractComponentCapability`. +- Both expose static `id()` and `label()` methods and register through `RegisterRuntimePreset` / `RegisterComponentCapability` in a provider or plugin `boot()`. Their registries live in `config/component.php`. +- Presets are stateless suggestions, not runtime-specific branches in core code. Every preset selects a `WebMode` (`fpm`, `proxy`, `static`); there is no default mode. All suggested scripts, commands and paths remain editable. Framework specifics belong only in the preset and its staged scripts under `resources/deployment-scripts/presets/{id}/`. +- Resolve presets with `Application::preset()` and capability handlers with `ComponentCapability::handler()`. A capability handler receives its model in the constructor; `form($preset)` describes its configuration. +- Capability `validate($component, $server)` delegates business validation to Actions, throws `ValidationException` for invalid input, and may normalize its model's in-memory config. It must not save, SSH or dispatch jobs. Callers must serialize validation and persistence per server (ports) / environment (scheduler uniqueness) when adding mutation flows. +- Bootstrap exposes only the public runtime/capability descriptors under `application.runtimes` and `application.capabilities`; keep the frontend `Configs` type in sync. + ## Migrations - Migrations may modify database schema and transform existing data — but never dispatch jobs, run SSH commands, or trigger server-side actions. diff --git a/app/Actions/Bootstrap/GetBootstrap.php b/app/Actions/Bootstrap/GetBootstrap.php index 90f4ddee5..988bdc655 100644 --- a/app/Actions/Bootstrap/GetBootstrap.php +++ b/app/Actions/Bootstrap/GetBootstrap.php @@ -2,6 +2,8 @@ namespace App\Actions\Bootstrap; +use App\Models\Application; +use App\Models\ComponentCapability; use App\Models\GithubApp; use App\Tooling\ToolingRegistry; use Illuminate\Support\Facades\Cache; @@ -73,6 +75,10 @@ private function configs(): array 'types' => config('site.types'), 'reserved_user_names' => config('core.reserved_user_names'), ], + 'application' => [ + 'runtimes' => $this->runtimes(), + 'capabilities' => $this->capabilities(), + ], 'source_control' => [ 'providers' => config('source-control.providers'), ], @@ -102,6 +108,47 @@ private function configs(): array ]; } + /** + * @return list, web_mode: string, form: array}> + */ + private function runtimes(): array + { + $runtimes = []; + + foreach (config('component.runtimes', []) as $id => $registration) { + $preset = (new Application(['runtime' => $id]))->preset(); + $runtimes[] = [ + 'id' => $id, + 'label' => $registration['label'], + 'versions' => $preset->versions(), + 'web_mode' => $preset->webMode()->getText(), + 'form' => $registration['form'], + ]; + } + + return $runtimes; + } + + /** + * @return list}> + */ + private function capabilities(): array + { + $capabilities = []; + + foreach (config('component.capabilities', []) as $id => $registration) { + $handler = (new ComponentCapability(['capability' => $id]))->handler(); + $capabilities[] = [ + 'id' => $id, + 'label' => $registration['label'], + 'description' => $handler->description(), + 'surfaces' => $handler->surfaces(), + ]; + } + + return $capabilities; + } + private function dashboardUrl(?string $domain, string $path): string { return ($domain ? '//'.$domain : '').'/'.trim($path, '/'); diff --git a/app/Actions/ComponentCapability/ValidateCapabilityServices.php b/app/Actions/ComponentCapability/ValidateCapabilityServices.php new file mode 100644 index 000000000..0422602ed --- /dev/null +++ b/app/Actions/ComponentCapability/ValidateCapabilityServices.php @@ -0,0 +1,44 @@ +component_id !== $component->id + || $component->server_id !== $server->id + || $component->environment->application->project_id !== $server->project_id + ) { + throw ValidationException::withMessages([ + 'capabilities' => __('The capability must belong to the selected component and server in the same project.'), + ]); + } + + if (! is_array($capability->getAttribute('config') ?? [])) { + throw ValidationException::withMessages([ + 'config' => __('The capability configuration must be an array.'), + ]); + } + + $preset = $component->environment->application->preset(); + + foreach ($capability->handler()->requiredServices($preset) as $type) { + if (! $server->services()->where('type', $type)->whereIn('status', [ServiceStatus::READY, ServiceStatus::STOPPED, ServiceStatus::DISABLED])->exists()) { + throw ValidationException::withMessages([ + 'capabilities' => __('The :service service must be installed on the server.', ['service' => $type]), + ]); + } + } + } +} diff --git a/app/Actions/ComponentCapability/ValidateSchedulerCapability.php b/app/Actions/ComponentCapability/ValidateSchedulerCapability.php new file mode 100644 index 000000000..2366f6c19 --- /dev/null +++ b/app/Actions/ComponentCapability/ValidateSchedulerCapability.php @@ -0,0 +1,37 @@ + $component->environment->application->preset()->schedulerCommand(), + ], $capability->config ?? []); + + Validator::make($config, [ + 'command' => ['required', 'string', 'max:65535', 'not_regex:/\x00/'], + ])->validate(); + + if (ComponentCapability::query() + ->where('capability', 'scheduler') + ->when($capability->exists, fn ($query) => $query->whereKeyNot($capability->id)) + ->whereHas('component', fn ($query) => $query->where('environment_id', $component->environment_id)) + ->exists()) { + throw ValidationException::withMessages([ + 'capabilities' => __('Only one scheduler is allowed per environment.'), + ]); + } + + $capability->config = $config; + } +} diff --git a/app/Actions/ComponentCapability/ValidateWebCapability.php b/app/Actions/ComponentCapability/ValidateWebCapability.php new file mode 100644 index 000000000..55cdb4e5b --- /dev/null +++ b/app/Actions/ComponentCapability/ValidateWebCapability.php @@ -0,0 +1,88 @@ +environment->application->preset(); + $proxy = $preset->webMode() === WebMode::PROXY; + $config = array_replace(['web_directory' => $preset->webRoot()], $capability->config ?? []); + + if ($proxy) { + $config += ['start_command' => $preset->startCommand(), 'port' => null]; + if ($config['port'] === '') { + $config['port'] = null; + } + } + + Validator::make($config, [ + 'web_directory' => ['nullable', 'string', 'max:255', 'regex:/\A(?!\/)(?!.*\.\.)[a-zA-Z0-9_.\/-]+\z/'], + 'start_command' => $proxy ? ['required', 'string', 'max:65535', 'not_regex:/\x00/'] : ['prohibited'], + 'port' => $proxy ? ['nullable', 'integer', 'between:1024,65535'] : ['prohibited'], + ])->validate(); + + if ($preset->webMode() === WebMode::FPM) { + $version = $component->environment->runtime_version; + if (! $version || ! $server->services()->where('type', 'php')->where('version', $version)->whereIn('status', [ServiceStatus::READY, ServiceStatus::STOPPED, ServiceStatus::DISABLED])->exists()) { + throw ValidationException::withMessages([ + 'runtime_version' => __('The environment PHP version must be installed on the server.'), + ]); + } + } + + if ($proxy) { + $config['port'] = $this->port($capability, $server, $config['port'] === null ? null : (int) $config['port']); + } + + $capability->config = $config; + } + + private function port(ComponentCapability $capability, Server $server, ?int $requested): int + { + $used = ComponentCapability::query() + ->where('capability', 'web') + ->when($capability->exists, fn ($query) => $query->whereKeyNot($capability->id)) + ->whereHas('component', fn ($query) => $query->where('server_id', $server->id)) + ->pluck('config') + ->map(fn (?array $config): int => (int) ($config['port'] ?? 0)) + ->merge($server->sites()->whereNotNull('port')->pluck('port')) + ->map(fn ($port): int => (int) $port) + ->all(); + + if ($requested !== null) { + if (in_array($requested, $used, true)) { + throw ValidationException::withMessages([ + 'port' => __('The port is already used by another component or Site on this server.'), + ]); + } + + return $requested; + } + + $min = max(1024, (int) config('component.port_range.min')); + $max = min(65535, (int) config('component.port_range.max')); + + for ($port = $min; $port <= $max; $port++) { + if (! in_array($port, $used, true)) { + return $port; + } + } + + throw ValidationException::withMessages([ + 'port' => __('No free application port is available on this server.'), + ]); + } +} diff --git a/app/ComponentCapabilities/AbstractComponentCapability.php b/app/ComponentCapabilities/AbstractComponentCapability.php new file mode 100644 index 000000000..9ee925a69 --- /dev/null +++ b/app/ComponentCapabilities/AbstractComponentCapability.php @@ -0,0 +1,54 @@ + + */ + public function requiredServices(AbstractRuntimePreset $preset): array + { + return []; + } + + /** + * @throws ValidationException + */ + public function validate(Component $component, Server $server): void + { + app(ValidateCapabilityServices::class)->validate($this->capability, $component, $server); + } + + /** + * @return list + */ + public function surfaces(): array + { + return []; + } + + public function form(AbstractRuntimePreset $preset): DynamicForm + { + return DynamicForm::make([]); + } +} diff --git a/app/ComponentCapabilities/Scheduler.php b/app/ComponentCapabilities/Scheduler.php new file mode 100644 index 000000000..ef8b891a6 --- /dev/null +++ b/app/ComponentCapabilities/Scheduler.php @@ -0,0 +1,50 @@ +validate($this->capability, $component); + } + + public function surfaces(): array + { + return ['cron_jobs']; + } + + public function form(AbstractRuntimePreset $preset): DynamicForm + { + return DynamicForm::make([ + DynamicField::make('command') + ->text() + ->label('Command') + ->default($preset->schedulerCommand()), + ]); + } +} diff --git a/app/ComponentCapabilities/Web.php b/app/ComponentCapabilities/Web.php new file mode 100644 index 000000000..3692dd60d --- /dev/null +++ b/app/ComponentCapabilities/Web.php @@ -0,0 +1,75 @@ +webMode()) { + WebMode::FPM => ['webserver', 'php'], + WebMode::PROXY => ['webserver', 'process_manager'], + WebMode::STATIC => ['webserver'], + }; + } + + public function validate(Component $component, Server $server): void + { + parent::validate($component, $server); + + app(ValidateWebCapability::class)->validate($this->capability, $component, $server); + } + + public function surfaces(): array + { + return ['domains']; + } + + public function form(AbstractRuntimePreset $preset): DynamicForm + { + $fields = [ + DynamicField::make('web_directory') + ->text() + ->label('Web Directory') + ->default($preset->webRoot()) + ->description('Relative to the release directory. Leave empty for the release root.'), + ]; + + if ($preset->webMode() === WebMode::PROXY) { + $fields[] = DynamicField::make('start_command') + ->text() + ->label('Start Command') + ->default($preset->startCommand()) + ->description('The application must listen on the PORT environment variable.'); + $fields[] = DynamicField::make('port') + ->text() + ->label('Port') + ->description('Leave empty to assign an available port automatically.'); + } + + return DynamicForm::make($fields); + } +} diff --git a/app/ComponentCapabilities/Worker.php b/app/ComponentCapabilities/Worker.php new file mode 100644 index 000000000..25553150b --- /dev/null +++ b/app/ComponentCapabilities/Worker.php @@ -0,0 +1,33 @@ + 'info', + self::PROXY => 'warning', + self::STATIC => 'gray', + }; + } + + public function getText(): string + { + return $this->value; + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index 0ce17469a..6bdc1d6e1 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2,11 +2,13 @@ namespace App\Models; +use App\RuntimePresets\AbstractRuntimePreset; use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Collection; +use InvalidArgumentException; /** * @property int $project_id @@ -36,6 +38,17 @@ class Application extends AbstractModel 'source_control_id' => 'integer', ]; + public function preset(): AbstractRuntimePreset + { + $handler = config('component.runtimes', [])[$this->runtime]['handler'] ?? null; + + if (! is_string($handler) || ! is_subclass_of($handler, AbstractRuntimePreset::class)) { + throw new InvalidArgumentException("Runtime preset for {$this->runtime} is not defined."); + } + + return new $handler; + } + /** * @return BelongsTo */ diff --git a/app/Models/ComponentCapability.php b/app/Models/ComponentCapability.php index 9ce3e92ba..7e86746f9 100644 --- a/app/Models/ComponentCapability.php +++ b/app/Models/ComponentCapability.php @@ -2,10 +2,12 @@ namespace App\Models; +use App\ComponentCapabilities\AbstractComponentCapability; use App\Enums\ComponentStatus; use Database\Factories\ComponentCapabilityFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use InvalidArgumentException; /** * @property int $component_id @@ -31,6 +33,17 @@ class ComponentCapability extends AbstractModel 'status' => ComponentStatus::class, ]; + public function handler(): AbstractComponentCapability + { + $handler = config('component.capabilities', [])[$this->capability]['handler'] ?? null; + + if (! is_string($handler) || ! is_subclass_of($handler, AbstractComponentCapability::class)) { + throw new InvalidArgumentException("Component capability for {$this->capability} is not defined."); + } + + return new $handler($this); + } + /** * @return BelongsTo */ diff --git a/app/Plugins/RegisterComponentCapability.php b/app/Plugins/RegisterComponentCapability.php new file mode 100644 index 000000000..05da6d340 --- /dev/null +++ b/app/Plugins/RegisterComponentCapability.php @@ -0,0 +1,63 @@ +label = $label; + + return $this; + } + + /** + * @param class-string $handler + */ + public function handler(string $handler): self + { + $this->handler = $handler; + + return $this; + } + + public function form(DynamicForm $form): self + { + $this->form = $form; + + return $this; + } + + public function register(): void + { + if (! is_subclass_of($this->handler, AbstractComponentCapability::class) || ! (new ReflectionClass($this->handler))->isInstantiable()) { + throw new InvalidArgumentException('Component capabilities must be instantiable and extend AbstractComponentCapability.'); + } + + $capabilities = config('component.capabilities', []); + $capabilities[$this->name] = [ + 'label' => $this->label !== '' ? $this->label : $this->handler::label(), + 'handler' => $this->handler, + 'form' => $this->form?->toArray(), + ]; + + config(['component.capabilities' => $capabilities]); + } +} diff --git a/app/Plugins/RegisterRuntimePreset.php b/app/Plugins/RegisterRuntimePreset.php new file mode 100644 index 000000000..a790f5363 --- /dev/null +++ b/app/Plugins/RegisterRuntimePreset.php @@ -0,0 +1,64 @@ +label = $label; + + return $this; + } + + /** + * @param class-string $handler + */ + public function handler(string $handler): self + { + $this->handler = $handler; + + return $this; + } + + public function form(DynamicForm $form): self + { + $this->form = $form; + + return $this; + } + + public function register(): void + { + if (! is_subclass_of($this->handler, AbstractRuntimePreset::class) || ! (new ReflectionClass($this->handler))->isInstantiable()) { + throw new InvalidArgumentException('Runtime presets must be instantiable and extend AbstractRuntimePreset.'); + } + + $preset = new $this->handler; + $runtimes = config('component.runtimes', []); + $runtimes[$this->name] = [ + 'label' => $this->label !== '' ? $this->label : $preset::label(), + 'handler' => $this->handler, + 'form' => ($this->form ?? $preset->form())->toArray(), + ]; + + config(['component.runtimes' => $runtimes]); + } +} diff --git a/app/Providers/ComponentServiceProvider.php b/app/Providers/ComponentServiceProvider.php new file mode 100644 index 000000000..f73e4c7d7 --- /dev/null +++ b/app/Providers/ComponentServiceProvider.php @@ -0,0 +1,36 @@ +handler($preset) + ->register(); + } + + foreach ([Web::class, Worker::class, Scheduler::class] as $capability) { + RegisterComponentCapability::make($capability::id()) + ->handler($capability) + ->register(); + } + } +} diff --git a/app/RuntimePresets/AbstractRuntimePreset.php b/app/RuntimePresets/AbstractRuntimePreset.php new file mode 100644 index 000000000..757de7fb6 --- /dev/null +++ b/app/RuntimePresets/AbstractRuntimePreset.php @@ -0,0 +1,80 @@ + + */ + public function versions(): array + { + return []; + } + + /** + * @return array + */ + public function requiredServices(string $version): array + { + return []; + } + + public function tooling(): ?string + { + return null; + } + + public function webRoot(): ?string + { + return null; + } + + public function startCommand(): ?string + { + return null; + } + + public function schedulerCommand(): ?string + { + return null; + } + + /** + * @return list + */ + public function sharedPaths(): array + { + return []; + } + + /** + * @return array{build: string, pre-release: string, post-release: string} + */ + public function defaultScripts(): array + { + $scripts = ['build' => '', 'pre-release' => '', 'post-release' => '']; + + foreach ($scripts as $stage => $script) { + $path = resource_path('deployment-scripts/presets/'.static::id().'/'.$stage.'.sh'); + $scripts[$stage] = File::exists($path) ? File::get($path) : ''; + } + + return $scripts; + } + + public function form(): DynamicForm + { + return DynamicForm::make([]); + } +} diff --git a/app/RuntimePresets/Bun.php b/app/RuntimePresets/Bun.php new file mode 100644 index 000000000..38bbbb658 --- /dev/null +++ b/app/RuntimePresets/Bun.php @@ -0,0 +1,39 @@ + $version]; + } + + public function webMode(): WebMode + { + return WebMode::FPM; + } +} diff --git a/app/RuntimePresets/StaticSite.php b/app/RuntimePresets/StaticSite.php new file mode 100644 index 000000000..fd4638181 --- /dev/null +++ b/app/RuntimePresets/StaticSite.php @@ -0,0 +1,23 @@ + [], + 'capabilities' => [], + 'port_range' => [ + 'min' => 10000, + 'max' => 19999, + ], + 'deploy' => [], +]; diff --git a/docs/4.x/plugins.md b/docs/4.x/plugins.md index 767cd8b16..d42d1d6b5 100644 --- a/docs/4.x/plugins.md +++ b/docs/4.x/plugins.md @@ -197,6 +197,121 @@ can be shared across sibling sites. See the these. ::: +### Application runtime presets (Beta) + +Applications use runtime presets independently of Site types. Presets only suggest editable defaults; +core deployment and component code must not branch on framework names. Extend +`App\RuntimePresets\AbstractRuntimePreset`, implement static `id()` / `label()` and explicitly choose a +`WebMode`: `FPM` for PHP-FPM, `PROXY` for a managed application process, or `STATIC` for files only. +There is no default mode. + +For example, a Python preset can reuse the existing Mise tooling provider: + +```php +use App\Enums\WebMode; +use App\RuntimePresets\AbstractRuntimePreset; +use App\Tooling\PythonTooling; + +class PythonPreset extends AbstractRuntimePreset +{ + public static function id(): string + { + return 'python'; + } + + public static function label(): string + { + return 'Python'; + } + + public function versions(): array + { + return PythonTooling::supportedVersions(); + } + + public function tooling(): ?string + { + return PythonTooling::id(); + } + + public function webMode(): WebMode + { + return WebMode::PROXY; + } + + public function startCommand(): ?string + { + return 'python -m uvicorn main:app --host 127.0.0.1 --port "$PORT"'; + } +} +``` + +Register it in your plugin's `boot()`: + +```php +\App\Plugins\RegisterRuntimePreset::make(PythonPreset::id()) + ->handler(PythonPreset::class) + ->register(); +``` + +The builder takes optional `label()` and `form(DynamicForm)` overrides; otherwise it uses the preset's +label and form. The registry is `component.runtimes`. `Application::preset()` resolves a stateless +handler. PHP and Laravel declare `requiredServices($version)` as `['php' => $version]`; Mise runtimes +return their `ToolingRegistry` tool id from `tooling()` rather than requiring a server-wide Node service. +Other optional suggestions are `webRoot()`, `startCommand()`, `schedulerCommand()`, `sharedPaths()` and +`form()`. Defaults are empty/null. + +`defaultScripts()` returns `build`, `pre-release`, and `post-release` strings, read from +`resources/deployment-scripts/presets/{id}/{stage}.sh` (missing files yield empty strings). Plugins can +override this method to load their own files. These are suggested script contents, not executed during +registration. Existing Site deployment scripts are unchanged. + +### Component capabilities (Beta) + +Extend `App\ComponentCapabilities\AbstractComponentCapability` and register with: + +```php +\App\Plugins\RegisterComponentCapability::make(YourCapability::id()) + ->handler(YourCapability::class) + ->register(); +``` + +The handler receives its `App\Models\ComponentCapability` as the protected `$capability` property. +Implement static `id()` / `label()` and override these hooks as needed: + +- `description(): string` and `surfaces(): array` describe the capability and its UI tabs. +- `requiredServices(AbstractRuntimePreset $preset): array` lists server service types. +- `form(AbstractRuntimePreset $preset): DynamicForm` describes fields stored in the model's `config`. + The registration builder also accepts a static `form(DynamicForm)` override in + `component.capabilities.{id}.form`; null means use the runtime-aware handler form. +- `validate(Component $component, Server $server): void` delegates validation to an Action. Call + `parent::validate()` to check ownership (including the server's project), null/array configuration, + and required installed services (`ready`, `stopped`, or `disabled`). Invalid input throws + `ValidationException` (422). Validation may normalize the model's **in-memory** config, but does not + save it, run SSH, or dispatch jobs. + +`ComponentCapability::handler()` resolves the handler. Built-ins are `web`, `worker`, and `scheduler`: + +- Web needs a webserver. FPM additionally needs the environment's exact PHP version installed; proxy + mode needs a process manager and a non-empty start command. `web_directory` is release-relative, + cannot contain `..`, and defaults to the preset's web root. Empty means the release root. +- Proxy `port` is an editable non-privileged port (1024–65535). Empty values select the first available + port in `component.port_range` (10000–19999 by default), excluding ports held by components and Sites + on that server. The managed process will receive this value as `PORT`. +- Worker needs a process manager and unlocks `workers` for arbitrary background commands. +- Scheduler unlocks `cron_jobs`, permits only one scheduler per environment, and requires `command` + unless the preset supplies a suggestion. Explicitly empty commands are rejected. + +Callers that create/update capabilities must serialize validation and persistence per server for ports +and per environment for schedulers; validation alone does not reserve either. Installation, release +hooks, and other remote operations are not part of this pure framework phase. + +Both registration builders require instantiable handler classes. Registry IDs are literal map keys, +so dotted plugin IDs are supported; access the registry map rather than building dot-separated config paths. + +Public descriptors appear in bootstrap under `application.runtimes` and `application.capabilities`. +Plugin lifecycle Actions already invalidate the bootstrap version when plugins are changed. + ### Register site features and actions Every site can have multiple features and every feature can have multiple actions. diff --git a/resources/deployment-scripts/presets/bun/build.sh b/resources/deployment-scripts/presets/bun/build.sh new file mode 100644 index 000000000..8fe5ae2ec --- /dev/null +++ b/resources/deployment-scripts/presets/bun/build.sh @@ -0,0 +1,2 @@ +bun install --frozen-lockfile +bun run --if-present build diff --git a/resources/deployment-scripts/presets/laravel/build.sh b/resources/deployment-scripts/presets/laravel/build.sh new file mode 100644 index 000000000..05b19d0e1 --- /dev/null +++ b/resources/deployment-scripts/presets/laravel/build.sh @@ -0,0 +1 @@ +composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader diff --git a/resources/deployment-scripts/presets/laravel/post-release.sh b/resources/deployment-scripts/presets/laravel/post-release.sh new file mode 100644 index 000000000..02073e7a2 --- /dev/null +++ b/resources/deployment-scripts/presets/laravel/post-release.sh @@ -0,0 +1 @@ +php artisan optimize diff --git a/resources/deployment-scripts/presets/laravel/pre-release.sh b/resources/deployment-scripts/presets/laravel/pre-release.sh new file mode 100644 index 000000000..b98f64815 --- /dev/null +++ b/resources/deployment-scripts/presets/laravel/pre-release.sh @@ -0,0 +1 @@ +php artisan migrate --force diff --git a/resources/deployment-scripts/presets/node/build.sh b/resources/deployment-scripts/presets/node/build.sh new file mode 100644 index 000000000..ab573c418 --- /dev/null +++ b/resources/deployment-scripts/presets/node/build.sh @@ -0,0 +1,2 @@ +npm ci +npm run build --if-present diff --git a/resources/deployment-scripts/presets/php/build.sh b/resources/deployment-scripts/presets/php/build.sh new file mode 100644 index 000000000..05b19d0e1 --- /dev/null +++ b/resources/deployment-scripts/presets/php/build.sh @@ -0,0 +1 @@ +composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index af428968a..c9a1d63fa 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -116,6 +116,21 @@ export interface Configs { }; reserved_user_names: string[]; }; + application: { + runtimes: { + id: string; + label: string; + versions: string[]; + web_mode: 'fpm' | 'proxy' | 'static'; + form: DynamicFieldConfig[]; + }[]; + capabilities: { + id: string; + label: string; + description: string; + surfaces: string[]; + }[]; + }; github_app: { installed: boolean; }; diff --git a/tests/Arch/ExtensionPointsTest.php b/tests/Arch/ExtensionPointsTest.php index bdd875fa8..f58e15651 100644 --- a/tests/Arch/ExtensionPointsTest.php +++ b/tests/Arch/ExtensionPointsTest.php @@ -1,7 +1,9 @@ classes() ->toExtend(AbstractSiteType::class); +arch('runtime presets extend the abstract runtime preset') + ->expect('App\RuntimePresets') + ->classes() + ->toExtend(AbstractRuntimePreset::class); + +arch('component capabilities extend the abstract component capability') + ->expect('App\ComponentCapabilities') + ->classes() + ->toExtend(AbstractComponentCapability::class); + arch('workflow actions extend the abstract workflow action') ->expect('App\WorkflowActions') ->classes() diff --git a/tests/Feature/ComponentCapabilityTest.php b/tests/Feature/ComponentCapabilityTest.php new file mode 100644 index 000000000..dc40e7539 --- /dev/null +++ b/tests/Feature/ComponentCapabilityTest.php @@ -0,0 +1,320 @@ +create(['project_id' => $this->server->project_id, 'runtime' => 'node']); + $environment = Environment::factory()->for($application)->create(['runtime_version' => '24']); + $this->component = Component::factory()->for($environment)->for($this->server)->create(); +}); + +function componentCapabilityForTest(Component $component, string $id = 'web', ?array $config = null): ComponentCapability +{ + return new ComponentCapability(['component_id' => $component->id, 'capability' => $id, 'config' => $config]); +} + +function assertCapabilityValidationError(Closure $callback, string $field): void +{ + try { + $callback(); + } catch (ValidationException $exception) { + expect($exception->status)->toBe(422) + ->and($exception->errors())->toHaveKey($field); + + return; + } + + test()->fail('Expected a capability validation error for '.$field); +} + +test('built-in capabilities are registered with surfaces and runtime-aware forms', function () { + $capabilities = $this->actingAs($this->user)->getJson('/bootstrap')->assertSuccessful()->json('configs.application.capabilities'); + + expect(array_column($capabilities, 'id'))->toBe(['web', 'worker', 'scheduler']); + + foreach (['web' => ['domains'], 'worker' => ['workers'], 'scheduler' => ['cron_jobs']] as $id => $surfaces) { + $handler = componentCapabilityForTest($this->component, $id)->handler(); + expect(collect($capabilities)->firstWhere('id', $id))->toBe([ + 'id' => $id, 'label' => $handler::label(), 'description' => $handler->description(), 'surfaces' => $surfaces, + ]); + } + + $web = new Web(new ComponentCapability); + expect($web->form(new NodeJS)->getFieldNames())->toBe(['web_directory', 'start_command', 'port']) + ->and($web->form(new StaticSite)->getFieldNames())->toBe(['web_directory']) + ->and($web->form(new Laravel)->toArray()[0]['default'])->toBe('public') + ->and($web->form(new NodeJS)->toArray()[1]['default'])->toBe('npm start') + ->and((new Worker(new ComponentCapability))->form(new Custom)->toArray())->toBe([]) + ->and((new Scheduler(new ComponentCapability))->form(new Laravel)->toArray()[0]['default'])->toBe('php artisan schedule:run'); +}); + +test('web service requirements follow the web mode', function () { + $web = new Web(new ComponentCapability); + + expect($web->requiredServices(new PHP))->toBe(['webserver', 'php']) + ->and($web->requiredServices(new NodeJS))->toBe(['webserver', 'process_manager']) + ->and($web->requiredServices(new StaticSite))->toBe(['webserver']); +}); + +test('capabilities reject missing or uninstalled services', function (string $capability, string $runtime, string $service, ?ServiceStatus $status) { + $this->component->environment->application->update(['runtime' => $runtime]); + $query = $this->server->services()->where('type', $service); + if ($status === null) { + $query->delete(); + } else { + $query->update(['status' => $status]); + } + $record = componentCapabilityForTest($this->component, $capability); + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), 'capabilities'); +})->with([ + ['web', 'node', 'webserver', null], + ['web', 'static', 'webserver', null], + ['web', 'php', 'php', null], + ['web', 'node', 'process_manager', null], + ['worker', 'node', 'process_manager', null], + ['worker', 'node', 'process_manager', ServiceStatus::INSTALLING], + ['web', 'node', 'webserver', ServiceStatus::INSTALLATION_FAILED], +]); + +test('capability validation does not change installed service defaults', function () { + $this->server->services()->update(['is_default' => false, 'status' => ServiceStatus::STOPPED]); + $record = componentCapabilityForTest($this->component, 'worker'); + $record->handler()->validate($this->component, $this->server); + + $this->assertDatabaseMissing('services', ['server_id' => $this->server->id, 'is_default' => true]); + $this->assertDatabaseCount('component_capabilities', 0); +}); + +test('fpm web requires the exact environment PHP version', function (?string $version) { + $this->component->environment->application->update(['runtime' => 'php']); + $this->component->environment->update(['runtime_version' => $version]); + $record = componentCapabilityForTest($this->component); + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), 'runtime_version'); +})->with(['8.4', null, '']); + +test('fpm and static web preserve editable directory suggestions without proxy config', function (string $runtime, ?string $directory) { + $this->component->environment->application->update(['runtime' => $runtime]); + $this->component->environment->update(['runtime_version' => '8.2']); + $record = componentCapabilityForTest($this->component); + $record->handler()->validate($this->component, $this->server); + + expect($record->config)->toBe(['web_directory' => $directory]); + + $record->config = ['web_directory' => 'dist']; + $record->handler()->validate($this->component, $this->server); + expect($record->config)->toBe(['web_directory' => 'dist']); + + $record->config = ['web_directory' => null]; + $record->handler()->validate($this->component, $this->server); + expect($record->config)->toBe(['web_directory' => null]); +})->with([['php', null], ['laravel', 'public'], ['static', null]]); + +test('web directories cannot escape the release or inject shell syntax', function (string $directory) { + $record = componentCapabilityForTest($this->component, config: ['web_directory' => $directory]); + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), 'web_directory'); +})->with(['../shared', 'public/../../etc', '/etc', '..', 'public\\..\\etc', "public\n", "public\0", 'public;id', '$(id)', 'public/..']); + +test('relative web directories are accepted', function (?string $directory) { + $record = componentCapabilityForTest($this->component, config: ['web_directory' => $directory]); + $record->handler()->validate($this->component, $this->server); + + expect($record->config['web_directory'])->toBe($directory); +})->with([null, '', 'public', 'dist/assets', '.output/public', 'www-root/']); + +test('proxy web requires a start command when the preset has none', function () { + $this->component->environment->application->update(['runtime' => 'custom']); + $record = componentCapabilityForTest($this->component); + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), 'start_command'); + + $record->config = ['start_command' => './server --port "$PORT"']; + $record->handler()->validate($this->component, $this->server); + expect($record->config['start_command'])->toBe('./server --port "$PORT"'); +}); + +test('explicitly empty proxy commands are not silently replaced by suggestions', function (?string $command) { + $record = componentCapabilityForTest($this->component, config: ['start_command' => $command]); + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), 'start_command'); +})->with([null, '', ' ']); + +test('proxy ports skip component and Site reservations without persisting validation', function ($empty) { + config(['component.port_range' => ['min' => 12000, 'max' => 12002]]); + $this->site->update(['type' => 'node', 'port' => 12000]); + $other = Component::factory()->for($this->server)->create(); + ComponentCapability::factory()->for($other)->create(['config' => ['port' => '12001']]); + $record = componentCapabilityForTest($this->component, config: ['port' => $empty]); + $record->handler()->validate($this->component, $this->server); + + expect($record->config['port'])->toBe(12002) + ->and($record->config['start_command'])->toBe('npm start') + ->and($record->exists)->toBeFalse(); + $this->assertDatabaseCount('component_capabilities', 1); +})->with([null, '']); + +test('explicit ports cannot conflict with another component or Site', function (string $owner) { + if ($owner === 'component') { + $other = Component::factory()->for($this->server)->create(); + ComponentCapability::factory()->for($other)->create(['config' => ['port' => 13000]]); + } else { + $this->site->update(['type' => 'node', 'port' => 13000]); + } + $record = componentCapabilityForTest($this->component, config: ['port' => '13000']); + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), 'port'); +})->with(['component', 'site']); + +test('proxy ports are server scoped and an existing capability may keep or edit its port', function () { + $other = Component::factory()->create(); + ComponentCapability::factory()->for($other)->create(['config' => ['port' => 13000]]); + Site::factory()->for($other->server)->create(['port' => 13000]); + $record = ComponentCapability::factory()->for($this->component)->create(['config' => ['port' => 13000]]); + $record->handler()->validate($this->component, $this->server); + + expect($record->config['port'])->toBe(13000); + + $record->config = ['start_command' => 'node server.js', 'port' => '14000']; + $record->handler()->validate($this->component, $this->server); + expect($record->config['port'])->toBe(14000); + $this->assertDatabaseHas('component_capabilities', ['id' => $record->id, 'config->port' => 13000]); +}); + +test('proxy ports must be non-privileged integers', function ($port) { + $record = componentCapabilityForTest($this->component, config: ['port' => $port]); + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), 'port'); +})->with([0, 80, 1023, 65536, -1, 'not-a-port', 3000.5, [[3000]]]); + +test('proxy port exhaustion returns a validation error', function () { + config(['component.port_range' => ['min' => 12000, 'max' => 12000]]); + $this->site->update(['port' => 12000]); + $record = componentCapabilityForTest($this->component); + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), 'port'); +}); + +test('non-proxy web rejects proxy-only configuration', function (array $config) { + $this->component->environment->application->update(['runtime' => 'static']); + $record = componentCapabilityForTest($this->component, config: $config); + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), array_key_first($config)); +})->with([[['port' => 12000]], [['start_command' => 'run']]]); + +test('scheduler uses preset suggestions and permits arbitrary editable commands', function () { + $this->component->environment->application->update(['runtime' => 'laravel']); + $record = componentCapabilityForTest($this->component, 'scheduler'); + $record->handler()->validate($this->component, $this->server); + expect($record->config)->toBe(['command' => 'php artisan schedule:run']); + + $this->component->environment->application->update(['runtime' => 'node']); + $record->config = ['command' => 'node tasks.js']; + $record->handler()->validate($this->component, $this->server); + expect($record->config)->toBe(['command' => 'node tasks.js']); + $this->assertDatabaseCount('component_capabilities', 0); +}); + +test('scheduler needs a command for presets without a suggestion', function (?array $config) { + $record = componentCapabilityForTest($this->component, 'scheduler', $config); + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), 'command'); +})->with([null, [['command' => '']], [['command' => null]], [['command' => ' ']]]); + +test('a second scheduler in an environment is rejected even on another server', function () { + $other = Component::factory()->for($this->component->environment)->create(); + ComponentCapability::factory()->for($other)->create(['capability' => 'scheduler', 'config' => ['command' => 'run']]); + $record = componentCapabilityForTest($this->component, 'scheduler', ['command' => 'node tasks.js']); + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), 'capabilities'); +}); + +test('scheduler uniqueness excludes itself and does not cross environments', function () { + ComponentCapability::factory()->create(['capability' => 'scheduler']); + $record = ComponentCapability::factory()->for($this->component)->create(['capability' => 'scheduler', 'config' => ['command' => 'node tasks.js']]); + $record->handler()->validate($this->component, $this->server); + + expect($record->config['command'])->toBe('node tasks.js'); +}); + +test('capability validation rejects mismatched parents', function (string $mismatch) { + $record = componentCapabilityForTest($this->component, 'worker'); + if ($mismatch === 'component') { + $record->component_id = $this->component->id + 1; + $server = $this->server; + } else { + $server = Server::factory()->create(); + } + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $server), 'capabilities'); +})->with(['component', 'server']); + +test('installed services satisfy capability requirements without changing their state', function (string $capability, string $runtime, ServiceStatus $status) { + $this->component->environment->application->update(['runtime' => $runtime]); + $this->component->environment->update(['runtime_version' => '8.2']); + $this->server->services()->update(['status' => $status]); + $record = componentCapabilityForTest($this->component, $capability); + + $record->handler()->validate($this->component, $this->server); + + expect($this->server->services()->where('status', '!=', $status)->exists())->toBeFalse(); + $this->assertDatabaseCount('component_capabilities', 0); +})->with([['web', 'php'], ['web', 'node'], ['worker', 'node']]) + ->with([ServiceStatus::READY, ServiceStatus::STOPPED, ServiceStatus::DISABLED]); + +test('capability validation rejects scalar configuration without changing it', function (string $capability, mixed $config) { + $record = componentCapabilityForTest($this->component, $capability); + $record->config = $config; + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), 'config'); + + expect($record->config)->toBe($config); + $this->assertDatabaseCount('component_capabilities', 0); +})->with(['web', 'worker', 'scheduler'])->with(['not-an-array', true, false, 123, '']); + +test('capability commands reject NUL bytes while preserving editable shell syntax', function (string $capability, string $field) { + $record = componentCapabilityForTest($this->component, $capability, [$field => "node\0server.js"]); + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), $field); + expect($record->config)->toBe([$field => "node\0server.js"]); + + $command = 'echo "$PORT" && node --version | cat'; + $record->config = [$field => $command]; + $record->handler()->validate($this->component, $this->server); + + expect($record->config[$field])->toBe($command); + $this->assertDatabaseCount('component_capabilities', 0); +})->with([['web', 'start_command'], ['scheduler', 'command']]); + +test('capability validation rejects cross-project servers', function (string $capability) { + $this->component->environment->application->update(['project_id' => \App\Models\Project::factory()->create()->id]); + $record = componentCapabilityForTest($this->component, $capability); + + assertCapabilityValidationError(fn () => $record->handler()->validate($this->component, $this->server), 'capabilities'); + + expect($record->config)->toBeNull(); + $this->assertDatabaseCount('component_capabilities', 0); +})->with(['web', 'worker', 'scheduler']); diff --git a/tests/Feature/RuntimePresetTest.php b/tests/Feature/RuntimePresetTest.php new file mode 100644 index 000000000..ac7f1a4ec --- /dev/null +++ b/tests/Feature/RuntimePresetTest.php @@ -0,0 +1,219 @@ + $id]))->preset(); + $runtimes = $this->actingAs($this->user)->getJson('/bootstrap') + ->assertSuccessful()->json('configs.application.runtimes'); + $descriptor = collect($runtimes)->firstWhere('id', $id); + + expect($preset)->toBeInstanceOf($class) + ->and($preset::id())->toBe($id) + ->and($preset->webMode())->toBe($mode) + ->and($preset->tooling())->toBe($tooling) + ->and($descriptor)->toBe([ + 'id' => $id, + 'label' => $preset::label(), + 'versions' => $preset->versions(), + 'web_mode' => $mode->getText(), + 'form' => $preset->form()->toArray(), + ]) + ->and($mode->getColor())->not->toBeEmpty(); + + if ($tooling !== null) { + $tool = ToolingRegistry::find($tooling); + expect($tool)->not->toBeNull() + ->and($preset->versions())->toBe($tool::supportedVersions()); + } +})->with([ + ['php', PHP::class, WebMode::FPM, null], + ['node', NodeJS::class, WebMode::PROXY, 'node'], + ['bun', Bun::class, WebMode::PROXY, 'bun'], + ['static', StaticSite::class, WebMode::STATIC, null], + ['custom', Custom::class, WebMode::PROXY, null], + ['laravel', Laravel::class, WebMode::FPM, null], +]); + +test('runtime requirements use the selected version without imposing server-wide mise services', function (string $id, array $services) { + $preset = (new Application(['runtime' => $id]))->preset(); + + expect($preset->requiredServices('8.4'))->toBe($services); + + if ($services !== []) { + expect($preset->requiredServices('8.2'))->toBe(['php' => '8.2']) + ->and($preset->versions())->toBe(config('service.services.php.versions')); + } +})->with([ + ['php', ['php' => '8.4']], + ['laravel', ['php' => '8.4']], + ['node', []], + ['bun', []], + ['static', []], + ['custom', []], +]); + +test('suggested deployment scripts are loaded by stage without reusing site scripts', function () { + expect((new Laravel)->defaultScripts())->toBe([ + 'build' => "composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader\n", + 'pre-release' => "php artisan migrate --force\n", + 'post-release' => "php artisan optimize\n", + ]) + ->and((new PHP)->defaultScripts()['build'])->toContain('composer install') + ->and((new NodeJS)->defaultScripts())->toBe([ + 'build' => "npm ci\nnpm run build --if-present\n", + 'pre-release' => '', + 'post-release' => '', + ]) + ->and((new Bun)->defaultScripts()['build'])->toContain('bun install'); +}); + +test('runtime suggestions are empty unless the preset supplies them', function () { + foreach ([new StaticSite, new Custom] as $preset) { + expect($preset->versions())->toBe([]) + ->and($preset->webRoot())->toBeNull() + ->and($preset->startCommand())->toBeNull() + ->and($preset->schedulerCommand())->toBeNull() + ->and($preset->sharedPaths())->toBe([]) + ->and($preset->form()->toArray())->toBe([]) + ->and($preset->defaultScripts())->toBe(['build' => '', 'pre-release' => '', 'post-release' => '']); + } + + expect((new Laravel)->webRoot())->toBe('public') + ->and((new Laravel)->sharedPaths())->toBe(['storage']) + ->and((new Laravel)->schedulerCommand())->toBe('php artisan schedule:run') + ->and((new NodeJS)->startCommand())->toBe('npm start') + ->and((new Bun)->startCommand())->toBe('bun run start'); +}); + +test('plugins can register runtime presets and capabilities in bootstrap', function () { + $preset = new class extends AbstractRuntimePreset + { + public static function id(): string + { + return 'python'; + } + + public static function label(): string + { + return 'Python'; + } + + public function webMode(): WebMode + { + return WebMode::PROXY; + } + + public function form(): DynamicForm + { + return DynamicForm::make([DynamicField::make('build_assets')->checkbox()->default(false)]); + } + }; + $capability = new class(new ComponentCapability) extends AbstractComponentCapability + { + public static function id(): string + { + return 'tasks'; + } + + public static function label(): string + { + return 'Tasks'; + } + }; + $version = app(GetBootstrap::class)->computeVersion(); + + RegisterRuntimePreset::make($preset::id())->handler($preset::class)->register(); + RegisterComponentCapability::make($capability::id())->handler($capability::class)->register(); + + $response = $this->actingAs($this->user)->getJson('/bootstrap')->assertSuccessful(); + $runtimes = collect($response->json('configs.application.runtimes'))->keyBy('id'); + $capabilities = collect($response->json('configs.application.capabilities'))->keyBy('id'); + + expect($runtimes['python'])->toBe([ + 'id' => 'python', 'label' => 'Python', 'versions' => [], 'web_mode' => 'proxy', 'form' => $preset->form()->toArray(), + ]) + ->and($capabilities['tasks'])->toBe(['id' => 'tasks', 'label' => 'Tasks', 'description' => '', 'surfaces' => []]) + ->and($response->json('version'))->not->toBe($version) + ->and((new Application(['runtime' => 'python']))->preset())->toBeInstanceOf($preset::class) + ->and((new ComponentCapability(['capability' => 'tasks']))->handler())->toBeInstanceOf($capability::class); +}); + +test('registration can override labels and serialized forms', function () { + $form = DynamicForm::make([DynamicField::make('command')->text()->default('run')]); + + RegisterRuntimePreset::make('custom')->handler(Custom::class)->label('Custom runtime')->form($form)->register(); + RegisterComponentCapability::make('worker')->handler(\App\ComponentCapabilities\Worker::class)->label('Jobs')->form($form)->register(); + + expect(config('component.runtimes.custom.label'))->toBe('Custom runtime') + ->and(config('component.runtimes.custom.form'))->toBe($form->toArray()) + ->and(config('component.capabilities.worker.label'))->toBe('Jobs') + ->and(config('component.capabilities.worker.form'))->toBe($form->toArray()); +}); + +test('unknown and invalid runtime handlers fail explicitly', function (?string $handler) { + config(['component.runtimes.unknown.handler' => $handler]); + + expect(fn () => (new Application(['runtime' => 'unknown']))->preset())->toThrow(InvalidArgumentException::class); +})->with([null, stdClass::class, 'MissingRuntimeClass']); + +test('unknown and invalid capability handlers fail explicitly', function (?string $handler) { + config(['component.capabilities.unknown.handler' => $handler]); + + expect(fn () => (new ComponentCapability(['capability' => 'unknown']))->handler())->toThrow(InvalidArgumentException::class); +})->with([null, stdClass::class, 'MissingCapabilityClass']); + +test('registration rejects handlers outside the extension contracts', function () { + expect(fn () => RegisterRuntimePreset::make('invalid')->handler(stdClass::class)->register())->toThrow(InvalidArgumentException::class) + ->and(fn () => RegisterComponentCapability::make('invalid')->handler(stdClass::class)->register())->toThrow(InvalidArgumentException::class); +}); + +test('runtime and capability registries resolve dotted identifiers literally', function () { + RegisterRuntimePreset::make('acme.custom')->handler(Custom::class)->register(); + RegisterComponentCapability::make('acme.worker')->handler(\App\ComponentCapabilities\Worker::class)->register(); + + expect((new Application(['runtime' => 'acme.custom']))->preset())->toBeInstanceOf(Custom::class) + ->and((new ComponentCapability(['capability' => 'acme.worker']))->handler())->toBeInstanceOf(\App\ComponentCapabilities\Worker::class); + + $configs = $this->actingAs($this->user)->getJson('/bootstrap')->assertSuccessful()->json('configs.application'); + + expect(array_column($configs['runtimes'], 'id'))->toContain('acme.custom') + ->and(array_column($configs['capabilities'], 'id'))->toContain('acme.worker'); +}); + +test('registration rejects abstract runtime presets before changing the registry', function () { + expect(fn () => RegisterRuntimePreset::make('invalid')->handler(AbstractRuntimePresetForRegistrationTest::class)->register()) + ->toThrow(InvalidArgumentException::class); + + expect(config('component.runtimes'))->not->toHaveKey('invalid'); +}); + +test('registration rejects abstract component capabilities before changing the registry', function () { + expect(fn () => RegisterComponentCapability::make('invalid')->handler(AbstractComponentCapabilityForRegistrationTest::class)->register()) + ->toThrow(InvalidArgumentException::class); + + expect(config('component.capabilities'))->not->toHaveKey('invalid'); +}); + +abstract class AbstractRuntimePresetForRegistrationTest extends Custom {} + +abstract class AbstractComponentCapabilityForRegistrationTest extends \App\ComponentCapabilities\Worker {} From 8440a7fe01ceaf9704b6662a3fba611302439d12 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sat, 17 Oct 2026 22:03:47 +0200 Subject: [PATCH 04/30] phase 4 --- .../Component/PurgeServerComponents.php | 30 ++ app/Actions/Server/TransferServer.php | 13 + .../Site/Tooling/UninstallSiteTooling.php | 8 + .../SourceControl/DeleteSourceControl.php | 6 + app/Models/IsolatedUser.php | 29 + app/Models/Server.php | 124 +++-- app/Models/Site.php | 12 +- app/Models/SourceControl.php | 8 + app/Services/NodeJS/NodeJS.php | 3 + app/Services/PHP/PHP.php | 3 + app/Services/Webserver/AbstractWebserver.php | 3 + app/SiteTypes/AbstractSiteType.php | 2 +- tests/Feature/ApplicationCoexistenceTest.php | 506 ++++++++++++++++++ tests/Feature/ServerTest.php | 23 + tests/Feature/SiteToolingTest.php | 59 ++ tests/Feature/SourceControlsTest.php | 19 + 16 files changed, 800 insertions(+), 48 deletions(-) create mode 100644 app/Actions/Component/PurgeServerComponents.php create mode 100644 tests/Feature/ApplicationCoexistenceTest.php diff --git a/app/Actions/Component/PurgeServerComponents.php b/app/Actions/Component/PurgeServerComponents.php new file mode 100644 index 000000000..f3c778df4 --- /dev/null +++ b/app/Actions/Component/PurgeServerComponents.php @@ -0,0 +1,30 @@ + + */ + public function purge(Server $server): array + { + return DB::transaction(function () use ($server): array { + $environmentIds = $server->components()->distinct()->pluck('environment_id')->all(); + + $server->components()->delete(); + + return $environmentIds; + }); + } + + /** + * Post-delete extension point for resynchronizing affected environments on surviving servers. + * + * @param array $environmentIds + */ + public function handle(array $environmentIds): void {} +} diff --git a/app/Actions/Server/TransferServer.php b/app/Actions/Server/TransferServer.php index 852c6fbf5..06d38050f 100755 --- a/app/Actions/Server/TransferServer.php +++ b/app/Actions/Server/TransferServer.php @@ -2,6 +2,7 @@ namespace App\Actions\Server; +use App\Models\Component; use App\Models\NetworkServer; use App\Models\Project; use App\Models\Server; @@ -32,6 +33,18 @@ public function transfer(User $user, Server $server, array $input): Server ]); } + $environments = $server->components()->with('environment.application')->get() + ->map(fn (Component $component): string => $component->environment->application->name.' / '.$component->environment->name) + ->unique(); + + if ($environments->isNotEmpty()) { + throw ValidationException::withMessages([ + 'project_id' => __('This server hosts components in the following environments: :environments. Remove them before transferring it.', [ + 'environments' => $environments->implode(', '), + ]), + ]); + } + $server->project_id = $project->id; $server->save(); diff --git a/app/Actions/Site/Tooling/UninstallSiteTooling.php b/app/Actions/Site/Tooling/UninstallSiteTooling.php index 71d58fe53..84ba25517 100644 --- a/app/Actions/Site/Tooling/UninstallSiteTooling.php +++ b/app/Actions/Site/Tooling/UninstallSiteTooling.php @@ -4,6 +4,7 @@ use App\DTOs\ToolingTargetDTO; use App\Jobs\Site\Tooling\UninstallSiteToolingJob; +use App\Models\Component; use App\Models\IsolatedUser; use App\Models\Site; use App\Tooling\SiteToolingState; @@ -41,6 +42,13 @@ public function uninstall(Site $site, string $toolId): void ]); } + if ($iuser->components()->with('environment.application')->get() + ->contains(fn (Component $component): bool => $component->environment->application->preset()->tooling() === $toolId)) { + throw ValidationException::withMessages([ + 'tool' => "{$tool::label()} is required by a component sharing this isolated user and cannot be uninstalled.", + ]); + } + $current = $iuser->toolingStatus($toolId); if ($current === SiteToolingState::STATUS_INSTALLING || $current === SiteToolingState::STATUS_UNINSTALLING) { throw ValidationException::withMessages([ diff --git a/app/Actions/SourceControl/DeleteSourceControl.php b/app/Actions/SourceControl/DeleteSourceControl.php index 58b35d956..02911e2a2 100644 --- a/app/Actions/SourceControl/DeleteSourceControl.php +++ b/app/Actions/SourceControl/DeleteSourceControl.php @@ -21,6 +21,12 @@ public function delete(SourceControl $sourceControl): void ]); } + if ($sourceControl->applications()->exists()) { + throw ValidationException::withMessages([ + 'source_control' => __('This source control is being used by an application.'), + ]); + } + $sourceControl->delete(); } } diff --git a/app/Models/IsolatedUser.php b/app/Models/IsolatedUser.php index d37c88d5d..b852ea8a8 100644 --- a/app/Models/IsolatedUser.php +++ b/app/Models/IsolatedUser.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\WebMode; use Database\Factories\IsolatedUserFactory; use Illuminate\Contracts\Cache\Lock; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -63,6 +64,34 @@ public function sites(): HasMany return $this->hasMany(Site::class); } + public function isUsedByOthers(Site|Component $except): bool + { + return $this->sites() + ->when($except instanceof Site, fn ($query) => $query->whereKeyNot($except->id)) + ->exists() + || $this->components() + ->when($except instanceof Component, fn ($query) => $query->whereKeyNot($except->id)) + ->exists(); + } + + public function fpmPoolUsedByOthers(string $phpVersion, Site|Component $except): bool + { + if ($this->sites() + ->where('php_version', $phpVersion) + ->when($except instanceof Site, fn ($query) => $query->whereKeyNot($except->id)) + ->exists()) { + return true; + } + + return $this->components() + ->when($except instanceof Component, fn ($query) => $query->whereKeyNot($except->id)) + ->with(['environment.application', 'capabilities']) + ->get() + ->contains(fn (Component $component): bool => $component->environment->runtime_version === $phpVersion + && $component->hasCapability('web') + && $component->environment->application->preset()->webMode() === WebMode::FPM); + } + public function lock(): Lock { return Cache::lock("isolate:{$this->server_id}:{$this->username}", 60); diff --git a/app/Models/Server.php b/app/Models/Server.php index c70ce1e9c..fd0555954 100755 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2,12 +2,14 @@ namespace App\Models; +use App\Actions\Component\PurgeServerComponents; use App\Actions\Network\ResyncNetworkSiblings; use App\Actions\Server\CheckConnection; use App\Enums\OperatingSystem; use App\Enums\SecurityControlStatus; use App\Enums\ServerStatus; use App\Enums\ServiceStatus; +use App\Enums\WebMode; use App\Exceptions\SSHError; use App\Facades\SSH; use App\ServerFeatures\ActionInterface; @@ -133,6 +135,9 @@ class Server extends AbstractModel */ protected array $networkDeparture = ['members' => [], 'networks' => []]; + /** @var array */ + protected array $componentDeparture = []; + public static function boot(): void { parent::boot(); @@ -143,50 +148,63 @@ public static function boot(): void static::deleted(function (Server $server): void { app(ResyncNetworkSiblings::class)->handle($server->networkDeparture); + app(PurgeServerComponents::class)->handle($server->componentDeparture); }); static::deleting(function (Server $server): void { - DB::beginTransaction(); - try { - $server->sites()->each(function ($site): void { - /** @var Site $site */ - $site->workers()->delete(); - $site->ssls()->delete(); - $site->deployments()->delete(); - $site->deploymentScript()->delete(); - }); - $server->sites()->delete(); - $server->logs()->each(function ($log): void { - /** @var ServerLog $log */ - $log->delete(); - }); - $server->backups()->each(function ($backup): void { - /** @var Backup $backup */ - $backup->delete(); - }); - $server->services()->delete(); - $server->databases()->delete(); - $server->databaseUsers()->delete(); - $server->firewallRules()->delete(); - $server->cronJobs()->delete(); - $server->workers()->delete(); - $server->daemons()->delete(); - $server->sshKeys()->detach(); - if ($server->deleteFromProvider) { - $server->provider()->delete(); - } - if (File::exists($server->sshKey()['public_key_path'])) { - File::delete($server->sshKey()['public_key_path']); - } - if (File::exists($server->sshKey()['private_key_path'])) { - File::delete($server->sshKey()['private_key_path']); - } + $server->componentDeparture = app(PurgeServerComponents::class)->purge($server); + $server->sites()->each(function ($site): void { + /** @var Site $site */ + $site->workers()->delete(); + $site->ssls()->delete(); + $site->deployments()->delete(); + $site->deploymentScript()->delete(); + }); + $server->sites()->delete(); + $server->logs()->each(function ($log): void { + /** @var ServerLog $log */ + $log->delete(); + }); + $server->backups()->each(function ($backup): void { + /** @var Backup $backup */ + $backup->delete(); + }); + $server->services()->delete(); + $server->databases()->delete(); + $server->databaseUsers()->delete(); + $server->firewallRules()->delete(); + $server->cronJobs()->delete(); + $server->workers()->delete(); + $server->daemons()->delete(); + $server->sshKeys()->detach(); + if ($server->deleteFromProvider) { + $server->provider()->delete(); + } + if (File::exists($server->sshKey()['public_key_path'])) { + File::delete($server->sshKey()['public_key_path']); + } + if (File::exists($server->sshKey()['private_key_path'])) { + File::delete($server->sshKey()['private_key_path']); + } + }); + } + + public function delete(): ?bool + { + DB::beginTransaction(); + try { + $deleted = parent::delete(); + if ($deleted) { DB::commit(); - } catch (Throwable $e) { + } else { DB::rollBack(); - throw $e; } - }); + + return $deleted; + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } } public function isReady(): bool @@ -236,6 +254,36 @@ public function components(): HasMany return $this->hasMany(Component::class); } + /** + * @return Collection + */ + public function componentsUsing(Service $service): Collection + { + return $this->components()->with(['environment.application', 'capabilities'])->get() + ->filter(function (Component $component) use ($service): bool { + $preset = $component->environment->application->preset(); + $version = $component->environment->runtime_version; + $services = $preset->requiredServices($version ?? ''); + + if (array_key_exists($service->type, $services) + && ($services[$service->type] === null || $services[$service->type] === $service->version)) { + return true; + } + + return $component->capabilities->contains(function (ComponentCapability $capability) use ($service, $preset, $version): bool { + if (! in_array($service->type, $capability->handler()->requiredServices($preset), true)) { + return false; + } + + if ($capability->capability === 'web' && $service->type === 'php' && $preset->webMode() === WebMode::FPM) { + return $version === $service->version; + } + + return true; + }); + }); + } + /** * @return HasMany */ diff --git a/app/Models/Site.php b/app/Models/Site.php index a46a2cbfe..d993d35bf 100755 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -698,7 +698,7 @@ public function isIsolated(): bool public function userSharedWithSiblings(): bool { - return $this->siblingsSharingUser()->exists(); + return $this->isolatedUser?->isUsedByOthers($this) ?? false; } /** @@ -765,15 +765,9 @@ public function requiredToolingMap(): array public function fpmPoolSharedWithSiblings(?string $phpVersion = null): bool { - if (! $this->isolated_user_id) { - return false; - } + $phpVersion ??= (string) $this->php_version; - return Site::query() - ->where('isolated_user_id', $this->isolated_user_id) - ->where('php_version', $phpVersion ?? $this->php_version) - ->where('id', '!=', $this->id) - ->exists(); + return $phpVersion !== '' && ($this->isolatedUser?->fpmPoolUsedByOthers($phpVersion, $this) ?? false); } public function webserver(): Webserver diff --git a/app/Models/SourceControl.php b/app/Models/SourceControl.php index 4c6dadbbd..3dc82776b 100755 --- a/app/Models/SourceControl.php +++ b/app/Models/SourceControl.php @@ -111,6 +111,14 @@ public function getRepo(string $repo): mixed return $this->provider()->getRepo($repo); } + /** + * @return HasMany + */ + public function applications(): HasMany + { + return $this->hasMany(Application::class); + } + /** * @return HasMany */ diff --git a/app/Services/NodeJS/NodeJS.php b/app/Services/NodeJS/NodeJS.php index bb66873ea..0075a2520 100644 --- a/app/Services/NodeJS/NodeJS.php +++ b/app/Services/NodeJS/NodeJS.php @@ -56,6 +56,9 @@ function (string $attribute, mixed $value, Closure $fail): void { if ($hasSite) { $fail('Some sites are using this NodeJS version.'); } + if ($this->service->server->componentsUsing($this->service)->isNotEmpty()) { + $fail('Some components are using this NodeJS version.'); + } }, ], ]; diff --git a/app/Services/PHP/PHP.php b/app/Services/PHP/PHP.php index 14a87159e..8c1dd6383 100644 --- a/app/Services/PHP/PHP.php +++ b/app/Services/PHP/PHP.php @@ -52,6 +52,9 @@ function (string $attribute, mixed $value, Closure $fail): void { if ($hasSite) { $fail('Some sites are using this PHP version.'); } + if ($this->service->server->componentsUsing($this->service)->isNotEmpty()) { + $fail('Some components are using this PHP version.'); + } }, ], ]; diff --git a/app/Services/Webserver/AbstractWebserver.php b/app/Services/Webserver/AbstractWebserver.php index f20f2d0c1..ecf39565d 100755 --- a/app/Services/Webserver/AbstractWebserver.php +++ b/app/Services/Webserver/AbstractWebserver.php @@ -34,6 +34,9 @@ function (string $attribute, mixed $value, Closure $fail): void { if ($hasSite) { $fail('Cannot uninstall webserver while you have websites using it.'); } + if ($this->service->server->componentsUsing($this->service)->isNotEmpty()) { + $fail('Cannot uninstall webserver while you have components using it.'); + } }, ], ]; diff --git a/app/SiteTypes/AbstractSiteType.php b/app/SiteTypes/AbstractSiteType.php index a5759c208..8ab915fa9 100755 --- a/app/SiteTypes/AbstractSiteType.php +++ b/app/SiteTypes/AbstractSiteType.php @@ -185,7 +185,7 @@ protected function isolate(): void app(EnsureIsolatedUser::class)->ensure($this->site->server, $this->site->user, $this->site->id); - if ($this->site->php_version && ! $this->site->fpmPoolSharedWithSiblings()) { + if ($this->site->php_version) { app(EnsureFpmPool::class)->ensure($this->site->server, $this->site->user, $this->site->php_version); } } diff --git a/tests/Feature/ApplicationCoexistenceTest.php b/tests/Feature/ApplicationCoexistenceTest.php new file mode 100644 index 000000000..99bb07c8c --- /dev/null +++ b/tests/Feature/ApplicationCoexistenceTest.php @@ -0,0 +1,506 @@ +create(['project_id' => $this->server->project_id, 'runtime' => 'node']); + $environment = Environment::factory()->for($application)->create(['runtime_version' => '20']); + $this->component = Component::factory()->for($environment)->for($this->server)->create(); +}); + +test('deleting a site preserves its component sibling user and only keeps a needed fpm pool', function (string $runtime, ?string $version, bool $web, bool $keepPool) { + $site = Site::factory()->create([ + 'server_id' => $this->server->id, 'type' => PHPBlank::id(), 'user' => 'shared', + 'domain' => 'shared.test', 'path' => '/home/shared/shared.test', 'php_version' => '8.2', + ]); + $this->component->update(['isolated_user_id' => $site->isolated_user_id, 'user' => $site->user]); + $this->component->environment->update(['runtime_version' => $version]); + $this->component->environment->application->update(['runtime' => $runtime]); + if ($web) { + ComponentCapability::factory()->for($this->component)->create(['capability' => 'web']); + } + + expect($site->userSharedWithSiblings())->toBeTrue() + ->and($site->siblingsSharingUser()->exists())->toBeFalse() + ->and($site->fpmPoolSharedWithSiblings())->toBe($keepPool); + + $this->actingAs($this->user)->delete(route('site-settings.destroy', [$this->server, $site]), [ + 'domain' => $site->domain, + ])->assertSessionDoesntHaveErrors(); + + SSH::assertNotExecutedContains('userdel'); + if ($keepPool) { + SSH::assertNotExecutedContains('rm -f /etc/php/8.2/fpm/pool.d/shared.conf'); + } else { + SSH::assertExecutedContains('rm -f /etc/php/8.2/fpm/pool.d/shared.conf'); + } + $this->assertDatabaseMissing('sites', ['id' => $site->id]); + $this->assertDatabaseHas('isolated_users', ['id' => $site->isolated_user_id]); + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'isolated_user_id' => $site->isolated_user_id]); +})->with([ + ['php', '8.2', true, true], + ['laravel', '8.2', true, true], + ['php', '8.4', true, false], + ['php', '8.2', false, false], + ['node', '8.2', true, false], + ['static', null, true, false], +]); + +test('changing site php version preserves the old pool used by a component', function () { + $site = Site::factory()->create([ + 'server_id' => $this->server->id, 'type' => PHPBlank::id(), 'user' => 'shared', + 'domain' => 'shared.test', 'path' => '/home/shared/shared.test', 'php_version' => '8.2', + ]); + $this->component->update(['isolated_user_id' => $site->isolated_user_id, 'user' => $site->user]); + $this->component->environment->update(['runtime_version' => '8.2']); + $this->component->environment->application->update(['runtime' => 'php']); + ComponentCapability::factory()->for($this->component)->create(['capability' => 'web']); + Service::factory()->create([ + 'server_id' => $this->server->id, 'type' => 'php', 'name' => 'php', 'version' => '8.4', 'status' => ServiceStatus::READY, + ]); + + $this->actingAs($this->user)->patch(route('site-settings.update-php-version', [$this->server, $site]), [ + 'version' => '8.4', + ])->assertSessionDoesntHaveErrors(); + + SSH::assertNotExecutedContains('rm -f /etc/php/8.2/fpm/pool.d/shared.conf'); + $this->assertDatabaseHas('sites', ['id' => $site->id, 'php_version' => '8.4']); + $this->assertDatabaseHas('environments', ['id' => $this->component->environment_id, 'runtime_version' => '8.2']); +}); + +test('isolated user sibling checks exclude only the supplied owner and use fresh relationships', function () { + $iuser = IsolatedUser::factory()->create(['server_id' => $this->server->id]); + $this->component->update(['isolated_user_id' => $iuser->id, 'user' => $iuser->username]); + $this->component->environment->update(['runtime_version' => '8.2']); + $this->component->environment->application->update(['runtime' => 'php']); + ComponentCapability::factory()->for($this->component)->create(); + $iuser->load('sites', 'components'); + + expect($iuser->isUsedByOthers($this->component))->toBeFalse() + ->and($iuser->fpmPoolUsedByOthers('8.2', $this->component))->toBeFalse(); + + $this->site->update(['isolated_user_id' => $iuser->id]); + expect($this->site->id)->toBe($this->component->id) + ->and($iuser->isUsedByOthers($this->component))->toBeTrue() + ->and($iuser->fpmPoolUsedByOthers('8.2', $this->component))->toBeTrue() + ->and($iuser->isUsedByOthers($this->site))->toBeTrue() + ->and($iuser->fpmPoolUsedByOthers('8.2', $this->site))->toBeTrue(); + + $this->site->update(['isolated_user_id' => null]); + $sibling = Component::factory()->for($this->component->environment, 'environment')->for($this->server)->create([ + 'isolated_user_id' => $iuser->id, 'user' => 'sibling', + ]); + ComponentCapability::factory()->for($sibling)->create(); + expect($iuser->isUsedByOthers($this->component))->toBeTrue() + ->and($iuser->fpmPoolUsedByOthers('8.2', $this->component))->toBeTrue() + ->and($iuser->fpmPoolUsedByOthers('8.4', $this->component))->toBeFalse(); +}); + +test('unrelated isolated users and servers do not preserve a site user or pool', function () { + $site = Site::factory()->create(['server_id' => $this->server->id, 'user' => 'alone']); + $otherUser = IsolatedUser::factory()->create(['server_id' => $this->server->id]); + $this->component->update(['isolated_user_id' => $otherUser->id, 'user' => $otherUser->username]); + $this->component->environment->update(['runtime_version' => '8.2']); + $this->component->environment->application->update(['runtime' => 'php']); + ComponentCapability::factory()->for($this->component)->create(); + $otherServer = Server::factory()->create(['project_id' => $this->server->project_id]); + $sameNameUser = IsolatedUser::factory()->create(['server_id' => $otherServer->id, 'username' => $site->user]); + $sibling = Component::factory()->for($this->component->environment, 'environment')->for($otherServer, 'server')->create([ + 'isolated_user_id' => $sameNameUser->id, 'user' => $sameNameUser->username, + ]); + ComponentCapability::factory()->for($sibling)->create(); + + expect($site->userSharedWithSiblings())->toBeFalse() + ->and($site->fpmPoolSharedWithSiblings())->toBeFalse() + ->and($this->site->userSharedWithSiblings())->toBeFalse() + ->and($this->site->fpmPoolSharedWithSiblings())->toBeFalse(); + $site->update(['php_version' => null]); + expect($site->fpmPoolSharedWithSiblings())->toBeFalse(); +}); + +test('service uninstall rejects dependencies used only by a component', function (string $runtime, ?string $version, ?string $capability, string $serviceType) { + Queue::fake(); + $this->server->sites()->delete(); + $this->component->environment->application->update(['runtime' => $runtime]); + $this->component->environment->update(['runtime_version' => $version]); + if ($capability !== null) { + ComponentCapability::factory()->for($this->component)->create(['capability' => $capability]); + } + $service = $this->server->services()->where('type', $serviceType)->firstOrFail(); + + expect($this->server->componentsUsing($service)->modelKeys())->toBe([$this->component->id]); + $this->actingAs($this->user)->deleteJson(route('services.destroy', [$this->server, $service])) + ->assertUnprocessable()->assertJsonValidationErrors('service'); + + $this->assertDatabaseHas('services', ['id' => $service->id, 'status' => ServiceStatus::READY]); + Queue::assertNothingPushed(); + expect(SSH::getExecutedCommands())->toBe([]); +})->with([ + ['php', '8.2', 'web', 'php'], + ['php', '8.2', 'worker', 'php'], + ['php', '8.2', null, 'php'], + ['node', '20', 'web', 'webserver'], + ['static', null, 'web', 'webserver'], +]); + +test('unused service versions and other server components do not block uninstall', function (string $runtime, ?string $version, ?string $capability, string $serviceType, bool $otherServer) { + Queue::fake(); + $this->server->sites()->delete(); + $this->component->environment->application->update(['runtime' => $runtime]); + $this->component->environment->update(['runtime_version' => $version]); + if ($otherServer) { + $server = Server::factory()->create(['project_id' => $this->server->project_id]); + $this->component->update(['server_id' => $server->id]); + } + if ($capability !== null) { + ComponentCapability::factory()->for($this->component)->create(['capability' => $capability]); + } + $service = $this->server->services()->where('type', $serviceType)->firstOrFail(); + + expect($this->server->componentsUsing($service))->toBeEmpty(); + $this->actingAs($this->user)->deleteJson(route('services.destroy', [$this->server, $service])) + ->assertRedirect()->assertSessionDoesntHaveErrors(); + + $this->assertDatabaseHas('services', ['id' => $service->id, 'status' => ServiceStatus::UNINSTALLING]); + Queue::assertPushed(UninstallJob::class); +})->with([ + ['php', '8.4', 'web', 'php', false], + ['node', '20', 'web', 'nodejs', false], + ['node', '20', 'worker', 'webserver', false], + ['static', null, null, 'webserver', false], + ['php', '8.2', 'web', 'php', true], + ['node', '20', 'web', 'webserver', true], +]); + +test('plugin runtime service dependencies protect the declared system node version', function (?string $requiredVersion, bool $blocked) { + $preset = new class extends NodeJS + { + public static function id(): string + { + return 'system-node'; + } + + public function requiredServices(string $version): array + { + return ['nodejs' => $version === '' ? null : $version]; + } + }; + RegisterRuntimePreset::make($preset::id())->handler($preset::class)->register(); + $this->component->environment->application->update(['runtime' => $preset::id()]); + $this->component->environment->update(['runtime_version' => $requiredVersion]); + Queue::fake(); + $service = $this->server->nodejs(); + + $response = $this->actingAs($this->user)->deleteJson(route('services.destroy', [$this->server, $service])); + + if ($blocked) { + $response->assertUnprocessable()->assertJsonValidationErrors(['service' => 'Some components are using this NodeJS version.']); + Queue::assertNothingPushed(); + } else { + $response->assertRedirect()->assertSessionDoesntHaveErrors(); + Queue::assertPushed(UninstallJob::class); + } +})->with([['20', true], [null, true], ['22', false]]); + +test('plugin capability service dependencies participate in the same service guard', function () { + $capability = new class(new ComponentCapability) extends AbstractComponentCapability + { + public static function id(): string + { + return 'node-build'; + } + + public static function label(): string + { + return 'Node build'; + } + + public function requiredServices(AbstractRuntimePreset $preset): array + { + return ['nodejs']; + } + }; + RegisterComponentCapability::make($capability::id())->handler($capability::class)->register(); + ComponentCapability::factory()->for($this->component)->create(['capability' => $capability::id()]); + + $this->actingAs($this->user)->deleteJson(route('services.destroy', [$this->server, $this->server->nodejs()])) + ->assertUnprocessable()->assertJsonValidationErrors('service'); + expect(SSH::getExecutedCommands())->toBe([]); +}); + +test('fpm capability dependencies respect the environment version without preset requirements', function (string $version, bool $used) { + $preset = new class extends PHP + { + public static function id(): string + { + return 'fpm-only'; + } + + public function requiredServices(string $version): array + { + return []; + } + }; + RegisterRuntimePreset::make($preset::id())->handler($preset::class)->register(); + $this->component->environment->application->update(['runtime' => $preset::id()]); + $this->component->environment->update(['runtime_version' => $version]); + ComponentCapability::factory()->for($this->component)->create(['capability' => 'web']); + + expect($this->server->componentsUsing($this->server->php('8.2'))->isNotEmpty())->toBe($used); +})->with([['8.2', true], ['8.4', false]]); + +test('component workers already protect their process manager from uninstall', function () { + Worker::factory()->create(['server_id' => $this->server->id, 'component_id' => $this->component->id]); + $service = $this->server->services()->where('type', 'process_manager')->firstOrFail(); + + $this->actingAs($this->user)->deleteJson(route('services.destroy', [$this->server, $service])) + ->assertUnprocessable()->assertJsonValidationErrors('service'); + expect(SSH::getExecutedCommands())->toBe([]); +}); + +test('server deletion purges only its components and their owned rows without remote teardown', function () { + $environment = $this->component->environment; + $environment->update(['primary_component_id' => $this->component->id]); + $otherServer = Server::factory()->create(['project_id' => $this->server->project_id]); + $survivor = Component::factory()->for($environment)->for($otherServer, 'server')->create(); + $capability = ComponentCapability::factory()->for($this->component)->create(); + $worker = Worker::factory()->create(['server_id' => $this->server->id, 'component_id' => $this->component->id]); + $cron = CronJob::factory()->create(['server_id' => $this->server->id, 'component_id' => $this->component->id]); + $deployment = Deployment::factory()->create(['site_id' => null, 'component_id' => $this->component->id, 'deployment_script_id' => null, 'log_id' => null]); + $script = DeploymentScript::factory()->create(['site_id' => null, 'component_id' => $this->component->id]); + $environmentScript = DeploymentScript::factory()->create(['site_id' => null, 'environment_id' => $environment->id]); + $survivorWorker = Worker::factory()->create(['server_id' => $otherServer->id, 'component_id' => $survivor->id]); + $otherEnvironment = Environment::factory()->for($environment->application, 'application')->create(); + Component::factory()->for($otherEnvironment, 'environment')->for($this->server)->create(); + Component::factory()->for($environment)->for($this->server)->create(['user' => 'other']); + + $this->partialMock(PurgeServerComponents::class)->shouldReceive('handle')->once() + ->with([$environment->id, $otherEnvironment->id]) + ->andReturnUsing(function () use ($environment): void { + $this->assertDatabaseMissing('servers', ['id' => $this->server->id]); + $this->assertDatabaseHas('environments', ['id' => $environment->id, 'primary_component_id' => null]); + }); + + $this->actingAs($this->user)->delete(route('servers.destroy', $this->server), ['name' => $this->server->name]) + ->assertSessionDoesntHaveErrors(); + + foreach ([$this->component, $capability, $worker, $cron, $deployment, $script] as $deleted) { + $this->assertDatabaseMissing($deleted->getTable(), ['id' => $deleted->id]); + } + foreach ([$survivor, $survivorWorker, $environmentScript] as $remaining) { + $this->assertDatabaseHas($remaining->getTable(), ['id' => $remaining->id]); + } + expect(SSH::getExecutedCommands())->toBe([]); + Http::assertNothingSent(); +}); + +test('component purges roll back with a failed server cleanup', function () { + $environment = $this->component->environment; + $environment->update(['primary_component_id' => $this->component->id]); + $capability = ComponentCapability::factory()->for($this->component)->create(); + ServerLog::factory()->create(['server_id' => $this->server->id]); + Event::listen('eloquent.deleting: '.ServerLog::class, function (): void { + throw new RuntimeException('Cleanup failed'); + }); + $this->partialMock(PurgeServerComponents::class)->shouldNotReceive('handle'); + + expect(fn () => $this->server->delete())->toThrow(RuntimeException::class, 'Cleanup failed'); + + foreach ([$this->server, $this->component, $capability, $this->site] as $preserved) { + $this->assertDatabaseHas($preserved->getTable(), ['id' => $preserved->id]); + } + $this->assertDatabaseHas('environments', ['id' => $environment->id, 'primary_component_id' => $this->component->id]); + expect(SSH::getExecutedCommands())->toBe([]); +}); + +test('failed server deletion after cleanup restores components and primary references', function (bool $throughProject) { + $environment = $this->component->environment; + $environment->update(['primary_component_id' => $this->component->id]); + $capability = ComponentCapability::factory()->for($this->component)->create(); + $worker = Worker::factory()->create(['server_id' => $this->server->id, 'component_id' => $this->component->id]); + Event::listen('eloquent.deleting: '.Server::class, function (): void { + throw new RuntimeException('Late deletion failure'); + }); + $this->partialMock(PurgeServerComponents::class)->shouldNotReceive('handle'); + $owner = $throughProject ? $this->server->project : $this->server; + + expect(fn () => $owner->delete())->toThrow(RuntimeException::class, 'Late deletion failure'); + + foreach ([$this->server, $this->component, $capability, $worker, $this->site] as $preserved) { + $this->assertDatabaseHas($preserved->getTable(), ['id' => $preserved->id]); + } + $this->assertDatabaseHas('environments', ['id' => $environment->id, 'primary_component_id' => $this->component->id]); +})->with([false, true]); + +test('server cleanup deadlocks preserve the failure and reset transaction depth', function () { + $pdo = Mockery::mock(PDO::class); + $pdo->shouldReceive('beginTransaction')->once()->andReturnTrue(); + $pdo->shouldReceive('inTransaction')->andReturnFalse(); + $pdo->shouldReceive('exec')->andReturnUsing(function (string $statement): int { + if (str_starts_with($statement, 'ROLLBACK')) { + throw new PDOException('Savepoint no longer exists'); + } + + return 0; + }); + $connection = new MySqlConnection($pdo); + $deadlock = new DeadlockException('Deadlock found when trying to get lock', 40001); + $this->mock(PurgeServerComponents::class)->shouldReceive('purge')->once()->andThrow($deadlock); + $database = DB::getFacadeRoot(); + DB::swap($connection); + + try { + expect(fn () => $this->server->delete())->toThrow(DeadlockException::class, $deadlock->getMessage()); + expect($connection->transactionLevel())->toBe(0); + } finally { + DB::swap($database); + } +}); + +test('cancelled server deletion restores components and primary references', function () { + $environment = $this->component->environment; + $environment->update(['primary_component_id' => $this->component->id]); + Event::listen('eloquent.deleting: '.Server::class, fn (): bool => false); + + expect($this->server->delete())->toBeFalse(); + + $this->assertDatabaseHas('servers', ['id' => $this->server->id]); + $this->assertDatabaseHas('components', ['id' => $this->component->id]); + $this->assertDatabaseHas('environments', ['id' => $environment->id, 'primary_component_id' => $this->component->id]); +}); + +test('preset and fpm web dependencies can protect different versions of the same service', function (string $version) { + $preset = new class extends PHP + { + public static function id(): string + { + return 'mixed-php'; + } + + public function requiredServices(string $version): array + { + return ['php' => '8.2']; + } + }; + RegisterRuntimePreset::make($preset::id())->handler($preset::class)->register(); + $this->component->environment->application->update(['runtime' => $preset::id()]); + $this->component->environment->update(['runtime_version' => '8.4']); + ComponentCapability::factory()->for($this->component)->create(['capability' => 'web']); + Service::factory()->create([ + 'server_id' => $this->server->id, 'type' => 'php', 'name' => 'php', 'version' => '8.4', 'status' => ServiceStatus::READY, + ]); + + expect($this->server->componentsUsing($this->server->php($version))->modelKeys())->toBe([$this->component->id]); +})->with(['8.2', '8.4']); + +test('non-web capability dependencies are independent of the fpm runtime version', function () { + $capability = new class(new ComponentCapability) extends AbstractComponentCapability + { + public static function id(): string + { + return 'php-tooling'; + } + + public static function label(): string + { + return 'PHP tooling'; + } + + public function requiredServices(AbstractRuntimePreset $preset): array + { + return ['php']; + } + }; + RegisterComponentCapability::make($capability::id())->handler($capability::class)->register(); + $this->component->environment->application->update(['runtime' => 'php']); + $this->component->environment->update(['runtime_version' => '8.4']); + ComponentCapability::factory()->for($this->component)->create(['capability' => $capability::id()]); + + expect($this->server->componentsUsing($this->server->php('8.2'))->modelKeys())->toBe([$this->component->id]); +}); + +test('site provisioning ensures its pool even when an uninstalled component shares it', function (string $status) { + $site = Site::factory()->create([ + 'server_id' => $this->server->id, 'type' => PHPBlank::id(), 'user' => 'shared', + 'domain' => 'shared.test', 'path' => '/home/shared/shared.test', 'php_version' => '8.2', + ]); + $this->component->update(['isolated_user_id' => $site->isolated_user_id, 'user' => $site->user]); + $this->component->status = \App\Enums\ComponentStatus::from($status); + $this->component->save(); + $this->component->environment->application->update(['runtime' => 'php']); + $this->component->environment->update(['runtime_version' => '8.2']); + ComponentCapability::factory()->for($this->component)->create(['capability' => 'web']); + $ssh = Mockery::mock(\App\Support\Testing\SSHFake::class, [null])->makePartial(); + SSH::swap($ssh); + $ssh->shouldReceive('exec') + ->withArgs(fn ($command): bool => str_contains((string) $command, 'test -f /etc/php/8.2/fpm/pool.d/shared.conf')) + ->once()->andThrow(new \App\Exceptions\SSHCommandError); + + $type = $site->type(); + (new ReflectionMethod($type, 'isolate'))->invoke($type); + + expect($ssh->getUploadedContent())->toContain('[shared]'); + SSH::assertExecutedContains('restart php8.2-fpm'); +})->with(['pending', 'installation_failed']); + +test('project deletion purges components across its servers without touching another project', function () { + $project = Project::factory()->create(); + $project->users()->create(['user_id' => $this->user->id, 'role' => UserRole::OWNER]); + $application = Application::factory()->for($project)->create(); + $environment = Environment::factory()->for($application)->create(); + $components = collect(); + foreach (range(1, 2) as $index) { + $server = Server::factory()->for($project)->create(['user_id' => $this->user->id]); + $components->push(Component::factory()->for($environment)->for($server)->create()); + } + $environment->update(['primary_component_id' => $components->first()->id]); + + $this->actingAs($this->user)->delete(route('projects.destroy', $project), ['name' => $project->name]) + ->assertSessionDoesntHaveErrors(); + + foreach ([$project, $application, $environment, ...$components] as $deleted) { + $this->assertDatabaseMissing($deleted->getTable(), ['id' => $deleted->id]); + } + $this->assertDatabaseHas('components', ['id' => $this->component->id]); + $this->assertDatabaseHas('servers', ['id' => $this->server->id]); + expect(SSH::getExecutedCommands())->toBe([]); + Http::assertNothingSent(); +}); diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index 6d485882b..9645b1cb7 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -9,6 +9,9 @@ use App\Facades\Notifier; use App\Facades\SSH; use App\Jobs\Server\InstallJob; +use App\Models\Application; +use App\Models\Component; +use App\Models\Environment; use App\Models\Network; use App\Models\NetworkServer; use App\Models\Project; @@ -677,6 +680,26 @@ function (object $notifiable, object $notification): bool { expect($this->user->refresh()->current_project_id)->toBe($this->server->project_id); }); +test('cannot transfer a server hosting components and names their environments', function (): void { + $application = Application::factory()->create(['project_id' => $this->server->project_id, 'name' => 'Store']); + $environment = Environment::factory()->for($application)->create(['name' => 'Production']); + $otherEnvironment = Environment::factory()->for($application)->create(['name' => 'Staging']); + Component::factory()->for($environment)->for($this->server)->create(); + Component::factory()->for($environment)->for($this->server)->create(['user' => 'other']); + Component::factory()->for($otherEnvironment, 'environment')->for($this->server)->create(); + $project = Project::factory()->create(); + $project->users()->create(['user_id' => $this->user->id, 'role' => UserRole::OWNER]); + + $this->actingAs($this->user)->postJson(route('servers.transfer', $this->server), ['project_id' => $project->id]) + ->assertUnprocessable() + ->assertJsonValidationErrors([ + 'project_id' => 'This server hosts components in the following environments: Store / Production, Store / Staging. Remove them before transferring it.', + ]); + + $this->assertDatabaseHas('servers', ['id' => $this->server->id, 'project_id' => $application->project_id]); + expect($this->user->refresh()->current_project_id)->toBe($application->project_id); +}); + test('cannot transfer a server while network memberships remain', function (NetworkServerStatus $status): void { SSH::fake(); Queue::fake(); diff --git a/tests/Feature/SiteToolingTest.php b/tests/Feature/SiteToolingTest.php index 47c968da4..66e1ea4ba 100644 --- a/tests/Feature/SiteToolingTest.php +++ b/tests/Feature/SiteToolingTest.php @@ -6,6 +6,9 @@ use App\Facades\SSH; use App\Jobs\Site\Tooling\InstallSiteToolingJob; use App\Jobs\Site\Tooling\UninstallSiteToolingJob; +use App\Models\Application; +use App\Models\Component; +use App\Models\Environment; use App\Models\IsolatedUser; use App\Models\Site; use App\Models\SourceControl; @@ -157,6 +160,62 @@ function vitoPestFeatureSiteToolingTestIuser(): IsolatedUser $this->assertArrayNotHasKey('node', $iuser->installed_tooling ?? []); }); +test('component tooling prevents uninstall but allows changing the site global version', function (string $runtime, string $tool, string $pinnedVersion, string $globalVersion) { + SSH::fake(); + $iuser = vitoPestFeatureSiteToolingTestIuser(); + $iuser->setToolingVersion($tool, $pinnedVersion); + $application = Application::factory()->create(['project_id' => $this->server->project_id, 'runtime' => $runtime]); + $environment = Environment::factory()->for($application)->create(['runtime_version' => $pinnedVersion]); + $component = Component::factory()->for($environment)->for($this->server)->create([ + 'isolated_user_id' => $iuser->id, + 'user' => $iuser->username, + ]); + + $this->actingAs($this->user)->deleteJson(route('site-tooling.uninstall', [ + 'server' => $this->server, 'site' => $this->isolatedSite, 'tool' => $tool, + ]))->assertUnprocessable()->assertJsonValidationErrors([ + 'tool' => ToolingRegistry::find($tool)::label().' is required by a component sharing this isolated user and cannot be uninstalled.', + ]); + + expect(SSH::getExecutedCommands())->toBe([]) + ->and($iuser->fresh()->toolingStatus($tool))->toBeNull(); + + $this->post(route('site-tooling.install', [ + 'server' => $this->server, 'site' => $this->isolatedSite, 'tool' => $tool, + ]), ['version' => $globalVersion])->assertSessionDoesntHaveErrors(); + + SSH::assertExecutedContains('mise use -g '.$tool.'@'.$globalVersion); + expect($iuser->fresh()->toolingVersion($tool))->toBe($globalVersion); + $this->assertDatabaseHas('environments', ['id' => $environment->id, 'runtime_version' => $pinnedVersion]); + $this->assertDatabaseHas('components', ['id' => $component->id, 'isolated_user_id' => $iuser->id]); +})->with([ + ['node', 'node', '22', '24'], + ['bun', 'bun', '1.1', '1.2'], +]); + +test('uninstall is not blocked by components using another tool or isolated user', function (bool $sameUser) { + SSH::fake(); + $iuser = vitoPestFeatureSiteToolingTestIuser(); + $iuser->setToolingVersion('node', '22'); + $componentUser = $sameUser ? $iuser : IsolatedUser::factory()->create(['server_id' => $this->server->id]); + $application = Application::factory()->create([ + 'project_id' => $this->server->project_id, + 'runtime' => $sameUser ? 'bun' : 'node', + ]); + $environment = Environment::factory()->for($application)->create(); + Component::factory()->for($environment)->for($this->server)->create([ + 'isolated_user_id' => $componentUser->id, + 'user' => $componentUser->username, + ]); + + $this->actingAs($this->user)->delete(route('site-tooling.uninstall', [ + 'server' => $this->server, 'site' => $this->isolatedSite, 'tool' => 'node', + ]))->assertSessionDoesntHaveErrors(); + + SSH::assertExecutedContains('mise uninstall node --all'); + expect($iuser->fresh()->toolingVersion('node'))->toBeNull(); +})->with([true, false]); + test('install validation rejects unsupported version', function () { SSH::fake(); $this->actingAs($this->user); diff --git a/tests/Feature/SourceControlsTest.php b/tests/Feature/SourceControlsTest.php index 9280fc66e..16f640a52 100644 --- a/tests/Feature/SourceControlsTest.php +++ b/tests/Feature/SourceControlsTest.php @@ -1,5 +1,6 @@ with('data'); +test('cannot delete source control used only by an application', function (bool $global) { + $sourceControl = SourceControl::factory()->github()->create([ + 'user_id' => $this->user->id, + 'project_id' => $global ? null : $this->server->project_id, + ]); + $application = Application::factory()->create([ + 'project_id' => $this->server->project_id, + 'source_control_id' => $sourceControl->id, + ]); + + $this->actingAs($this->user)->deleteJson(route('source-controls.destroy', $sourceControl)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['source_control' => 'This source control is being used by an application.']); + + $this->assertNotSoftDeleted('source_controls', ['id' => $sourceControl->id]); + $this->assertDatabaseHas('applications', ['id' => $application->id, 'source_control_id' => $sourceControl->id]); +})->with([true, false]); + test('edit source control', function (string $provider, ?string $url, array $input) { Http::fake(); From c4328c326eb447c2dd378a6d6c80dbbca23cb8a1 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sun, 18 Oct 2026 00:36:39 +0200 Subject: [PATCH 05/30] phase 5 --- app/Actions/Application/CreateApplication.php | 47 +++ app/Actions/Application/DeleteApplication.php | 22 ++ app/Actions/Application/UpdateApplication.php | 25 ++ .../Application/ValidateApplication.php | 54 +++ app/Actions/Environment/CreateEnvironment.php | 31 ++ app/Actions/Environment/DeleteEnvironment.php | 22 ++ app/Actions/Environment/UpdateEnvironment.php | 24 ++ .../Environment/ValidateEnvironment.php | 35 ++ app/Actions/Site/CreateSite.php | 3 +- .../Applications/ApplicationController.php | 89 +++++ .../Applications/EnvironmentController.php | 70 ++++ app/Http/Middleware/HandleInertiaRequests.php | 27 ++ app/Http/Resources/ApplicationResource.php | 28 ++ app/Http/Resources/EnvironmentResource.php | 30 ++ app/Models/SourceControl.php | 12 +- .../AbstractSourceControlProvider.php | 9 + app/SourceControlProviders/Gitlab.php | 5 + .../SourceControlProvider.php | 5 + app/Tables/ApplicationTable.php | 36 ++ resources/js/components/app-sidebar.tsx | 15 +- resources/js/components/dialogs/registry.ts | 7 + resources/js/layouts/environment/layout.tsx | 17 + .../components/application-fields.tsx | 63 ++++ .../components/create-application.tsx | 75 ++++ .../applications/components/delete-dialog.tsx | 48 +++ .../components/environment-fields.tsx | 58 +++ .../components/environment-form.tsx | 58 +++ .../applications/components/settings.tsx | 44 +++ .../pages/applications/environments/show.tsx | 49 +++ resources/js/pages/applications/index.tsx | 43 +++ resources/js/pages/applications/show.tsx | 64 ++++ resources/js/types/application.d.ts | 35 ++ resources/js/types/index.d.ts | 4 + tests/Feature/ApplicationsCrudTest.php | 329 ++++++++++++++++++ tests/Feature/EnvironmentsCrudTest.php | 169 +++++++++ tests/Feature/SitesTest.php | 25 ++ 36 files changed, 1672 insertions(+), 5 deletions(-) create mode 100644 app/Actions/Application/CreateApplication.php create mode 100644 app/Actions/Application/DeleteApplication.php create mode 100644 app/Actions/Application/UpdateApplication.php create mode 100644 app/Actions/Application/ValidateApplication.php create mode 100644 app/Actions/Environment/CreateEnvironment.php create mode 100644 app/Actions/Environment/DeleteEnvironment.php create mode 100644 app/Actions/Environment/UpdateEnvironment.php create mode 100644 app/Actions/Environment/ValidateEnvironment.php create mode 100644 app/Http/Controllers/Applications/ApplicationController.php create mode 100644 app/Http/Controllers/Applications/EnvironmentController.php create mode 100644 app/Http/Resources/ApplicationResource.php create mode 100644 app/Http/Resources/EnvironmentResource.php create mode 100644 app/Tables/ApplicationTable.php create mode 100644 resources/js/layouts/environment/layout.tsx create mode 100644 resources/js/pages/applications/components/application-fields.tsx create mode 100644 resources/js/pages/applications/components/create-application.tsx create mode 100644 resources/js/pages/applications/components/delete-dialog.tsx create mode 100644 resources/js/pages/applications/components/environment-fields.tsx create mode 100644 resources/js/pages/applications/components/environment-form.tsx create mode 100644 resources/js/pages/applications/components/settings.tsx create mode 100644 resources/js/pages/applications/environments/show.tsx create mode 100644 resources/js/pages/applications/index.tsx create mode 100644 resources/js/pages/applications/show.tsx create mode 100644 resources/js/types/application.d.ts create mode 100644 tests/Feature/ApplicationsCrudTest.php create mode 100644 tests/Feature/EnvironmentsCrudTest.php diff --git a/app/Actions/Application/CreateApplication.php b/app/Actions/Application/CreateApplication.php new file mode 100644 index 000000000..407023bbc --- /dev/null +++ b/app/Actions/Application/CreateApplication.php @@ -0,0 +1,47 @@ + $input + */ + public function create(Project $project, array $input, User $actor): Application + { + Validator::make($input, [ + 'runtime' => ['required', 'string', Rule::in(array_keys(config('component.runtimes', [])))], + 'environment' => ['required', 'array'], + ])->validate(); + + app(ValidateApplication::class)->validate($project, $input, $actor); + + return DB::transaction(function () use ($project, $input): Application { + $application = $project->applications()->create([ + 'name' => $input['name'], + 'source_control_id' => $input['source_control_id'], + 'repository' => $input['repository'], + 'runtime' => $input['runtime'], + ]); + + try { + app(CreateEnvironment::class)->create($application, $input['environment']); + } catch (ValidationException $e) { + throw ValidationException::withMessages( + collect($e->errors())->mapWithKeys(fn (array $messages, string $field): array => ['environment.'.$field => $messages])->all() + ); + } + + return $application; + }); + } +} diff --git a/app/Actions/Application/DeleteApplication.php b/app/Actions/Application/DeleteApplication.php new file mode 100644 index 000000000..63479a68c --- /dev/null +++ b/app/Actions/Application/DeleteApplication.php @@ -0,0 +1,22 @@ + $input + */ + public function delete(Application $application, array $input): void + { + Validator::make($input, [ + 'name' => ['required', 'string', Rule::in([$application->name])], + ])->validate(); + + $application->delete(); + } +} diff --git a/app/Actions/Application/UpdateApplication.php b/app/Actions/Application/UpdateApplication.php new file mode 100644 index 000000000..3fa3d4c8c --- /dev/null +++ b/app/Actions/Application/UpdateApplication.php @@ -0,0 +1,25 @@ + $input + */ + public function update(Application $application, array $input, User $actor): Application + { + app(ValidateApplication::class)->validate($application->project, $input, $actor, $application); + + $application->update([ + 'name' => $input['name'], + 'source_control_id' => $input['source_control_id'], + 'repository' => $input['repository'], + ]); + + return $application; + } +} diff --git a/app/Actions/Application/ValidateApplication.php b/app/Actions/Application/ValidateApplication.php new file mode 100644 index 000000000..508da829e --- /dev/null +++ b/app/Actions/Application/ValidateApplication.php @@ -0,0 +1,54 @@ + $input + */ + public function validate(Project $project, array $input, User $actor, ?Application $application = null): void + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255', Rule::unique('applications')->where('project_id', $project->id)->ignore($application)], + 'source_control_id' => ['integer', ...SourceControl::repositoryValidationRules($project->id)], + 'repository' => ['required', 'string', 'max:255', 'not_regex:~(?:\A|/)\.{1,2}(?:/|\z)~'], + ])->validate(); + + $sourceControl = SourceControl::query()->findOrFail($input['source_control_id']); + Gate::forUser($actor)->authorize('view', $sourceControl); + $provider = $sourceControl->provider(); + + Validator::make($input, [ + 'repository' => $provider->repositoryRules(), + ])->validate(); + + try { + $provider->getRepo($input['repository']); + } catch (SourceControlIsNotConnected) { + throw ValidationException::withMessages([ + 'source_control_id' => 'Source control is not connected', + ]); + } catch (RepositoryPermissionDenied) { + throw ValidationException::withMessages([ + 'repository' => 'You do not have permission to access this repository', + ]); + } catch (RepositoryNotFound) { + throw ValidationException::withMessages([ + 'repository' => 'Repository not found', + ]); + } + } +} diff --git a/app/Actions/Environment/CreateEnvironment.php b/app/Actions/Environment/CreateEnvironment.php new file mode 100644 index 000000000..aa9a239f4 --- /dev/null +++ b/app/Actions/Environment/CreateEnvironment.php @@ -0,0 +1,31 @@ + $input + */ + public function create(Application $application, array $input): Environment + { + app(ValidateEnvironment::class)->validate($application, $input); + + $environment = new Environment([ + 'application_id' => $application->id, + 'name' => $input['name'], + 'slug' => Str::slug($input['name']), + 'branch' => $input['branch'], + 'runtime_version' => $input['runtime_version'] ?? null, + ]); + $environment->status = EnvironmentStatus::READY; + $environment->save(); + + return $environment; + } +} diff --git a/app/Actions/Environment/DeleteEnvironment.php b/app/Actions/Environment/DeleteEnvironment.php new file mode 100644 index 000000000..31d3e4916 --- /dev/null +++ b/app/Actions/Environment/DeleteEnvironment.php @@ -0,0 +1,22 @@ + $input + */ + public function delete(Environment $environment, array $input): void + { + Validator::make($input, [ + 'name' => ['required', 'string', Rule::in([$environment->name])], + ])->validate(); + + $environment->delete(); + } +} diff --git a/app/Actions/Environment/UpdateEnvironment.php b/app/Actions/Environment/UpdateEnvironment.php new file mode 100644 index 000000000..0b9eba814 --- /dev/null +++ b/app/Actions/Environment/UpdateEnvironment.php @@ -0,0 +1,24 @@ + $input + */ + public function update(Environment $environment, array $input): Environment + { + app(ValidateEnvironment::class)->validate($environment->application, $input, $environment); + + $environment->update([ + 'name' => $input['name'], + 'branch' => $input['branch'], + 'runtime_version' => $input['runtime_version'] ?? null, + ]); + + return $environment; + } +} diff --git a/app/Actions/Environment/ValidateEnvironment.php b/app/Actions/Environment/ValidateEnvironment.php new file mode 100644 index 000000000..bf4afc0ad --- /dev/null +++ b/app/Actions/Environment/ValidateEnvironment.php @@ -0,0 +1,35 @@ + $input + */ + public function validate(Application $application, array $input, ?Environment $environment = null): void + { + $versions = $application->preset()->versions(); + + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255', Rule::unique('environments')->where('application_id', $application->id)->ignore($environment)], + 'branch' => ['required', 'string', 'max:255'], + 'runtime_version' => [Rule::requiredIf($versions !== []), 'nullable', 'string', 'max:255', Rule::in($versions)], + ])->validate(); + + if ($environment === null) { + Validator::make(['name' => Str::slug($input['name'])], [ + 'name' => ['required', 'max:255', Rule::unique('environments', 'slug')->where('application_id', $application->id)], + ], [ + 'name.required' => 'The name must produce a non-empty slug.', + 'name.unique' => 'An environment with the same slug already exists.', + ])->validate(); + } + } +} diff --git a/app/Actions/Site/CreateSite.php b/app/Actions/Site/CreateSite.php index d9823db37..1624a4394 100755 --- a/app/Actions/Site/CreateSite.php +++ b/app/Actions/Site/CreateSite.php @@ -20,6 +20,7 @@ use App\ValidationRules\DomainRule; use Exception; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Validator; @@ -134,7 +135,7 @@ public function create(Server $server, array $input, User $actor): Site return $site; } catch (Exception $e) { DB::rollBack(); - if ($e instanceof AuthorizationException) { + if ($e instanceof AuthorizationException || $e instanceof RequestException) { throw $e; } diff --git a/app/Http/Controllers/Applications/ApplicationController.php b/app/Http/Controllers/Applications/ApplicationController.php new file mode 100644 index 000000000..bd3f9ce67 --- /dev/null +++ b/app/Http/Controllers/Applications/ApplicationController.php @@ -0,0 +1,89 @@ +currentProject; + $this->authorize('viewAny', [Application::class, $project]); + + return Inertia::render('applications/index', [ + 'applications' => ApplicationTable::make($project->applications())->simplePaginate(), + 'sourceControls' => SourceControlResource::collection(SourceControl::getByProjectId($project->id, user())->get()), + 'canCreate' => user()->can('create', [Application::class, $project]), + ]); + } + + #[Post('/', name: 'applications.store')] + public function store(Request $request): RedirectResponse + { + $project = user()->currentProject; + $this->authorize('create', [Application::class, $project]); + + $application = app(CreateApplication::class)->create($project, $request->all(), user()); + + return to_route('applications.show', $application)->with('success', 'Application created.'); + } + + #[Get('/{application}', name: 'applications.show')] + public function show(Application $application): Response + { + $this->authorize('view', $application); + + return Inertia::render('applications/show', [ + 'environments' => EnvironmentResource::collection($application->environments()->orderBy('name')->get()), + 'sourceControls' => SourceControlResource::collection(SourceControl::getByProjectId($application->project_id, user())->get()), + 'can' => [ + 'update' => user()->can('update', $application), + 'delete' => user()->can('delete', $application), + 'createEnvironment' => user()->can('create', [Environment::class, $application]), + ], + ]); + } + + #[Put('/{application}', name: 'applications.update')] + public function update(Request $request, Application $application): RedirectResponse + { + $this->authorize('update', $application); + + app(UpdateApplication::class)->update($application, $request->all(), user()); + + return back()->with('success', 'Application updated. Changes apply on the next deploy.'); + } + + #[Delete('/{application}', name: 'applications.destroy')] + public function destroy(Request $request, Application $application): RedirectResponse + { + $this->authorize('delete', $application); + + app(DeleteApplication::class)->delete($application, $request->all()); + + return to_route('applications')->with('success', 'Application deleted.'); + } +} diff --git a/app/Http/Controllers/Applications/EnvironmentController.php b/app/Http/Controllers/Applications/EnvironmentController.php new file mode 100644 index 000000000..e77c234a1 --- /dev/null +++ b/app/Http/Controllers/Applications/EnvironmentController.php @@ -0,0 +1,70 @@ +authorize('create', [Environment::class, $application]); + + $environment = app(CreateEnvironment::class)->create($application, $request->all()); + + return to_route('environments.show', [$application, $environment])->with('success', 'Environment created.'); + } + + #[Get('/{environment}', name: 'environments.show')] + public function show(Application $application, Environment $environment): Response + { + $this->authorize('view', [$environment, $application]); + + return Inertia::render('applications/environments/show', [ + 'can' => [ + 'update' => user()->can('update', [$environment, $application]), + 'delete' => user()->can('delete', [$environment, $application]), + ], + ]); + } + + #[Put('/{environment}', name: 'environments.update')] + public function update(Request $request, Application $application, Environment $environment): RedirectResponse + { + $this->authorize('update', [$environment, $application]); + + app(UpdateEnvironment::class)->update($environment, $request->all()); + + return back()->with('success', 'Environment updated. Changes apply on the next deploy.'); + } + + #[Delete('/{environment}', name: 'environments.destroy')] + public function destroy(Request $request, Application $application, Environment $environment): RedirectResponse + { + $this->authorize('delete', [$environment, $application]); + + app(DeleteEnvironment::class)->delete($environment, $request->all()); + + return to_route('applications.show', $application)->with('success', 'Environment deleted.'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 8f67af10e..a9d6acb57 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -3,11 +3,15 @@ namespace App\Http\Middleware; use App\Actions\Bootstrap\GetBootstrap; +use App\Http\Resources\ApplicationResource; +use App\Http\Resources\EnvironmentResource; use App\Http\Resources\NetworkResource; use App\Http\Resources\ProjectResource; use App\Http\Resources\ServerResource; use App\Http\Resources\SiteResource; use App\Http\Resources\UserResource; +use App\Models\Application; +use App\Models\Environment; use App\Models\Network; use App\Models\Server; use App\Models\Site; @@ -94,6 +98,29 @@ public function share(Request $request): array } } + if ($request->route('application')) { + /** @var Application $application */ + $application = $request->route('application'); + if ($user && $user->can('view', $application)) { + if ($user->current_project_id !== $application->project_id) { + $user->current_project_id = $application->project_id; + $user->save(); + $user->unsetRelation('currentProject'); + $currentProject = $user->currentProject; + } + + $data['application'] = fn () => ApplicationResource::make($application); + + if ($request->route('environment')) { + /** @var Environment $environment */ + $environment = $request->route('environment'); + if ($user->can('view', [$environment, $application])) { + $data['environment'] = fn () => EnvironmentResource::make($environment); + } + } + } + } + return [ ...parent::share($request), ...$data, diff --git a/app/Http/Resources/ApplicationResource.php b/app/Http/Resources/ApplicationResource.php new file mode 100644 index 000000000..0cdc98bb3 --- /dev/null +++ b/app/Http/Resources/ApplicationResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'project_id' => $this->project_id, + 'source_control_id' => $this->source_control_id, + 'name' => $this->name, + 'repository' => $this->repository, + 'runtime' => $this->runtime, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/EnvironmentResource.php b/app/Http/Resources/EnvironmentResource.php new file mode 100644 index 000000000..a6c30a88b --- /dev/null +++ b/app/Http/Resources/EnvironmentResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'application_id' => $this->application_id, + 'name' => $this->name, + 'slug' => $this->slug, + 'branch' => $this->branch, + 'runtime_version' => $this->runtime_version, + 'status' => $this->status->getText(), + 'status_color' => $this->status->getColor(), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Models/SourceControl.php b/app/Models/SourceControl.php index 3dc82776b..01c323c7f 100755 --- a/app/Models/SourceControl.php +++ b/app/Models/SourceControl.php @@ -76,6 +76,14 @@ public function cloneVariables(): array * @return array */ public static function siteValidationRules(Server $server): array + { + return self::repositoryValidationRules($server->project_id); + } + + /** + * @return array + */ + public static function repositoryValidationRules(int $projectId): array { /** @var array> $providers */ $providers = config('source-control.providers', []); @@ -90,8 +98,8 @@ public static function siteValidationRules(Server $server): array Rule::exists('source_controls', 'id') ->whereIn('provider', $usableProviders) ->withoutTrashed() - ->where(function ($query) use ($server): void { - $query->where('project_id', $server->project_id)->orWhereNull('project_id'); + ->where(function ($query) use ($projectId): void { + $query->where('project_id', $projectId)->orWhereNull('project_id'); }), ]; } diff --git a/app/SourceControlProviders/AbstractSourceControlProvider.php b/app/SourceControlProviders/AbstractSourceControlProvider.php index 7f201f0de..9c7c8f21a 100755 --- a/app/SourceControlProviders/AbstractSourceControlProvider.php +++ b/app/SourceControlProviders/AbstractSourceControlProvider.php @@ -6,6 +6,7 @@ use App\Exceptions\RepositoryPermissionDenied; use App\Exceptions\SourceControlIsNotConnected; use App\Models\SourceControl; +use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\Response; abstract class AbstractSourceControlProvider implements SourceControlProvider @@ -19,6 +20,11 @@ public function createRules(array $input): array ]; } + public function repositoryRules(): array + { + return ['regex:~\A[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+\z~']; + } + public function createData(array $input): array { return [ @@ -40,6 +46,7 @@ public function data(): array * @throws SourceControlIsNotConnected * @throws RepositoryNotFound * @throws RepositoryPermissionDenied + * @throws RequestException */ protected function handleResponseErrors(Response $res, string $repo): void { @@ -54,6 +61,8 @@ protected function handleResponseErrors(Response $res, string $repo): void if ($res->status() == 403) { throw new RepositoryPermissionDenied($repo); } + + $res->throw(); } public function getWebhookBranch(array $payload): string diff --git a/app/SourceControlProviders/Gitlab.php b/app/SourceControlProviders/Gitlab.php index 1857f6e24..f8ddb03a3 100755 --- a/app/SourceControlProviders/Gitlab.php +++ b/app/SourceControlProviders/Gitlab.php @@ -29,6 +29,11 @@ public static function id(): string return 'gitlab'; } + public function repositoryRules(): array + { + return ['regex:~\A(?:[1-9][0-9]*|[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)+)\z~']; + } + public function createRules(array $input): array { return [ diff --git a/app/SourceControlProviders/SourceControlProvider.php b/app/SourceControlProviders/SourceControlProvider.php index d29e9f1a0..b4d2a0467 100755 --- a/app/SourceControlProviders/SourceControlProvider.php +++ b/app/SourceControlProviders/SourceControlProvider.php @@ -31,6 +31,11 @@ public function connect(): bool; public function getRepo(string $repo): mixed; + /** + * @return array + */ + public function repositoryRules(): array; + public function fullRepoUrl(string $repo, string $key): string; public function getSshPort(): int; diff --git a/app/Tables/ApplicationTable.php b/app/Tables/ApplicationTable.php new file mode 100644 index 000000000..f68ed9ab4 --- /dev/null +++ b/app/Tables/ApplicationTable.php @@ -0,0 +1,36 @@ +perPage = config('web.pagination_size'); + $this->query->withCount('environments')->latest(); + } + + protected function columns(): array + { + return [ + TextColumn::make('name', 'Name')->sortable(), + TextColumn::make('repository', 'Repository'), + TextColumn::make('runtime', 'Runtime') + ->value(fn (Application $application): string => config('component.runtimes', [])[$application->runtime]['label'] ?? $application->runtime), + TextColumn::make('environments_count', 'Environments'), + Column::data('id'), + ActionsColumn::make(), + ]; + } + + protected function searchable(): array + { + return ['name', 'repository']; + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 05e5b44ca..04f5937e3 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -36,6 +36,8 @@ import { Icon } from '@/components/icon'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useConfigs } from '@/stores/bootstrap-store'; +import { Badge } from '@/components/ui/badge'; +import { BoxesIcon } from 'lucide-react'; export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems?: NavItem[]; secondNavTitle?: string }) { const page = usePage(); @@ -57,6 +59,12 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? href: '/sites', icon: MousePointerClickIcon, }, + { + title: 'Applications', + href: '/applications', + icon: BoxesIcon, + badge: 'Beta', + }, { title: 'Backups', href: '/backups', @@ -146,7 +154,10 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? {item.title}{item.badge && {item.badge}}, + hidden: false, + }} hidden={item.hidden} > {item.external ? ( @@ -157,7 +168,7 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? ) : ( {item.icon && } - {item.title} + {item.title}{item.badge && {item.badge}} )} diff --git a/resources/js/components/dialogs/registry.ts b/resources/js/components/dialogs/registry.ts index 5e9852458..f599cf0fb 100644 --- a/resources/js/components/dialogs/registry.ts +++ b/resources/js/components/dialogs/registry.ts @@ -41,6 +41,9 @@ import EditNetworkServer from '@/pages/networks/components/edit-network-server'; import NetworkFirewallRuleForm from '@/pages/networks/components/firewall-rule-form'; import AddNetworkPeer from '@/pages/networks/components/add-peer'; import PeerConfigDialog from '@/pages/networks/components/peer-config'; +import CreateApplication from '@/pages/applications/components/create-application'; +import EnvironmentForm from '@/pages/applications/components/environment-form'; +import DeleteApplicationResource from '@/pages/applications/components/delete-dialog'; export type DialogControlProps = { open: boolean; onOpenChange: (open: boolean) => void }; @@ -99,6 +102,10 @@ export const dialogs = { networkFirewallForm: NetworkFirewallRuleForm, networkAddPeer: AddNetworkPeer, networkPeerConfig: PeerConfigDialog, + createApplication: CreateApplication, + createEnvironment: EnvironmentForm, + editEnvironment: EnvironmentForm, + deleteApplicationResource: DeleteApplicationResource, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as const satisfies Record>; diff --git a/resources/js/layouts/environment/layout.tsx b/resources/js/layouts/environment/layout.tsx new file mode 100644 index 000000000..d5f4d4c06 --- /dev/null +++ b/resources/js/layouts/environment/layout.tsx @@ -0,0 +1,17 @@ +import { ReactNode } from 'react'; +import { usePage } from '@inertiajs/react'; +import { ArrowLeftIcon, HomeIcon } from 'lucide-react'; +import { NavItem } from '@/types'; +import { Application, Environment } from '@/types/application'; +import Layout from '@/layouts/app/layout'; + +export default function EnvironmentLayout({ children }: { children: ReactNode }) { + const { application, environment } = usePage<{ application: Application; environment: Environment }>().props; + const baseUrl = `/applications/${application.id}/environments/${environment.id}`; + const sidebarNavItems: NavItem[] = [ + { title: application.name, href: `/applications/${application.id}`, onlyActivePath: `/applications/${application.id}`, icon: ArrowLeftIcon }, + { title: 'Overview', href: baseUrl, onlyActivePath: baseUrl, icon: HomeIcon }, + ]; + + return {children}; +} diff --git a/resources/js/pages/applications/components/application-fields.tsx b/resources/js/pages/applications/components/application-fields.tsx new file mode 100644 index 000000000..69564336d --- /dev/null +++ b/resources/js/pages/applications/components/application-fields.tsx @@ -0,0 +1,63 @@ +import { Link } from '@inertiajs/react'; +import { FormField } from '@/components/ui/form'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import InputError from '@/components/ui/input-error'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { SourceControl } from '@/types/source-control'; +import { useConfigs } from '@/stores/bootstrap-store'; + +export type ApplicationFormData = { + name: string; + repository: string; + source_control_id: string; +}; + +export default function ApplicationFields({ + data, + errors, + onChange, + sourceControls, +}: { + data: ApplicationFormData; + errors: Partial>; + onChange: (name: keyof ApplicationFormData, value: string) => void; + sourceControls: SourceControl[]; +}) { + const configs = useConfigs()!; + const available = sourceControls.filter((source) => configs.source_control.providers[source.provider]?.usable_for_sites !== false); + + return ( + <> + + + onChange('name', e.target.value)} /> + + + + + + {available.length === 0 && ( +

+ Connect a source control to select a repository. +

+ )} + +
+ + + onChange('repository', e.target.value)} placeholder="organization/repository" /> + + + + ); +} diff --git a/resources/js/pages/applications/components/create-application.tsx b/resources/js/pages/applications/components/create-application.tsx new file mode 100644 index 000000000..b8ee3d67e --- /dev/null +++ b/resources/js/pages/applications/components/create-application.tsx @@ -0,0 +1,75 @@ +import { FormEvent } from 'react'; +import { useForm } from '@inertiajs/react'; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { DialogControlProps } from '@/components/dialogs/registry'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import InputError from '@/components/ui/input-error'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { LoaderCircleIcon } from 'lucide-react'; +import { useConfigs } from '@/stores/bootstrap-store'; +import { SourceControl } from '@/types/source-control'; +import { EnvironmentFormData } from '@/types/application'; +import ApplicationFields, { ApplicationFormData } from './application-fields'; +import EnvironmentFields, { environmentDefaults } from './environment-fields'; + +export default function CreateApplication({ open, onOpenChange, sourceControls }: DialogControlProps & { sourceControls: SourceControl[] }) { + const runtimes = useConfigs()!.application.runtimes; + const form = useForm({ + name: '', repository: '', source_control_id: '', runtime: '', environment: environmentDefaults(), + }); + const preset = runtimes.find((runtime) => runtime.id === form.data.runtime); + const environmentErrors = Object.fromEntries( + Object.entries(form.errors).filter(([key]) => key.startsWith('environment.')).map(([key, value]) => [key.slice('environment.'.length), value]), + ); + + const submit = (e: FormEvent) => { + e.preventDefault(); + form.post('/applications', { onSuccess: () => onOpenChange(false) }); + }; + + return ( + + e.preventDefault()}> + + Create application + Choose a repository and runtime preset, then create your first environment. + +
+ + form.setData(name, value)} sourceControls={sourceControls} /> + + + +

The preset cannot be changed after creation.

+ +
+ form.setData((data) => ({ ...data, environment: { ...data.environment, [name]: value } }))} + /> + +
+
+ + + + +
+
+ ); +} diff --git a/resources/js/pages/applications/components/delete-dialog.tsx b/resources/js/pages/applications/components/delete-dialog.tsx new file mode 100644 index 000000000..657f35797 --- /dev/null +++ b/resources/js/pages/applications/components/delete-dialog.tsx @@ -0,0 +1,48 @@ +import { FormEvent } from 'react'; +import { useForm } from '@inertiajs/react'; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { DialogControlProps } from '@/components/dialogs/registry'; +import { Form, FormField } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import InputError from '@/components/ui/input-error'; +import { LoaderCircleIcon } from 'lucide-react'; +import { Application, Environment } from '@/types/application'; + +export default function DeleteDialog({ open, onOpenChange, application, environment }: DialogControlProps & { + application: Application; + environment?: Environment; +}) { + const target = environment ?? application; + const form = useForm({ name: '' }); + const submit = (e: FormEvent) => { + e.preventDefault(); + const url = environment ? `/applications/${application.id}/environments/${environment.id}` : `/applications/${application.id}`; + form.delete(url, { onSuccess: () => onOpenChange(false) }); + }; + + return ( + + e.preventDefault()}> + + Delete {target.name} + This permanently deletes the {environment ? 'environment' : 'application and its environments'}. This cannot be undone. + +
+ + + form.setData('name', e.target.value)} autoComplete="off" /> + + +
+ + + + +
+
+ ); +} diff --git a/resources/js/pages/applications/components/environment-fields.tsx b/resources/js/pages/applications/components/environment-fields.tsx new file mode 100644 index 000000000..a4f01c388 --- /dev/null +++ b/resources/js/pages/applications/components/environment-fields.tsx @@ -0,0 +1,58 @@ +import { FormField } from '@/components/ui/form'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import InputError from '@/components/ui/input-error'; +import DynamicField from '@/components/ui/dynamic-field'; +import { EnvironmentFormData, RuntimePreset } from '@/types/application'; +import { DynamicFieldConfig, DynamicFieldValue } from '@/types/dynamic-field-config'; + +export function environmentDefaults(preset?: RuntimePreset): EnvironmentFormData { + return { + ...Object.fromEntries((preset?.form ?? []).map((field) => [field.name, field.default ?? ''])), + name: 'Production', + branch: 'main', + runtime_version: String(preset?.form.find((field) => field.name === 'runtime_version')?.default ?? ''), + }; +} + +export default function EnvironmentFields({ + preset, + data, + errors, + onChange, +}: { + preset?: RuntimePreset; + data: EnvironmentFormData; + errors: Record; + onChange: (name: string, value: DynamicFieldValue) => void; +}) { + const fields: DynamicFieldConfig[] = (preset?.form ?? []).filter((field) => field.name !== 'name' && field.name !== 'branch'); + if (preset && preset.versions.length > 0 && !fields.some((field) => field.name === 'runtime_version')) { + fields.unshift({ name: 'runtime_version', label: 'Runtime version', type: 'select', options: preset.versions }); + } + + return ( + <> + + + onChange('name', e.target.value)} /> + + + + + onChange('branch', e.target.value)} /> + + + {fields.map((field) => ( + onChange(field.name, value)} + error={errors[field.name]} + /> + ))} + {!fields.some((field) => field.name === 'runtime_version') && } + + ); +} diff --git a/resources/js/pages/applications/components/environment-form.tsx b/resources/js/pages/applications/components/environment-form.tsx new file mode 100644 index 000000000..440025a41 --- /dev/null +++ b/resources/js/pages/applications/components/environment-form.tsx @@ -0,0 +1,58 @@ +import { FormEvent } from 'react'; +import { useForm } from '@inertiajs/react'; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { DialogControlProps } from '@/components/dialogs/registry'; +import { Form, FormFields } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { LoaderCircleIcon } from 'lucide-react'; +import { useConfigs } from '@/stores/bootstrap-store'; +import { Application, Environment, EnvironmentFormData } from '@/types/application'; +import EnvironmentFields, { environmentDefaults } from './environment-fields'; + +export default function EnvironmentForm({ open, onOpenChange, application, environment }: DialogControlProps & { + application: Application; + environment?: Environment; +}) { + const preset = useConfigs()!.application.runtimes.find((runtime) => runtime.id === application.runtime); + const form = useForm({ + ...environmentDefaults(preset), + name: environment?.name ?? '', + branch: environment?.branch ?? 'main', + runtime_version: environment?.runtime_version ?? environmentDefaults(preset).runtime_version, + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + const options = { onSuccess: () => onOpenChange(false) }; + if (environment) { + form.put(`/applications/${application.id}/environments/${environment.id}`, options); + } else { + form.post(`/applications/${application.id}/environments`, options); + } + }; + + return ( + + e.preventDefault()}> + + {environment ? 'Edit environment' : 'Create environment'} + + {environment ? 'Changes apply on the next deploy. The slug remains unchanged.' : `Create an environment for ${application.name}.`} + + +
+ + {!preset &&

This runtime preset is unavailable. Restore its plugin before editing environments.

} + form.setData(name, value)} /> +
+
+ + + + +
+
+ ); +} diff --git a/resources/js/pages/applications/components/settings.tsx b/resources/js/pages/applications/components/settings.tsx new file mode 100644 index 000000000..2e8807f38 --- /dev/null +++ b/resources/js/pages/applications/components/settings.tsx @@ -0,0 +1,44 @@ +import { FormEvent } from 'react'; +import { useForm } from '@inertiajs/react'; +import { Form, FormFields } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import FormSuccessful from '@/components/form-successful'; +import { LoaderCircleIcon } from 'lucide-react'; +import { Application } from '@/types/application'; +import { SourceControl } from '@/types/source-control'; +import ApplicationFields from './application-fields'; + +export default function Settings({ application, sourceControls }: { application: Application; sourceControls: SourceControl[] }) { + const form = useForm({ + name: application.name, + repository: application.repository, + source_control_id: application.source_control_id ? String(application.source_control_id) : '', + }); + const submit = (e: FormEvent) => { + e.preventDefault(); + form.put(`/applications/${application.id}`, { preserveScroll: true }); + }; + + return ( + + + Application settings + Repository changes apply on the next deploy. The runtime preset cannot be changed. + + +
+ + form.setData(name, value)} sourceControls={sourceControls} /> + +
+ + +
+
+
+
+ ); +} diff --git a/resources/js/pages/applications/environments/show.tsx b/resources/js/pages/applications/environments/show.tsx new file mode 100644 index 000000000..ce3e8b453 --- /dev/null +++ b/resources/js/pages/applications/environments/show.tsx @@ -0,0 +1,49 @@ +import { Head, usePage } from '@inertiajs/react'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { PencilIcon, Trash2Icon } from 'lucide-react'; +import EnvironmentLayout from '@/layouts/environment/layout'; +import { useDialog } from '@/hooks/use-dialog'; +import { Application, Environment } from '@/types/application'; + +export default function ShowEnvironment() { + const { application, environment, can } = usePage<{ + application: Application; + environment: Environment; + can: { update: boolean; delete: boolean }; + }>().props; + const dialog = useDialog(); + + return ( + + + + +
+ + Beta + {environment.status} +
+
+ {can.update && } + {can.delete && } +
+
+ + +
+
Slug
{environment.slug}
+
Branch
{environment.branch}
+
Runtime version
{environment.runtime_version ?? 'Not required'}
+
+
+
+

Components and deployment controls will be available in a future update.

+
+
+ ); +} diff --git a/resources/js/pages/applications/index.tsx b/resources/js/pages/applications/index.tsx new file mode 100644 index 000000000..7d512b923 --- /dev/null +++ b/resources/js/pages/applications/index.tsx @@ -0,0 +1,43 @@ +import { Head, Link, usePage } from '@inertiajs/react'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { EyeIcon, PlusIcon } from 'lucide-react'; +import { VitoTable } from '@/components/vito-table'; +import Layout from '@/layouts/app/layout'; +import { InertiaTableData } from '@forjedio/inertia-table-react'; +import { useDialog } from '@/hooks/use-dialog'; +import { SourceControl } from '@/types/source-control'; + +export default function Applications() { + const { applications, sourceControls, canCreate } = usePage<{ + applications: InertiaTableData; + sourceControls: SourceControl[]; + canCreate: boolean; + }>().props; + const dialog = useDialog(); + + return ( + + + + +
+ + Beta +
+ {canCreate && } +
+ ( +
+ +
+ )} /> +
+
+ ); +} diff --git a/resources/js/pages/applications/show.tsx b/resources/js/pages/applications/show.tsx new file mode 100644 index 000000000..999ab7353 --- /dev/null +++ b/resources/js/pages/applications/show.tsx @@ -0,0 +1,64 @@ +import { Head, Link, usePage } from '@inertiajs/react'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { ArrowLeftIcon, PlusIcon, Trash2Icon } from 'lucide-react'; +import Layout from '@/layouts/app/layout'; +import { useDialog } from '@/hooks/use-dialog'; +import { useConfigs } from '@/stores/bootstrap-store'; +import { Application, Environment } from '@/types/application'; +import { SourceControl } from '@/types/source-control'; +import Settings from './components/settings'; + +export default function ShowApplication() { + const { application, environments, sourceControls, can } = usePage<{ + application: Application; + environments: Environment[]; + sourceControls: SourceControl[]; + can: { update: boolean; delete: boolean; createEnvironment: boolean }; + }>().props; + const dialog = useDialog(); + const runtime = useConfigs()?.application.runtimes.find((preset) => preset.id === application.runtime); + + return ( + + + +
+ +
+ + Beta + {runtime?.label ?? application.runtime} +
+ {can.delete && } +
+
+ + + {can.createEnvironment && } + + {environments.length === 0 &&

No environments yet. Create one to get started.

} +
+ {environments.map((environment) => ( + + +
+ {environment.name} + {environment.status} +
+

Branch: {environment.branch}

+

Runtime version: {environment.runtime_version ?? 'Not required'}

+
+
+ ))} +
+
+ {can.update && } +
+
+ ); +} diff --git a/resources/js/types/application.d.ts b/resources/js/types/application.d.ts new file mode 100644 index 000000000..31a15fbe6 --- /dev/null +++ b/resources/js/types/application.d.ts @@ -0,0 +1,35 @@ +import { Configs } from '@/types'; +import { DynamicFieldValue } from '@/types/dynamic-field-config'; + +export interface Application { + id: number; + project_id: number; + source_control_id: number | null; + name: string; + repository: string; + runtime: string; + created_at: string; + updated_at: string; +} + +export interface Environment { + id: number; + application_id: number; + name: string; + slug: string; + branch: string; + runtime_version: string | null; + status: string; + status_color: 'success' | 'danger'; + created_at: string; + updated_at: string; +} + +export type RuntimePreset = Configs['application']['runtimes'][number]; + +export type EnvironmentFormData = { + name: string; + branch: string; + runtime_version: string; + [key: string]: DynamicFieldValue; +}; diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index c9a1d63fa..b44c66806 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -4,6 +4,7 @@ import { Project } from '@/types/project'; import { User } from '@/types/user'; import { Site, SiteType } from '@/types/site'; import { DynamicFieldConfig } from './dynamic-field-config'; +import { Application, Environment } from '@/types/application'; export interface Auth { user: User; @@ -30,6 +31,7 @@ export interface NavItem { children?: NavItem[]; hidden?: boolean; external?: boolean; + badge?: string; } export interface Configs { @@ -160,6 +162,8 @@ export interface SharedData { auth: Auth; server?: Server; site?: Site; + application?: Application; + environment?: Environment; csrf_token: string; bootstrap_version: string; flash?: { diff --git a/tests/Feature/ApplicationsCrudTest.php b/tests/Feature/ApplicationsCrudTest.php new file mode 100644 index 000000000..b3b901039 --- /dev/null +++ b/tests/Feature/ApplicationsCrudTest.php @@ -0,0 +1,329 @@ +repositoryResponse = Http::response(['full_name' => 'organization/repository']); + Http::fake(['api.github.com/repos/*' => fn () => $this->repositoryResponse]); + $this->actingAs($this->user); + $this->sourceControl = SourceControl::factory()->github()->create([ + 'user_id' => $this->user->id, + 'project_id' => $this->user->current_project_id, + ]); + $this->applicationInput = [ + 'name' => 'Example application', + 'source_control_id' => $this->sourceControl->id, + 'repository' => 'organization/repository', + 'runtime' => 'node', + 'environment' => ['name' => 'Production', 'branch' => 'main', 'runtime_version' => '22'], + ]; +}); + +test('create application and its first environment without server work', function (string $runtime) { + $preset = (new Application(['runtime' => $runtime]))->preset(); + $input = $this->applicationInput; + $input['runtime'] = $runtime; + $input['environment']['runtime_version'] = $preset->versions()[0] ?? null; + $input['project_id'] = Project::factory()->create()->id; + $input['environment']['status'] = EnvironmentStatus::DELETING->value; + $input['environment']['slug'] = 'untrusted'; + $input['environment']['primary_component_id'] = 123; + + $response = $this->post(route('applications.store'), $input)->assertSessionDoesntHaveErrors(); + + $application = Application::query()->sole(); + $response->assertRedirect(route('applications.show', $application)); + $this->assertDatabaseHas('applications', [ + 'id' => $application->id, + 'project_id' => $this->user->current_project_id, + 'name' => $input['name'], + 'repository' => $input['repository'], + 'source_control_id' => $this->sourceControl->id, + 'runtime' => $runtime, + ]); + $this->assertDatabaseHas('environments', [ + 'application_id' => $application->id, + 'name' => 'Production', + 'slug' => 'production', + 'branch' => 'main', + 'runtime_version' => $input['environment']['runtime_version'], + 'status' => EnvironmentStatus::READY->value, + 'primary_component_id' => null, + ]); + $this->assertDatabaseCount('components', 0); + Queue::assertNothingPushed(); + SSH::assertNotExecutedContains(''); + Http::assertSentCount(1); +})->with(['node', 'php', 'bun', 'static', 'custom', 'laravel']); + +test('application action returns the created application and accepts dotted preset ids', function () { + $runtimes = config('component.runtimes'); + $runtimes['plugin.node'] = $runtimes['node']; + config(['component.runtimes' => $runtimes]); + $input = array_replace($this->applicationInput, ['runtime' => 'plugin.node']); + + $application = app(CreateApplication::class)->create($this->user->currentProject, $input, $this->user); + + expect($application)->toBeInstanceOf(Application::class); + $this->assertDatabaseHas('applications', ['id' => $application->id, 'runtime' => 'plugin.node']); +}); + +test('application creation validates input and rolls back its first environment', function (string $field, mixed $value) { + $input = $this->applicationInput; + data_set($input, $field, $value); + + $this->postJson(route('applications.store'), $input)->assertUnprocessable()->assertJsonValidationErrors($field); + + $this->assertDatabaseCount('applications', 0); + $this->assertDatabaseCount('environments', 0); +})->with([ + ['name', ''], ['name', ['invalid']], ['name', str_repeat('a', 256)], + ['source_control_id', null], ['source_control_id', 99999], ['source_control_id', ['invalid']], + ['repository', ''], ['repository', ['invalid']], ['repository', str_repeat('a', 256)], + ['runtime', 'unknown'], ['runtime', ['node']], ['environment', 'invalid'], + ['environment.name', ''], ['environment.name', '!!!'], ['environment.branch', ''], + ['environment.runtime_version', null], ['environment.runtime_version', 'unsupported'], +]); + +test('application names are unique within a project on create and update', function (bool $update) { + Application::factory()->create(['project_id' => $this->user->current_project_id, 'name' => $this->applicationInput['name']]); + $application = Application::factory()->create(['project_id' => $this->user->current_project_id]); + + $this->json($update ? 'PUT' : 'POST', $update ? route('applications.update', $application) : route('applications.store'), $this->applicationInput) + ->assertUnprocessable()->assertJsonValidationErrors('name'); +})->with([false, true]); + +test('application names may be reused in different projects and global source controls are accepted', function () { + Application::factory()->create(['name' => $this->applicationInput['name']]); + $this->sourceControl->update(['project_id' => null]); + + $this->post(route('applications.store'), $this->applicationInput)->assertSessionDoesntHaveErrors(); + + $this->assertDatabaseHas('applications', ['project_id' => $this->user->current_project_id, 'name' => $this->applicationInput['name']]); +}); + +test('applications reject foreign deleted and unusable source controls on create and update', function (string $kind, bool $update) { + if ($kind === 'foreign') { + $this->sourceControl->update(['project_id' => Project::factory()->create()->id]); + } elseif ($kind === 'deleted') { + $this->sourceControl->delete(); + } else { + config(['source-control.providers.github.usable_for_sites' => false]); + } + $application = Application::factory()->create(['project_id' => $this->user->current_project_id]); + + $this->json($update ? 'PUT' : 'POST', $update ? route('applications.update', $application) : route('applications.store'), $this->applicationInput) + ->assertUnprocessable()->assertJsonValidationErrors('source_control_id'); + + Http::assertNothingSent(); +})->with(['foreign', 'deleted', 'unusable'])->with([false, true]); + +test('applications authorize source control ownership before contacting its provider', function (bool $update) { + $this->sourceControl->update(['user_id' => User::factory()->create()->id]); + $application = Application::factory()->create(['project_id' => $this->user->current_project_id]); + + $this->json($update ? 'PUT' : 'POST', $update ? route('applications.update', $application) : route('applications.store'), $this->applicationInput) + ->assertForbidden(); + + Http::assertNothingSent(); +})->with([false, true]); + +test('applications report repository access failures on create and update', function (int $status, string $field, bool $update) { + $this->repositoryResponse = Http::response([], $status); + $application = Application::factory()->create(['project_id' => $this->user->current_project_id]); + $original = $application->getAttributes(); + + $this->json($update ? 'PUT' : 'POST', $update ? route('applications.update', $application) : route('applications.store'), $this->applicationInput) + ->assertUnprocessable()->assertJsonValidationErrors($field); + + $this->assertDatabaseHas('applications', $original); + $this->assertDatabaseCount('applications', 1); + $this->assertDatabaseCount('environments', 0); +})->with([[401, 'source_control_id'], [403, 'repository'], [404, 'repository']])->with([false, true]); + +test('applications reject unsafe and non-repository paths before contacting providers', function (string $repository, bool $update) { + $input = array_replace($this->applicationInput, ['repository' => $repository]); + $application = Application::factory()->create(['project_id' => $this->user->current_project_id]); + $original = $application->getAttributes(); + + $this->json($update ? 'PUT' : 'POST', $update ? route('applications.update', $application) : route('applications.store'), $input) + ->assertUnprocessable()->assertJsonValidationErrors('repository'); + + $this->assertDatabaseHas('applications', $original); + $this->assertDatabaseCount('applications', 1); + Http::assertNothingSent(); +})->with([ + '0', 'organization/repository/branches', '../user/repos', 'organization/..', './repository', + 'organization//repository', 'organization/repository/', 'organization/repository?query=value', + 'organization/repository#fragment', 'organization/%2e%2e', 'https://github.com/organization/repository', + 'organization\\repository', +])->with([false, true]); + +test('application repository rules preserve gitlab subgroups and numeric project ids', function (string $repository, bool $update) { + $this->sourceControl->update(['provider' => 'gitlab', 'url' => 'https://gitlab.com/']); + Http::fake(['gitlab.com/api/v4/projects/*/repository/commits' => Http::response([['id' => 'commit']])]); + $input = array_replace($this->applicationInput, ['repository' => $repository]); + $application = Application::factory()->create(['project_id' => $this->user->current_project_id]); + + $this->json($update ? 'PUT' : 'POST', $update ? route('applications.update', $application) : route('applications.store'), $input) + ->assertRedirect()->assertSessionDoesntHaveErrors(); + + $this->assertDatabaseHas('applications', ['name' => $input['name'], 'repository' => $repository, 'source_control_id' => $this->sourceControl->id]); + Http::assertSentCount(1); +})->with(['organization/sub-group/repository.name', '1234'])->with([false, true]); + +test('application provider http failures bubble up without mutations', function (int $status, bool $update) { + $this->withoutExceptionHandling(); + $this->repositoryResponse = Http::response([], $status); + $application = Application::factory()->create(['project_id' => $this->user->current_project_id]); + $original = $application->getAttributes(); + + expect(fn () => $this->json($update ? 'PUT' : 'POST', $update ? route('applications.update', $application) : route('applications.store'), $this->applicationInput)) + ->toThrow(RequestException::class); + + $this->assertDatabaseHas('applications', $original); + $this->assertDatabaseCount('applications', 1); + $this->assertDatabaseCount('environments', 0); + Queue::assertNothingPushed(); +})->with([400, 422, 429, 500, 503])->with([false, true]); + +test('unexpected provider failures bubble up without persisting an application', function () { + $this->repositoryResponse = fn () => throw new RuntimeException('Provider unavailable'); + + expect(fn () => app(CreateApplication::class)->create($this->user->currentProject, $this->applicationInput, $this->user)) + ->toThrow(RuntimeException::class, 'Provider unavailable'); + + $this->assertDatabaseCount('applications', 0); +}); + +test('update application changes only its editable fields', function () { + $application = Application::factory()->create(['project_id' => $this->user->current_project_id]); + $environment = Environment::factory()->for($application)->create(); + $input = array_replace($this->applicationInput, [ + 'runtime' => 'php', + 'project_id' => Project::factory()->create()->id, + 'repository' => 'organization/another-repository', + ]); + + $this->put(route('applications.update', $application), $input)->assertSessionDoesntHaveErrors(); + + $this->assertDatabaseHas('applications', [ + 'id' => $application->id, + 'project_id' => $application->project_id, + 'runtime' => 'node', + 'name' => $input['name'], + 'repository' => $input['repository'], + 'source_control_id' => $this->sourceControl->id, + ]); + expect($environment->fresh()->name)->toBe($environment->name); + $this->put(route('applications.update', $application), $input)->assertSessionDoesntHaveErrors(); +}); + +test('delete application requires its name and cascades environments', function () { + $application = Application::factory()->create(['project_id' => $this->user->current_project_id]); + $environment = Environment::factory()->for($application)->create(); + + $this->deleteJson(route('applications.destroy', $application), ['name' => 'wrong'])->assertUnprocessable()->assertJsonValidationErrors('name'); + $this->assertDatabaseHas('applications', ['id' => $application->id]); + $this->delete(route('applications.destroy', $application), ['name' => $application->name])->assertRedirect(route('applications')); + + $this->assertDatabaseMissing('applications', ['id' => $application->id]); + $this->assertDatabaseMissing('environments', ['id' => $environment->id]); + Queue::assertNothingPushed(); + SSH::assertNotExecutedContains(''); +}); + +test('application index is project scoped searchable and sortable', function () { + $alpha = Application::factory()->create(['project_id' => $this->user->current_project_id, 'name' => 'Alpha']); + $beta = Application::factory()->create(['project_id' => $this->user->current_project_id, 'name' => 'Beta']); + Environment::factory()->for($alpha)->create(); + Application::factory()->create(['name' => 'Foreign']); + + $this->get(route('applications', ['sort' => 'name']))->assertSuccessful()->assertInertia(fn (AssertableInertia $page) => $page + ->component('applications/index')->has('applications.columns')->has('applications.meta') + ->has('applications.data', 2)->where('applications.data.0.id', $alpha->id) + ->where('applications.data.0.environments_count', 1)->where('canCreate', true)); + $this->get(route('applications', ['search' => 'Beta']))->assertInertia(fn (AssertableInertia $page) => $page + ->has('applications.data', 1)->where('applications.data.0.id', $beta->id)); +}); + +test('application pages share whitelisted resources and only authorized source controls', function () { + $application = Application::factory()->create(['project_id' => $this->user->current_project_id]); + $environment = Environment::factory()->for($application)->create(); + SourceControl::factory()->github()->create(['user_id' => $this->user->id, 'project_id' => Project::factory()->create()->id]); + SourceControl::factory()->github()->create(['project_id' => $this->user->current_project_id]); + + $this->get(route('applications.show', $application))->assertSuccessful()->assertInertia(fn (AssertableInertia $page) => $page + ->component('applications/show')->where('application.id', $application->id) + ->where('application.source_control_id', null)->missing('application.sourceControl') + ->has('environments', 1)->where('environments.0.id', $environment->id) + ->where('environments.0.status', 'ready')->where('environments.0.status_color', 'success') + ->missing('environments.0.primary_component_id')->has('sourceControls', 1) + ->where('sourceControls.0.id', $this->sourceControl->id)->missing('sourceControls.0.provider_data') + ->missing('sourceControls.0.access_token')->where('can.update', true)); +}); + +test('authorized application links switch the current project consistently', function () { + $project = Project::factory()->create(); + $project->users()->create(['user_id' => $this->user->id, 'role' => UserRole::ADMIN]); + $application = Application::factory()->for($project)->create(); + + $this->get(route('applications.show', $application))->assertSuccessful()->assertInertia(fn (AssertableInertia $page) => $page + ->where('application.project_id', $project->id)->where('auth.currentProject.id', $project->id)); + + $this->assertDatabaseHas('users', ['id' => $this->user->id, 'current_project_id' => $project->id]); +}); + +test('read only members can view applications but cannot mutate them', function () { + $this->user->currentProject->users()->where('user_id', $this->user->id)->update(['role' => UserRole::USER]); + $application = Application::factory()->create(['project_id' => $this->user->current_project_id]); + + $this->get(route('applications'))->assertSuccessful()->assertInertia(fn (AssertableInertia $page) => $page->where('canCreate', false)); + $this->get(route('applications.show', $application))->assertSuccessful()->assertInertia(fn (AssertableInertia $page) => $page + ->where('can.update', false)->where('can.delete', false)->where('can.createEnvironment', false)); + $this->postJson(route('applications.store'), $this->applicationInput)->assertForbidden(); + $this->putJson(route('applications.update', $application), $this->applicationInput)->assertForbidden(); + $this->deleteJson(route('applications.destroy', $application), ['name' => $application->name])->assertForbidden(); + Http::assertNothingSent(); +}); + +test('application endpoints reject other projects without switching the current project', function () { + $application = Application::factory()->create(); + $projectId = $this->user->current_project_id; + + $this->get(route('applications.show', $application))->assertForbidden(); + $this->putJson(route('applications.update', $application), $this->applicationInput)->assertForbidden(); + $this->deleteJson(route('applications.destroy', $application), ['name' => $application->name])->assertForbidden(); + + $this->assertDatabaseHas('users', ['id' => $this->user->id, 'current_project_id' => $projectId]); + $this->assertDatabaseHas('applications', ['id' => $application->id]); +}); + +test('application endpoints require authentication', function () { + auth()->logout(); + $application = Application::factory()->create(); + + $this->getJson(route('applications'))->assertUnauthorized(); + $this->getJson(route('applications.show', $application))->assertUnauthorized(); + $this->postJson(route('applications.store'), $this->applicationInput)->assertUnauthorized(); + $this->putJson(route('applications.update', $application), $this->applicationInput)->assertUnauthorized(); + $this->deleteJson(route('applications.destroy', $application))->assertUnauthorized(); +}); diff --git a/tests/Feature/EnvironmentsCrudTest.php b/tests/Feature/EnvironmentsCrudTest.php new file mode 100644 index 000000000..85ce94a3e --- /dev/null +++ b/tests/Feature/EnvironmentsCrudTest.php @@ -0,0 +1,169 @@ +actingAs($this->user); + $this->application = Application::factory()->create(['project_id' => $this->user->current_project_id]); + $this->environment = Environment::factory()->for($this->application)->create(['name' => 'Production', 'slug' => 'production', 'runtime_version' => '22']); + $this->environmentInput = ['name' => 'Preview Environment', 'branch' => 'preview', 'runtime_version' => '24']; +}); + +test('create environment derives its slug and ignores lifecycle and parent input', function () { + $input = array_replace($this->environmentInput, [ + 'slug' => 'untrusted', 'application_id' => Application::factory()->create()->id, + 'status' => 'deleting', 'primary_component_id' => 999, + ]); + + $response = $this->post(route('environments.store', $this->application), $input)->assertSessionDoesntHaveErrors(); + + $environment = $this->application->environments()->where('name', $input['name'])->sole(); + $response->assertRedirect(route('environments.show', [$this->application, $environment])); + $this->assertDatabaseHas('environments', [ + 'id' => $environment->id, 'application_id' => $this->application->id, + 'name' => $input['name'], 'slug' => 'preview-environment', 'branch' => $input['branch'], + 'runtime_version' => '24', 'primary_component_id' => null, 'status' => EnvironmentStatus::READY->value, + ]); + Queue::assertNothingPushed(); + Http::assertNothingSent(); + SSH::assertNotExecutedContains(''); +}); + +test('update environment preserves slug and internal fields', function () { + $input = array_replace($this->environmentInput, [ + 'slug' => 'changed', 'application_id' => Application::factory()->create()->id, + 'status' => 'deleting', 'primary_component_id' => 999, + ]); + + $this->put(route('environments.update', [$this->application, $this->environment]), $input)->assertSessionDoesntHaveErrors(); + + $this->assertDatabaseHas('environments', [ + 'id' => $this->environment->id, 'application_id' => $this->application->id, + 'name' => $input['name'], 'slug' => 'production', 'branch' => $input['branch'], + 'runtime_version' => '24', 'primary_component_id' => null, 'status' => EnvironmentStatus::READY->value, + ]); + $this->put(route('environments.update', [$this->application, $this->environment]), $input)->assertSessionDoesntHaveErrors(); +}); + +test('environments validate names branches and runtime versions on create and update', function (string $field, mixed $value, bool $update) { + $input = array_replace($this->environmentInput, [$field => $value]); + $route = $update ? route('environments.update', [$this->application, $this->environment]) : route('environments.store', $this->application); + + $this->json($update ? 'PUT' : 'POST', $route, $input)->assertUnprocessable()->assertJsonValidationErrors($field); + + $this->assertDatabaseCount('environments', 1); + $this->assertDatabaseHas('environments', ['id' => $this->environment->id, 'name' => 'Production']); +})->with([ + ['name', ''], ['name', []], ['name', str_repeat('a', 256)], + ['branch', ''], ['branch', []], ['branch', str_repeat('a', 256)], + ['runtime_version', null], ['runtime_version', 'unsupported'], ['runtime_version', []], +])->with([false, true]); + +test('versionless presets allow null versions but reject arbitrary versions', function (string $runtime, bool $update) { + $this->application->update(['runtime' => $runtime]); + $input = array_replace($this->environmentInput, ['runtime_version' => null]); + $route = $update ? route('environments.update', [$this->application, $this->environment]) : route('environments.store', $this->application); + + $this->json($update ? 'PUT' : 'POST', $route, $input)->assertSessionDoesntHaveErrors()->assertRedirect(); + $this->assertDatabaseHas('environments', ['application_id' => $this->application->id, 'name' => $input['name'], 'runtime_version' => null]); + $input['name'] = 'Another environment'; + $input['runtime_version'] = 'arbitrary'; + $this->json($update ? 'PUT' : 'POST', $route, $input)->assertUnprocessable()->assertJsonValidationErrors('runtime_version'); +})->with(['static', 'custom'])->with([false, true]); + +test('environment names and slugs are unique per application', function () { + $route = route('environments.store', $this->application); + $this->postJson($route, array_replace($this->environmentInput, ['name' => 'Production']))->assertUnprocessable()->assertJsonValidationErrors('name'); + $this->postJson($route, array_replace($this->environmentInput, ['name' => 'Production!']))->assertUnprocessable()->assertJsonValidationErrors('name'); + $this->postJson($route, array_replace($this->environmentInput, ['name' => '!!!']))->assertUnprocessable()->assertJsonValidationErrors('name'); + Environment::factory()->for($this->application)->create(['name' => $this->environmentInput['name']]); + $this->putJson(route('environments.update', [$this->application, $this->environment]), $this->environmentInput) + ->assertUnprocessable()->assertJsonValidationErrors('name'); + + $other = Application::factory()->create(['project_id' => $this->user->current_project_id]); + $this->post(route('environments.store', $other), array_replace($this->environmentInput, ['name' => 'Production']))->assertSessionDoesntHaveErrors(); +}); + +test('delete environment requires its name and retains its application and siblings', function () { + $sibling = Environment::factory()->for($this->application)->create(); + $route = route('environments.destroy', [$this->application, $this->environment]); + + $this->deleteJson($route, ['name' => 'wrong'])->assertUnprocessable()->assertJsonValidationErrors('name'); + $this->assertDatabaseHas('environments', ['id' => $this->environment->id]); + $this->delete($route, ['name' => $this->environment->name])->assertRedirect(route('applications.show', $this->application)); + + $this->assertDatabaseMissing('environments', ['id' => $this->environment->id]); + $this->assertDatabaseHas('environments', ['id' => $sibling->id]); + $this->assertDatabaseHas('applications', ['id' => $this->application->id]); + Queue::assertNothingPushed(); + SSH::assertNotExecutedContains(''); +}); + +test('environment page shares its authorized application and environment', function () { + $this->get(route('environments.show', [$this->application, $this->environment]))->assertSuccessful() + ->assertInertia(fn (AssertableInertia $page) => $page->component('applications/environments/show') + ->where('application.id', $this->application->id)->where('environment.id', $this->environment->id) + ->where('environment.application_id', $this->application->id)->where('environment.slug', 'production') + ->where('environment.runtime_version', '22')->where('environment.status', 'ready') + ->where('environment.status_color', 'success')->where('can.update', true)->where('can.delete', true)); +}); + +test('environment links switch the project only for authorized users', function () { + $project = Project::factory()->create(); + $project->users()->create(['user_id' => $this->user->id, 'role' => UserRole::ADMIN]); + $this->application->update(['project_id' => $project->id]); + + $this->get(route('environments.show', [$this->application, $this->environment]))->assertSuccessful() + ->assertInertia(fn (AssertableInertia $page) => $page->where('auth.currentProject.id', $project->id)); + + $this->assertDatabaseHas('users', ['id' => $this->user->id, 'current_project_id' => $project->id]); +}); + +test('environment scoped bindings reject mismatched parent ids', function () { + $other = Application::factory()->create(['project_id' => $this->user->current_project_id]); + + $this->get(route('environments.show', [$other, $this->environment]))->assertNotFound(); + $this->putJson(route('environments.update', [$other, $this->environment]), $this->environmentInput)->assertNotFound(); + $this->deleteJson(route('environments.destroy', [$other, $this->environment]), ['name' => $this->environment->name])->assertNotFound(); + $this->assertDatabaseHas('environments', ['id' => $this->environment->id, 'name' => 'Production']); +}); + +test('read only members can view but cannot mutate environments', function () { + $this->user->currentProject->users()->where('user_id', $this->user->id)->update(['role' => UserRole::USER]); + + $this->get(route('environments.show', [$this->application, $this->environment]))->assertSuccessful() + ->assertInertia(fn (AssertableInertia $page) => $page->where('can.update', false)->where('can.delete', false)); + $this->postJson(route('environments.store', $this->application), $this->environmentInput)->assertForbidden(); + $this->putJson(route('environments.update', [$this->application, $this->environment]), $this->environmentInput)->assertForbidden(); + $this->deleteJson(route('environments.destroy', [$this->application, $this->environment]), ['name' => $this->environment->name])->assertForbidden(); +}); + +test('environment endpoints reject other projects and guests', function (bool $guest) { + if ($guest) { + auth()->logout(); + } else { + $this->application->update(['project_id' => Project::factory()->create()->id]); + } + $status = $guest ? 401 : 403; + + $this->getJson(route('environments.show', [$this->application, $this->environment]))->assertStatus($status); + $this->postJson(route('environments.store', $this->application), $this->environmentInput)->assertStatus($status); + $this->putJson(route('environments.update', [$this->application, $this->environment]), $this->environmentInput)->assertStatus($status); + $this->deleteJson(route('environments.destroy', [$this->application, $this->environment]))->assertStatus($status); + $this->assertDatabaseHas('users', ['id' => $this->user->id, 'current_project_id' => $this->user->current_project_id]); +})->with([false, true]); diff --git a/tests/Feature/SitesTest.php b/tests/Feature/SitesTest.php index a9156eae3..c0e556ace 100644 --- a/tests/Feature/SitesTest.php +++ b/tests/Feature/SitesTest.php @@ -21,6 +21,7 @@ use App\SiteTypes\Wordpress; use App\SourceControlProviders\Github; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Inertia\Testing\AssertableInertia; @@ -169,6 +170,30 @@ } })->with(['create', 'update'])->with(['creator', 'global', 'project', 'foreign-project']); +test('site repository http failures bubble up without mutations or provider details in form errors', function (int $status, bool $update) { + SSH::fake(); + Queue::fake(); + Http::preventStrayRequests(); + Http::fake(['api.github.com/repos/*' => Http::response(['message' => 'provider response details'], $status)]); + $this->withoutExceptionHandling()->actingAs($this->user); + $sourceControl = SourceControl::factory()->github()->create(['user_id' => $this->user->id]); + $originalSource = $this->site->source_control_id; + + expect(fn () => $update + ? $this->patch(route('site-settings.update-source-control', [$this->server, $this->site]), ['source_control' => $sourceControl->id]) + : $this->post(route('sites.store', $this->server), [ + 'type' => Blank::id(), 'domain' => 'provider-failure.example.com', 'port' => 3000, + 'user' => 'providerfailure', 'use_source_control' => true, 'source_control' => $sourceControl->id, + 'repository' => 'organization/repository', 'branch' => 'main', + ]))->toThrow(RequestException::class); + + $this->assertDatabaseMissing('sites', ['domain' => 'provider-failure.example.com']); + $this->assertDatabaseMissing('isolated_users', ['server_id' => $this->server->id, 'username' => 'providerfailure']); + $this->assertDatabaseHas('sites', ['id' => $this->site->id, 'source_control_id' => $originalSource]); + Queue::assertNothingPushed(); + SSH::assertNotExecutedContains(''); +})->with([429, 500])->with([false, true]); + test('web site resources hide installation passwords from read-only users', function () { $this->server->project->users()->where('user_id', $this->user->id)->update(['role' => UserRole::USER]); $typeData = [ From 6e099e36286c57d3481d14b7b918d7223e82ec32 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sun, 18 Oct 2026 10:26:52 +0200 Subject: [PATCH 06/30] phase 6 --- app/Actions/Application/DeleteApplication.php | 81 +++- app/Actions/Application/UpdateApplication.php | 35 +- .../Component/BroadcastComponentUpdate.php | 41 ++ app/Actions/Component/CreateComponent.php | 144 ++++++ app/Actions/Component/DeleteComponent.php | 90 ++++ app/Actions/Component/GetComponentOptions.php | 52 +++ app/Actions/Component/InstallComponent.php | 78 ++++ .../Component/PurgeServerComponents.php | 44 +- .../Component/RetryComponentInstall.php | 33 ++ .../Component/SyncComponentDeployKeys.php | 126 ++++++ app/Actions/Environment/CreateEnvironment.php | 31 +- app/Actions/Environment/DeleteEnvironment.php | 97 +++- app/Actions/Environment/UpdateEnvironment.php | 45 +- app/Actions/PHP/EnsureFpmPool.php | 2 +- app/Actions/Server/TransferServer.php | 43 +- app/Actions/Site/CreateSite.php | 21 +- .../AssertToolingNotUsedByComponents.php | 24 + .../Site/Tooling/UninstallSiteTooling.php | 8 +- app/Actions/SourceControl/SetupDeployKey.php | 3 +- app/DTOs/ToolingTargetDTO.php | 1 + app/Enums/ApplicationStatus.php | 26 ++ .../Applications/ApplicationController.php | 6 +- .../Applications/ComponentController.php | 87 ++++ .../Applications/EnvironmentController.php | 4 +- app/Http/Middleware/HandleInertiaRequests.php | 2 +- app/Http/Resources/ApplicationResource.php | 2 + app/Http/Resources/ComponentResource.php | 39 ++ app/Http/Resources/EnvironmentResource.php | 3 + app/Jobs/Application/DeleteApplicationJob.php | 27 ++ app/Jobs/Component/DeleteComponentJob.php | 30 ++ .../DeletePurgedComponentDeployKeyJob.php | 36 ++ app/Jobs/Component/InstallComponentJob.php | 30 ++ .../Component/SyncComponentDeployKeysJob.php | 31 ++ app/Jobs/Environment/DeleteEnvironmentJob.php | 27 ++ .../Site/Tooling/UninstallSiteToolingJob.php | 3 + app/Models/Application.php | 3 + app/Models/Component.php | 6 +- app/Models/Environment.php | 5 +- app/Models/Server.php | 4 +- app/SSH/Mise/Mise.php | 13 + .../AbstractSourceControlProvider.php | 71 +++ app/SourceControlProviders/Bitbucket.php | 44 +- app/SourceControlProviders/BitbucketV2.php | 61 +-- app/SourceControlProviders/Gitea.php | 45 +- app/SourceControlProviders/Github.php | 50 +-- app/SourceControlProviders/GithubApp.php | 7 +- app/SourceControlProviders/Gitlab.php | 46 +- .../SourceControlProvider.php | 4 +- app/Tables/ApplicationTable.php | 2 + app/Tables/Applications/ComponentTable.php | 38 ++ ..._224408_add_component_lifecycle_fields.php | 48 ++ resources/js/components/dialogs/registry.ts | 4 + resources/js/layouts/environment/layout.tsx | 82 +++- .../applications/components/add-component.tsx | 95 ++++ .../components/delete-component.tsx | 52 +++ .../applications/components/delete-dialog.tsx | 2 +- .../components/environment-fields.tsx | 11 +- .../components/environment-form.tsx | 10 +- .../components/primary-component-picker.tsx | 27 ++ .../applications/components/settings.tsx | 2 +- .../applications/environments/component.tsx | 61 +++ .../applications/environments/components.tsx | 43 ++ .../pages/applications/environments/show.tsx | 19 +- resources/js/pages/applications/index.tsx | 3 +- resources/js/pages/applications/show.tsx | 49 +- resources/js/types/application.d.ts | 35 +- .../components/create-directories.blade.php | 1 + .../components/delete-directories.blade.php | 6 + .../views/ssh/components/delete-key.blade.php | 14 + .../views/ssh/mise/install-runtime.blade.php | 5 + .../ssh/os/delete-isolated-user.blade.php | 8 +- .../views/ssh/os/generate-ssh-key.blade.php | 9 +- tests/Feature/ApplicationCoexistenceTest.php | 42 +- tests/Feature/ApplicationDataModelTest.php | 8 +- tests/Feature/ApplicationsCrudTest.php | 2 +- tests/Feature/ComponentLifecycleTest.php | 423 ++++++++++++++++++ tests/Feature/ComponentsTest.php | 249 +++++++++++ tests/Feature/EnvironmentsCrudTest.php | 2 +- tests/Feature/ServerTest.php | 20 + tests/Feature/SiteGroundworkTest.php | 13 +- tests/Feature/SitesTest.php | 22 +- tests/Feature/SourceControlsTest.php | 111 +++++ 82 files changed, 2919 insertions(+), 310 deletions(-) create mode 100644 app/Actions/Component/BroadcastComponentUpdate.php create mode 100644 app/Actions/Component/CreateComponent.php create mode 100644 app/Actions/Component/DeleteComponent.php create mode 100644 app/Actions/Component/GetComponentOptions.php create mode 100644 app/Actions/Component/InstallComponent.php create mode 100644 app/Actions/Component/RetryComponentInstall.php create mode 100644 app/Actions/Component/SyncComponentDeployKeys.php create mode 100644 app/Actions/Site/Tooling/AssertToolingNotUsedByComponents.php create mode 100644 app/Enums/ApplicationStatus.php create mode 100644 app/Http/Controllers/Applications/ComponentController.php create mode 100644 app/Http/Resources/ComponentResource.php create mode 100644 app/Jobs/Application/DeleteApplicationJob.php create mode 100644 app/Jobs/Component/DeleteComponentJob.php create mode 100644 app/Jobs/Component/DeletePurgedComponentDeployKeyJob.php create mode 100644 app/Jobs/Component/InstallComponentJob.php create mode 100644 app/Jobs/Component/SyncComponentDeployKeysJob.php create mode 100644 app/Jobs/Environment/DeleteEnvironmentJob.php create mode 100644 app/Tables/Applications/ComponentTable.php create mode 100644 database/migrations/2026_10_17_224408_add_component_lifecycle_fields.php create mode 100644 resources/js/pages/applications/components/add-component.tsx create mode 100644 resources/js/pages/applications/components/delete-component.tsx create mode 100644 resources/js/pages/applications/components/primary-component-picker.tsx create mode 100644 resources/js/pages/applications/environments/component.tsx create mode 100644 resources/js/pages/applications/environments/components.tsx create mode 100644 resources/views/ssh/components/create-directories.blade.php create mode 100644 resources/views/ssh/components/delete-directories.blade.php create mode 100644 resources/views/ssh/components/delete-key.blade.php create mode 100644 tests/Feature/ComponentLifecycleTest.php create mode 100644 tests/Feature/ComponentsTest.php diff --git a/app/Actions/Application/DeleteApplication.php b/app/Actions/Application/DeleteApplication.php index 63479a68c..324ce5517 100644 --- a/app/Actions/Application/DeleteApplication.php +++ b/app/Actions/Application/DeleteApplication.php @@ -2,9 +2,21 @@ namespace App\Actions\Application; +use App\Actions\Environment\DeleteEnvironment; +use App\DTOs\SocketEventDTO; +use App\Enums\ApplicationStatus; +use App\Enums\EnvironmentStatus; +use App\Events\SocketEvent; +use App\Jobs\Application\DeleteApplicationJob; use App\Models\Application; +use App\Models\Component; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; +use RuntimeException; +use Throwable; class DeleteApplication { @@ -13,10 +25,73 @@ class DeleteApplication */ public function delete(Application $application, array $input): void { - Validator::make($input, [ - 'name' => ['required', 'string', Rule::in([$application->name])], - ])->validate(); + Validator::make($input, ['name' => ['required', 'string', Rule::in([$application->name])]])->validate(); + DB::transaction(function () use ($application): void { + $application = Application::query()->lockForUpdate()->findOrFail($application->id); + if ($application->status === ApplicationStatus::DELETING) { + throw ValidationException::withMessages(['name' => 'Application teardown is already in progress.']); + } + + if (! Component::query()->whereHas('environment', fn ($query) => $query->where('application_id', $application->id))->exists()) { + $application->delete(); + $this->broadcast($application, true); + + return; + } + + $application->status = ApplicationStatus::DELETING; + $application->save(); + $jobs = []; + foreach ($application->environments()->orderBy('id')->get() as $environment) { + $jobs = array_merge($jobs, app(DeleteEnvironment::class)->jobs($environment)); + } + $jobs[] = new DeleteApplicationJob($application->id); + $this->broadcast($application); + $id = $application->id; + DB::afterCommit(static function () use ($jobs, $id): void { + Bus::chain($jobs)->onQueue('ssh')->catch(static function (Throwable $e) use ($id): void { + app(self::class)->failed($id); + })->dispatch(); + }); + }); + } + + public function handle(int $applicationId): void + { + $application = Application::query()->find($applicationId); + if (! $application || $application->status !== ApplicationStatus::DELETING) { + return; + } + if ($application->environments()->exists()) { + throw new RuntimeException('Environment teardown is incomplete.'); + } $application->delete(); + $this->broadcast($application, true); + } + + public function failed(int $applicationId): void + { + $application = Application::query()->find($applicationId); + if (! $application) { + return; + } + $application->status = ApplicationStatus::DELETION_FAILED; + $application->save(); + $application->environments()->where('status', EnvironmentStatus::DELETING)->update([ + 'status' => EnvironmentStatus::DELETION_FAILED, + 'last_error' => 'Application teardown failed. Retry deletion from the application.', + ]); + $this->broadcast($application); + } + + private function broadcast(Application $application, bool $deleted = false): void + { + $event = new SocketEventDTO( + projectId: $application->project_id, + type: $deleted ? 'application.deleted' : 'application.updated', + data: ['id' => $application->id, 'status' => $application->status->getText()], + ); + DB::afterCommit(static fn () => SocketEvent::dispatch($event)); } } diff --git a/app/Actions/Application/UpdateApplication.php b/app/Actions/Application/UpdateApplication.php index 3fa3d4c8c..dd789fab4 100644 --- a/app/Actions/Application/UpdateApplication.php +++ b/app/Actions/Application/UpdateApplication.php @@ -2,8 +2,14 @@ namespace App\Actions\Application; +use App\Enums\ApplicationStatus; +use App\Enums\ComponentStatus; +use App\Jobs\Component\SyncComponentDeployKeysJob; use App\Models\Application; +use App\Models\Component; use App\Models\User; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class UpdateApplication { @@ -14,12 +20,29 @@ public function update(Application $application, array $input, User $actor): App { app(ValidateApplication::class)->validate($application->project, $input, $actor, $application); - $application->update([ - 'name' => $input['name'], - 'source_control_id' => $input['source_control_id'], - 'repository' => $input['repository'], - ]); + return DB::transaction(function () use ($application, $input): Application { + $application = Application::query()->lockForUpdate()->findOrFail($application->id); + if ($application->status !== ApplicationStatus::READY) { + throw ValidationException::withMessages(['name' => 'The application cannot be edited during teardown.']); + } - return $application; + $changed = $application->source_control_id !== (int) $input['source_control_id'] || $application->repository !== $input['repository']; + $application->update([ + 'name' => $input['name'], + 'source_control_id' => $input['source_control_id'], + 'repository' => $input['repository'], + ]); + + if ($changed) { + Component::query()->whereHas('environment', fn ($query) => $query->where('application_id', $application->id)) + ->where('status', ComponentStatus::READY)->each(function (Component $component): void { + $component->needs_redeploy = true; + $component->save(); + SyncComponentDeployKeysJob::dispatch($component->id, $component->server_id)->afterCommit(); + }); + } + + return $application; + }); } } diff --git a/app/Actions/Component/BroadcastComponentUpdate.php b/app/Actions/Component/BroadcastComponentUpdate.php new file mode 100644 index 000000000..2742c7a2f --- /dev/null +++ b/app/Actions/Component/BroadcastComponentUpdate.php @@ -0,0 +1,41 @@ +with('application')->find($component->environment_id); + if (! $environment?->application) { + return; + } + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $environment->application->project_id, + type: 'component.deleted', + data: [ + 'id' => $component->id, 'environment_id' => $component->environment_id, + 'application_status' => $environment->application->status->getText(), + 'environment_status' => $environment->status->getText(), + ], + )); + } + + public function broadcast(Component $component): void + { + $component->refresh()->load(['environment.application', 'server', 'capabilities']); + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $component->environment->application->project_id, + type: 'component.updated', + data: new ComponentResource($component), + )); + } +} diff --git a/app/Actions/Component/CreateComponent.php b/app/Actions/Component/CreateComponent.php new file mode 100644 index 000000000..f43327ecc --- /dev/null +++ b/app/Actions/Component/CreateComponent.php @@ -0,0 +1,144 @@ + $input + */ + public function create(Environment $environment, array $input): Component + { + Validator::make($input, [ + 'server_id' => ['required', 'integer', Rule::exists('servers', 'id')->where('project_id', $environment->application->project_id)->where('status', ServerStatus::READY->value)], + 'user' => ['required', 'string', 'regex:/\A[a-z_][a-z0-9_-]*[a-z0-9]\z/', 'min:3', 'max:32'], + ])->validate(); + + try { + $component = Cache::lock('unique-queue:server-'.$input['server_id'], 600)->block(10, function () use ($environment, $input): Component { + $server = Server::query()->findOrFail($input['server_id']); + + return $server->isolatedUserLock($input['user'])->block(30, fn (): Component => $this->attach($environment, $input)); + }); + } catch (LockTimeoutException) { + throw ValidationException::withMessages(['server_id' => 'Another operation on this server or Linux user is in progress. Please retry.']); + } + + InstallComponentJob::dispatch($component->id, $component->server_id)->afterCommit(); + + return $component; + } + + /** + * @param array $input + */ + private function attach(Environment $environment, array $input): Component + { + return DB::transaction(function () use ($environment, $input): Component { + $application = Application::query()->lockForUpdate()->findOrFail($environment->application_id); + $environment = Environment::query()->lockForUpdate()->findOrFail($environment->id); + $server = Server::query()->lockForUpdate()->findOrFail($input['server_id']); + $environment->setRelation('application', $application); + $this->validate($environment, $server, $input); + + $isolatedUser = $input['user'] === $server->getSshUser() ? null : IsolatedUser::query()->firstOrCreate([ + 'server_id' => $server->id, 'username' => $input['user'], + ]); + $component = new Component([ + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'user' => $input['user'], + 'name' => $input['name'], + 'type' => ComponentType::APP, + ]); + $component->isolated_user_id = $isolatedUser?->id; + $component->path = '/home/'.$component->user.'/app-'.$application->id.'-'.$environment->slug; + $component->status = ComponentStatus::PENDING; + $component->save(); + $component->setRelation('environment', $environment); + + foreach ($input['capabilities'] ?? [] as $configuration) { + $capability = new ComponentCapability([ + 'component_id' => $component->id, + 'capability' => $configuration['capability'], + 'config' => $configuration['config'] ?? [], + ]); + try { + $capability->handler()->validate($component, $server); + } catch (ValidationException $e) { + throw ValidationException::withMessages(['capabilities' => array_merge(...array_values($e->errors()))]); + } + $capability->status = ComponentStatus::PENDING; + $capability->save(); + } + + if (! $environment->primary_component_initialized) { + $environment->primary_component_id = $component->id; + $environment->primary_component_initialized = true; + $environment->save(); + } + + return $component; + }); + } + + /** + * @param array $input + */ + private function validate(Environment $environment, Server $server, array $input): void + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255', Rule::unique('components')->where('environment_id', $environment->id)], + 'server_id' => [Rule::exists('servers', 'id')->where('project_id', $environment->application->project_id)->where('status', ServerStatus::READY->value)], + 'type' => ['required', Rule::in([ComponentType::APP->value])], + 'user' => [Rule::notIn(array_diff(config('core.reserved_user_names'), [$server->getSshUser()])), + Rule::unique('components')->where('environment_id', $environment->id)->where('server_id', $server->id), + ], + 'capabilities' => ['sometimes', 'array', 'list'], + 'capabilities.*' => ['array:capability,config'], + 'capabilities.*.capability' => ['required', 'string', 'distinct', Rule::in(array_keys(config('component.capabilities', [])))], + 'capabilities.*.config' => ['sometimes', 'nullable', 'array'], + ])->validate(); + + if ($environment->status !== EnvironmentStatus::READY || $environment->application->status !== ApplicationStatus::READY) { + throw ValidationException::withMessages(['name' => 'Components cannot be added during teardown.']); + } + + if (! $environment->application->sourceControl) { + throw ValidationException::withMessages(['name' => 'Connect a source control before adding components.']); + } + + $preset = $environment->application->preset(); + foreach ($preset->requiredServices($environment->runtime_version ?? '') as $type => $version) { + if (! $server->services()->where('type', $type)->when($version !== null, fn ($query) => $query->where('version', $version)) + ->whereIn('status', [ServiceStatus::READY, ServiceStatus::STOPPED, ServiceStatus::DISABLED])->exists()) { + throw ValidationException::withMessages(['server_id' => "The required {$type} service version must be installed on the server."]); + } + } + + if (in_array('web', array_column($input['capabilities'] ?? [], 'capability'), true) + && $environment->components()->where('server_id', $server->id)->whereHas('capabilities', fn ($query) => $query->where('capability', 'web'))->exists()) { + throw ValidationException::withMessages(['capabilities' => 'Only one web component per environment is allowed on each server.']); + } + } +} diff --git a/app/Actions/Component/DeleteComponent.php b/app/Actions/Component/DeleteComponent.php new file mode 100644 index 000000000..efc10d72e --- /dev/null +++ b/app/Actions/Component/DeleteComponent.php @@ -0,0 +1,90 @@ + $input + */ + public function delete(Component $component, array $input): void + { + DB::transaction(function () use ($component, $input): void { + Application::query()->lockForUpdate()->findOrFail($component->environment->application_id); + $environment = Environment::query()->lockForUpdate()->findOrFail($component->environment_id); + $component->refresh(); + Validator::make($input, [ + 'primary_component_id' => ['nullable', 'integer', Rule::exists('components', 'id') + ->where('environment_id', $environment->id)->where('type', ComponentType::APP->value) + ->whereNot('id', $component->id)->whereNotIn('status', [ComponentStatus::DELETING->value, ComponentStatus::DELETION_FAILED->value])], + ])->validate(); + + if ($component->status === ComponentStatus::DELETING) { + throw ValidationException::withMessages(['component' => 'Component deletion is already in progress.']); + } + + if ($environment->primary_component_id === $component->id) { + $environment->primary_component_id = $input['primary_component_id'] ?? null; + $environment->save(); + } + + $component->status = ComponentStatus::DELETING; + $component->last_error = null; + $component->save(); + DeleteComponentJob::dispatch($component->id, $component->server_id)->afterCommit(); + }); + } + + public function handle(int $componentId): void + { + $component = Component::query()->with('server', 'isolatedUser')->find($componentId); + if (! $component || $component->status !== ComponentStatus::DELETING) { + return; + } + + app(BroadcastComponentUpdate::class)->broadcast($component); + $keys = app(SyncComponentDeployKeys::class); + $keys->remove($component); + $keys->removeLocal($component); + $component->server->ssh()->exec(view('ssh.components.delete-directories', ['component' => $component]), 'component-delete-directories'); + + $isolatedUser = $component->isolatedUser; + if ($isolatedUser) { + $isolatedUser->lock()->block(30, function () use ($isolatedUser, $component): void { + if (! $isolatedUser->isUsedByOthers($component)) { + $component->server->os()->deleteIsolatedUser($component->user); + $isolatedUser->delete(); + } + $component->delete(); + }); + } else { + $component->delete(); + } + + app(BroadcastComponentUpdate::class)->deleted($component); + } + + public function failed(int $componentId): void + { + $component = Component::query()->find($componentId); + if (! $component) { + return; + } + + $component->status = ComponentStatus::DELETION_FAILED; + $component->last_error = 'Component deletion failed. Check server connectivity and source-control access, then retry deletion.'; + $component->save(); + app(BroadcastComponentUpdate::class)->broadcast($component); + } +} diff --git a/app/Actions/Component/GetComponentOptions.php b/app/Actions/Component/GetComponentOptions.php new file mode 100644 index 000000000..abf8e234b --- /dev/null +++ b/app/Actions/Component/GetComponentOptions.php @@ -0,0 +1,52 @@ +application->runtime])) { + $preset = $environment->application->preset(); + foreach (config('component.capabilities', []) as $id => $configuration) { + $handler = (new ComponentCapability(['capability' => $id]))->handler(); + $capabilities[] = [ + 'id' => $id, + 'label' => $configuration['label'], + 'description' => $handler->description(), + 'form' => $configuration['form'] ?? $handler->form($preset)->toArray(), + ]; + } + } + + return [ + 'servers' => Server::query()->where('project_id', $environment->application->project_id)->where('status', ServerStatus::READY) + ->orderBy('name')->get()->map(fn (Server $server): array => [ + 'id' => $server->id, 'name' => $server->name, 'ssh_user' => $server->getSshUser(), + ])->all(), + 'capabilities' => $capabilities, + ]; + } + + /** + * @return list + */ + public function primaryCandidates(Environment $environment): array + { + return $environment->components()->where('type', ComponentType::APP) + ->whereNotIn('status', [ComponentStatus::DELETING, ComponentStatus::DELETION_FAILED])->orderBy('id') + ->get()->map(fn (Component $component): array => ['id' => $component->id, 'name' => $component->name])->all(); + } +} diff --git a/app/Actions/Component/InstallComponent.php b/app/Actions/Component/InstallComponent.php new file mode 100644 index 000000000..9dcff4b20 --- /dev/null +++ b/app/Actions/Component/InstallComponent.php @@ -0,0 +1,78 @@ +with('environment.application', 'server', 'isolatedUser')->find($componentId); + if (! $component) { + return; + } + + $install = DB::transaction(function () use ($component): bool { + Application::query()->lockForUpdate()->findOrFail($component->environment->application_id); + $component->refresh(); + if (! in_array($component->status, [ComponentStatus::PENDING, ComponentStatus::INSTALLING], true) + || $component->environment->status !== EnvironmentStatus::READY + || $component->environment->application->status !== ApplicationStatus::READY) { + return false; + } + + $component->status = ComponentStatus::INSTALLING; + $component->last_error = null; + $component->save(); + + return true; + }); + if (! $install) { + return; + } + app(BroadcastComponentUpdate::class)->broadcast($component); + + if ($component->isolatedUser) { + app(EnsureIsolatedUser::class)->ensure($component->server, $component->user); + } + + $component->server->ssh($component->user)->exec(view('ssh.components.create-directories', ['component' => $component]), 'component-directories'); + + $toolId = $component->environment->application->preset()->tooling(); + if ($toolId !== null) { + $tool = ToolingRegistry::find($toolId) ?? throw new RuntimeException('The runtime tooling is unavailable.'); + $tool->install(new ToolingTargetDTO( + server: $component->server, + user: $component->user, + isolatedUser: $component->isolatedUser, + phpVersion: $component->environment->runtime_version, + configDir: $component->path, + ), $component->environment->runtime_version ?? ''); + } + + app(SyncComponentDeployKeys::class)->handle($componentId, ComponentStatus::INSTALLING); + } + + public function failed(int $componentId): void + { + $component = Component::query()->find($componentId); + if (! $component || in_array($component->status, [ComponentStatus::DELETING, ComponentStatus::DELETION_FAILED], true)) { + return; + } + + $component->status = ComponentStatus::INSTALLATION_FAILED; + $component->last_error = 'Component installation failed. Check server connectivity, required services and repository access, then retry.'; + $component->save(); + app(BroadcastComponentUpdate::class)->broadcast($component); + } +} diff --git a/app/Actions/Component/PurgeServerComponents.php b/app/Actions/Component/PurgeServerComponents.php index f3c778df4..f743e5a04 100644 --- a/app/Actions/Component/PurgeServerComponents.php +++ b/app/Actions/Component/PurgeServerComponents.php @@ -2,29 +2,51 @@ namespace App\Actions\Component; +use App\Jobs\Component\DeletePurgedComponentDeployKeyJob; +use App\Models\Environment; use App\Models\Server; use Illuminate\Support\Facades\DB; +use Throwable; class PurgeServerComponents { /** - * @return array + * @return array{environments: list, keys: list} */ public function purge(Server $server): array { - return DB::transaction(function () use ($server): array { - $environmentIds = $server->components()->distinct()->pluck('environment_id')->all(); + $server = Server::query()->lockForUpdate()->findOrFail($server->id); + $components = $server->components()->lockForUpdate() + ->get(['id', 'server_id', 'environment_id', 'deploy_key_source_control_id', 'deploy_key_repository', 'deploy_key_id', 'ssh_key']); + $departure = [ + 'environments' => $components->pluck('environment_id')->unique()->values()->all(), + 'keys' => $components->filter(fn ($component) => $component->deploy_key_id !== null || $component->deploy_key_source_control_id !== null)->values()->toArray(), + ]; + $server->components()->delete(); - $server->components()->delete(); - - return $environmentIds; - }); + return $departure; } /** - * Post-delete extension point for resynchronizing affected environments on surviving servers. - * - * @param array $environmentIds + * @param array{environments: list, keys: list} $departure */ - public function handle(array $environmentIds): void {} + public function handle(array $departure): void + { + DB::afterCommit(function () use ($departure): void { + foreach ($departure['keys'] as $key) { + try { + DeletePurgedComponentDeployKeyJob::dispatch($key['id'], $key['server_id'], $key['environment_id'], $key['deploy_key_source_control_id'], $key['deploy_key_repository'], $key['deploy_key_id'], $key['ssh_key']); + } catch (Throwable) { + $this->failed($key['environment_id']); + } + } + }); + } + + public function failed(int $environmentId): void + { + Environment::query()->whereKey($environmentId)->update([ + 'last_error' => 'A removed server component deploy key could not be removed. Remove the key from the source control manually.', + ]); + } } diff --git a/app/Actions/Component/RetryComponentInstall.php b/app/Actions/Component/RetryComponentInstall.php new file mode 100644 index 000000000..ec1d50779 --- /dev/null +++ b/app/Actions/Component/RetryComponentInstall.php @@ -0,0 +1,33 @@ +lockForUpdate()->findOrFail($component->environment->application_id); + $component->refresh(); + if ($component->status !== ComponentStatus::INSTALLATION_FAILED + || $component->environment->status !== EnvironmentStatus::READY + || $component->environment->application->status !== ApplicationStatus::READY) { + throw ValidationException::withMessages(['component' => 'Only a failed installation outside teardown can be retried.']); + } + + $component->status = ComponentStatus::PENDING; + $component->last_error = null; + $component->save(); + InstallComponentJob::dispatch($component->id, $component->server_id)->afterCommit(); + }); + } +} diff --git a/app/Actions/Component/SyncComponentDeployKeys.php b/app/Actions/Component/SyncComponentDeployKeys.php new file mode 100644 index 000000000..62c82edc8 --- /dev/null +++ b/app/Actions/Component/SyncComponentDeployKeys.php @@ -0,0 +1,126 @@ +with('environment.application', 'server')->find($componentId); + if (! $component || $component->status !== $expectedStatus) { + return; + } + + $application = $component->environment->application; + $this->sync($component); + + DB::transaction(function () use ($component, $application, $expectedStatus): void { + $current = Application::query()->lockForUpdate()->findOrFail($application->id); + $component->refresh(); + if ($component->status !== $expectedStatus) { + return; + } + if ($current->source_control_id !== $application->source_control_id || $current->repository !== $application->repository) { + throw new RuntimeException('The application repository changed during key registration. Retry installation.'); + } + if ($expectedStatus === ComponentStatus::READY) { + $component->needs_redeploy = true; + } + $component->status = ComponentStatus::READY; + $component->last_error = null; + $component->save(); + }); + + app(BroadcastComponentUpdate::class)->broadcast($component); + } + + /** + * Persist ownership before remote registration so even a lost response can be reconciled on retry or teardown. + */ + public function sync(Component $component): void + { + $application = $component->environment->application; + $sourceControl = $application->sourceControl ?? throw new RuntimeException('The application source control is unavailable.'); + + if ($component->deploy_key_source_control_id === $sourceControl->id + && $component->deploy_key_repository === $application->repository) { + if ($component->deploy_key_id !== null) { + return; + } + $component->deploy_key_id = $sourceControl->provider()->findDeployKey($application->repository, $component->ssh_key ?? ''); + $component->save(); + if ($component->deploy_key_id !== null) { + return; + } + } else { + $this->remove($component); + } + + if ($sourceControl->isGithubApp()) { + if ($component->ssh_key !== null) { + $this->removeLocal($component); + } + + return; + } + + if ($component->ssh_key === null) { + $component->ssh_key = app(SetupDeployKey::class)->generateKey($component->server, $component->user, 'component_'.$component->id); + } + $component->deploy_key_source_control_id = $sourceControl->id; + $component->deploy_key_repository = $application->repository; + DB::transaction(function () use ($component): void { + Component::query()->lockForUpdate()->findOrFail($component->id); + $component->save(); + }); + + $result = app(SetupDeployKey::class)->setup( + $component->server, $component->user, 'component_'.$component->id, $sourceControl, + $application->repository, 'component_'.$component->id, publicKey: $component->ssh_key, readOnly: true, + ); + if ($result['deploy_key_id'] === null || $result['deploy_key_id'] === '') { + throw new RuntimeException('The source control did not return a deploy key identifier.'); + } + $component->deploy_key_id = $result['deploy_key_id']; + $component->save(); + } + + public function remove(Component $component): void + { + if ($component->deploy_key_id !== null || $component->deploy_key_source_control_id !== null) { + $this->removeProviderKey($component->deploy_key_source_control_id, $component->deploy_key_repository, $component->deploy_key_id, $component->ssh_key); + $component->deploy_key_id = null; + $component->deploy_key_source_control_id = null; + $component->deploy_key_repository = null; + $component->save(); + } + } + + public function removeProviderKey(?int $sourceControlId, ?string $repository, ?string $keyId, ?string $publicKey = null): void + { + $sourceControl = SourceControl::withTrashed()->find($sourceControlId); + if (! $sourceControl || $repository === null) { + throw new RuntimeException('The deploy key source control is unavailable.'); + } + + $keyId ??= $sourceControl->provider()->findDeployKey($repository, $publicKey ?? ''); + if ($keyId !== null) { + $sourceControl->provider()->deleteDeployKey($keyId, $repository); + } + } + + public function removeLocal(Component $component): void + { + $component->server->ssh()->exec(view('ssh.components.delete-key', ['key' => 'component_'.$component->id, 'user' => $component->user]), 'component-delete-key'); + $component->ssh_key = null; + $component->save(); + } +} diff --git a/app/Actions/Environment/CreateEnvironment.php b/app/Actions/Environment/CreateEnvironment.php index aa9a239f4..4bb03f3d8 100644 --- a/app/Actions/Environment/CreateEnvironment.php +++ b/app/Actions/Environment/CreateEnvironment.php @@ -2,10 +2,13 @@ namespace App\Actions\Environment; +use App\Enums\ApplicationStatus; use App\Enums\EnvironmentStatus; use App\Models\Application; use App\Models\Environment; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; class CreateEnvironment { @@ -14,18 +17,24 @@ class CreateEnvironment */ public function create(Application $application, array $input): Environment { - app(ValidateEnvironment::class)->validate($application, $input); + return DB::transaction(function () use ($application, $input): Environment { + $application = Application::query()->lockForUpdate()->findOrFail($application->id); + app(ValidateEnvironment::class)->validate($application, $input); + if ($application->status !== ApplicationStatus::READY) { + throw ValidationException::withMessages(['name' => 'Environments cannot be added during teardown.']); + } - $environment = new Environment([ - 'application_id' => $application->id, - 'name' => $input['name'], - 'slug' => Str::slug($input['name']), - 'branch' => $input['branch'], - 'runtime_version' => $input['runtime_version'] ?? null, - ]); - $environment->status = EnvironmentStatus::READY; - $environment->save(); + $environment = new Environment([ + 'application_id' => $application->id, + 'name' => $input['name'], + 'branch' => $input['branch'], + 'runtime_version' => $input['runtime_version'] ?? null, + ]); + $environment->slug = Str::slug($input['name']); + $environment->status = EnvironmentStatus::READY; + $environment->save(); - return $environment; + return $environment; + }); } } diff --git a/app/Actions/Environment/DeleteEnvironment.php b/app/Actions/Environment/DeleteEnvironment.php index 31d3e4916..b686f1073 100644 --- a/app/Actions/Environment/DeleteEnvironment.php +++ b/app/Actions/Environment/DeleteEnvironment.php @@ -2,9 +2,22 @@ namespace App\Actions\Environment; +use App\DTOs\SocketEventDTO; +use App\Enums\ApplicationStatus; +use App\Enums\ComponentStatus; +use App\Enums\EnvironmentStatus; +use App\Events\SocketEvent; +use App\Jobs\Component\DeleteComponentJob; +use App\Jobs\Environment\DeleteEnvironmentJob; +use App\Models\Application; use App\Models\Environment; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; +use RuntimeException; +use Throwable; class DeleteEnvironment { @@ -13,10 +26,88 @@ class DeleteEnvironment */ public function delete(Environment $environment, array $input): void { - Validator::make($input, [ - 'name' => ['required', 'string', Rule::in([$environment->name])], - ])->validate(); + Validator::make($input, ['name' => ['required', 'string', Rule::in([$environment->name])]])->validate(); + DB::transaction(function () use ($environment): void { + $application = Application::query()->lockForUpdate()->findOrFail($environment->application_id); + $environment->refresh(); + if ($environment->status === EnvironmentStatus::DELETING || $application->status !== ApplicationStatus::READY) { + throw ValidationException::withMessages(['name' => 'Teardown is already in progress. Retry it from the application if necessary.']); + } + + if (! $environment->components()->exists()) { + $environment->delete(); + $this->broadcast($environment, true); + + return; + } + + $jobs = $this->jobs($environment); + $id = $environment->id; + DB::afterCommit(static function () use ($jobs, $id): void { + Bus::chain($jobs)->onQueue('ssh')->catch(static function (Throwable $e) use ($id): void { + app(self::class)->failed($id); + })->dispatch(); + }); + }); + } + + /** + * @return list + */ + public function jobs(Environment $environment): array + { + $environment->status = EnvironmentStatus::DELETING; + $environment->last_error = null; + $environment->save(); + $jobs = []; + foreach ($environment->components()->orderBy('id')->get() as $component) { + $component->status = ComponentStatus::DELETING; + $component->last_error = null; + $component->save(); + $jobs[] = new DeleteComponentJob($component->id, $component->server_id); + } + $this->broadcast($environment); + $jobs[] = new DeleteEnvironmentJob($environment->id); + + return $jobs; + } + + public function handle(int $environmentId): void + { + $environment = Environment::query()->find($environmentId); + if (! $environment || $environment->status !== EnvironmentStatus::DELETING) { + return; + } + if ($environment->components()->exists()) { + throw new RuntimeException('Component teardown is incomplete.'); + } $environment->delete(); + $this->broadcast($environment, true); + } + + public function failed(int $environmentId): void + { + $environment = Environment::query()->find($environmentId); + if (! $environment) { + return; + } + $environment->status = EnvironmentStatus::DELETION_FAILED; + $environment->last_error = 'Environment teardown failed. Resolve the failed component and retry deletion.'; + $environment->save(); + $this->broadcast($environment); + } + + private function broadcast(Environment $environment, bool $deleted = false): void + { + $event = new SocketEventDTO( + projectId: $environment->application->project_id, + type: $deleted ? 'environment.deleted' : 'environment.updated', + data: [ + 'id' => $environment->id, 'application_id' => $environment->application_id, + 'status' => $environment->status->getText(), 'application_status' => $environment->application->status->getText(), + ], + ); + DB::afterCommit(static fn () => SocketEvent::dispatch($event)); } } diff --git a/app/Actions/Environment/UpdateEnvironment.php b/app/Actions/Environment/UpdateEnvironment.php index 0b9eba814..b34e0fc88 100644 --- a/app/Actions/Environment/UpdateEnvironment.php +++ b/app/Actions/Environment/UpdateEnvironment.php @@ -2,7 +2,16 @@ namespace App\Actions\Environment; +use App\Enums\ApplicationStatus; +use App\Enums\ComponentStatus; +use App\Enums\ComponentType; +use App\Enums\EnvironmentStatus; +use App\Models\Application; use App\Models\Environment; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; class UpdateEnvironment { @@ -11,14 +20,36 @@ class UpdateEnvironment */ public function update(Environment $environment, array $input): Environment { - app(ValidateEnvironment::class)->validate($environment->application, $input, $environment); + return DB::transaction(function () use ($environment, $input): Environment { + $application = Application::query()->lockForUpdate()->findOrFail($environment->application_id); + $environment = Environment::query()->lockForUpdate()->findOrFail($environment->id); + app(ValidateEnvironment::class)->validate($application, $input, $environment); - $environment->update([ - 'name' => $input['name'], - 'branch' => $input['branch'], - 'runtime_version' => $input['runtime_version'] ?? null, - ]); + if ($application->status !== ApplicationStatus::READY || $environment->status !== EnvironmentStatus::READY) { + throw ValidationException::withMessages(['name' => 'The environment cannot be edited during teardown.']); + } - return $environment; + if (($input['runtime_version'] ?? null) !== $environment->runtime_version && $environment->components()->exists()) { + throw ValidationException::withMessages(['runtime_version' => 'The runtime version cannot change while the environment has components.']); + } + + Validator::make($input, [ + 'primary_component_id' => ['sometimes', 'nullable', 'integer', Rule::exists('components', 'id') + ->where('environment_id', $environment->id)->where('type', ComponentType::APP->value) + ->whereNotIn('status', [ComponentStatus::DELETING->value, ComponentStatus::DELETION_FAILED->value])], + ])->validate(); + + $environment->fill([ + 'name' => $input['name'], + 'branch' => $input['branch'], + 'runtime_version' => $input['runtime_version'] ?? null, + ]); + if (array_key_exists('primary_component_id', $input)) { + $environment->primary_component_id = $input['primary_component_id']; + } + $environment->save(); + + return $environment; + }); } } diff --git a/app/Actions/PHP/EnsureFpmPool.php b/app/Actions/PHP/EnsureFpmPool.php index 8d7decac0..c3602d5ae 100644 --- a/app/Actions/PHP/EnsureFpmPool.php +++ b/app/Actions/PHP/EnsureFpmPool.php @@ -16,7 +16,7 @@ class EnsureFpmPool */ public function ensure(Server $server, string $user, string $phpVersion): void { - $service = $server->php($phpVersion); + $service = $server->services()->where('type', 'php')->where('version', $phpVersion)->first(); if (! $service instanceof Service) { throw new RuntimeException('PHP service not found'); } diff --git a/app/Actions/Server/TransferServer.php b/app/Actions/Server/TransferServer.php index 06d38050f..6920903bf 100755 --- a/app/Actions/Server/TransferServer.php +++ b/app/Actions/Server/TransferServer.php @@ -7,6 +7,7 @@ use App\Models\Project; use App\Models\Server; use App\Models\User; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; @@ -26,29 +27,33 @@ public function transfer(User $user, Server $server, array $input): Server $project = Project::query()->findOrFail($input['project_id']); Gate::forUser($user)->authorize('create', [Server::class, $project]); - if ($server->project_id !== $project->id - && NetworkServer::query()->where('server_id', $server->id)->exists()) { - throw ValidationException::withMessages([ - 'project_id' => 'Remove the server from its networks and wait for removal to complete before transferring it.', - ]); - } + return DB::transaction(function () use ($server, $project): Server { + $server = Server::query()->lockForUpdate()->findOrFail($server->id); - $environments = $server->components()->with('environment.application')->get() - ->map(fn (Component $component): string => $component->environment->application->name.' / '.$component->environment->name) - ->unique(); + if ($server->project_id !== $project->id + && NetworkServer::query()->where('server_id', $server->id)->exists()) { + throw ValidationException::withMessages([ + 'project_id' => 'Remove the server from its networks and wait for removal to complete before transferring it.', + ]); + } - if ($environments->isNotEmpty()) { - throw ValidationException::withMessages([ - 'project_id' => __('This server hosts components in the following environments: :environments. Remove them before transferring it.', [ - 'environments' => $environments->implode(', '), - ]), - ]); - } + $environments = $server->components()->with('environment.application')->get() + ->map(fn (Component $component): string => $component->environment->application->name.' / '.$component->environment->name) + ->unique(); - $server->project_id = $project->id; - $server->save(); + if ($environments->isNotEmpty()) { + throw ValidationException::withMessages([ + 'project_id' => __('This server hosts components in the following environments: :environments. Remove them before transferring it.', [ + 'environments' => $environments->implode(', '), + ]), + ]); + } - return $server; + $server->project_id = $project->id; + $server->save(); + + return $server; + }); } private function validate(User $user, array $input): void diff --git a/app/Actions/Site/CreateSite.php b/app/Actions/Site/CreateSite.php index 1624a4394..e840a1b19 100755 --- a/app/Actions/Site/CreateSite.php +++ b/app/Actions/Site/CreateSite.php @@ -20,6 +20,7 @@ use App\ValidationRules\DomainRule; use Exception; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; @@ -43,8 +44,16 @@ public function create(Server $server, array $input, User $actor): Site $this->validate($server, $input); - DB::beginTransaction(); + $lock = $server->isolatedUserLock($input['user']); try { + $lock->block(30); + } catch (LockTimeoutException) { + throw ValidationException::withMessages(['user' => 'Another operation on this Linux user is in progress. Please retry.']); + } + + try { + DB::beginTransaction(); + $input = $this->lockRuntimeVersionsToExistingUser($server, $input); $user = $input['user']; $isolatedUser = $user !== $server->getSshUser() @@ -129,10 +138,6 @@ public function create(Server $server, array $input, User $actor): Site $site->commands()->createMany($site->type()->baseCommands()); DB::commit(); - - dispatch(new CreateJob($site)); - - return $site; } catch (Exception $e) { DB::rollBack(); if ($e instanceof AuthorizationException || $e instanceof RequestException) { @@ -142,7 +147,13 @@ public function create(Server $server, array $input, User $actor): Site throw ValidationException::withMessages([ 'type' => $e->getMessage(), ]); + } finally { + $lock->release(); } + + dispatch(new CreateJob($site)); + + return $site; } private function validate(Server $server, array $input): void diff --git a/app/Actions/Site/Tooling/AssertToolingNotUsedByComponents.php b/app/Actions/Site/Tooling/AssertToolingNotUsedByComponents.php new file mode 100644 index 000000000..8783bd717 --- /dev/null +++ b/app/Actions/Site/Tooling/AssertToolingNotUsedByComponents.php @@ -0,0 +1,24 @@ +where('server_id', $site->server_id)->where('user', $site->user) + ->with('environment.application')->get() + ->contains(fn (Component $component): bool => $component->environment->application->preset()->tooling() === $toolId)) { + $label = ToolingRegistry::find($toolId)?->label() ?? $toolId; + + throw ValidationException::withMessages([ + 'tool' => "{$label} is required by a component sharing this isolated user and cannot be uninstalled.", + ]); + } + } +} diff --git a/app/Actions/Site/Tooling/UninstallSiteTooling.php b/app/Actions/Site/Tooling/UninstallSiteTooling.php index 84ba25517..ed9225a5c 100644 --- a/app/Actions/Site/Tooling/UninstallSiteTooling.php +++ b/app/Actions/Site/Tooling/UninstallSiteTooling.php @@ -4,7 +4,6 @@ use App\DTOs\ToolingTargetDTO; use App\Jobs\Site\Tooling\UninstallSiteToolingJob; -use App\Models\Component; use App\Models\IsolatedUser; use App\Models\Site; use App\Tooling\SiteToolingState; @@ -42,12 +41,7 @@ public function uninstall(Site $site, string $toolId): void ]); } - if ($iuser->components()->with('environment.application')->get() - ->contains(fn (Component $component): bool => $component->environment->application->preset()->tooling() === $toolId)) { - throw ValidationException::withMessages([ - 'tool' => "{$tool::label()} is required by a component sharing this isolated user and cannot be uninstalled.", - ]); - } + app(AssertToolingNotUsedByComponents::class)->assert($site, $toolId); $current = $iuser->toolingStatus($toolId); if ($current === SiteToolingState::STATUS_INSTALLING || $current === SiteToolingState::STATUS_UNINSTALLING) { diff --git a/app/Actions/SourceControl/SetupDeployKey.php b/app/Actions/SourceControl/SetupDeployKey.php index 9da2bfbcb..eefa17945 100644 --- a/app/Actions/SourceControl/SetupDeployKey.php +++ b/app/Actions/SourceControl/SetupDeployKey.php @@ -24,6 +24,7 @@ public function setup( string $title, ?string $publicKey = null, ?int $siteId = null, + bool $readOnly = false, ): array { if ($sourceControl->isGithubApp()) { return ['public_key' => null, 'deploy_key_id' => null]; @@ -33,7 +34,7 @@ public function setup( return [ 'public_key' => $publicKey, - 'deploy_key_id' => $sourceControl->provider()->deployKey($title, $repository, $publicKey), + 'deploy_key_id' => $sourceControl->provider()->deployKey($title, $repository, $publicKey, $readOnly), ]; } diff --git a/app/DTOs/ToolingTargetDTO.php b/app/DTOs/ToolingTargetDTO.php index a0e41ced8..9771fc8ac 100644 --- a/app/DTOs/ToolingTargetDTO.php +++ b/app/DTOs/ToolingTargetDTO.php @@ -14,6 +14,7 @@ public function __construct( public ?IsolatedUser $isolatedUser = null, public ?string $phpVersion = null, public ?int $siteId = null, + public ?string $configDir = null, ) {} public static function forSite(Site $site): self diff --git a/app/Enums/ApplicationStatus.php b/app/Enums/ApplicationStatus.php new file mode 100644 index 000000000..0c43a1e9a --- /dev/null +++ b/app/Enums/ApplicationStatus.php @@ -0,0 +1,26 @@ + 'success', + self::DELETING, + self::DELETION_FAILED => 'danger', + }; + } + + public function getText(): string + { + return $this->value; + } +} diff --git a/app/Http/Controllers/Applications/ApplicationController.php b/app/Http/Controllers/Applications/ApplicationController.php index bd3f9ce67..3e9d1085a 100644 --- a/app/Http/Controllers/Applications/ApplicationController.php +++ b/app/Http/Controllers/Applications/ApplicationController.php @@ -57,7 +57,7 @@ public function show(Application $application): Response $this->authorize('view', $application); return Inertia::render('applications/show', [ - 'environments' => EnvironmentResource::collection($application->environments()->orderBy('name')->get()), + 'environments' => EnvironmentResource::collection($application->environments()->withCount('components')->orderBy('name')->get()), 'sourceControls' => SourceControlResource::collection(SourceControl::getByProjectId($application->project_id, user())->get()), 'can' => [ 'update' => user()->can('update', $application), @@ -74,7 +74,7 @@ public function update(Request $request, Application $application): RedirectResp app(UpdateApplication::class)->update($application, $request->all(), user()); - return back()->with('success', 'Application updated. Changes apply on the next deploy.'); + return back()->with('success', 'Application updated. Repository changes sync component deploy keys now and apply to the running release on the next deploy.'); } #[Delete('/{application}', name: 'applications.destroy')] @@ -84,6 +84,6 @@ public function destroy(Request $request, Application $application): RedirectRes app(DeleteApplication::class)->delete($application, $request->all()); - return to_route('applications')->with('success', 'Application deleted.'); + return to_route('applications')->with('success', 'Application deletion requested. Components are cleaned up before the application is removed.'); } } diff --git a/app/Http/Controllers/Applications/ComponentController.php b/app/Http/Controllers/Applications/ComponentController.php new file mode 100644 index 000000000..7caff27e5 --- /dev/null +++ b/app/Http/Controllers/Applications/ComponentController.php @@ -0,0 +1,87 @@ +authorize('viewAny', [Component::class, $environment, $application]); + + return Inertia::render('applications/environments/components', [ + 'components' => ComponentTable::make($environment->components())->simplePaginate(), + 'options' => app(GetComponentOptions::class)->get($environment), + 'canCreate' => user()->can('create', [Component::class, $environment, $application]), + ]); + } + + #[Post('/', name: 'components.store')] + public function store(Request $request, Application $application, Environment $environment): RedirectResponse + { + $this->authorize('create', [Component::class, $environment, $application]); + + $component = app(CreateComponent::class)->create($environment, $request->all()); + + return to_route('components.show', [$application, $environment, $component])->with('success', 'Component installation queued.'); + } + + #[Get('/{component}', name: 'components.show')] + public function show(Application $application, Environment $environment, Component $component): Response + { + $this->authorize('view', [$component, $environment, $application]); + + return Inertia::render('applications/environments/component', [ + 'component' => new ComponentResource($component->load('server', 'capabilities')), + 'primaryCandidates' => app(GetComponentOptions::class)->primaryCandidates($environment), + 'can' => [ + 'update' => user()->can('update', [$component, $environment, $application]), + 'delete' => user()->can('delete', [$component, $environment, $application]), + ], + ]); + } + + #[Post('/{component}/retry', name: 'components.retry')] + public function retry(Application $application, Environment $environment, Component $component): RedirectResponse + { + $this->authorize('update', [$component, $environment, $application]); + + app(RetryComponentInstall::class)->retry($component); + + return back()->with('success', 'Component installation queued.'); + } + + #[Delete('/{component}', name: 'components.destroy')] + public function destroy(Request $request, Application $application, Environment $environment, Component $component): RedirectResponse + { + $this->authorize('delete', [$component, $environment, $application]); + + app(DeleteComponent::class)->delete($component, $request->all()); + + return to_route('components.index', [$application, $environment])->with('success', 'Component deletion queued.'); + } +} diff --git a/app/Http/Controllers/Applications/EnvironmentController.php b/app/Http/Controllers/Applications/EnvironmentController.php index e77c234a1..7bed43198 100644 --- a/app/Http/Controllers/Applications/EnvironmentController.php +++ b/app/Http/Controllers/Applications/EnvironmentController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Applications; +use App\Actions\Component\GetComponentOptions; use App\Actions\Environment\CreateEnvironment; use App\Actions\Environment\DeleteEnvironment; use App\Actions\Environment\UpdateEnvironment; @@ -41,6 +42,7 @@ public function show(Application $application, Environment $environment): Respon $this->authorize('view', [$environment, $application]); return Inertia::render('applications/environments/show', [ + 'primaryCandidates' => app(GetComponentOptions::class)->primaryCandidates($environment), 'can' => [ 'update' => user()->can('update', [$environment, $application]), 'delete' => user()->can('delete', [$environment, $application]), @@ -65,6 +67,6 @@ public function destroy(Request $request, Application $application, Environment app(DeleteEnvironment::class)->delete($environment, $request->all()); - return to_route('applications.show', $application)->with('success', 'Environment deleted.'); + return to_route('applications.show', $application)->with('success', 'Environment deletion requested. Components are cleaned up before the environment is removed.'); } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index a9d6acb57..3ec5908ea 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -115,7 +115,7 @@ public function share(Request $request): array /** @var Environment $environment */ $environment = $request->route('environment'); if ($user->can('view', [$environment, $application])) { - $data['environment'] = fn () => EnvironmentResource::make($environment); + $data['environment'] = fn () => EnvironmentResource::make($environment->loadCount('components')); } } } diff --git a/app/Http/Resources/ApplicationResource.php b/app/Http/Resources/ApplicationResource.php index 0cdc98bb3..208024926 100644 --- a/app/Http/Resources/ApplicationResource.php +++ b/app/Http/Resources/ApplicationResource.php @@ -21,6 +21,8 @@ public function toArray(Request $request): array 'name' => $this->name, 'repository' => $this->repository, 'runtime' => $this->runtime, + 'status' => $this->status->getText(), + 'status_color' => $this->status->getColor(), 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/app/Http/Resources/ComponentResource.php b/app/Http/Resources/ComponentResource.php new file mode 100644 index 000000000..3b10d2fb9 --- /dev/null +++ b/app/Http/Resources/ComponentResource.php @@ -0,0 +1,39 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'environment_id' => $this->environment_id, + 'environment_status' => $this->whenLoaded('environment', fn (): string => $this->environment->status->getText()), + 'application_status' => $this->whenLoaded('environment', fn (): string => $this->environment->application->status->getText()), + 'server_id' => $this->server_id, + 'server_name' => $this->whenLoaded('server', fn (): string => $this->server->name), + 'name' => $this->name, + 'type' => $this->type->getText(), + 'type_color' => $this->type->getColor(), + 'user' => $this->user, + 'path' => $this->path, + 'status' => $this->status->getText(), + 'status_color' => $this->status->getColor(), + 'last_error' => $this->last_error, + 'needs_redeploy' => $this->needs_redeploy, + 'ssh_key' => $this->ssh_key, + 'capabilities' => $this->whenLoaded('capabilities', fn (): array => $this->capabilities->pluck('capability')->all()), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/EnvironmentResource.php b/app/Http/Resources/EnvironmentResource.php index a6c30a88b..b059cd866 100644 --- a/app/Http/Resources/EnvironmentResource.php +++ b/app/Http/Resources/EnvironmentResource.php @@ -21,6 +21,9 @@ public function toArray(Request $request): array 'slug' => $this->slug, 'branch' => $this->branch, 'runtime_version' => $this->runtime_version, + 'primary_component_id' => $this->primary_component_id, + 'components_count' => $this->whenCounted('components'), + 'last_error' => $this->last_error, 'status' => $this->status->getText(), 'status_color' => $this->status->getColor(), 'created_at' => $this->created_at, diff --git a/app/Jobs/Application/DeleteApplicationJob.php b/app/Jobs/Application/DeleteApplicationJob.php new file mode 100644 index 000000000..d7c41074d --- /dev/null +++ b/app/Jobs/Application/DeleteApplicationJob.php @@ -0,0 +1,27 @@ +run("application-{$this->applicationId}", fn () => app(DeleteApplication::class)->handle($this->applicationId)); + } + + public function failed(Exception $e): void + { + app(DeleteApplication::class)->failed($this->applicationId); + } +} diff --git a/app/Jobs/Component/DeleteComponentJob.php b/app/Jobs/Component/DeleteComponentJob.php new file mode 100644 index 000000000..f08be0308 --- /dev/null +++ b/app/Jobs/Component/DeleteComponentJob.php @@ -0,0 +1,30 @@ +onQueue('ssh'); + } + + public function handle(): void + { + $this->run("server-{$this->serverId}", fn () => app(DeleteComponent::class)->handle($this->componentId)); + } + + public function failed(Exception $e): void + { + app(DeleteComponent::class)->failed($this->componentId); + } +} diff --git a/app/Jobs/Component/DeletePurgedComponentDeployKeyJob.php b/app/Jobs/Component/DeletePurgedComponentDeployKeyJob.php new file mode 100644 index 000000000..7bce4cc24 --- /dev/null +++ b/app/Jobs/Component/DeletePurgedComponentDeployKeyJob.php @@ -0,0 +1,36 @@ +run("server-{$this->serverId}", fn () => app(SyncComponentDeployKeys::class)->removeProviderKey($this->sourceControlId, $this->repository, $this->keyId, $this->publicKey)); + } + + public function failed(Exception $e): void + { + app(PurgeServerComponents::class)->failed($this->environmentId); + } +} diff --git a/app/Jobs/Component/InstallComponentJob.php b/app/Jobs/Component/InstallComponentJob.php new file mode 100644 index 000000000..0bbced779 --- /dev/null +++ b/app/Jobs/Component/InstallComponentJob.php @@ -0,0 +1,30 @@ +onQueue('ssh'); + } + + public function handle(): void + { + $this->run("server-{$this->serverId}", fn () => app(InstallComponent::class)->install($this->componentId)); + } + + public function failed(Exception $e): void + { + app(InstallComponent::class)->failed($this->componentId); + } +} diff --git a/app/Jobs/Component/SyncComponentDeployKeysJob.php b/app/Jobs/Component/SyncComponentDeployKeysJob.php new file mode 100644 index 000000000..ee8edd883 --- /dev/null +++ b/app/Jobs/Component/SyncComponentDeployKeysJob.php @@ -0,0 +1,31 @@ +onQueue('ssh'); + } + + public function handle(): void + { + $this->run("server-{$this->serverId}", fn () => app(SyncComponentDeployKeys::class)->handle($this->componentId)); + } + + public function failed(Exception $e): void + { + app(InstallComponent::class)->failed($this->componentId); + } +} diff --git a/app/Jobs/Environment/DeleteEnvironmentJob.php b/app/Jobs/Environment/DeleteEnvironmentJob.php new file mode 100644 index 000000000..4d1cfe8f1 --- /dev/null +++ b/app/Jobs/Environment/DeleteEnvironmentJob.php @@ -0,0 +1,27 @@ +run("environment-{$this->environmentId}", fn () => app(DeleteEnvironment::class)->handle($this->environmentId)); + } + + public function failed(Exception $e): void + { + app(DeleteEnvironment::class)->failed($this->environmentId); + } +} diff --git a/app/Jobs/Site/Tooling/UninstallSiteToolingJob.php b/app/Jobs/Site/Tooling/UninstallSiteToolingJob.php index 2ade2a4b9..22894784b 100644 --- a/app/Jobs/Site/Tooling/UninstallSiteToolingJob.php +++ b/app/Jobs/Site/Tooling/UninstallSiteToolingJob.php @@ -2,6 +2,7 @@ namespace App\Jobs\Site\Tooling; +use App\Actions\Site\Tooling\AssertToolingNotUsedByComponents; use App\DTOs\ToolingTargetDTO; use App\Jobs\Worker\RefreshSiteWorkerConfigsJob; use App\Models\ServerLog; @@ -43,6 +44,8 @@ public function handle(): void throw new RuntimeException("Tooling '{$this->toolId}' is not registered; it may have been removed or the worker is out of sync."); } + app(AssertToolingNotUsedByComponents::class)->assert($this->site->refresh(), $this->toolId); + $tool->uninstall(ToolingTargetDTO::forSite($this->site)); SiteToolingState::completeUninstall($this->site, $this->toolId); diff --git a/app/Models/Application.php b/app/Models/Application.php index 6bdc1d6e1..c7991688b 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\ApplicationStatus; use App\RuntimePresets\AbstractRuntimePreset; use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -16,6 +17,7 @@ * @property string $name * @property string $repository * @property string $runtime + * @property ApplicationStatus $status * @property Project $project * @property ?SourceControl $sourceControl * @property Collection $environments @@ -36,6 +38,7 @@ class Application extends AbstractModel protected $casts = [ 'project_id' => 'integer', 'source_control_id' => 'integer', + 'status' => ApplicationStatus::class, ]; public function preset(): AbstractRuntimePreset diff --git a/app/Models/Component.php b/app/Models/Component.php index f3c249edc..65c9ecc68 100644 --- a/app/Models/Component.php +++ b/app/Models/Component.php @@ -21,6 +21,10 @@ * @property ComponentStatus $status * @property ?string $last_error * @property bool $needs_redeploy + * @property ?string $deploy_key_id + * @property ?string $ssh_key + * @property ?int $deploy_key_source_control_id + * @property ?string $deploy_key_repository * @property Environment $environment * @property Server $server * @property ?IsolatedUser $isolatedUser @@ -38,7 +42,6 @@ class Component extends AbstractModel protected $fillable = [ 'environment_id', 'server_id', - 'isolated_user_id', 'user', 'name', 'type', @@ -48,6 +51,7 @@ class Component extends AbstractModel 'environment_id' => 'integer', 'server_id' => 'integer', 'isolated_user_id' => 'integer', + 'deploy_key_source_control_id' => 'integer', 'type' => ComponentType::class, 'status' => ComponentStatus::class, 'needs_redeploy' => 'boolean', diff --git a/app/Models/Environment.php b/app/Models/Environment.php index dea6ddff4..876707216 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -16,6 +16,8 @@ * @property string $branch * @property ?string $runtime_version * @property ?int $primary_component_id + * @property bool $primary_component_initialized + * @property ?string $last_error * @property EnvironmentStatus $status * @property Application $application * @property ?Component $primaryComponent @@ -30,15 +32,14 @@ class Environment extends AbstractModel protected $fillable = [ 'application_id', 'name', - 'slug', 'branch', 'runtime_version', - 'primary_component_id', ]; protected $casts = [ 'application_id' => 'integer', 'primary_component_id' => 'integer', + 'primary_component_initialized' => 'boolean', 'status' => EnvironmentStatus::class, ]; diff --git a/app/Models/Server.php b/app/Models/Server.php index fd0555954..ae26d1415 100755 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -135,8 +135,8 @@ class Server extends AbstractModel */ protected array $networkDeparture = ['members' => [], 'networks' => []]; - /** @var array */ - protected array $componentDeparture = []; + /** @var array{environments: list, keys: list} */ + protected array $componentDeparture = ['environments' => [], 'keys' => []]; public static function boot(): void { diff --git a/app/SSH/Mise/Mise.php b/app/SSH/Mise/Mise.php index 02a4ea5be..61fc81b6a 100644 --- a/app/SSH/Mise/Mise.php +++ b/app/SSH/Mise/Mise.php @@ -5,6 +5,7 @@ use App\DTOs\ToolingTargetDTO; use App\Exceptions\SSHError; use App\Models\Server; +use InvalidArgumentException; class Mise { @@ -36,10 +37,13 @@ public function isInstalled(): bool */ public function installRuntime(ToolingTargetDTO $target, string $runtime, string $version): void { + $this->assertTarget($target); + $this->server->ssh($target->user)->exec( view('ssh.mise.install-runtime', [ 'runtime' => $runtime, 'version' => $version, + 'configDir' => $target->configDir, ]), 'mise-install-'.$runtime.'-'.$version, $target->siteId @@ -51,6 +55,8 @@ public function installRuntime(ToolingTargetDTO $target, string $runtime, string */ public function uninstallRuntime(ToolingTargetDTO $target, string $runtime): void { + $this->assertTarget($target); + $this->server->ssh($target->user)->exec( view('ssh.mise.uninstall-runtime', [ 'runtime' => $runtime, @@ -59,4 +65,11 @@ public function uninstallRuntime(ToolingTargetDTO $target, string $runtime): voi $target->siteId ); } + + private function assertTarget(ToolingTargetDTO $target): void + { + if ($target->server->id !== $this->server->id) { + throw new InvalidArgumentException('The tooling target must belong to the bound server.'); + } + } } diff --git a/app/SourceControlProviders/AbstractSourceControlProvider.php b/app/SourceControlProviders/AbstractSourceControlProvider.php index 9c7c8f21a..1193340f3 100755 --- a/app/SourceControlProviders/AbstractSourceControlProvider.php +++ b/app/SourceControlProviders/AbstractSourceControlProvider.php @@ -2,12 +2,17 @@ namespace App\SourceControlProviders; +use App\Exceptions\FailedToDeployGitKey; use App\Exceptions\RepositoryNotFound; use App\Exceptions\RepositoryPermissionDenied; use App\Exceptions\SourceControlIsNotConnected; use App\Models\SourceControl; +use Closure; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\Response; +use Throwable; abstract class AbstractSourceControlProvider implements SourceControlProvider { @@ -65,6 +70,72 @@ protected function handleResponseErrors(Response $res, string $repo): void $res->throw(); } + /** + * @param Closure(): PendingRequest $client + * @param array{key: string, title?: string, label?: string, read_only?: bool, can_push?: bool} $payload + * + * @throws FailedToDeployGitKey + */ + protected function registerDeployKey(Closure $client, string $url, array $payload, string $pageSizeParameter = 'per_page', ?string $collectionKey = null): string + { + try { + try { + $response = $client()->post($url, $payload); + } catch (ConnectionException) { + $response = null; + } + $id = $response?->json('id'); + if ($response?->successful() && (is_int($id) || is_string($id)) && (string) $id !== '') { + return (string) $id; + } + + return $this->lookupDeployKey($client, $url, $payload['key'], $pageSizeParameter, $collectionKey) + ?? throw new FailedToDeployGitKey('Unable to register the source-control deploy key.'); + } catch (Throwable) { + throw new FailedToDeployGitKey('Unable to register the source-control deploy key.'); + } + } + + /** + * @param Closure(): PendingRequest $client + * + * @throws FailedToDeployGitKey + */ + protected function lookupDeployKey(Closure $client, string $url, string $publicKey, string $pageSizeParameter = 'per_page', ?string $collectionKey = null): ?string + { + try { + $request = $client(); + $key = array_slice(preg_split('/\s+/', trim($publicKey)) ?: [], 0, 2); + if (count($key) !== 2) { + throw new FailedToDeployGitKey('Invalid deploy public key.'); + } + for ($page = 1; $page <= 25; $page++) { + $response = $request->get($url, ['page' => $page, $pageSizeParameter => 100])->throw(); + $keys = $response->json($collectionKey); + if (! is_array($keys) || ! array_is_list($keys)) { + throw new FailedToDeployGitKey('Invalid deploy-key response.'); + } + foreach ($keys as $existing) { + $candidate = array_slice(preg_split('/\s+/', trim($existing['key'] ?? '')) ?: [], 0, 2); + $id = $existing['id'] ?? null; + if (count($candidate) !== 2 || (! is_int($id) && ! is_string($id)) || (string) $id === '') { + throw new FailedToDeployGitKey('Invalid deploy-key response.'); + } + if ($candidate === $key) { + return (string) $id; + } + } + if ($keys === []) { + return null; + } + } + } catch (Throwable) { + throw new FailedToDeployGitKey('Unable to look up the source-control deploy key.'); + } + + throw new FailedToDeployGitKey('Unable to look up the source-control deploy key.'); + } + public function getWebhookBranch(array $payload): string { return str($payload['ref'] ?? '')->after('refs/heads/')->toString(); diff --git a/app/SourceControlProviders/Bitbucket.php b/app/SourceControlProviders/Bitbucket.php index a807b10c4..f4e73cbe2 100755 --- a/app/SourceControlProviders/Bitbucket.php +++ b/app/SourceControlProviders/Bitbucket.php @@ -151,25 +151,18 @@ public function getLastCommit(string $repo, string $branch): ?array /** * @throws FailedToDeployGitKey */ - public function deployKey(string $title, string $repo, string $key): string + public function deployKey(string $title, string $repo, string $key, bool $readOnly = false): string { - try { - $res = Http::withHeaders($this->getAuthenticationHeaders())->post( - $this->apiUrl."/repositories/$repo/deploy-keys", - [ - 'label' => $title, - 'key' => $key, - ] - ); - - if ($res->status() != 200) { - throw new FailedToDeployGitKey($res->json()['error']['message']); - } + return $this->registerDeployKey( + fn () => Http::withHeaders($this->getAuthenticationHeaders()), + $this->apiUrl."/repositories/$repo/deploy-keys", + ['label' => $title, 'key' => $key], pageSizeParameter: 'pagelen', collectionKey: 'values', + ); + } - return $res->json()['id'] ?? ''; - } catch (Exception $e) { - throw new FailedToDeployGitKey($e->getMessage()); - } + public function findDeployKey(string $repo, string $key): ?string + { + return $this->lookupDeployKey(fn () => Http::withHeaders($this->getAuthenticationHeaders()), $this->apiUrl."/repositories/$repo/deploy-keys", $key, pageSizeParameter: 'pagelen', collectionKey: 'values'); } public function deleteDeployKey(string $keyId, string $repo): void @@ -178,20 +171,11 @@ public function deleteDeployKey(string $keyId, string $repo): void $response = Http::withHeaders($this->getAuthenticationHeaders()) ->delete($this->apiUrl."/repositories/$repo/deploy-keys/$keyId"); - if (! $response->successful()) { - Log::warning('Failed to delete Bitbucket deploy key', [ - 'repo' => $repo, - 'key_id' => $keyId, - 'response' => $response->body(), - ]); + if (! $response->successful() && $response->status() !== 404) { + throw new \RuntimeException('Deploy key removal was rejected by the source control.'); } - - } catch (Throwable $e) { - Log::error('Error deleting Bitbucket deploy key', [ - 'repo' => $repo, - 'key_id' => $keyId, - 'error' => $e->getMessage(), - ]); + } catch (\Throwable) { + throw new \RuntimeException('Unable to remove the source-control deploy key.'); } } diff --git a/app/SourceControlProviders/BitbucketV2.php b/app/SourceControlProviders/BitbucketV2.php index 484e6d2a2..2d8e2b0cd 100644 --- a/app/SourceControlProviders/BitbucketV2.php +++ b/app/SourceControlProviders/BitbucketV2.php @@ -90,22 +90,13 @@ private function getAccessTokenWithClientCredentials(string $key, string $secret return $tokenData['access_token'] ?? null; } - $errorBody = $response->json(); - $errorMessage = $errorBody['error_description'] ?? $errorBody['error'] ?? $errorBody['message'] ?? $response->body(); - Log::error('Failed to get Bitbucket access token with client credentials', [ 'status' => $response->status(), - 'body' => $response->body(), - 'error' => $errorMessage, ]); - throw new Exception($errorMessage); - } catch (Exception $e) { - Log::error('Error getting Bitbucket access token with client credentials', [ - 'error' => $e->getMessage(), - ]); - - throw $e; + throw new Exception('Unable to authenticate with Bitbucket.'); + } catch (Throwable) { + throw new Exception('Unable to authenticate with Bitbucket.'); } } @@ -246,27 +237,18 @@ public function getLastCommit(string $repo, string $branch): ?array /** * @throws FailedToDeployGitKey */ - public function deployKey(string $title, string $repo, string $key): string + public function deployKey(string $title, string $repo, string $key, bool $readOnly = false): string { - try { - $res = Http::withHeaders($this->getAuthenticationHeaders())->post( - $this->apiUrl."/repositories/$repo/deploy-keys", - [ - 'label' => $title, - 'key' => $key, - ] - ); - - if ($res->status() !== 200) { - $errorBody = $res->json(); - $errorMessage = $errorBody['error']['message'] ?? $errorBody['error_description'] ?? $errorBody['error'] ?? $errorBody['message'] ?? $res->body(); - throw new FailedToDeployGitKey($errorMessage); - } + return $this->registerDeployKey( + fn () => Http::withHeaders($this->getAuthenticationHeaders()), + $this->apiUrl."/repositories/$repo/deploy-keys", + ['label' => $title, 'key' => $key], pageSizeParameter: 'pagelen', collectionKey: 'values', + ); + } - return $res->json()['id'] ?? ''; - } catch (Exception $e) { - throw new FailedToDeployGitKey($e->getMessage()); - } + public function findDeployKey(string $repo, string $key): ?string + { + return $this->lookupDeployKey(fn () => Http::withHeaders($this->getAuthenticationHeaders()), $this->apiUrl."/repositories/$repo/deploy-keys", $key, pageSizeParameter: 'pagelen', collectionKey: 'values'); } public function deleteDeployKey(string $keyId, string $repo): void @@ -275,20 +257,11 @@ public function deleteDeployKey(string $keyId, string $repo): void $response = Http::withHeaders($this->getAuthenticationHeaders()) ->delete($this->apiUrl."/repositories/$repo/deploy-keys/$keyId"); - if (! $response->successful()) { - Log::warning('Failed to delete Bitbucket deploy key', [ - 'repo' => $repo, - 'key_id' => $keyId, - 'response' => $response->body(), - ]); + if (! $response->successful() && $response->status() !== 404) { + throw new \RuntimeException('Deploy key removal was rejected by the source control.'); } - - } catch (Exception $e) { - Log::error('Error deleting Bitbucket deploy key', [ - 'repo' => $repo, - 'key_id' => $keyId, - 'error' => $e->getMessage(), - ]); + } catch (\Throwable) { + throw new \RuntimeException('Unable to remove the source-control deploy key.'); } } diff --git a/app/SourceControlProviders/Gitea.php b/app/SourceControlProviders/Gitea.php index 1cc01fe43..fc93a249c 100755 --- a/app/SourceControlProviders/Gitea.php +++ b/app/SourceControlProviders/Gitea.php @@ -205,26 +205,18 @@ public function getLastCommit(string $repo, string $branch): ?array /** * @throws FailedToDeployGitKey */ - public function deployKey(string $title, string $repo, string $key): string + public function deployKey(string $title, string $repo, string $key, bool $readOnly = false): string { - try { - $response = Http::withToken($this->data()['token'])->post( - $this->getApiUrl().'/repos/'.$repo.'/keys', - [ - 'title' => $title, - 'key' => $key, - 'read_only' => true, - ] - ); - - if ($response->status() != 201) { - throw new FailedToDeployGitKey($response->body()); - } + return $this->registerDeployKey( + fn () => Http::withToken($this->data()['token']), + $this->getApiUrl().'/repos/'.$repo.'/keys', + ['title' => $title, 'key' => $key, 'read_only' => true], pageSizeParameter: 'limit', + ); + } - return $response->json()['id'] ?? ''; - } catch (Exception $e) { - throw new FailedToDeployGitKey($e->getMessage()); - } + public function findDeployKey(string $repo, string $key): ?string + { + return $this->lookupDeployKey(fn () => Http::withToken($this->data()['token']), $this->getApiUrl().'/repos/'.$repo.'/keys', $key, pageSizeParameter: 'limit'); } public function deleteDeployKey(string $keyId, string $repo): void @@ -234,20 +226,11 @@ public function deleteDeployKey(string $keyId, string $repo): void $this->getApiUrl().'/repos/'.$repo.'/keys/'.$keyId ); - if (! $response->successful()) { - Log::warning('Failed to delete Gitea deploy key', [ - 'repo' => $repo, - 'key_id' => $keyId, - 'response' => $response->body(), - ]); + if (! $response->successful() && $response->status() !== 404) { + throw new \RuntimeException('Deploy key removal was rejected by the source control.'); } - - } catch (Throwable $e) { - Log::error('Error deleting Gitea deploy key', [ - 'repo' => $repo, - 'key_id' => $keyId, - 'error' => $e->getMessage(), - ]); + } catch (\Throwable) { + throw new \RuntimeException('Unable to remove the source-control deploy key.'); } } diff --git a/app/SourceControlProviders/Github.php b/app/SourceControlProviders/Github.php index 5114e17da..07145ab96 100755 --- a/app/SourceControlProviders/Github.php +++ b/app/SourceControlProviders/Github.php @@ -187,31 +187,18 @@ public function getLastCommit(string $repo, string $branch): ?array /** * @throws FailedToDeployGitKey */ - public function deployKey(string $title, string $repo, string $key): string + public function deployKey(string $title, string $repo, string $key, bool $readOnly = false): string { - try { - $response = $this->getClient() - ->post(self::API_BASE_URL.'/repos/'.$repo.'/keys', [ - 'title' => $title, - 'key' => $key, - 'read_only' => false, - ]); - - if ($response->status() !== 201) { - throw new FailedToDeployGitKey($response->body()); - } - - return $response->json()['id'] ?? ''; - - } catch (Throwable $e) { - Log::error('Failed to deploy GitHub key', [ - 'repo' => $repo, - 'title' => $title, - 'error' => $e->getMessage(), - ]); + return $this->registerDeployKey( + fn () => $this->getClient(), + self::API_BASE_URL.'/repos/'.$repo.'/keys', + ['title' => $title, 'key' => $key, 'read_only' => $readOnly], + ); + } - throw new FailedToDeployGitKey($e->getMessage()); - } + public function findDeployKey(string $repo, string $key): ?string + { + return $this->lookupDeployKey(fn () => $this->getClient(), self::API_BASE_URL.'/repos/'.$repo.'/keys', $key); } public function deleteDeployKey(string $keyId, string $repo): void @@ -220,20 +207,11 @@ public function deleteDeployKey(string $keyId, string $repo): void $response = $this->getClient() ->delete(self::API_BASE_URL."/repos/$repo/keys/$keyId"); - if (! $response->successful()) { - Log::warning('Failed to delete GitHub deploy key', [ - 'repo' => $repo, - 'key_id' => $keyId, - 'response' => $response->body(), - ]); + if (! $response->successful() && $response->status() !== 404) { + throw new \RuntimeException('Deploy key removal was rejected by the source control.'); } - - } catch (Throwable $e) { - Log::error('Error deleting GitHub deploy key', [ - 'repo' => $repo, - 'key_id' => $keyId, - 'error' => $e->getMessage(), - ]); + } catch (\Throwable) { + throw new \RuntimeException('Unable to remove the source-control deploy key.'); } } diff --git a/app/SourceControlProviders/GithubApp.php b/app/SourceControlProviders/GithubApp.php index 1794be37f..d2b628db9 100644 --- a/app/SourceControlProviders/GithubApp.php +++ b/app/SourceControlProviders/GithubApp.php @@ -127,11 +127,16 @@ public function getLastCommit(string $repo, string $branch): ?array return null; } - public function deployKey(string $title, string $repo, string $key): string + public function deployKey(string $title, string $repo, string $key, bool $readOnly = false): string { return ''; } + public function findDeployKey(string $repo, string $key): ?string + { + return null; + } + public function deleteDeployKey(string $keyId, string $repo): void {} public function getRepos(bool $useCache = true): array diff --git a/app/SourceControlProviders/Gitlab.php b/app/SourceControlProviders/Gitlab.php index f8ddb03a3..8629fb6b9 100755 --- a/app/SourceControlProviders/Gitlab.php +++ b/app/SourceControlProviders/Gitlab.php @@ -217,27 +217,18 @@ public function getLastCommit(string $repo, string $branch): ?array /** * @throws FailedToDeployGitKey */ - public function deployKey(string $title, string $repo, string $key): string + public function deployKey(string $title, string $repo, string $key, bool $readOnly = false): string { - $repository = urlencode($repo); - try { - $response = Http::withToken($this->data()['token'])->post( - $this->getApiUrl().'/projects/'.$repository.'/deploy_keys', - [ - 'title' => $title, - 'key' => $key, - 'can_push' => true, - ] - ); - - if ($response->status() != 201) { - throw new FailedToDeployGitKey($response->body()); - } + return $this->registerDeployKey( + fn () => Http::withToken($this->data()['token']), + $this->getApiUrl().'/projects/'.urlencode($repo).'/deploy_keys', + ['title' => $title, 'key' => $key, 'can_push' => ! $readOnly], + ); + } - return $response->json()['id'] ?? ''; - } catch (Exception $e) { - throw new FailedToDeployGitKey($e->getMessage()); - } + public function findDeployKey(string $repo, string $key): ?string + { + return $this->lookupDeployKey(fn () => Http::withToken($this->data()['token']), $this->getApiUrl().'/projects/'.urlencode($repo).'/deploy_keys', $key); } public function deleteDeployKey(string $keyId, string $repo): void @@ -248,20 +239,11 @@ public function deleteDeployKey(string $keyId, string $repo): void $this->getApiUrl().'/projects/'.$repository.'/deploy_keys/'.$keyId ); - if (! $response->successful()) { - Log::warning('Failed to delete Gitlab deploy key', [ - 'repo' => $repo, - 'key_id' => $keyId, - 'response' => $response->body(), - ]); + if (! $response->successful() && $response->status() !== 404) { + throw new \RuntimeException('Deploy key removal was rejected by the source control.'); } - - } catch (Throwable $e) { - Log::error('Error deleting Gitlab deploy key', [ - 'repo' => $repo, - 'key_id' => $keyId, - 'error' => $e->getMessage(), - ]); + } catch (\Throwable) { + throw new \RuntimeException('Unable to remove the source-control deploy key.'); } } diff --git a/app/SourceControlProviders/SourceControlProvider.php b/app/SourceControlProviders/SourceControlProvider.php index b4d2a0467..e5306500b 100755 --- a/app/SourceControlProviders/SourceControlProvider.php +++ b/app/SourceControlProviders/SourceControlProvider.php @@ -78,7 +78,9 @@ public function getLastCommit(string $repo, string $branch): ?array; /** * @throws FailedToDeployGitKey */ - public function deployKey(string $title, string $repo, string $key): string; + public function deployKey(string $title, string $repo, string $key, bool $readOnly = false): string; + + public function findDeployKey(string $repo, string $key): ?string; public function deleteDeployKey(string $keyId, string $repo): void; diff --git a/app/Tables/ApplicationTable.php b/app/Tables/ApplicationTable.php index f68ed9ab4..072005bbe 100644 --- a/app/Tables/ApplicationTable.php +++ b/app/Tables/ApplicationTable.php @@ -5,6 +5,7 @@ use App\Models\Application; use Forjed\InertiaTable\Column; use Forjed\InertiaTable\Columns\ActionsColumn; +use Forjed\InertiaTable\Columns\EnumColumn; use Forjed\InertiaTable\Columns\TextColumn; use Forjed\InertiaTable\Table; @@ -24,6 +25,7 @@ protected function columns(): array TextColumn::make('runtime', 'Runtime') ->value(fn (Application $application): string => config('component.runtimes', [])[$application->runtime]['label'] ?? $application->runtime), TextColumn::make('environments_count', 'Environments'), + EnumColumn::make('status', 'Status'), Column::data('id'), ActionsColumn::make(), ]; diff --git a/app/Tables/Applications/ComponentTable.php b/app/Tables/Applications/ComponentTable.php new file mode 100644 index 000000000..bbad8fbc0 --- /dev/null +++ b/app/Tables/Applications/ComponentTable.php @@ -0,0 +1,38 @@ +perPage = config('web.pagination_size'); + $this->query->with('server', 'capabilities')->latest(); + } + + protected function columns(): array + { + return [ + TextColumn::make('name', 'Name')->sortable(), + TextColumn::make('server.name', 'Server'), + TextColumn::make('user', 'User'), + EnumColumn::make('type', 'Type'), + TextColumn::make('capabilities', 'Capabilities')->value(fn (Component $component): string => $component->capabilities->pluck('capability')->implode(', ')), + EnumColumn::make('status', 'Status'), + Column::data('id'), + ActionsColumn::make(), + ]; + } + + protected function searchable(): array + { + return ['name', 'user']; + } +} diff --git a/database/migrations/2026_10_17_224408_add_component_lifecycle_fields.php b/database/migrations/2026_10_17_224408_add_component_lifecycle_fields.php new file mode 100644 index 000000000..4596c5739 --- /dev/null +++ b/database/migrations/2026_10_17_224408_add_component_lifecycle_fields.php @@ -0,0 +1,48 @@ +string('status')->default(ApplicationStatus::READY->value); + }); + + Schema::table('environments', function (Blueprint $table): void { + $table->boolean('primary_component_initialized')->default(false); + $table->text('last_error')->nullable(); + }); + + Schema::table('components', function (Blueprint $table): void { + $table->string('deploy_key_id')->nullable(); + $table->text('ssh_key')->nullable(); + $table->foreignId('deploy_key_source_control_id')->nullable()->constrained('source_controls')->restrictOnDelete(); + $table->string('deploy_key_repository')->nullable(); + }); + + DB::table('environments')->whereIn('id', DB::table('components')->where('type', 'app')->select('environment_id')) + ->update(['primary_component_initialized' => true]); + } + + public function down(): void + { + Schema::table('components', function (Blueprint $table): void { + $table->dropConstrainedForeignId('deploy_key_source_control_id'); + $table->dropColumn(['deploy_key_id', 'ssh_key', 'deploy_key_repository']); + }); + + Schema::table('environments', function (Blueprint $table): void { + $table->dropColumn(['primary_component_initialized', 'last_error']); + }); + + Schema::table('applications', function (Blueprint $table): void { + $table->dropColumn('status'); + }); + } +}; diff --git a/resources/js/components/dialogs/registry.ts b/resources/js/components/dialogs/registry.ts index f599cf0fb..c6b6a36dd 100644 --- a/resources/js/components/dialogs/registry.ts +++ b/resources/js/components/dialogs/registry.ts @@ -44,6 +44,8 @@ import PeerConfigDialog from '@/pages/networks/components/peer-config'; import CreateApplication from '@/pages/applications/components/create-application'; import EnvironmentForm from '@/pages/applications/components/environment-form'; import DeleteApplicationResource from '@/pages/applications/components/delete-dialog'; +import AddComponent from '@/pages/applications/components/add-component'; +import DeleteComponent from '@/pages/applications/components/delete-component'; export type DialogControlProps = { open: boolean; onOpenChange: (open: boolean) => void }; @@ -106,6 +108,8 @@ export const dialogs = { createEnvironment: EnvironmentForm, editEnvironment: EnvironmentForm, deleteApplicationResource: DeleteApplicationResource, + addComponent: AddComponent, + deleteComponent: DeleteComponent, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as const satisfies Record>; diff --git a/resources/js/layouts/environment/layout.tsx b/resources/js/layouts/environment/layout.tsx index d5f4d4c06..b705cd18f 100644 --- a/resources/js/layouts/environment/layout.tsx +++ b/resources/js/layouts/environment/layout.tsx @@ -1,16 +1,88 @@ -import { ReactNode } from 'react'; -import { usePage } from '@inertiajs/react'; -import { ArrowLeftIcon, HomeIcon } from 'lucide-react'; +import { ReactNode, useCallback, useEffect, useRef } from 'react'; +import { router, usePage, usePoll } from '@inertiajs/react'; +import { ReloadOptions } from '@inertiajs/core'; +import { toast } from 'sonner'; +import { useSocketListener } from '@/hooks/use-socket-events'; +import { ArrowLeftIcon, BoxesIcon, HomeIcon } from 'lucide-react'; import { NavItem } from '@/types'; -import { Application, Environment } from '@/types/application'; +import { Application, Component, Environment } from '@/types/application'; import Layout from '@/layouts/app/layout'; export default function EnvironmentLayout({ children }: { children: ReactNode }) { - const { application, environment } = usePage<{ application: Application; environment: Environment }>().props; + const { application, environment, component } = usePage<{ application: Application; environment: Environment; component?: Component }>().props; const baseUrl = `/applications/${application.id}/environments/${environment.id}`; + const leaving = useRef(false); + const leave = useCallback((url: string) => { + if (leaving.current) return; + leaving.current = true; + let fallingBack = false; + const onFinish = () => { leaving.current = false; }; + const onNetworkError = () => { + toast.error('Unable to open the parent page. Check your connection and try again.'); + return false; + }; + router.visit(url, { + onNetworkError, + onHttpException: (response) => { + if (response.status === 404 && url !== '/applications') { + fallingBack = true; + router.visit('/applications', { onFinish, onNetworkError }); + return false; + } + }, + onFinish: () => { if (!fallingBack) onFinish(); }, + }); + }, []); + const reloadOptions: ReloadOptions = { + only: ['application', 'environment', 'components', 'component', 'primaryCandidates'], + onBefore: () => !leaving.current, + onHttpException: (response) => { + if (response.status === 404) { + leave(`/applications/${application.id}`); + return false; + } + }, + }; + usePoll(10000, reloadOptions); + useEffect(() => { + if (application.status === 'deleting') { + leave('/applications'); + } else if (environment.status === 'deleting') { + leave(`/applications/${application.id}`); + } + }, [application.id, application.status, environment.status, leave]); + useSocketListener((event) => { + if (leaving.current) return; + if (event.type?.startsWith('application.') && event.data?.id === application.id) { + if (event.type === 'application.deleted' || event.data.status === 'deleting') { + leave('/applications'); + } else { + router.reload(reloadOptions); + } + } else if (event.type?.startsWith('environment.') && event.data?.id === environment.id) { + if (event.data.application_status === 'deleting') { + leave('/applications'); + } else if (event.type === 'environment.deleted' || event.data.status === 'deleting') { + leave(`/applications/${application.id}`); + } else { + router.reload(reloadOptions); + } + } else if (event.type?.startsWith('component.') && event.data?.environment_id === environment.id) { + if (event.data.application_status === 'deleting') { + leave('/applications'); + } else if (event.data.environment_status === 'deleting') { + leave(`/applications/${application.id}`); + } else if (event.type === 'component.deleted' && event.data.id === component?.id) { + leave(`${baseUrl}/components`); + } else { + router.reload(reloadOptions); + } + } + }); const sidebarNavItems: NavItem[] = [ { title: application.name, href: `/applications/${application.id}`, onlyActivePath: `/applications/${application.id}`, icon: ArrowLeftIcon }, { title: 'Overview', href: baseUrl, onlyActivePath: baseUrl, icon: HomeIcon }, + { title: 'Components', href: `${baseUrl}/components`, icon: BoxesIcon }, ]; return {children}; diff --git a/resources/js/pages/applications/components/add-component.tsx b/resources/js/pages/applications/components/add-component.tsx new file mode 100644 index 000000000..a454e5d20 --- /dev/null +++ b/resources/js/pages/applications/components/add-component.tsx @@ -0,0 +1,95 @@ +import { FormEvent } from 'react'; +import { useForm } from '@inertiajs/react'; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { DialogControlProps } from '@/components/dialogs/registry'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import InputError from '@/components/ui/input-error'; +import DynamicField from '@/components/ui/dynamic-field'; +import { LoaderCircleIcon } from 'lucide-react'; +import { Application, ComponentOptions, Environment } from '@/types/application'; +import { DynamicFieldValue } from '@/types/dynamic-field-config'; + +export default function AddComponent({ open, onOpenChange, application, environment, options }: DialogControlProps & { + application: Application; + environment: Environment; + options: ComponentOptions; +}) { + const form = useForm({ + name: '', + server_id: '', + user: '', + type: 'app', + capabilities: [] as { capability: string; config: Record }[], + }); + const submit = (e: FormEvent) => { + e.preventDefault(); + form.post(`/applications/${application.id}/environments/${environment.id}/components`, { onSuccess: () => onOpenChange(false) }); + }; + + return ( + + e.preventDefault()}> + + Add component + Place an app component in {environment.name}. For a deployed environment, its live release follows once installation completes when deployments are available. + +
+ + + + form.setData('name', e.target.value)} /> + + + + + + + + + + form.setData('user', e.target.value)} autoComplete="off" /> +

Use the server SSH user or an isolated user. Required services must already be installed.

+ +
+
+

Capabilities

+

Configuration is saved now; capability installation will be available in a future update.

+ {options.capabilities.map((option) => { + const selected = form.data.capabilities.find((capability) => capability.capability === option.id); + return ( +
+
+ form.setData('capabilities', checked === true + ? [...form.data.capabilities, { capability: option.id, config: Object.fromEntries(option.form.map((field) => [field.name, field.default ?? ''])) }] + : form.data.capabilities.filter((capability) => capability.capability !== option.id))} /> + +
+

{option.description}

+ {selected && option.form.map((field) => form.setData('capabilities', form.data.capabilities.map((capability) => capability.capability === option.id + ? { ...capability, config: { ...capability.config, [field.name]: value } } : capability))} />)} +
+ ); + })} + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/resources/js/pages/applications/components/delete-component.tsx b/resources/js/pages/applications/components/delete-component.tsx new file mode 100644 index 000000000..c16de648f --- /dev/null +++ b/resources/js/pages/applications/components/delete-component.tsx @@ -0,0 +1,52 @@ +import { FormEvent } from 'react'; +import { useForm } from '@inertiajs/react'; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { DialogControlProps } from '@/components/dialogs/registry'; +import { Form, FormFields } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import InputError from '@/components/ui/input-error'; +import { LoaderCircleIcon } from 'lucide-react'; +import { Application, Component, Environment, PrimaryCandidate } from '@/types/application'; +import PrimaryComponentPicker from './primary-component-picker'; + +export default function DeleteComponent({ open, onOpenChange, application, environment, component, primaryCandidates }: DialogControlProps & { + application: Application; + environment: Environment; + component: Component; + primaryCandidates: PrimaryCandidate[]; +}) { + const candidates = primaryCandidates.filter((candidate) => candidate.id !== component.id); + const primary = environment.primary_component_id === component.id; + const form = useForm({ primary_component_id: '' }); + const submit = (e: FormEvent) => { + e.preventDefault(); + form.delete(`/applications/${application.id}/environments/${environment.id}/components/${component.id}`, { onSuccess: () => onOpenChange(false) }); + }; + + return ( + + e.preventDefault()}> + + Delete {component.name} + This removes its deploy key, files and any unshared isolated user. It cannot be undone. + +
+ + {primary && <> +

This is the primary component. {candidates[0] ? `Consider ${candidates[0].name} as its replacement.` : 'No replacement is available.'} Leaving it unset blocks deployments until you select a primary.

+ form.setData('primary_component_id', value)} candidates={candidates} error={form.errors.primary_component_id} /> + } + + ).component} /> +
+
+ + + + +
+
+ ); +} diff --git a/resources/js/pages/applications/components/delete-dialog.tsx b/resources/js/pages/applications/components/delete-dialog.tsx index 657f35797..704f9b9a0 100644 --- a/resources/js/pages/applications/components/delete-dialog.tsx +++ b/resources/js/pages/applications/components/delete-dialog.tsx @@ -27,7 +27,7 @@ export default function DeleteDialog({ open, onOpenChange, application, environm e.preventDefault()}> Delete {target.name} - This permanently deletes the {environment ? 'environment' : 'application and its environments'}. This cannot be undone. + This tears down all components before permanently deleting the {environment ? 'environment' : 'application and its environments'}. Failed cleanup can be retried. This cannot be undone.
diff --git a/resources/js/pages/applications/components/environment-fields.tsx b/resources/js/pages/applications/components/environment-fields.tsx index a4f01c388..eead6965a 100644 --- a/resources/js/pages/applications/components/environment-fields.tsx +++ b/resources/js/pages/applications/components/environment-fields.tsx @@ -20,8 +20,10 @@ export default function EnvironmentFields({ data, errors, onChange, + runtimeLocked = false, }: { preset?: RuntimePreset; + runtimeLocked?: boolean; data: EnvironmentFormData; errors: Record; onChange: (name: string, value: DynamicFieldValue) => void; @@ -43,7 +45,14 @@ export default function EnvironmentFields({ onChange('branch', e.target.value)} /> - {fields.map((field) => ( + {fields.map((field) => runtimeLocked && field.name === 'runtime_version' ? ( + + + +

Remove all components before changing the runtime version.

+ +
+ ) : ( runtime.id === application.runtime); const form = useForm({ @@ -19,6 +21,7 @@ export default function EnvironmentForm({ open, onOpenChange, application, envir name: environment?.name ?? '', branch: environment?.branch ?? 'main', runtime_version: environment?.runtime_version ?? environmentDefaults(preset).runtime_version, + primary_component_id: environment?.primary_component_id ? String(environment.primary_component_id) : '', }); const submit = (e: FormEvent) => { @@ -43,7 +46,8 @@ export default function EnvironmentForm({ open, onOpenChange, application, envir {!preset &&

This runtime preset is unavailable. Restore its plugin before editing environments.

} - form.setData(name, value)} /> + 0} onChange={(name, value) => form.setData(name, value)} /> + {environment && form.setData('primary_component_id', value)} candidates={primaryCandidates} error={form.errors.primary_component_id} />}
diff --git a/resources/js/pages/applications/components/primary-component-picker.tsx b/resources/js/pages/applications/components/primary-component-picker.tsx new file mode 100644 index 000000000..d7dfc0410 --- /dev/null +++ b/resources/js/pages/applications/components/primary-component-picker.tsx @@ -0,0 +1,27 @@ +import { FormField } from '@/components/ui/form'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import InputError from '@/components/ui/input-error'; +import { PrimaryCandidate } from '@/types/application'; + +export default function PrimaryComponentPicker({ value, onChange, candidates, error }: { + value: string; + onChange: (value: string) => void; + candidates: PrimaryCandidate[]; + error?: string; +}) { + return ( + + + +

The primary runs the release command. Deployments require a primary component.

+ +
+ ); +} diff --git a/resources/js/pages/applications/components/settings.tsx b/resources/js/pages/applications/components/settings.tsx index 2e8807f38..27e00cf53 100644 --- a/resources/js/pages/applications/components/settings.tsx +++ b/resources/js/pages/applications/components/settings.tsx @@ -24,7 +24,7 @@ export default function Settings({ application, sourceControls }: { application: Application settings - Repository changes apply on the next deploy. The runtime preset cannot be changed. + Repository changes immediately re-register component deploy keys. The running release changes on the next deploy. The runtime preset cannot be changed.
diff --git a/resources/js/pages/applications/environments/component.tsx b/resources/js/pages/applications/environments/component.tsx new file mode 100644 index 000000000..51d61cbf9 --- /dev/null +++ b/resources/js/pages/applications/environments/component.tsx @@ -0,0 +1,61 @@ +import { Head, Link, useForm, usePage } from '@inertiajs/react'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import InputError from '@/components/ui/input-error'; +import { RotateCwIcon, Trash2Icon } from 'lucide-react'; +import EnvironmentLayout from '@/layouts/environment/layout'; +import { useDialog } from '@/hooks/use-dialog'; +import { Application, Component, Environment, PrimaryCandidate } from '@/types/application'; + +export default function ShowComponent() { + const { application, environment, component, primaryCandidates, can } = usePage<{ + application: Application; + environment: Environment; + component: Component; + primaryCandidates: PrimaryCandidate[]; + can: { update: boolean; delete: boolean }; + }>().props; + const dialog = useDialog(); + const retry = useForm({}); + const baseUrl = `/applications/${application.id}/environments/${environment.id}/components`; + + return ( + + + + +
+ + {component.status} +
+
+ {can.update && component.status === 'installation_failed' && application.status === 'ready' && environment.status === 'ready' && } + {can.delete && component.status !== 'deleting' && } +
+
+ ).component} /> + {component.last_error &&

{component.last_error}

} + {component.needs_redeploy &&

Repository changes require a new deployment.

} + + +
+
Server
{component.server_name}
+
Linux user
{component.user}
+
Type
{component.type}
+
Path
{component.path}
+
Capabilities
{component.capabilities.join(', ') || 'None'}
+
+
+

Deploy public key

+ {component.ssh_key ?
{component.ssh_key}
:

No deploy key. GitHub App sources do not need one; other sources create it during installation.

} +
+
+
+
+
+ ); +} diff --git a/resources/js/pages/applications/environments/components.tsx b/resources/js/pages/applications/environments/components.tsx new file mode 100644 index 000000000..b487658df --- /dev/null +++ b/resources/js/pages/applications/environments/components.tsx @@ -0,0 +1,43 @@ +import { Head, Link, usePage } from '@inertiajs/react'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { EyeIcon, PlusIcon } from 'lucide-react'; +import { VitoTable } from '@/components/vito-table'; +import EnvironmentLayout from '@/layouts/environment/layout'; +import { InertiaTableData } from '@forjedio/inertia-table-react'; +import { useDialog } from '@/hooks/use-dialog'; +import { Application, ComponentOptions, Environment } from '@/types/application'; + +export default function Components() { + const { application, environment, components, options, canCreate } = usePage<{ + application: Application; + environment: Environment; + components: InertiaTableData; + options: ComponentOptions; + canCreate: boolean; + }>().props; + const dialog = useDialog(); + const baseUrl = `/applications/${application.id}/environments/${environment.id}/components`; + + return ( + + + + + + {canCreate && application.status === 'ready' && environment.status === 'ready' && } + + {environment.last_error &&

{environment.last_error}

} + ( +
+ +
+ )} /> +
+
+ ); +} diff --git a/resources/js/pages/applications/environments/show.tsx b/resources/js/pages/applications/environments/show.tsx index ce3e8b453..cc440143a 100644 --- a/resources/js/pages/applications/environments/show.tsx +++ b/resources/js/pages/applications/environments/show.tsx @@ -1,4 +1,4 @@ -import { Head, usePage } from '@inertiajs/react'; +import { Head, Link, usePage } from '@inertiajs/react'; import Container from '@/components/container'; import HeaderContainer from '@/components/header-container'; import Heading from '@/components/heading'; @@ -8,15 +8,19 @@ import { Card, CardContent } from '@/components/ui/card'; import { PencilIcon, Trash2Icon } from 'lucide-react'; import EnvironmentLayout from '@/layouts/environment/layout'; import { useDialog } from '@/hooks/use-dialog'; -import { Application, Environment } from '@/types/application'; +import { Application, Environment, PrimaryCandidate } from '@/types/application'; export default function ShowEnvironment() { - const { application, environment, can } = usePage<{ + const { application, environment, primaryCandidates, can } = usePage<{ application: Application; environment: Environment; + primaryCandidates: PrimaryCandidate[]; can: { update: boolean; delete: boolean }; }>().props; const dialog = useDialog(); + const ready = application.status === 'ready' && environment.status === 'ready'; + const baseUrl = `/applications/${application.id}/environments/${environment.id}`; + const primary = primaryCandidates.find((candidate) => candidate.id === environment.primary_component_id); return ( @@ -29,20 +33,23 @@ export default function ShowEnvironment() { {environment.status}
- {can.update && } - {can.delete && } + {can.update && ready && } + {can.delete && application.status === 'ready' && environment.status !== 'deleting' && }
+ {environment.last_error &&

{environment.last_error}

}
Slug
{environment.slug}
Branch
{environment.branch}
Runtime version
{environment.runtime_version ?? 'Not required'}
+
Primary component
{primary ? {primary.name} : 'None selected'}
+
Components
{environment.components_count ?? 0}
-

Components and deployment controls will be available in a future update.

+

Deployment controls will be available in a future update.

); diff --git a/resources/js/pages/applications/index.tsx b/resources/js/pages/applications/index.tsx index 7d512b923..57f121251 100644 --- a/resources/js/pages/applications/index.tsx +++ b/resources/js/pages/applications/index.tsx @@ -1,4 +1,4 @@ -import { Head, Link, usePage } from '@inertiajs/react'; +import { Head, Link, usePage, usePoll } from '@inertiajs/react'; import Container from '@/components/container'; import HeaderContainer from '@/components/header-container'; import Heading from '@/components/heading'; @@ -18,6 +18,7 @@ export default function Applications() { canCreate: boolean; }>().props; const dialog = useDialog(); + usePoll(10000, { only: ['applications'] }); return ( diff --git a/resources/js/pages/applications/show.tsx b/resources/js/pages/applications/show.tsx index 999ab7353..c37f88082 100644 --- a/resources/js/pages/applications/show.tsx +++ b/resources/js/pages/applications/show.tsx @@ -1,4 +1,8 @@ -import { Head, Link, usePage } from '@inertiajs/react'; +import { useRef } from 'react'; +import { Head, Link, router, usePage, usePoll } from '@inertiajs/react'; +import { ReloadOptions } from '@inertiajs/core'; +import { toast } from 'sonner'; +import { useSocketListener } from '@/hooks/use-socket-events'; import Container from '@/components/container'; import HeaderContainer from '@/components/header-container'; import Heading from '@/components/heading'; @@ -22,6 +26,38 @@ export default function ShowApplication() { }>().props; const dialog = useDialog(); const runtime = useConfigs()?.application.runtimes.find((preset) => preset.id === application.runtime); + const leaving = useRef(false); + const leave = () => { + if (leaving.current) return; + leaving.current = true; + router.visit('/applications', { + onFinish: () => { leaving.current = false; }, + onNetworkError: () => { + toast.error('Unable to open Applications. Check your connection and try again.'); + return false; + }, + }); + }; + const reloadOptions: ReloadOptions = { + only: ['application', 'environments'], + onBefore: () => !leaving.current, + onHttpException: (response) => { + if (response.status === 404) { + leave(); + return false; + } + }, + }; + usePoll(10000, reloadOptions); + useSocketListener((event) => { + if (leaving.current) return; + if (event.type === 'application.deleted' && event.data?.id === application.id) { + leave(); + } else if ((event.type === 'application.updated' && event.data?.id === application.id) + || (event.type?.startsWith('environment.') && event.data?.application_id === application.id)) { + router.reload(reloadOptions); + } + }); return ( @@ -33,13 +69,14 @@ export default function ShowApplication() { Beta {runtime?.label ?? application.runtime} + {application.status} - {can.delete && } + {can.delete && application.status !== 'deleting' && }
- {can.createEnvironment && } + {can.createEnvironment && application.status === 'ready' && } {environments.length === 0 &&

No environments yet. Create one to get started.

}
@@ -47,7 +84,9 @@ export default function ShowApplication() {
- {environment.name} + {application.status === 'deleting' || environment.status === 'deleting' + ? {environment.name} + : {environment.name}} {environment.status}

Branch: {environment.branch}

@@ -57,7 +96,7 @@ export default function ShowApplication() { ))}
- {can.update && } + {can.update && application.status === 'ready' && }
); diff --git a/resources/js/types/application.d.ts b/resources/js/types/application.d.ts index 31a15fbe6..51cb809ef 100644 --- a/resources/js/types/application.d.ts +++ b/resources/js/types/application.d.ts @@ -1,5 +1,5 @@ import { Configs } from '@/types'; -import { DynamicFieldValue } from '@/types/dynamic-field-config'; +import { DynamicFieldConfig, DynamicFieldValue } from '@/types/dynamic-field-config'; export interface Application { id: number; @@ -8,6 +8,8 @@ export interface Application { name: string; repository: string; runtime: string; + status: string; + status_color: 'success' | 'danger'; created_at: string; updated_at: string; } @@ -19,12 +21,43 @@ export interface Environment { slug: string; branch: string; runtime_version: string | null; + primary_component_id: number | null; + components_count?: number; + last_error: string | null; status: string; status_color: 'success' | 'danger'; created_at: string; updated_at: string; } +export interface Component { + id: number; + environment_id: number; + environment_status?: string; + application_status?: string; + server_id: number; + server_name: string; + name: string; + type: string; + type_color: 'info' | 'warning'; + user: string; + path: string; + status: string; + status_color: 'success' | 'danger' | 'warning' | 'gray'; + last_error: string | null; + needs_redeploy: boolean; + ssh_key: string | null; + capabilities: string[]; + created_at: string; + updated_at: string; +} + +export interface ComponentOptions { + servers: { id: number; name: string; ssh_user: string }[]; + capabilities: { id: string; label: string; description: string; form: DynamicFieldConfig[] }[]; +} + +export type PrimaryCandidate = { id: number; name: string }; export type RuntimePreset = Configs['application']['runtimes'][number]; export type EnvironmentFormData = { diff --git a/resources/views/ssh/components/create-directories.blade.php b/resources/views/ssh/components/create-directories.blade.php new file mode 100644 index 000000000..9e2fbf9fd --- /dev/null +++ b/resources/views/ssh/components/create-directories.blade.php @@ -0,0 +1 @@ +mkdir -p -- {!! escapeshellarg($component->releasesPath()) !!} {!! escapeshellarg($component->sharedPath()) !!} diff --git a/resources/views/ssh/components/delete-directories.blade.php b/resources/views/ssh/components/delete-directories.blade.php new file mode 100644 index 000000000..5af2cfdb2 --- /dev/null +++ b/resources/views/ssh/components/delete-directories.blade.php @@ -0,0 +1,6 @@ +if id -u {!! escapeshellarg($component->user) !!} >/dev/null 2>&1; then + sudo -H -u {!! escapeshellarg($component->user) !!} bash <<'VITO_COMPONENT_CLEANUP' +set -e +rm -rf -- {!! escapeshellarg($component->basePath()) !!} +VITO_COMPONENT_CLEANUP +fi diff --git a/resources/views/ssh/components/delete-key.blade.php b/resources/views/ssh/components/delete-key.blade.php new file mode 100644 index 000000000..52424478d --- /dev/null +++ b/resources/views/ssh/components/delete-key.blade.php @@ -0,0 +1,14 @@ +if id -u {!! escapeshellarg($user) !!} >/dev/null 2>&1; then + sudo -H -u {!! escapeshellarg($user) !!} bash <<'VITO_COMPONENT_CLEANUP' +set -e +rm -f -- "$HOME/.ssh/{{ $key }}" "$HOME/.ssh/{{ $key }}.pub" +if [ -f "$HOME/.ssh/config" ]; then + awk -v suffix='-{{ $key }}' ' + /^Host[[:space:]]/ { skip = (NF == 2 && substr($2, length($2) - length(suffix) + 1) == suffix) } + !skip { print } + ' "$HOME/.ssh/config" > "$HOME/.ssh/config.vito.tmp" + mv -- "$HOME/.ssh/config.vito.tmp" "$HOME/.ssh/config" + chmod 600 "$HOME/.ssh/config" +fi +VITO_COMPONENT_CLEANUP +fi diff --git a/resources/views/ssh/mise/install-runtime.blade.php b/resources/views/ssh/mise/install-runtime.blade.php index 14aa55aac..aac233efd 100644 --- a/resources/views/ssh/mise/install-runtime.blade.php +++ b/resources/views/ssh/mise/install-runtime.blade.php @@ -1,6 +1,11 @@ export PATH=$HOME/.local/share/mise/shims:$PATH +@if (! empty($configDir)) +mise use --path {!! escapeshellarg($configDir.'/mise.toml') !!} {!! escapeshellarg($runtime.'@'.$version) !!} --verbose +mise trust {!! escapeshellarg($configDir.'/mise.toml') !!} +@else mise use -g {{ $runtime . '@' . $version }} --verbose +@endif # Make the mise shims available to interactive SSH sessions for this user # so commands like `node`/`bun`/`pnpm`/`yarn` work from a plain shell, not diff --git a/resources/views/ssh/os/delete-isolated-user.blade.php b/resources/views/ssh/os/delete-isolated-user.blade.php index dff57fa41..27bdd51c0 100644 --- a/resources/views/ssh/os/delete-isolated-user.blade.php +++ b/resources/views/ssh/os/delete-isolated-user.blade.php @@ -1,3 +1,7 @@ -sudo gpasswd -d {{ $serverUser }} {{ $user }} -sudo userdel -r -f "{{ $user }}" +if id -u {{ $user }} >/dev/null 2>&1; then + if id -nG {{ $serverUser }} | tr ' ' '\n' | grep -Fxq '{{ $user }}'; then + sudo gpasswd -d {{ $serverUser }} {{ $user }} + fi + sudo userdel -r -f "{{ $user }}" +fi echo "User {{ $user }} has been deleted." diff --git a/resources/views/ssh/os/generate-ssh-key.blade.php b/resources/views/ssh/os/generate-ssh-key.blade.php index 0371d02ca..d998a6386 100644 --- a/resources/views/ssh/os/generate-ssh-key.blade.php +++ b/resources/views/ssh/os/generate-ssh-key.blade.php @@ -1 +1,8 @@ -ssh-keygen -t rsa -b 4096 -N "" -f ~/.ssh/{{ $name }} +mkdir -p ~/.ssh +chmod 700 ~/.ssh +if [ ! -f ~/.ssh/{{ $name }} ]; then + ssh-keygen -t rsa -b 4096 -N "" -f ~/.ssh/{{ $name }} +fi +if [ ! -f ~/.ssh/{{ $name }}.pub ]; then + ssh-keygen -y -f ~/.ssh/{{ $name }} > ~/.ssh/{{ $name }}.pub +fi diff --git a/tests/Feature/ApplicationCoexistenceTest.php b/tests/Feature/ApplicationCoexistenceTest.php index 99bb07c8c..805bce979 100644 --- a/tests/Feature/ApplicationCoexistenceTest.php +++ b/tests/Feature/ApplicationCoexistenceTest.php @@ -51,7 +51,7 @@ 'server_id' => $this->server->id, 'type' => PHPBlank::id(), 'user' => 'shared', 'domain' => 'shared.test', 'path' => '/home/shared/shared.test', 'php_version' => '8.2', ]); - $this->component->update(['isolated_user_id' => $site->isolated_user_id, 'user' => $site->user]); + $this->component->forceFill(['isolated_user_id' => $site->isolated_user_id, 'user' => $site->user])->save(); $this->component->environment->update(['runtime_version' => $version]); $this->component->environment->application->update(['runtime' => $runtime]); if ($web) { @@ -89,7 +89,7 @@ 'server_id' => $this->server->id, 'type' => PHPBlank::id(), 'user' => 'shared', 'domain' => 'shared.test', 'path' => '/home/shared/shared.test', 'php_version' => '8.2', ]); - $this->component->update(['isolated_user_id' => $site->isolated_user_id, 'user' => $site->user]); + $this->component->forceFill(['isolated_user_id' => $site->isolated_user_id, 'user' => $site->user])->save(); $this->component->environment->update(['runtime_version' => '8.2']); $this->component->environment->application->update(['runtime' => 'php']); ComponentCapability::factory()->for($this->component)->create(['capability' => 'web']); @@ -108,7 +108,7 @@ test('isolated user sibling checks exclude only the supplied owner and use fresh relationships', function () { $iuser = IsolatedUser::factory()->create(['server_id' => $this->server->id]); - $this->component->update(['isolated_user_id' => $iuser->id, 'user' => $iuser->username]); + $this->component->forceFill(['isolated_user_id' => $iuser->id, 'user' => $iuser->username])->save(); $this->component->environment->update(['runtime_version' => '8.2']); $this->component->environment->application->update(['runtime' => 'php']); ComponentCapability::factory()->for($this->component)->create(); @@ -137,7 +137,7 @@ test('unrelated isolated users and servers do not preserve a site user or pool', function () { $site = Site::factory()->create(['server_id' => $this->server->id, 'user' => 'alone']); $otherUser = IsolatedUser::factory()->create(['server_id' => $this->server->id]); - $this->component->update(['isolated_user_id' => $otherUser->id, 'user' => $otherUser->username]); + $this->component->forceFill(['isolated_user_id' => $otherUser->id, 'user' => $otherUser->username])->save(); $this->component->environment->update(['runtime_version' => '8.2']); $this->component->environment->application->update(['runtime' => 'php']); ComponentCapability::factory()->for($this->component)->create(); @@ -298,7 +298,7 @@ public function requiredServices(string $version): array test('server deletion purges only its components and their owned rows without remote teardown', function () { $environment = $this->component->environment; - $environment->update(['primary_component_id' => $this->component->id]); + $environment->forceFill(['primary_component_id' => $this->component->id])->save(); $otherServer = Server::factory()->create(['project_id' => $this->server->project_id]); $survivor = Component::factory()->for($environment)->for($otherServer, 'server')->create(); $capability = ComponentCapability::factory()->for($this->component)->create(); @@ -313,7 +313,7 @@ public function requiredServices(string $version): array Component::factory()->for($environment)->for($this->server)->create(['user' => 'other']); $this->partialMock(PurgeServerComponents::class)->shouldReceive('handle')->once() - ->with([$environment->id, $otherEnvironment->id]) + ->with(['environments' => [$environment->id, $otherEnvironment->id], 'keys' => []]) ->andReturnUsing(function () use ($environment): void { $this->assertDatabaseMissing('servers', ['id' => $this->server->id]); $this->assertDatabaseHas('environments', ['id' => $environment->id, 'primary_component_id' => null]); @@ -334,7 +334,7 @@ public function requiredServices(string $version): array test('component purges roll back with a failed server cleanup', function () { $environment = $this->component->environment; - $environment->update(['primary_component_id' => $this->component->id]); + $environment->forceFill(['primary_component_id' => $this->component->id])->save(); $capability = ComponentCapability::factory()->for($this->component)->create(); ServerLog::factory()->create(['server_id' => $this->server->id]); Event::listen('eloquent.deleting: '.ServerLog::class, function (): void { @@ -353,7 +353,7 @@ public function requiredServices(string $version): array test('failed server deletion after cleanup restores components and primary references', function (bool $throughProject) { $environment = $this->component->environment; - $environment->update(['primary_component_id' => $this->component->id]); + $environment->forceFill(['primary_component_id' => $this->component->id])->save(); $capability = ComponentCapability::factory()->for($this->component)->create(); $worker = Worker::factory()->create(['server_id' => $this->server->id, 'component_id' => $this->component->id]); Event::listen('eloquent.deleting: '.Server::class, function (): void { @@ -370,6 +370,26 @@ public function requiredServices(string $version): array $this->assertDatabaseHas('environments', ['id' => $environment->id, 'primary_component_id' => $this->component->id]); })->with([false, true]); +test('server purge locks placement before capturing components in the deletion transaction', function () { + $locked = false; + $depth = DB::transactionLevel(); + Server::addGlobalScope('verify-purge-lock', function (\Illuminate\Database\Eloquent\Builder $query) use (&$locked, $depth): void { + if ($query->getQuery()->lock === true) { + expect(DB::transactionLevel())->toBeGreaterThan($depth); + $locked = true; + } + }); + Event::listen('eloquent.retrieved: '.Component::class, function () use (&$locked): void { + expect($locked)->toBeTrue(); + }); + + $this->server->delete(); + + expect($locked)->toBeTrue(); + $this->assertDatabaseMissing('servers', ['id' => $this->server->id]); + $this->assertDatabaseMissing('components', ['id' => $this->component->id]); +}); + test('server cleanup deadlocks preserve the failure and reset transaction depth', function () { $pdo = Mockery::mock(PDO::class); $pdo->shouldReceive('beginTransaction')->once()->andReturnTrue(); @@ -397,7 +417,7 @@ public function requiredServices(string $version): array test('cancelled server deletion restores components and primary references', function () { $environment = $this->component->environment; - $environment->update(['primary_component_id' => $this->component->id]); + $environment->forceFill(['primary_component_id' => $this->component->id])->save(); Event::listen('eloquent.deleting: '.Server::class, fn (): bool => false); expect($this->server->delete())->toBeFalse(); @@ -462,7 +482,7 @@ public function requiredServices(AbstractRuntimePreset $preset): array 'server_id' => $this->server->id, 'type' => PHPBlank::id(), 'user' => 'shared', 'domain' => 'shared.test', 'path' => '/home/shared/shared.test', 'php_version' => '8.2', ]); - $this->component->update(['isolated_user_id' => $site->isolated_user_id, 'user' => $site->user]); + $this->component->forceFill(['isolated_user_id' => $site->isolated_user_id, 'user' => $site->user])->save(); $this->component->status = \App\Enums\ComponentStatus::from($status); $this->component->save(); $this->component->environment->application->update(['runtime' => 'php']); @@ -491,7 +511,7 @@ public function requiredServices(AbstractRuntimePreset $preset): array $server = Server::factory()->for($project)->create(['user_id' => $this->user->id]); $components->push(Component::factory()->for($environment)->for($server)->create()); } - $environment->update(['primary_component_id' => $components->first()->id]); + $environment->forceFill(['primary_component_id' => $components->first()->id])->save(); $this->actingAs($this->user)->delete(route('projects.destroy', $project), ['name' => $project->name]) ->assertSessionDoesntHaveErrors(); diff --git a/tests/Feature/ApplicationDataModelTest.php b/tests/Feature/ApplicationDataModelTest.php index 681131e23..c433f4abd 100644 --- a/tests/Feature/ApplicationDataModelTest.php +++ b/tests/Feature/ApplicationDataModelTest.php @@ -37,7 +37,7 @@ $capability = ComponentCapability::factory()->for($component)->create([ 'config' => ['port' => 3000, 'enabled' => true], ]); - $environment->update(['primary_component_id' => $component->id]); + $environment->forceFill(['primary_component_id' => $component->id])->save(); $environmentScript = DeploymentScript::factory()->create(['site_id' => null, 'environment_id' => $environment->id]); $componentScript = DeploymentScript::factory()->create(['site_id' => null, 'component_id' => $component->id]); $worker = Worker::factory()->create(['server_id' => $this->server->id, 'component_id' => $component->id]); @@ -152,7 +152,7 @@ 'server_id' => $this->server->id, 'isolated_user_id' => $isolatedUser->id, ]); - $environment->update(['primary_component_id' => $component->id]); + $environment->forceFill(['primary_component_id' => $component->id])->save(); $application->sourceControl->forceDelete(); $isolatedUser->delete(); @@ -176,7 +176,7 @@ SSH::fake(); $component = Component::factory()->create(); $environment = $component->environment; - $environment->update(['primary_component_id' => $component->id]); + $environment->forceFill(['primary_component_id' => $component->id])->save(); $capability = ComponentCapability::factory()->for($component)->create(); $worker = Worker::factory()->create(['server_id' => $component->server_id, 'component_id' => $component->id]); $cron = CronJob::factory()->create(['server_id' => $component->server_id, 'component_id' => $component->id]); @@ -209,7 +209,7 @@ $siteWorker = Worker::factory()->create(['server_id' => $this->server->id, 'site_id' => $this->site->id]); $siteCron = CronJob::factory()->create(['server_id' => $this->server->id, 'site_id' => $this->site->id]); $component = Component::factory()->create(); - $component->environment->update(['primary_component_id' => $component->id]); + $component->environment->forceFill(['primary_component_id' => $component->id])->save(); ComponentCapability::factory()->for($component)->create(); Deployment::factory()->create(['site_id' => null, 'component_id' => $component->id, 'deployment_script_id' => null]); DeploymentScript::factory()->create(['site_id' => null, 'environment_id' => $component->environment_id]); diff --git a/tests/Feature/ApplicationsCrudTest.php b/tests/Feature/ApplicationsCrudTest.php index b3b901039..5b5a4c225 100644 --- a/tests/Feature/ApplicationsCrudTest.php +++ b/tests/Feature/ApplicationsCrudTest.php @@ -276,7 +276,7 @@ ->where('application.source_control_id', null)->missing('application.sourceControl') ->has('environments', 1)->where('environments.0.id', $environment->id) ->where('environments.0.status', 'ready')->where('environments.0.status_color', 'success') - ->missing('environments.0.primary_component_id')->has('sourceControls', 1) + ->where('environments.0.primary_component_id', null)->where('environments.0.components_count', 0)->has('sourceControls', 1) ->where('sourceControls.0.id', $this->sourceControl->id)->missing('sourceControls.0.provider_data') ->missing('sourceControls.0.access_token')->where('can.update', true)); }); diff --git a/tests/Feature/ComponentLifecycleTest.php b/tests/Feature/ComponentLifecycleTest.php new file mode 100644 index 000000000..1573d87ff --- /dev/null +++ b/tests/Feature/ComponentLifecycleTest.php @@ -0,0 +1,423 @@ + Http::response($request->method() === 'POST' ? ['id' => 123] : [], $request->method() === 'POST' ? 201 : 204)); + Queue::fake(); + Event::fake([SocketEvent::class]); + $this->application = Application::factory()->create(['project_id' => $this->server->project_id, 'source_control_id' => $this->site->source_control_id]); + $this->environment = Environment::factory()->for($this->application)->create(['runtime_version' => '22']); + $this->component = app(CreateComponent::class)->create($this->environment, [ + 'name' => 'Runtime', 'server_id' => $this->server->id, 'user' => 'runtime', 'type' => 'app', + ]); +}); + +test('component install provisions directories tooling and its own deploy key idempotently', function () { + app(InstallComponent::class)->install($this->component->id); + app(InstallComponent::class)->install($this->component->id); + + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'status' => 'ready', 'ssh_key' => 'ssh-rsa component-public-key', 'deploy_key_id' => '123', 'deploy_key_repository' => $this->application->repository]); + SSH::assertExecutedContains('if id -u runtime'); + SSH::assertExecutedContains('mkdir -p -- '.escapeshellarg($this->component->releasesPath()).' '.escapeshellarg($this->component->sharedPath())); + SSH::assertExecutedContains('mise use --path '.escapeshellarg($this->component->path.'/mise.toml')." 'node@22'"); + SSH::assertExecutedContains('mise trust '.escapeshellarg($this->component->path.'/mise.toml')); + SSH::assertExecutedContains('~/.ssh/component_'.$this->component->id); + SSH::assertNotExecutedContains('mise use -g'); + Http::assertSentCount(1); + Http::assertSent(fn ($request) => $request['title'] === 'component_'.$this->component->id); + Event::assertDispatched(SocketEvent::class, fn ($event) => $event->data->type === 'component.updated'); +}); + +test('shared linux users support independent component runtime versions without changing site globals', function () { + $environment = Environment::factory()->for($this->application)->create(['runtime_version' => '24']); + $other = app(CreateComponent::class)->create($environment, ['name' => 'Other', 'server_id' => $this->server->id, 'user' => 'runtime', 'type' => 'app']); + app(InstallComponent::class)->install($this->component->id); + app(InstallComponent::class)->install($other->id); + expect($other->isolated_user_id)->toBe($this->component->isolated_user_id); + SSH::assertExecutedContains('mise use --path '.escapeshellarg($other->path.'/mise.toml')." 'node@24'"); + SSH::assertNotExecutedContains('mise use -g'); + expect($this->component->isolatedUser->fresh()->installed_tooling)->toBeNull(); + (new Mise($this->server))->installRuntime(ToolingTargetDTO::forSite($this->site), 'node', '22'); + SSH::assertExecutedContains('mise use -g node@22'); +}); + +test('github app components do not create ssh or provider keys', function () { + $this->application->update(['source_control_id' => SourceControl::factory()->githubApp()->create()->id]); + app(InstallComponent::class)->install($this->component->id); + Http::assertNothingSent(); + SSH::assertNotExecutedContains('ssh-keygen'); + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'status' => 'ready', 'deploy_key_id' => null, 'ssh_key' => null]); +}); + +test('component failed jobs store only safe errors and installation can be retried', function () { + (new InstallComponentJob($this->component->id, $this->server->id))->failed(new Exception('SECRET=never-expose')); + expect($this->component->fresh()->last_error)->not->toContain('SECRET'); + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'status' => 'installation_failed']); + app(RetryComponentInstall::class)->retry($this->component); + (new InstallComponentJob($this->component->id, $this->server->id))->handle(); + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'status' => 'ready', 'last_error' => null]); + expect(fn () => app(RetryComponentInstall::class)->retry($this->component))->toThrow(ValidationException::class); +}); + +test('component teardown preserves shared isolated users and deletes unshared users', function (bool $shared) { + Event::listen('eloquent.deleting: '.Component::class, function (Component $component): void { + $lock = $component->server->isolatedUserLock($component->user); + $acquired = $lock->get(); + if ($acquired) { + $lock->release(); + } + expect($acquired)->toBeFalse(); + }); + if ($shared) { + $this->site->update(['user' => 'runtime', 'isolated_user_id' => $this->component->isolated_user_id]); + } + app(InstallComponent::class)->install($this->component->id); + app(DeleteComponent::class)->delete($this->component, []); + (new DeleteComponentJob($this->component->id, $this->server->id))->handle(); + $this->assertDatabaseMissing('components', ['id' => $this->component->id]); + expect(IsolatedUser::query()->whereKey($this->component->isolated_user_id)->exists())->toBe($shared); + SSH::assertExecutedContains('rm -rf -- '.escapeshellarg($this->component->path)); + SSH::assertExecutedContains("awk -v suffix='-component_{$this->component->id}'"); + SSH::assertNotExecutedContains('mise uninstall'); + if ($shared) { + SSH::assertNotExecutedContains('userdel'); + } else { + SSH::assertExecutedContains('userdel -r -f "runtime"'); + } + Http::assertSent(fn ($request) => $request->method() === 'DELETE'); +})->with([false, true]); + +test('deleting the primary accepts an explicit replacement or leaves it unset', function (bool $replace) { + $other = Component::factory()->for($this->environment)->for($this->server)->create(['user' => 'another']); + app(DeleteComponent::class)->delete($this->component, ['primary_component_id' => $replace ? $other->id : null]); + expect($this->environment->fresh()->primary_component_id)->toBe($replace ? $other->id : null); + (new DeleteComponentJob($this->component->id, $this->server->id))->failed(new Exception('SECRET')); + expect($this->component->fresh()->last_error)->not->toContain('SECRET'); + app(DeleteComponent::class)->delete($this->component, []); + (new DeleteComponentJob($this->component->id, $this->server->id))->handle(); + $this->assertDatabaseMissing('components', ['id' => $this->component->id]); +})->with([false, true]); + +test('environment teardown chains components before row deletion and can retry failure', function () { + Bus::fake(); + app(DeleteEnvironment::class)->delete($this->environment, ['name' => $this->environment->name]); + Bus::assertChained([new DeleteComponentJob($this->component->id, $this->server->id), new DeleteEnvironmentJob($this->environment->id)]); + app(DeleteEnvironment::class)->failed($this->environment->id); + $this->assertDatabaseHas('environments', ['id' => $this->environment->id, 'status' => 'deletion_failed']); + app(DeleteEnvironment::class)->delete($this->environment, ['name' => $this->environment->name]); + (new DeleteComponentJob($this->component->id, $this->server->id))->handle(); + (new DeleteEnvironmentJob($this->environment->id))->handle(); + $this->assertDatabaseMissing('environments', ['id' => $this->environment->id]); + $this->assertDatabaseHas('applications', ['id' => $this->application->id]); +}); + +test('application teardown chains environment cleanup and retains failed parents for retry', function () { + Bus::fake(); + app(DeleteApplication::class)->delete($this->application, ['name' => $this->application->name]); + Bus::assertChained([new DeleteComponentJob($this->component->id, $this->server->id), new DeleteEnvironmentJob($this->environment->id), new DeleteApplicationJob($this->application->id)]); + (new DeleteApplicationJob($this->application->id))->failed(new Exception('SECRET')); + $this->assertDatabaseHas('applications', ['id' => $this->application->id, 'status' => 'deletion_failed']); + $this->assertDatabaseHas('environments', ['id' => $this->environment->id, 'status' => 'deletion_failed']); + app(DeleteApplication::class)->delete($this->application, ['name' => $this->application->name]); + (new DeleteComponentJob($this->component->id, $this->server->id))->handle(); + (new DeleteEnvironmentJob($this->environment->id))->handle(); + (new DeleteApplicationJob($this->application->id))->handle(); + $this->assertDatabaseMissing('applications', ['id' => $this->application->id]); +}); + +test('repository changes remove keys from their original source and register the current repository', function () { + $this->site->sourceControl->update(['user_id' => $this->user->id, 'project_id' => $this->server->project_id]); + app(InstallComponent::class)->install($this->component->id); + app(UpdateApplication::class)->update($this->application, ['name' => $this->application->name, 'source_control_id' => $this->site->source_control_id, 'repository' => 'other/repo'], $this->user); + Queue::assertPushed(SyncComponentDeployKeysJob::class); + (new SyncComponentDeployKeysJob($this->component->id, $this->server->id))->handle(); + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'deploy_key_repository' => 'other/repo', 'needs_redeploy' => true]); + Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_contains($request->url(), '/organization/repository/keys/123')); + Http::assertSent(fn ($request) => $request->method() === 'POST' && str_contains($request->url(), '/other/repo/keys')); +}); + +test('changing to github app removes old provider and local keys', function () { + app(InstallComponent::class)->install($this->component->id); + $this->application->update(['source_control_id' => SourceControl::factory()->githubApp()->create()->id]); + app(SyncComponentDeployKeys::class)->handle($this->component->id); + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'ssh_key' => null, 'deploy_key_id' => null]); + SSH::assertExecutedContains('rm -f -- "$HOME/.ssh/component_'.$this->component->id.'"'); +}); + +test('purging servers queues provider cleanup without remote teardown and records safe failures', function () { + app(InstallComponent::class)->install($this->component->id); + SSH::fake(); + $departure = app(PurgeServerComponents::class)->purge($this->server); + app(PurgeServerComponents::class)->handle($departure); + Queue::assertPushed(DeletePurgedComponentDeployKeyJob::class); + $job = new DeletePurgedComponentDeployKeyJob($this->component->id, $this->server->id, $this->environment->id, $this->application->source_control_id, $this->application->repository, '123'); + $job->handle(); + $job->failed(new Exception('SECRET')); + expect($this->environment->fresh()->last_error)->toContain('deploy key')->not->toContain('SECRET'); + SSH::assertNotExecutedContains(''); +}); + +test('mise rejects mismatched target servers before any remote command', function (bool $install) { + SSH::fake(); + $target = new ToolingTargetDTO(Server::factory()->create(), 'runtime'); + $mise = new Mise($this->server); + expect(fn () => $install ? $mise->installRuntime($target, 'node', '22') : $mise->uninstallRuntime($target, 'node')) + ->toThrow(InvalidArgumentException::class); + SSH::assertNotExecutedContains(''); +})->with([false, true]); + +test('queued site tooling removal rechecks components attached after enqueue', function () { + $this->site->update(['user' => 'runtime', 'isolated_user_id' => $this->component->isolated_user_id]); + $this->component->isolatedUser->setToolingVersion('node', '22'); + $this->component->delete(); + app(\App\Actions\Site\Tooling\UninstallSiteTooling::class)->uninstall($this->site, 'node'); + Queue::assertPushed(UninstallSiteToolingJob::class); + app(CreateComponent::class)->create($this->environment, [ + 'name' => 'Attached later', 'server_id' => $this->server->id, 'user' => 'runtime', 'type' => 'app', + ]); + $job = new UninstallSiteToolingJob($this->site, 'node'); + SSH::fake(); + $job->withFakeQueueInteractions()->handle(); + $job->assertFailedWith(ValidationException::class); + SSH::assertNotExecutedContains('mise uninstall'); +}); + +test('failed key registration retains the public key and completed removal for retry', function () { + app(InstallComponent::class)->install($this->component->id); + $this->application->update(['repository' => 'other/repository']); + $this->mock(\App\Actions\SourceControl\SetupDeployKey::class, function ($mock) { + $mock->shouldReceive('setup')->once()->andThrow(new RuntimeException('Provider unavailable')); + }); + + expect(fn () => app(SyncComponentDeployKeys::class)->handle($this->component->id))->toThrow(RuntimeException::class); + $this->assertDatabaseHas('components', [ + 'id' => $this->component->id, 'ssh_key' => 'ssh-rsa component-public-key', + 'deploy_key_id' => null, 'deploy_key_source_control_id' => $this->application->source_control_id, 'deploy_key_repository' => 'other/repository', + ]); + app()->forgetInstance(\App\Actions\SourceControl\SetupDeployKey::class); + app(SyncComponentDeployKeys::class)->handle($this->component->id); + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'deploy_key_repository' => 'other/repository']); +}); + +test('failed initial key registration persists its generated public key', function () { + $this->mock(\App\Actions\SourceControl\SetupDeployKey::class, function ($mock) { + $mock->shouldReceive('generateKey')->once()->andReturn('ssh-rsa persisted-key'); + $mock->shouldReceive('setup')->once()->andThrow(new RuntimeException('Provider unavailable')); + }); + + expect(fn () => app(InstallComponent::class)->install($this->component->id))->toThrow(RuntimeException::class); + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'ssh_key' => 'ssh-rsa persisted-key', 'deploy_key_id' => null]); +}); + +test('pending deploy key ownership survives lost responses and is reconciled before cleanup', function (string $operation) { + Http::swap(new \Illuminate\Http\Client\Factory); + Http::preventStrayRequests(); + Http::fake(function ($request) { + $this->assertDatabaseHas('components', [ + 'id' => $this->component->id, 'deploy_key_source_control_id' => $this->application->source_control_id, + 'deploy_key_repository' => $this->application->repository, 'ssh_key' => 'ssh-rsa component-public-key', + ]); + if ($request->method() === 'POST') { + throw new \Illuminate\Http\Client\ConnectionException('Lost response'); + } + + return Http::response([], 503); + }); + expect(fn () => app(InstallComponent::class)->install($this->component->id))->toThrow(\App\Exceptions\FailedToDeployGitKey::class); + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'deploy_key_id' => null, 'deploy_key_repository' => $this->application->repository]); + + Http::swap(new \Illuminate\Http\Client\Factory); + Http::preventStrayRequests(); + Http::fake(fn ($request) => match ($request->method()) { + 'GET' => Http::response([['id' => 321, 'key' => 'ssh-rsa component-public-key']]), + 'POST' => Http::response(['id' => 456], 201), + default => Http::response([], 204), + }); + if ($operation === 'repository') { + $this->application->update(['repository' => 'new/repository']); + app(InstallComponent::class)->install($this->component->id); + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'deploy_key_id' => '456', 'deploy_key_repository' => 'new/repository']); + } elseif ($operation === 'purge') { + $purge = app(PurgeServerComponents::class); + $departure = $purge->purge($this->server); + expect($departure['keys'])->toHaveCount(1); + $purge->handle($departure); + Queue::assertPushed(DeletePurgedComponentDeployKeyJob::class, function ($job): bool { + $job->handle(); + return true; + }); + } else { + app(DeleteComponent::class)->delete($this->component, []); + app(DeleteComponent::class)->handle($this->component->id); + $this->assertDatabaseMissing('components', ['id' => $this->component->id]); + } + Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_contains($request->url(), '/organization/repository/keys/321')); +})->with(['repository', 'delete', 'purge']); + +test('parent teardown chains propagate real job failures and broadcast retry state', function (bool $application) { + Queue::fake()->except([DeleteComponentJob::class, DeleteEnvironmentJob::class, DeleteApplicationJob::class]); + SSH::fake()->execWillFail(); + $parent = $application ? $this->application : $this->environment; + $action = $application ? DeleteApplication::class : DeleteEnvironment::class; + app($action)->delete($parent, ['name' => $parent->name]); + + $this->assertDatabaseHas($parent->getTable(), ['id' => $parent->id, 'status' => 'deletion_failed']); + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'status' => 'deletion_failed']); + Event::assertDispatched(SocketEvent::class, fn ($event) => $event->data->type === ($application ? 'application.updated' : 'environment.updated') && $event->data->data['status'] === 'deletion_failed'); +})->with([false, true]); + +test('component deletion events carry current parent teardown context', function () { + Bus::fake(); + app(DeleteApplication::class)->delete($this->application, ['name' => $this->application->name]); + app(DeleteComponent::class)->handle($this->component->id); + app(DeleteEnvironment::class)->handle($this->environment->id); + app(DeleteApplication::class)->handle($this->application->id); + + Event::assertDispatched(SocketEvent::class, fn ($event) => $event->data->type === 'component.deleted' && $event->data->data['application_status'] === 'deleting' && $event->data->data['environment_status'] === 'deleting'); + Event::assertDispatched(SocketEvent::class, fn ($event) => $event->data->type === 'environment.deleted'); + Event::assertDispatched(SocketEvent::class, fn ($event) => $event->data->type === 'application.deleted'); +}); + +test('component deletion refreshes parent teardown context after remote cleanup', function () { + app(DeleteComponent::class)->delete($this->component, []); + Event::listen('eloquent.deleting: '.Component::class, function (): void { + $this->application->forceFill(['status' => 'deleting'])->save(); + $this->environment->forceFill(['status' => 'deleting'])->save(); + }); + + app(DeleteComponent::class)->handle($this->component->id); + + Event::assertDispatched(SocketEvent::class, fn ($event) => $event->data->type === 'component.deleted' && $event->data->data['application_status'] === 'deleting' && $event->data->data['environment_status'] === 'deleting'); +}); + +test('component deletion broadcasts skip parents already removed', function () { + $this->component->load('environment.application'); + $this->component->delete(); + $this->environment->delete(); + + app(\App\Actions\Component\BroadcastComponentUpdate::class)->deleted($this->component); + + Event::assertNotDispatched(SocketEvent::class, fn ($event) => $event->data->type === 'component.deleted'); +}); + +test('server purge cleanup waits for in flight deploy key registration', function () { + Http::swap(new \Illuminate\Http\Client\Factory); + Http::preventStrayRequests(); + Http::fake(function ($request) { + if ($request->method() === 'POST') { + $this->server->deleteFromProvider = false; + $this->server->delete(); + Queue::assertPushed(DeletePurgedComponentDeployKeyJob::class, function ($job): bool { + $job->withFakeQueueInteractions()->handle(); + $job->assertReleased(30); + + return true; + }); + + return Http::response(['id' => 456], 201); + } + + return $request->method() === 'GET' + ? Http::response([['id' => 456, 'key' => 'ssh-rsa component-public-key']]) + : Http::response([], 204); + }); + + (new InstallComponentJob($this->component->id, $this->server->id))->withFakeQueueInteractions()->handle(); + $this->assertDatabaseMissing('servers', ['id' => $this->server->id]); + Queue::assertPushed(DeletePurgedComponentDeployKeyJob::class, function ($job): bool { + $job->handle(); + + return true; + }); + Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_ends_with($request->url(), '/456')); +}); + +test('components removed before pending ownership is persisted never register a provider key', function () { + $this->mock(\App\Actions\SourceControl\SetupDeployKey::class, function ($mock) { + $mock->shouldReceive('generateKey')->once()->andReturnUsing(function (): string { + $this->component->delete(); + + return 'ssh-rsa component-public-key'; + }); + $mock->shouldNotReceive('setup'); + }); + + expect(fn () => app(InstallComponent::class)->install($this->component->id))->toThrow(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + Http::assertNothingSent(); +}); + +test('pending key retries recover their recorded repository without another registration', function () { + $this->component->forceFill([ + 'ssh_key' => 'ssh-rsa component-public-key', 'deploy_key_source_control_id' => $this->application->source_control_id, + 'deploy_key_repository' => $this->application->repository, + ])->save(); + Http::swap(new \Illuminate\Http\Client\Factory); + Http::fake(['*' => Http::response([['id' => 789, 'key' => 'ssh-rsa component-public-key']])]); + + app(InstallComponent::class)->install($this->component->id); + + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'status' => 'ready', 'deploy_key_id' => '789']); + Http::assertNotSent(fn ($request) => $request->method() === 'POST'); + SSH::assertNotExecutedContains('ssh-keygen'); +}); + +test('inconclusive pending key cleanup preserves ownership and local resources', function () { + $this->component->forceFill([ + 'ssh_key' => 'ssh-rsa component-public-key', 'deploy_key_source_control_id' => $this->application->source_control_id, + 'deploy_key_repository' => $this->application->repository, + ])->save(); + Http::swap(new \Illuminate\Http\Client\Factory); + Http::fake(['*' => Http::response('invalid JSON')]); + app(DeleteComponent::class)->delete($this->component, []); + + expect(fn () => app(DeleteComponent::class)->handle($this->component->id))->toThrow(\App\Exceptions\FailedToDeployGitKey::class); + $this->assertDatabaseHas('components', ['id' => $this->component->id, 'deploy_key_repository' => $this->application->repository, 'ssh_key' => 'ssh-rsa component-public-key']); + expect(SSH::getExecutedCommands())->toBe([]); +}); + +test('parent teardown runs the complete job chain before removing parents', function (bool $application) { + Queue::fake()->except([DeleteComponentJob::class, DeleteEnvironmentJob::class, DeleteApplicationJob::class]); + $parent = $application ? $this->application : $this->environment; + app($application ? DeleteApplication::class : DeleteEnvironment::class)->delete($parent, ['name' => $parent->name]); + + $this->assertDatabaseMissing('components', ['id' => $this->component->id]); + $this->assertDatabaseMissing('environments', ['id' => $this->environment->id]); + expect(Application::query()->whereKey($this->application->id)->exists())->toBe(! $application); + Event::assertDispatched(SocketEvent::class, fn ($event) => $event->data->type === ($application ? 'application.deleted' : 'environment.deleted')); +})->with([false, true]); diff --git a/tests/Feature/ComponentsTest.php b/tests/Feature/ComponentsTest.php new file mode 100644 index 000000000..1992fe2f0 --- /dev/null +++ b/tests/Feature/ComponentsTest.php @@ -0,0 +1,249 @@ +application = Application::factory()->create(['project_id' => $this->server->project_id, 'source_control_id' => $this->site->source_control_id]); + $this->environment = Environment::factory()->for($this->application)->create(['runtime_version' => '22']); + $this->input = ['name' => 'Web', 'server_id' => $this->server->id, 'user' => 'runtime', 'type' => 'app', 'capabilities' => [['capability' => 'web', 'config' => []]]]; +}); + +test('components create atomically with normalized capabilities and a first primary', function (bool $defaultUser) { + $input = array_replace($this->input, ['user' => $defaultUser ? $this->server->getSshUser() : 'runtime', 'path' => '/etc', 'status' => 'ready']); + $component = app(CreateComponent::class)->create($this->environment, $input); + + $this->assertDatabaseHas('components', ['id' => $component->id, 'status' => 'pending', 'user' => $input['user'], 'path' => '/home/'.$input['user'].'/app-'.$this->application->id.'-'.$this->environment->slug]); + $this->assertDatabaseHas('component_capabilities', ['component_id' => $component->id, 'capability' => 'web', 'config->port' => 10000, 'status' => 'pending']); + $this->assertDatabaseHas('environments', ['id' => $this->environment->id, 'primary_component_id' => $component->id, 'primary_component_initialized' => true]); + expect($component->isolated_user_id === null)->toBe($defaultUser); + Queue::assertPushed(InstallComponentJob::class); + SSH::assertNotExecutedContains(''); +})->with([false, true]); + +test('components reject invalid placement and capability input without partial rows', function (string $field, mixed $value) { + $input = $this->input; + data_set($input, $field, $value); + + expect(fn () => app(CreateComponent::class)->create($this->environment, $input))->toThrow(ValidationException::class); + $this->assertDatabaseCount('components', 0); + $this->assertDatabaseCount('component_capabilities', 0); + $this->assertDatabaseMissing('isolated_users', ['username' => 'runtime']); + Queue::assertNothingPushed(); +})->with([ + ['name', ''], ['name', []], ['name', str_repeat('a', 256)], ['server_id', 999999], ['server_id', []], + ['user', 'root'], ['user', 'ab'], ['user', 'unsafe;id'], ['user', "runtime\n"], ['user', []], + ['type', 'load_balancer'], ['type', 'other'], ['capabilities', 'web'], ['capabilities', ['web' => []]], + ['capabilities.0.capability', 'unknown'], ['capabilities.0.config', 'invalid'], ['capabilities.0.config.port', 80], + ['capabilities.0.config.start_command', "bad\0command"], +]); + +test('components reject foreign and unready servers', function (bool $foreign) { + $server = $foreign ? Server::factory()->create() : $this->server; + if (! $foreign) { + $server->update(['status' => ServerStatus::INSTALLING]); + } + expect(fn () => app(CreateComponent::class)->create($this->environment, array_replace($this->input, ['server_id' => $server->id]))) + ->toThrow(ValidationException::class); +})->with([false, true]); + +test('component placement names users and web ownership are unique', function (string $kind) { + app(CreateComponent::class)->create($this->environment, $this->input); + $input = array_replace($this->input, ['name' => 'Another', 'user' => 'another']); + if ($kind === 'name') { + $input['name'] = $this->input['name']; + $input['capabilities'] = []; + } elseif ($kind === 'user') { + $input['user'] = $this->input['user']; + $input['capabilities'] = []; + } + expect(fn () => app(CreateComponent::class)->create($this->environment, $input))->toThrow(ValidationException::class); + $this->assertDatabaseCount('components', 1); +})->with(['name', 'user', 'web']); + +test('component creation checks preset services even without capabilities', function (string $status) { + $this->application->update(['runtime' => 'php']); + $this->environment->update(['runtime_version' => '8.2']); + $this->server->services()->where('type', 'php')->update(['status' => $status]); + $input = array_replace($this->input, ['capabilities' => []]); + if ($status === ServiceStatus::INSTALLING->value) { + expect(fn () => app(CreateComponent::class)->create($this->environment->fresh(), $input))->toThrow(ValidationException::class); + } else { + expect(app(CreateComponent::class)->create($this->environment->fresh(), $input))->toBeInstanceOf(Component::class); + } +})->with(['ready', 'stopped', 'disabled', 'installing']); + +test('component validation rejects missing php versions and capability services', function (bool $php) { + if ($php) { + $this->application->update(['runtime' => 'php']); + $this->environment->update(['runtime_version' => '0.0']); + } else { + $this->server->services()->where('type', 'webserver')->delete(); + } + expect(fn () => app(CreateComponent::class)->create($this->environment->fresh(), $this->input))->toThrow(ValidationException::class); + $this->assertDatabaseCount('components', 0); +})->with([false, true]); + +test('primary selection is never automatically reassigned after the first component', function () { + $first = app(CreateComponent::class)->create($this->environment, $this->input); + app(DeleteComponent::class)->delete($first, []); + $first->delete(); + $next = app(CreateComponent::class)->create($this->environment, $this->input); + expect($this->environment->fresh()->primary_component_id)->toBeNull(); + $input = ['name' => $this->environment->name, 'branch' => 'main', 'runtime_version' => '22', 'primary_component_id' => $next->id]; + app(UpdateEnvironment::class)->update($this->environment, $input); + $this->assertDatabaseHas('environments', ['id' => $this->environment->id, 'primary_component_id' => $next->id]); +}); + +test('environment runtime is locked once components exist but primary can be cleared', function () { + app(CreateComponent::class)->create($this->environment, $this->input); + $input = ['name' => $this->environment->name, 'branch' => 'main', 'runtime_version' => '24']; + expect(fn () => app(UpdateEnvironment::class)->update($this->environment, $input))->toThrow(ValidationException::class); + app(UpdateEnvironment::class)->update($this->environment, array_replace($input, ['runtime_version' => '22', 'primary_component_id' => null])); + expect($this->environment->fresh()->primary_component_id)->toBeNull(); +}); + +test('primary selection rejects other environments load balancers and deleting components', function (string $kind) { + $component = Component::factory()->for($this->environment)->for($this->server)->create(); + if ($kind === 'foreign') { + $component->update(['environment_id' => Environment::factory()->create()->id]); + } elseif ($kind === 'lb') { + $component->update(['type' => ComponentType::LOAD_BALANCER]); + } else { + $component->status = ComponentStatus::DELETING; + $component->save(); + } + $input = ['name' => $this->environment->name, 'branch' => 'main', 'runtime_version' => '22', 'primary_component_id' => $component->id]; + expect(fn () => app(UpdateEnvironment::class)->update($this->environment, $input))->toThrow(ValidationException::class); +})->with(['foreign', 'lb', 'deleting']); + +test('component endpoints create show retry and queue deletion with scoped resource fields', function () { + $this->actingAs($this->user); + $parents = [$this->application, $this->environment]; + $this->post(route('components.store', $parents), $this->input)->assertSessionDoesntHaveErrors(); + $component = $this->environment->components()->sole(); + $this->get(route('components.show', [...$parents, $component]))->assertSuccessful() + ->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page->component('applications/environments/component') + ->where('component.id', $component->id)->where('component.status', 'pending')->where('component.status_color', 'gray') + ->where('component.server_name', $this->server->name)->where('component.capabilities', ['web']) + ->where('environment.primary_component_id', $component->id)->where('environment.components_count', 1) + ->missing('component.deploy_key_id')->missing('component.deploy_key_source_control_id')->missing('component.server') + ->where('can.update', true)->where('can.delete', true)); + $component->forceFill(['status' => ComponentStatus::INSTALLATION_FAILED])->save(); + $this->post(route('components.retry', [...$parents, $component]))->assertSessionDoesntHaveErrors(); + $this->assertDatabaseHas('components', ['id' => $component->id, 'status' => 'pending']); + $this->delete(route('components.destroy', [...$parents, $component]))->assertRedirect(route('components.index', $parents)); + Queue::assertPushed(\App\Jobs\Component\DeleteComponentJob::class); + $this->assertDatabaseHas('components', ['id' => $component->id, 'status' => 'deleting']); +}); + +test('component listing scopes tables ready servers and dynamic capability forms', function () { + $this->actingAs($this->user); + $component = app(CreateComponent::class)->create($this->environment, $this->input); + Component::factory()->create(); + Server::factory()->create(['project_id' => $this->server->project_id, 'status' => ServerStatus::INSTALLING]); + $this->get(route('components.index', [$this->application, $this->environment]))->assertSuccessful() + ->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page->component('applications/environments/components') + ->has('components.data', 1)->where('components.data.0.id', $component->id) + ->has('options.servers', 1)->where('options.servers.0.id', $this->server->id) + ->has('options.capabilities', 3)->where('options.capabilities.0.id', 'web') + ->where('options.capabilities.0.form.0.name', 'web_directory')->where('canCreate', true)); +}); + +test('component routes reject mismatched parents before exposing data', function () { + $this->actingAs($this->user); + $component = Component::factory()->for($this->environment)->for($this->server)->create(); + $other = Environment::factory()->for($this->application)->create(); + $parents = [$this->application, $other, $component]; + $this->getJson(route('components.show', $parents))->assertNotFound(); + $this->postJson(route('components.retry', $parents))->assertNotFound(); + $this->deleteJson(route('components.destroy', $parents))->assertNotFound(); + $otherApplication = Application::factory()->create(); + $this->getJson(route('components.index', [$otherApplication, $this->environment]))->assertNotFound(); + $this->postJson(route('components.store', [$otherApplication, $this->environment]), $this->input)->assertNotFound(); +}); + +test('component endpoints enforce project membership authentication and write access', function (string $access) { + $this->actingAs($this->user); + $component = Component::factory()->for($this->environment)->for($this->server)->create(); + if ($access === 'guest') { + auth()->logout(); + } elseif ($access === 'foreign') { + $this->application->update(['project_id' => \App\Models\Project::factory()->create()->id]); + } else { + $this->user->currentProject->users()->where('user_id', $this->user->id)->update(['role' => UserRole::USER]); + } + $parents = [$this->application, $this->environment]; + $status = $access === 'guest' ? 401 : 403; + if ($access === 'reader') { + $this->get(route('components.index', $parents))->assertSuccessful() + ->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page->where('canCreate', false)); + $this->get(route('components.show', [...$parents, $component]))->assertSuccessful() + ->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page->where('can.update', false)->where('can.delete', false)); + } else { + $this->getJson(route('components.index', $parents))->assertStatus($status); + $this->getJson(route('components.show', [...$parents, $component]))->assertStatus($status); + } + $this->postJson(route('components.store', $parents), $this->input)->assertStatus($status); + $this->postJson(route('components.retry', [...$parents, $component]))->assertStatus($status); + $this->deleteJson(route('components.destroy', [...$parents, $component]))->assertStatus($status); + Queue::assertNothingPushed(); +})->with(['guest', 'foreign', 'reader']); + +test('component attachment holds the same isolated user lock as site teardown', function () { + Event::listen('eloquent.creating: '.Component::class, function (): void { + expect($this->server->isolatedUserLock('runtime')->get())->toBeFalse(); + expect(\Illuminate\Support\Facades\Cache::lock('unique-queue:server-'.$this->server->id, 600)->get())->toBeFalse(); + }); + + app(CreateComponent::class)->create($this->environment, $this->input); + expect($this->server->isolatedUserLock('runtime')->get(fn () => true))->toBeTrue(); +}); + +test('busy component servers return validation errors without attaching rows', function () { + $lock = Mockery::mock(\Illuminate\Contracts\Cache\Lock::class); + $lock->shouldReceive('block')->once()->with(10, Mockery::type(Closure::class))->andThrow(new \Illuminate\Contracts\Cache\LockTimeoutException); + \Illuminate\Support\Facades\Cache::shouldReceive('lock')->once()->with('unique-queue:server-'.$this->server->id, 600)->andReturn($lock); + + try { + app(CreateComponent::class)->create($this->environment, $this->input); + $this->fail('Expected a busy server validation error.'); + } catch (ValidationException $e) { + expect($e->errors())->toHaveKey('server_id'); + } + $this->assertDatabaseCount('components', 0); + Queue::assertNothingPushed(); +}); + +test('component options honor registered capability labels and explicit forms', function () { + \App\Plugins\RegisterComponentCapability::make('web')->handler(\App\ComponentCapabilities\Web::class) + ->label('Custom web')->form(new \App\DTOs\DynamicForm)->register(); + + $options = app(\App\Actions\Component\GetComponentOptions::class)->get($this->environment); + expect($options['capabilities'][0]['label'])->toBe('Custom web') + ->and($options['capabilities'][0]['form'])->toBe([]); +}); diff --git a/tests/Feature/EnvironmentsCrudTest.php b/tests/Feature/EnvironmentsCrudTest.php index 85ce94a3e..a5dcae5a3 100644 --- a/tests/Feature/EnvironmentsCrudTest.php +++ b/tests/Feature/EnvironmentsCrudTest.php @@ -47,7 +47,7 @@ test('update environment preserves slug and internal fields', function () { $input = array_replace($this->environmentInput, [ 'slug' => 'changed', 'application_id' => Application::factory()->create()->id, - 'status' => 'deleting', 'primary_component_id' => 999, + 'status' => 'deleting', 'primary_component_initialized' => true, ]); $this->put(route('environments.update', [$this->application, $this->environment]), $input)->assertSessionDoesntHaveErrors(); diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index 9645b1cb7..45446fecc 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -660,6 +660,26 @@ function (object $notifiable, object $notification): bool { expect($this->user->refresh()->current_project_id)->toEqual($newProject->id); })->with([UserRole::OWNER, UserRole::ADMIN]); +test('server transfer refreshes placement under a transaction before saving', function () { + $project = Project::factory()->create(); + $project->users()->create(['user_id' => $this->user->id, 'role' => UserRole::OWNER]); + Server::query()->whereKey($this->server->id)->update(['name' => 'Fresh server']); + $depth = \Illuminate\Support\Facades\DB::transactionLevel(); + $retrieved = false; + \Illuminate\Support\Facades\Event::listen('eloquent.retrieved: '.Server::class, function () use ($depth, &$retrieved): void { + $retrieved = true; + expect(\Illuminate\Support\Facades\DB::transactionLevel())->toBeGreaterThan($depth); + }); + \Illuminate\Support\Facades\Event::listen('eloquent.updating: '.Server::class, function () use ($depth): void { + expect(\Illuminate\Support\Facades\DB::transactionLevel())->toBeGreaterThan($depth); + }); + + $server = app(\App\Actions\Server\TransferServer::class)->transfer($this->user, $this->server, ['project_id' => $project->id]); + + expect($retrieved)->toBeTrue()->and($server->name)->toBe('Fresh server'); + $this->assertDatabaseHas('servers', ['id' => $this->server->id, 'project_id' => $project->id]); +}); + test('cannot transfer a server to a read-only project', function (): void { $project = Project::factory()->create(); $project->users()->create([ diff --git a/tests/Feature/SiteGroundworkTest.php b/tests/Feature/SiteGroundworkTest.php index d1594ffbf..f17643b4e 100644 --- a/tests/Feature/SiteGroundworkTest.php +++ b/tests/Feature/SiteGroundworkTest.php @@ -80,7 +80,8 @@ test('site deploy key retries preserve generated keys and skip completed registration', function (bool $isolated) { SSH::fake('ssh-rsa retry-public-key'); - Http::fake(['api.github.com/repos/*/keys' => Http::sequence()->push([], 403)->push(['id' => 123], 201)]); + $responses = Http::sequence()->push([], 403)->push(['id' => 123], 201); + Http::fake(fn ($request) => $request->method() === 'POST' ? $responses($request) : Http::response([])); $site = $isolated ? Site::factory()->create([ 'server_id' => $this->server->id, 'source_control_id' => $this->site->source_control_id, @@ -103,7 +104,7 @@ $method->invoke($type); expect(SSH::getExecutedCommands())->toBe([]); - Http::assertSentCount(2); + Http::assertSentCount(3); $this->assertDatabaseHas('sites', ['id' => $site->id, 'type_data->deploy_key_id' => '123']); })->with([false, true]); @@ -162,12 +163,14 @@ expect($this->server->isolatedUserLock('runtime')->get(fn () => true))->toBeTrue(); }); -test('fpm pool setup requires the requested php service', function () { +test('fpm pool setup requires an exact php service without changing the default', function (string $version) { SSH::fake(); + $this->server->services()->update(['is_default' => false]); - expect(fn () => app(EnsureFpmPool::class)->ensure($this->server, 'runtime', '0.0'))->toThrow(RuntimeException::class, 'PHP service not found'); + expect(fn () => app(EnsureFpmPool::class)->ensure($this->server, 'runtime', $version))->toThrow(RuntimeException::class, 'PHP service not found'); expect(SSH::getExecutedCommands())->toBe([]); -}); + expect($this->server->services()->where('is_default', true)->exists())->toBeFalse(); +})->with(['0.0', '', '0']); test('repository clone supports token and ssh authentication with optional commit pinning', function (bool $usesToken, ?string $commit) { SSH::fake(); diff --git a/tests/Feature/SitesTest.php b/tests/Feature/SitesTest.php index c0e556ace..47b2c0092 100644 --- a/tests/Feature/SitesTest.php +++ b/tests/Feature/SitesTest.php @@ -28,6 +28,23 @@ uses(RefreshDatabase::class); +test('site attachment shares the isolated user teardown lock and releases it before dispatch', function () { + SSH::fake(); + Http::fake(); + Queue::fake(); + \Illuminate\Support\Facades\Event::listen('eloquent.creating: '.Site::class, function (): void { + expect($this->server->isolatedUserLock('sharedruntime')->get())->toBeFalse(); + }); + + $this->actingAs($this->user)->post(route('sites.store', $this->server), [ + 'type' => Blank::id(), 'domain' => 'shared-runtime.example.com', 'port' => 3000, 'user' => 'sharedruntime', + ])->assertRedirect()->assertSessionDoesntHaveErrors(); + + $this->assertDatabaseHas('sites', ['domain' => 'shared-runtime.example.com', 'user' => 'sharedruntime']); + expect($this->server->isolatedUserLock('sharedruntime')->get(fn () => true))->toBeTrue(); + Queue::assertPushed(CreateJob::class); +}); + test('site creation rejects domains and aliases already served on the server', function (string $field, bool $hostedAlias) { SSH::fake(); Http::fake(); @@ -219,8 +236,8 @@ SSH::fake(); Http::fake([ - 'https://api.github.com/repos/*' => Http::response([ - ], 201), + 'https://api.github.com/repos/*/keys' => Http::response(['id' => 123], 201), + 'https://api.github.com/repos/*' => Http::response([], 201), ]); if (isset($inputs['database']) && isset($inputs['database_user'])) { @@ -555,6 +572,7 @@ test('create laravel site dispatches ensure env script', function () { SSH::fake(); Http::fake([ + 'https://api.github.com/repos/*/keys' => Http::response(['id' => 123], 201), 'https://api.github.com/repos/*' => Http::response([], 201), ]); diff --git a/tests/Feature/SourceControlsTest.php b/tests/Feature/SourceControlsTest.php index 16f640a52..70e279937 100644 --- a/tests/Feature/SourceControlsTest.php +++ b/tests/Feature/SourceControlsTest.php @@ -465,3 +465,114 @@ [BitbucketV2::id(), null, ['key' => 'test', 'secret' => 'test']], ]; }); + +test('deploy key deletion is retryable and rejects provider failures without exposing responses', function (string $provider, int $status) { + Http::preventStrayRequests(); + Http::fake([ + 'bitbucket.org/site/oauth2/access_token' => Http::response(['access_token' => 'token'], 200), + '*' => Http::response(['message' => 'SECRET'], $status), + ]); + $sourceControl = SourceControl::factory()->create(['provider' => $provider]); + if ($status === 500) { + expect(fn () => $sourceControl->provider()->deleteDeployKey('123', 'owner/repository')) + ->toThrow(RuntimeException::class, 'Unable to remove the source-control deploy key.'); + } else { + $sourceControl->provider()->deleteDeployKey('123', 'owner/repository'); + Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_ends_with($request->url(), '/123')); + } +})->with(['github', 'gitlab', 'gitea', 'bitbucket', 'bitbucket-v2'])->with([204, 404, 500]); + +test('deploy key registration recovers existing keys after an ambiguous response', function (string $provider) { + Http::preventStrayRequests(); + Http::fake(function ($request) use ($provider) { + if (str_contains($request->url(), 'oauth2')) { + return Http::response(['access_token' => 'token'], 200); + } + if ($request->method() === 'POST') { + return Http::response(['message' => 'already registered'], 422); + } + $keys = [['id' => 321, 'key' => 'ssh-rsa unique-public-key existing-comment']]; + return Http::response(str_starts_with($provider, 'bitbucket') ? ['values' => $keys] : $keys); + }); + $sourceControl = SourceControl::factory()->create(['provider' => $provider]); + expect($sourceControl->provider()->deployKey('component_123', 'owner/repository', 'ssh-rsa unique-public-key'))->toBe('321'); +})->with(['github', 'gitlab', 'gitea', 'bitbucket', 'bitbucket-v2']); + +test('deploy key registration sanitizes errors and does not accept empty key ids', function (string $provider) { + Http::preventStrayRequests(); + Http::fake([ + 'bitbucket.org/site/oauth2/access_token' => Http::response(['access_token' => 'token'], 200), + '*' => Http::response(['message' => 'SECRET'], 500), + ]); + $sourceControl = SourceControl::factory()->create(['provider' => $provider]); + expect(fn () => $sourceControl->provider()->deployKey('component_123', 'owner/repository', 'ssh-rsa unique-public-key')) + ->toThrow(\App\Exceptions\FailedToDeployGitKey::class, 'Unable to register the source-control deploy key.'); +})->with(['github', 'gitlab', 'gitea', 'bitbucket', 'bitbucket-v2']); + +test('deploy key recovery scans capped pages and fails safely if no key matches', function (bool $match, string $providerId) { + Http::fake(function ($request) use ($match, $providerId) { + if ($request->method() === 'POST') { + return Http::response([], 201); + } + if ($request['page'] === 1) { + return Http::response(array_fill(0, $providerId === 'gitea' ? 50 : 100, ['id' => 1, 'key' => 'ssh-rsa another-key'])); + } + return Http::response($match ? [['id' => 321, 'key' => 'ssh-rsa unique-public-key']] : []); + }); + $provider = SourceControl::factory()->create(['provider' => $providerId])->provider(); + if ($match) { + expect($provider->deployKey('component_123', 'owner/repository', 'ssh-rsa unique-public-key'))->toBe('321'); + } else { + expect(fn () => $provider->deployKey('component_123', 'owner/repository', 'ssh-rsa unique-public-key')) + ->toThrow(\App\Exceptions\FailedToDeployGitKey::class); + } + Http::assertSentCount(3); +})->with([false, true])->with(['github', 'gitea']); + +test('deploy key registration reconciles lost post responses', function (string $provider) { + Http::fake(function ($request) use ($provider) { + if (str_contains($request->url(), 'oauth2')) { + return Http::response(['access_token' => 'token']); + } + if ($request->method() === 'POST') { + throw new \Illuminate\Http\Client\ConnectionException('SECRET'); + } + $keys = [['id' => 987, 'key' => 'ssh-rsa lost-response-key']]; + + return Http::response(str_starts_with($provider, 'bitbucket') ? ['values' => $keys] : $keys); + }); + expect(SourceControl::factory()->create(['provider' => $provider])->provider() + ->deployKey('component_123', 'owner/repository', 'ssh-rsa lost-response-key', true))->toBe('987'); +})->with(['github', 'gitlab', 'gitea', 'bitbucket', 'bitbucket-v2']); + +test('component keys are read only while legacy site key permissions are preserved', function (string $provider, bool $readOnly) { + Http::fake(['*' => Http::response(['id' => 123], 201)]); + SourceControl::factory()->create(['provider' => $provider])->provider()->deployKey('test', 'owner/repository', 'ssh-rsa key', $readOnly); + + Http::assertSent(fn ($request) => $provider === 'github' ? $request['read_only'] === $readOnly : $request['can_push'] === ! $readOnly); +})->with(['github', 'gitlab'])->with([false, true]); + +test('bitbucket oauth errors cannot escape deploy key sanitization or enter logs', function () { + \Illuminate\Support\Facades\Log::spy(); + Http::fake(['*' => Http::response(['error_description' => 'SECRET'], 401)]); + expect(fn () => SourceControl::factory()->create(['provider' => 'bitbucket-v2'])->provider()->deployKey('test', 'owner/repository', 'ssh-rsa key')) + ->toThrow(\App\Exceptions\FailedToDeployGitKey::class, 'Unable to register the source-control deploy key.'); + \Illuminate\Support\Facades\Log::shouldHaveReceived('error')->once()->with('Failed to get Bitbucket access token with client credentials', ['status' => 401]); +}); + +test('deploy key lookup never treats malformed responses as confirmed absence', function (mixed $body) { + Http::fake(['*' => Http::response($body)]); + expect(fn () => SourceControl::factory()->create()->provider()->findDeployKey('owner/repository', 'ssh-rsa public-key')) + ->toThrow(\App\Exceptions\FailedToDeployGitKey::class, 'Unable to look up the source-control deploy key.'); +})->with([ + 'invalid JSON' => ['invalid JSON'], + 'missing id' => [[['key' => 'ssh-rsa public-key']]], + 'missing key' => [[['id' => 123]]], +]); + +test('deploy key lookup rejects missing public keys before any request', function () { + Http::fake(); + expect(fn () => SourceControl::factory()->create()->provider()->findDeployKey('owner/repository', '')) + ->toThrow(\App\Exceptions\FailedToDeployGitKey::class); + Http::assertNothingSent(); +}); From f6df5ae2e79a6f7401837fbd435758f5f9650885 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sun, 18 Oct 2026 15:35:15 +0200 Subject: [PATCH 07/30] phase 7 --- app/Actions/Component/CreateComponent.php | 4 - app/Actions/Component/DeleteComponent.php | 9 + app/Actions/Component/HasComponentRelease.php | 15 + app/Actions/Component/InstallComponent.php | 32 +- .../Component/RestartComponentWorkers.php | 18 + .../Component/SyncComponentCapabilities.php | 61 +++ .../Component/SyncComponentRuntime.php | 57 +++ .../Component/UpdateComponentCapabilities.php | 87 ++++ .../Component/ValidateComponentProcess.php | 91 ++++ .../InstallSchedulerCapability.php | 44 ++ .../InstallWebCapability.php | 72 +++ .../ValidateWebCapability.php | 8 + app/Actions/CronJob/CreateCronJob.php | 62 +-- app/Actions/CronJob/DeleteCronJob.php | 22 +- app/Actions/CronJob/DisableCronJob.php | 18 +- app/Actions/CronJob/EditCronJob.php | 74 ++-- app/Actions/CronJob/EnableCronJob.php | 18 +- app/Actions/CronJob/SyncCronJobs.php | 39 +- app/Actions/Environment/UpdateEnvironment.php | 31 +- app/Actions/Worker/CreateWorker.php | 73 +-- app/Actions/Worker/DeleteWorker.php | 34 +- app/Actions/Worker/EditWorker.php | 66 +-- app/Actions/Worker/InstallWorker.php | 42 ++ app/Actions/Worker/ManageWorker.php | 13 + app/Actions/Worker/RestartAllWorkers.php | 1 + app/Actions/Worker/RestartSiteWorkers.php | 138 +----- app/Actions/Worker/RestartWorkers.php | 156 +++++++ app/Actions/Worker/SyncWorkerStatuses.php | 3 +- .../Worker/UpdateWorkerEnvironment.php | 4 + .../AbstractComponentCapability.php | 4 + app/ComponentCapabilities/Scheduler.php | 10 + app/ComponentCapabilities/Web.php | 12 +- app/ComponentCapabilities/Worker.php | 5 + app/DTOs/ToolingTargetDTO.php | 12 + app/Helpers/ComponentShell.php | 31 ++ .../Applications/ComponentController.php | 17 + .../ComponentCronJobController.php | 78 ++++ .../ComponentWorkerController.php | 87 ++++ app/Http/Controllers/CronJobController.php | 2 +- app/Http/Controllers/WorkerController.php | 4 +- app/Http/Resources/ComponentResource.php | 10 + app/Http/Resources/CronJobResource.php | 7 +- app/Http/Resources/WorkerResource.php | 7 +- .../SyncComponentCapabilitiesJob.php | 31 ++ .../Component/SyncComponentRuntimeJob.php | 31 ++ app/Jobs/Worker/CreateJob.php | 18 +- app/Jobs/Worker/EditJob.php | 20 +- app/Jobs/Worker/ManageJob.php | 2 + app/Jobs/Worker/RestartAllJob.php | 3 +- app/Models/Component.php | 1 + app/Models/CronJob.php | 15 +- app/Models/IsolatedUser.php | 2 +- app/Models/Server.php | 20 +- app/Models/Worker.php | 6 +- app/Policies/CronJobPolicy.php | 47 +- app/Policies/WorkerPolicy.php | 50 +-- app/Services/ProcessManager/Supervisor.php | 15 +- .../Applications/ComponentCronJobTable.php | 39 ++ .../Applications/ComponentWorkerTable.php | 39 ++ app/Traits/HandlesWorkerFailure.php | 4 + app/Traits/UniqueQueue.php | 6 + ...component_capability_process_ownership.php | 34 ++ resources/js/components/dialogs/registry.ts | 2 + resources/js/components/ui/dynamic-field.tsx | 32 +- resources/js/layouts/environment/layout.tsx | 4 +- .../applications/components/add-component.tsx | 29 +- .../components/capabilities-dialog.tsx | 42 ++ .../components/capability-fields.tsx | 38 ++ .../components/environment-fields.tsx | 11 +- .../components/environment-form.tsx | 2 +- .../applications/environments/component.tsx | 95 +++- .../js/pages/cronjobs/components/columns.tsx | 26 +- .../js/pages/cronjobs/components/form.tsx | 24 +- .../js/pages/workers/components/columns.tsx | 20 +- .../js/pages/workers/components/form.tsx | 34 +- .../workers/components/worker-row-actions.tsx | 2 +- resources/js/types/application.d.ts | 4 + resources/js/types/cronjob.d.ts | 3 + resources/js/types/worker.d.ts | 3 + .../ssh/components/has-current.blade.php | 3 + .../supervisor/create-worker.blade.php | 2 + .../supervisor/delete-worker.blade.php | 2 + .../ssh/site/check-fpm-pool-exists.blade.php | 4 +- .../ComponentCapabilityInstallTest.php | 418 ++++++++++++++++++ tests/Feature/ComponentProcessesTest.php | 335 ++++++++++++++ tests/Feature/UniqueQueueTest.php | 12 + 86 files changed, 2574 insertions(+), 534 deletions(-) create mode 100644 app/Actions/Component/HasComponentRelease.php create mode 100644 app/Actions/Component/RestartComponentWorkers.php create mode 100644 app/Actions/Component/SyncComponentCapabilities.php create mode 100644 app/Actions/Component/SyncComponentRuntime.php create mode 100644 app/Actions/Component/UpdateComponentCapabilities.php create mode 100644 app/Actions/Component/ValidateComponentProcess.php create mode 100644 app/Actions/ComponentCapability/InstallSchedulerCapability.php create mode 100644 app/Actions/ComponentCapability/InstallWebCapability.php create mode 100644 app/Actions/Worker/InstallWorker.php create mode 100644 app/Actions/Worker/RestartWorkers.php create mode 100644 app/Helpers/ComponentShell.php create mode 100644 app/Http/Controllers/Applications/ComponentCronJobController.php create mode 100644 app/Http/Controllers/Applications/ComponentWorkerController.php create mode 100644 app/Jobs/Component/SyncComponentCapabilitiesJob.php create mode 100644 app/Jobs/Component/SyncComponentRuntimeJob.php create mode 100644 app/Tables/Applications/ComponentCronJobTable.php create mode 100644 app/Tables/Applications/ComponentWorkerTable.php create mode 100644 database/migrations/2026_10_18_083748_add_component_capability_process_ownership.php create mode 100644 resources/js/pages/applications/components/capabilities-dialog.tsx create mode 100644 resources/js/pages/applications/components/capability-fields.tsx create mode 100644 resources/views/ssh/components/has-current.blade.php create mode 100644 tests/Feature/ComponentCapabilityInstallTest.php create mode 100644 tests/Feature/ComponentProcessesTest.php diff --git a/app/Actions/Component/CreateComponent.php b/app/Actions/Component/CreateComponent.php index f43327ecc..7a2a6c62e 100644 --- a/app/Actions/Component/CreateComponent.php +++ b/app/Actions/Component/CreateComponent.php @@ -136,9 +136,5 @@ private function validate(Environment $environment, Server $server, array $input } } - if (in_array('web', array_column($input['capabilities'] ?? [], 'capability'), true) - && $environment->components()->where('server_id', $server->id)->whereHas('capabilities', fn ($query) => $query->where('capability', 'web'))->exists()) { - throw ValidationException::withMessages(['capabilities' => 'Only one web component per environment is allowed on each server.']); - } } } diff --git a/app/Actions/Component/DeleteComponent.php b/app/Actions/Component/DeleteComponent.php index efc10d72e..e1e991c0d 100644 --- a/app/Actions/Component/DeleteComponent.php +++ b/app/Actions/Component/DeleteComponent.php @@ -2,6 +2,8 @@ namespace App\Actions\Component; +use App\Actions\CronJob\DeleteCronJob; +use App\Actions\Worker\DeleteWorker; use App\Enums\ComponentStatus; use App\Enums\ComponentType; use App\Jobs\Component\DeleteComponentJob; @@ -54,6 +56,13 @@ public function handle(int $componentId): void } app(BroadcastComponentUpdate::class)->broadcast($component); + foreach ($component->capabilities()->get() as $capability) { + $capability->handler()->uninstall($component); + } + app(DeleteWorker::class)->deleteForComponent($component); + foreach ($component->cronJobs()->get() as $cronJob) { + app(DeleteCronJob::class)->remove($component->server, $cronJob); + } $keys = app(SyncComponentDeployKeys::class); $keys->remove($component); $keys->removeLocal($component); diff --git a/app/Actions/Component/HasComponentRelease.php b/app/Actions/Component/HasComponentRelease.php new file mode 100644 index 000000000..c9998438e --- /dev/null +++ b/app/Actions/Component/HasComponentRelease.php @@ -0,0 +1,15 @@ +server->ssh($component->user)->exec( + view('ssh.components.has-current', ['component' => $component]), + )) === 'VITO_COMPONENT_CURRENT_EXISTS'; + } +} diff --git a/app/Actions/Component/InstallComponent.php b/app/Actions/Component/InstallComponent.php index 9dcff4b20..435db8916 100644 --- a/app/Actions/Component/InstallComponent.php +++ b/app/Actions/Component/InstallComponent.php @@ -3,13 +3,11 @@ namespace App\Actions\Component; use App\Actions\IsolatedUser\EnsureIsolatedUser; -use App\DTOs\ToolingTargetDTO; use App\Enums\ApplicationStatus; use App\Enums\ComponentStatus; use App\Enums\EnvironmentStatus; use App\Models\Application; use App\Models\Component; -use App\Tooling\ToolingRegistry; use Illuminate\Support\Facades\DB; use RuntimeException; @@ -48,19 +46,25 @@ public function install(int $componentId): void $component->server->ssh($component->user)->exec(view('ssh.components.create-directories', ['component' => $component]), 'component-directories'); - $toolId = $component->environment->application->preset()->tooling(); - if ($toolId !== null) { - $tool = ToolingRegistry::find($toolId) ?? throw new RuntimeException('The runtime tooling is unavailable.'); - $tool->install(new ToolingTargetDTO( - server: $component->server, - user: $component->user, - isolatedUser: $component->isolatedUser, - phpVersion: $component->environment->runtime_version, - configDir: $component->path, - ), $component->environment->runtime_version ?? ''); - } + app(SyncComponentRuntime::class)->sync($component); + $application = $component->environment->application; + app(SyncComponentDeployKeys::class)->sync($component); + app(SyncComponentCapabilities::class)->sync($component); - app(SyncComponentDeployKeys::class)->handle($componentId, ComponentStatus::INSTALLING); + DB::transaction(function () use ($component, $application): void { + $current = Application::query()->lockForUpdate()->findOrFail($application->id); + $component->refresh(); + if ($component->status !== ComponentStatus::INSTALLING) { + return; + } + if ($current->source_control_id !== $application->source_control_id || $current->repository !== $application->repository) { + throw new RuntimeException('The application repository changed during installation. Retry installation.'); + } + $component->status = ComponentStatus::READY; + $component->last_error = null; + $component->save(); + }); + app(BroadcastComponentUpdate::class)->broadcast($component); } public function failed(int $componentId): void diff --git a/app/Actions/Component/RestartComponentWorkers.php b/app/Actions/Component/RestartComponentWorkers.php new file mode 100644 index 000000000..aae0e4e65 --- /dev/null +++ b/app/Actions/Component/RestartComponentWorkers.php @@ -0,0 +1,18 @@ +check($component)) { + return; + } + + app(RestartWorkers::class)->restart($component->server, $component->workers()->with('component.environment.application', 'component.isolatedUser')->get()); + } +} diff --git a/app/Actions/Component/SyncComponentCapabilities.php b/app/Actions/Component/SyncComponentCapabilities.php new file mode 100644 index 000000000..3a0479715 --- /dev/null +++ b/app/Actions/Component/SyncComponentCapabilities.php @@ -0,0 +1,61 @@ +with('environment.application')->find($componentId); + if (! $component || ! in_array($component->status, [ComponentStatus::PENDING, ComponentStatus::INSTALLING], true) + || $component->environment->status !== EnvironmentStatus::READY + || $component->environment->application->status !== ApplicationStatus::READY) { + return; + } + + $this->sync($component); + $component->refresh(); + if (in_array($component->status, [ComponentStatus::PENDING, ComponentStatus::INSTALLING], true)) { + $component->status = ComponentStatus::READY; + $component->last_error = null; + $component->save(); + app(BroadcastComponentUpdate::class)->broadcast($component); + } + } + + public function sync(Component $component): void + { + foreach ($component->capabilities()->orderBy('id')->get() as $capability) { + $removing = in_array($capability->status, [ComponentStatus::DELETING, ComponentStatus::DELETION_FAILED], true); + if ($capability->status === ComponentStatus::READY) { + continue; + } + try { + if ($removing) { + $capability->handler()->uninstall($component); + $capability->delete(); + } else { + $capability->status = ComponentStatus::INSTALLING; + $capability->save(); + $capability->handler()->install($component); + $capability->status = ComponentStatus::READY; + $capability->save(); + } + } catch (Throwable $e) { + if ($e instanceof LockTimeoutException) { + throw $e; + } + $capability->status = $removing ? ComponentStatus::DELETION_FAILED : ComponentStatus::INSTALLATION_FAILED; + $capability->save(); + throw $e; + } + } + } +} diff --git a/app/Actions/Component/SyncComponentRuntime.php b/app/Actions/Component/SyncComponentRuntime.php new file mode 100644 index 000000000..369781b7f --- /dev/null +++ b/app/Actions/Component/SyncComponentRuntime.php @@ -0,0 +1,57 @@ +with('environment.application')->find($componentId); + if (! $component || ! in_array($component->status, [ComponentStatus::PENDING, ComponentStatus::INSTALLING], true) + || $component->environment->status !== EnvironmentStatus::READY + || $component->environment->application->status !== ApplicationStatus::READY) { + return; + } + $this->sync($component); + $component->refresh(); + if (in_array($component->status, [ComponentStatus::PENDING, ComponentStatus::INSTALLING], true)) { + $component->status = ComponentStatus::READY; + $component->last_error = null; + $component->save(); + app(BroadcastComponentUpdate::class)->broadcast($component); + } + } + + public function sync(Component $component): void + { + $preset = $component->environment->application->preset(); + $version = $component->environment->runtime_version; + $toolId = $preset->tooling(); + if ($toolId !== null) { + $tool = ToolingRegistry::find($toolId) ?? throw new RuntimeException('The runtime tooling is unavailable.'); + $tool->install(ToolingTargetDTO::forComponent($component), $version ?? ''); + } + + if ($preset->webMode() === WebMode::FPM && $component->hasCapability('web')) { + if ($component->user !== $component->server->getSshUser()) { + app(EnsureFpmPool::class)->ensure($component->server, $component->user, $version ?? ''); + } + if ($component->installed_runtime_version !== null && $component->installed_runtime_version !== $version) { + app(InstallWebCapability::class)->removePool($component, $component->installed_runtime_version); + } + } + $component->installed_runtime_version = $version; + $component->save(); + } +} diff --git a/app/Actions/Component/UpdateComponentCapabilities.php b/app/Actions/Component/UpdateComponentCapabilities.php new file mode 100644 index 000000000..87039b8c1 --- /dev/null +++ b/app/Actions/Component/UpdateComponentCapabilities.php @@ -0,0 +1,87 @@ + $input + */ + public function update(Component $component, array $input): Component + { + Validator::make($input, [ + 'capabilities' => ['present', 'array', 'list'], + 'capabilities.*' => ['array:capability,config'], + 'capabilities.*.capability' => ['required', 'string', 'distinct', Rule::in(array_keys(config('component.capabilities', [])))], + 'capabilities.*.config' => ['sometimes', 'nullable', 'array'], + ])->validate(); + + try { + $component = Cache::lock('unique-queue:server-'.$component->server_id, 600)->block(10, fn (): Component => DB::transaction(function () use ($component, $input): Component { + Application::query()->lockForUpdate()->findOrFail($component->environment->application_id); + Environment::query()->lockForUpdate()->findOrFail($component->environment_id); + $server = Server::query()->lockForUpdate()->findOrFail($component->server_id); + $component->refresh(); + if ($component->status !== ComponentStatus::READY || ! $server->isReady() + || $component->environment->status !== EnvironmentStatus::READY + || $component->environment->application->status !== ApplicationStatus::READY) { + throw ValidationException::withMessages(['capabilities' => 'Only a ready component outside teardown can change capabilities.']); + } + + $existing = $component->capabilities()->get()->keyBy('capability'); + foreach ($input['capabilities'] as $configuration) { + $capability = $existing->get($configuration['capability']) ?? new ComponentCapability([ + 'component_id' => $component->id, 'capability' => $configuration['capability'], + ]); + $capability->config = $configuration['config'] ?? []; + try { + $capability->handler()->validate($component, $server); + } catch (ValidationException $e) { + throw ValidationException::withMessages(['capabilities' => array_merge(...array_values($e->errors()))]); + } + if (! $capability->exists || $capability->isDirty('config')) { + $capability->status = ComponentStatus::PENDING; + $capability->save(); + } + $existing->forget($configuration['capability']); + } + foreach ($existing as $capability) { + $capability->status = ComponentStatus::DELETING; + $capability->save(); + } + if ($component->capabilities()->where('status', '!=', ComponentStatus::READY)->exists()) { + $component->status = ComponentStatus::PENDING; + $component->needs_redeploy = true; + $component->save(); + DB::afterCommit(fn () => app(BroadcastComponentUpdate::class)->broadcast($component)); + } + + return $component; + })); + } catch (LockTimeoutException) { + throw ValidationException::withMessages(['capabilities' => 'Another operation on this server is in progress. Please retry.']); + } + + if ($component->status === ComponentStatus::PENDING) { + SyncComponentCapabilitiesJob::dispatch($component->id, $component->server_id)->afterCommit(); + } + + return $component; + } +} diff --git a/app/Actions/Component/ValidateComponentProcess.php b/app/Actions/Component/ValidateComponentProcess.php new file mode 100644 index 000000000..cfd298b4b --- /dev/null +++ b/app/Actions/Component/ValidateComponentProcess.php @@ -0,0 +1,91 @@ +server_id, 600)->block(0, fn () => DB::transaction(function () use ($component, $callback) { + Application::query()->lockForUpdate()->findOrFail($component->environment->application_id); + Environment::query()->lockForUpdate()->findOrFail($component->environment_id); + Server::query()->lockForUpdate()->findOrFail($component->server_id); + $component->refresh(); + + return $callback(); + })); + } catch (LockTimeoutException) { + throw ValidationException::withMessages(['name' => 'Another operation on this server is in progress. Please retry.']); + } + } + + /** + * @param array $input + */ + public function validate(Server $server, array $input, ?Component $component, string $capability, Worker|CronJob|null $process = null, ?ComponentCapability $managedBy = null): void + { + Validator::make($input, [ + 'component_id' => ['prohibited'], + 'component_capability_id' => ['prohibited'], + ])->validate(); + + if ($process?->component_capability_id !== null && $managedBy === null) { + throw ValidationException::withMessages(['name' => 'This process is managed by its component capability. Edit the capability instead.']); + } + + if ($managedBy !== null && ($component === null || $managedBy->component_id !== $component->id)) { + throw ValidationException::withMessages(['name' => 'A managed process must belong to its capability component.']); + } + + if ($component === null) { + return; + } + + if ($component->server_id !== $server->id || $component->project()->id !== $server->project_id + || ($process && $process->component_id !== $component->id)) { + throw ValidationException::withMessages(['name' => 'The process must belong to this component and server.']); + } + + if ($managedBy === null && ($component->status !== ComponentStatus::READY + || $component->environment->status !== EnvironmentStatus::READY + || $component->environment->application->status !== ApplicationStatus::READY + || ($process === null && ! $component->hasCapability($capability)))) { + throw ValidationException::withMessages(['name' => 'The component and its capability must be ready before managing processes.']); + } + + Validator::make($input, [ + 'user' => ['sometimes', Rule::in([$component->user])], + 'site_id' => ['prohibited'], + 'environment' => ['prohibited'], + 'directory' => ['prohibited'], + ])->validate(); + } +} diff --git a/app/Actions/ComponentCapability/InstallSchedulerCapability.php b/app/Actions/ComponentCapability/InstallSchedulerCapability.php new file mode 100644 index 000000000..982d379e0 --- /dev/null +++ b/app/Actions/ComponentCapability/InstallSchedulerCapability.php @@ -0,0 +1,44 @@ +cronJobs()->where('component_capability_id', $capability->id)->first(); + if (! $cronJob) { + app(CreateCronJob::class)->create($component->server, [ + 'name' => 'scheduler', 'user' => $component->user, + 'command' => $capability->config['command'], 'frequency' => '* * * * *', + ], component: $component, managedBy: $capability); + + return; + } + + app(SyncCronJobs::class)->run($component->server, function () use ($capability, $component, $cronJob): void { + app(SyncCronJobs::class)->sync($component->server, locked: true); + $cronJob->command = $capability->config['command']; + $cronJob->status = CronjobStatus::UPDATING; + $cronJob->save(); + $component->server->cron()->update($component->user, CronJob::crontab($component->server, $component->user)); + $cronJob->status = CronjobStatus::READY; + $cronJob->save(); + }, background: true); + } + + public function uninstall(ComponentCapability $capability, Component $component): void + { + foreach ($component->cronJobs()->where('component_capability_id', $capability->id)->get() as $cronJob) { + app(DeleteCronJob::class)->remove($component->server, $cronJob); + } + } +} diff --git a/app/Actions/ComponentCapability/InstallWebCapability.php b/app/Actions/ComponentCapability/InstallWebCapability.php new file mode 100644 index 000000000..87f078b3f --- /dev/null +++ b/app/Actions/ComponentCapability/InstallWebCapability.php @@ -0,0 +1,72 @@ +environment->application->preset()->webMode(); + if ($mode === WebMode::FPM && $component->user !== $component->server->getSshUser()) { + app(EnsureFpmPool::class)->ensure($component->server, $component->user, $component->environment->runtime_version ?? ''); + } + if ($mode !== WebMode::PROXY) { + return; + } + + $worker = $component->workers()->where('component_capability_id', $capability->id)->first(); + if (! $worker) { + app(CreateWorker::class)->create($component->server, [ + 'name' => 'app', 'command' => $capability->config['start_command'], + 'user' => $component->user, 'auto_start' => true, 'auto_restart' => true, 'numprocs' => 1, + ], component: $component, managedBy: $capability); + + return; + } + + $worker->command = $capability->config['start_command']; + $worker->save(); + app(InstallWorker::class)->install($worker, replace: true); + } + + public function uninstall(ComponentCapability $capability, Component $component): void + { + foreach ($component->workers()->where('component_capability_id', $capability->id)->get() as $worker) { + app(DeleteWorker::class)->remove($worker); + } + if ($component->environment->application->preset()->webMode() === WebMode::FPM) { + foreach (array_unique(array_filter([$component->installed_runtime_version, $component->environment->runtime_version])) as $version) { + $this->removePool($component, $version); + } + } + } + + public function removePool(Component $component, string $version): void + { + $user = $component->isolatedUser; + if (! $user || $component->user === $component->server->getSshUser()) { + return; + } + $user->lock()->block(30, function () use ($component, $user, $version): void { + if ($user->fpmPoolUsedByOthers($version, $component)) { + return; + } + $service = $component->server->services()->where('type', 'php')->where('version', $version)->first(); + if ($service) { + /** @var PHP $php */ + $php = $service->handler(); + $php->removeFpmPool($component->user, $version, null); + } + }); + } +} diff --git a/app/Actions/ComponentCapability/ValidateWebCapability.php b/app/Actions/ComponentCapability/ValidateWebCapability.php index 55cdb4e5b..6354cc151 100644 --- a/app/Actions/ComponentCapability/ValidateWebCapability.php +++ b/app/Actions/ComponentCapability/ValidateWebCapability.php @@ -17,6 +17,11 @@ class ValidateWebCapability */ public function validate(ComponentCapability $capability, Component $component, Server $server): void { + if ($component->environment->components()->where('server_id', $server->id)->whereKeyNot($component->id) + ->whereHas('capabilities', fn ($query) => $query->where('capability', 'web'))->exists()) { + throw ValidationException::withMessages(['capabilities' => 'Only one web component per environment is allowed on each server.']); + } + $preset = $component->environment->application->preset(); $proxy = $preset->webMode() === WebMode::PROXY; $config = array_replace(['web_directory' => $preset->webRoot()], $capability->config ?? []); @@ -44,6 +49,9 @@ public function validate(ComponentCapability $capability, Component $component, } if ($proxy) { + if ($component->workers()->where('name', 'app')->whereNull('component_capability_id')->exists()) { + throw ValidationException::withMessages(['capabilities' => 'Rename the worker named "app" before adding the web capability.']); + } $config['port'] = $this->port($capability, $server, $config['port'] === null ? null : (int) $config['port']); } diff --git a/app/Actions/CronJob/CreateCronJob.php b/app/Actions/CronJob/CreateCronJob.php index cb3336460..a8c02f3e4 100755 --- a/app/Actions/CronJob/CreateCronJob.php +++ b/app/Actions/CronJob/CreateCronJob.php @@ -2,6 +2,9 @@ namespace App\Actions\CronJob; +use App\Actions\Component\ValidateComponentProcess; +use App\Models\Component; +use App\Models\ComponentCapability; use App\Enums\CronjobStatus; use App\Exceptions\SSHError; use App\Models\CronJob; @@ -18,43 +21,53 @@ class CreateCronJob * * @throws SSHError */ - public function create(Server $server, array $input, ?Site $site = null): CronJob + public function create(Server $server, array $input, ?Site $site = null, ?Component $component = null, ?ComponentCapability $managedBy = null): CronJob { - $this->validate($input, $server, $site); + return app(SyncCronJobs::class)->run($server, fn () => app(ValidateComponentProcess::class)->run($managedBy ? null : $component, function () use ($server, $input, $site, $component, $managedBy): CronJob { + app(ValidateComponentProcess::class)->validate($server, $input, $component, 'scheduler', managedBy: $managedBy); + if ($component) { + $input['user'] = $component->user; + } + $this->validate($input, $server, $site, $component); + if ($component) { + app(SyncCronJobs::class)->sync($server, locked: true); + } - // Determine site_id: use provided site or from input - $siteId = $site?->id; - if (! $site && isset($input['site_id']) && ! empty($input['site_id'])) { - $siteId = (int) $input['site_id']; - } + $siteId = $site?->id; + if (! $site && isset($input['site_id']) && ! empty($input['site_id'])) { + $siteId = (int) $input['site_id']; + } - $cronJob = new CronJob([ - 'name' => $input['name'] ?? null, - 'server_id' => $server->id, - 'site_id' => $siteId, - 'user' => $input['user'], - 'command' => $input['command'], - 'frequency' => $input['frequency'] == 'custom' ? $input['custom'] : $input['frequency'], - 'status' => CronjobStatus::CREATING, - ]); - $cronJob->save(); + $cronJob = new CronJob([ + 'name' => $input['name'] ?? null, + 'server_id' => $server->id, + 'site_id' => $siteId, + 'user' => $input['user'], + 'command' => $input['command'], + 'frequency' => $input['frequency'] == 'custom' ? $input['custom'] : $input['frequency'], + 'status' => CronjobStatus::CREATING, + ]); + $cronJob->component_id = $component?->id; + $cronJob->component_capability_id = $managedBy?->id; + $cronJob->save(); - $server->cron()->update($cronJob->user, CronJob::crontab($server, $cronJob->user)); - $cronJob->status = CronjobStatus::READY; - $cronJob->save(); + $server->cron()->update($cronJob->user, CronJob::crontab($server, $cronJob->user)); + $cronJob->status = CronjobStatus::READY; + $cronJob->save(); - return $cronJob; + return $cronJob; + }), background: $managedBy !== null); } - private function validate(array $input, Server $server, ?Site $site = null): void + private function validate(array $input, Server $server, ?Site $site = null, ?Component $component = null): void { $rules = [ 'command' => [ - 'required', + 'required', 'string', 'max:65535', 'not_regex:/\x00/', ], 'user' => [ 'required', - Rule::in($site?->getSshUsers() ?? $server->getSshUsers()), + Rule::in($component ? [$component->user] : ($site?->getSshUsers() ?? $server->getSshUsers())), ], 'frequency' => [ 'required', @@ -67,7 +80,6 @@ private function validate(array $input, Server $server, ?Site $site = null): voi ], ]; - // Add site_id validation if provided in input if (isset($input['site_id']) && ! empty($input['site_id'])) { $rules['site_id'] = [ 'required', diff --git a/app/Actions/CronJob/DeleteCronJob.php b/app/Actions/CronJob/DeleteCronJob.php index 45d315603..b9e6417e7 100755 --- a/app/Actions/CronJob/DeleteCronJob.php +++ b/app/Actions/CronJob/DeleteCronJob.php @@ -2,6 +2,8 @@ namespace App\Actions\CronJob; +use App\Actions\Component\ValidateComponentProcess; +use App\Models\Component; use App\Enums\CronjobStatus; use App\Exceptions\SSHError; use App\Models\CronJob; @@ -12,10 +14,24 @@ class DeleteCronJob /** * @throws SSHError */ - public function delete(Server $server, CronJob $cronJob): void + public function delete(Server $server, CronJob $cronJob, ?Component $component = null): void { - // Sync before deleting to preserve any manual cronjobs - app(SyncCronJobs::class)->sync($server); + $component ??= $cronJob->component; + app(SyncCronJobs::class)->run($server, fn () => app(ValidateComponentProcess::class)->run($component, function () use ($server, $cronJob, $component): void { + $cronJob->refresh(); + app(ValidateComponentProcess::class)->validate($server, [], $component, 'scheduler', $cronJob); + $this->remove($server, $cronJob, locked: true); + })); + } + + public function remove(Server $server, CronJob $cronJob, bool $locked = false): void + { + if (! $locked) { + app(SyncCronJobs::class)->run($server, fn () => $this->remove($server, $cronJob, locked: true), background: true); + + return; + } + app(SyncCronJobs::class)->sync($server, locked: true); $user = $cronJob->user; $cronJob->status = CronjobStatus::DELETING; diff --git a/app/Actions/CronJob/DisableCronJob.php b/app/Actions/CronJob/DisableCronJob.php index 7fd43219a..a49bc97fa 100755 --- a/app/Actions/CronJob/DisableCronJob.php +++ b/app/Actions/CronJob/DisableCronJob.php @@ -2,6 +2,7 @@ namespace App\Actions\CronJob; +use App\Actions\Component\ValidateComponentProcess; use App\Enums\CronjobStatus; use App\Exceptions\SSHError; use App\Models\CronJob; @@ -14,14 +15,17 @@ class DisableCronJob */ public function disable(Server $server, CronJob $cronJob): void { - // Sync before disabling to preserve any manual cronjobs - app(SyncCronJobs::class)->sync($server); + app(SyncCronJobs::class)->run($server, fn () => app(ValidateComponentProcess::class)->run($cronJob->component, function () use ($server, $cronJob): void { + $cronJob->refresh(); + app(ValidateComponentProcess::class)->validate($server, [], $cronJob->component, 'scheduler', $cronJob); + app(SyncCronJobs::class)->sync($server, locked: true); - $cronJob->status = CronjobStatus::DISABLING; - $cronJob->save(); + $cronJob->status = CronjobStatus::DISABLING; + $cronJob->save(); - $server->cron()->update($cronJob->user, CronJob::crontab($server, $cronJob->user)); - $cronJob->status = CronjobStatus::DISABLED; - $cronJob->save(); + $server->cron()->update($cronJob->user, CronJob::crontab($server, $cronJob->user)); + $cronJob->status = CronjobStatus::DISABLED; + $cronJob->save(); + })); } } diff --git a/app/Actions/CronJob/EditCronJob.php b/app/Actions/CronJob/EditCronJob.php index ccab33a92..5d2b4cb76 100755 --- a/app/Actions/CronJob/EditCronJob.php +++ b/app/Actions/CronJob/EditCronJob.php @@ -2,6 +2,8 @@ namespace App\Actions\CronJob; +use App\Actions\Component\ValidateComponentProcess; +use App\Models\Component; use App\Enums\CronjobStatus; use App\Exceptions\SSHError; use App\Models\CronJob; @@ -18,56 +20,59 @@ class EditCronJob * * @throws SSHError */ - public function edit(Server $server, CronJob $cronJob, array $input, ?Site $site = null): CronJob + public function edit(Server $server, CronJob $cronJob, array $input, ?Site $site = null, ?Component $component = null): CronJob { - $this->validate($input, $server, $site); + $component ??= $cronJob->component; + return app(SyncCronJobs::class)->run($server, fn () => app(ValidateComponentProcess::class)->run($component, function () use ($server, $cronJob, $input, $site, $component): CronJob { + $cronJob->refresh(); + app(ValidateComponentProcess::class)->validate($server, $input, $component, 'scheduler', $cronJob); + if ($component) { + $input['user'] = $component->user; + } + $this->validate($input, $server, $site, $component); - // Sync before editing to preserve any manual cronjobs - app(SyncCronJobs::class)->sync($server); + app(SyncCronJobs::class)->sync($server, locked: true); - // Determine site_id: use provided site or from input - $siteId = $site?->id; - if (! $site && isset($input['site_id'])) { - $siteId = ! empty($input['site_id']) ? (int) $input['site_id'] : null; - } + $siteId = $site?->id; + if (! $site && isset($input['site_id'])) { + $siteId = ! empty($input['site_id']) ? (int) $input['site_id'] : null; + } - // Check if user has changed - $originalUser = $cronJob->user; - $newUser = $input['user']; - $userChanged = $originalUser !== $newUser; + $originalUser = $cronJob->user; + $newUser = $input['user']; + $userChanged = $originalUser !== $newUser; - $cronJob->update([ - 'site_id' => $siteId, - 'name' => $input['name'] ?? null, - 'user' => $input['user'], - 'command' => $input['command'], - 'frequency' => $input['frequency'] == 'custom' ? $input['custom'] : $input['frequency'], - 'status' => CronjobStatus::UPDATING, - ]); - $cronJob->save(); + $cronJob->update([ + 'site_id' => $siteId, + 'name' => $input['name'] ?? null, + 'user' => $input['user'], + 'command' => $input['command'], + 'frequency' => $input['frequency'] == 'custom' ? $input['custom'] : $input['frequency'], + 'status' => CronjobStatus::UPDATING, + ]); + $cronJob->save(); - // If user changed, remove from original user's crontab first - if ($userChanged) { - $server->cron()->update($originalUser, CronJob::crontab($server, $originalUser)); - } + if ($userChanged) { + $server->cron()->update($originalUser, CronJob::crontab($server, $originalUser)); + } - // Update the new user's crontab - $server->cron()->update($cronJob->user, CronJob::crontab($server, $cronJob->user)); - $cronJob->status = CronjobStatus::READY; - $cronJob->save(); + $server->cron()->update($cronJob->user, CronJob::crontab($server, $cronJob->user)); + $cronJob->status = CronjobStatus::READY; + $cronJob->save(); - return $cronJob; + return $cronJob; + })); } - private function validate(array $input, Server $server, ?Site $site = null): void + private function validate(array $input, Server $server, ?Site $site = null, ?Component $component = null): void { $rules = [ 'command' => [ - 'required', + 'required', 'string', 'max:65535', 'not_regex:/\x00/', ], 'user' => [ 'required', - Rule::in($site?->getSshUsers() ?? $server->getSshUsers()), + Rule::in($component ? [$component->user] : ($site?->getSshUsers() ?? $server->getSshUsers())), ], 'frequency' => [ 'required', @@ -80,7 +85,6 @@ private function validate(array $input, Server $server, ?Site $site = null): voi ], ]; - // Add site_id validation if provided in input if (isset($input['site_id']) && ! empty($input['site_id'])) { $rules['site_id'] = [ 'required', diff --git a/app/Actions/CronJob/EnableCronJob.php b/app/Actions/CronJob/EnableCronJob.php index 6d01435fb..3e8cd6b11 100755 --- a/app/Actions/CronJob/EnableCronJob.php +++ b/app/Actions/CronJob/EnableCronJob.php @@ -2,6 +2,7 @@ namespace App\Actions\CronJob; +use App\Actions\Component\ValidateComponentProcess; use App\Enums\CronjobStatus; use App\Exceptions\SSHError; use App\Models\CronJob; @@ -14,14 +15,17 @@ class EnableCronJob */ public function enable(Server $server, CronJob $cronJob): void { - // Sync before enabling to preserve any manual cronjobs - app(SyncCronJobs::class)->sync($server); + app(SyncCronJobs::class)->run($server, fn () => app(ValidateComponentProcess::class)->run($cronJob->component, function () use ($server, $cronJob): void { + $cronJob->refresh(); + app(ValidateComponentProcess::class)->validate($server, [], $cronJob->component, 'scheduler', $cronJob); + app(SyncCronJobs::class)->sync($server, locked: true); - $cronJob->status = CronjobStatus::ENABLING; - $cronJob->save(); + $cronJob->status = CronjobStatus::ENABLING; + $cronJob->save(); - $server->cron()->update($cronJob->user, CronJob::crontab($server, $cronJob->user)); - $cronJob->status = CronjobStatus::READY; - $cronJob->save(); + $server->cron()->update($cronJob->user, CronJob::crontab($server, $cronJob->user)); + $cronJob->status = CronjobStatus::READY; + $cronJob->save(); + })); } } diff --git a/app/Actions/CronJob/SyncCronJobs.php b/app/Actions/CronJob/SyncCronJobs.php index 04194e9b6..68b9b5b4c 100644 --- a/app/Actions/CronJob/SyncCronJobs.php +++ b/app/Actions/CronJob/SyncCronJobs.php @@ -6,14 +6,41 @@ use App\Exceptions\SSHError; use App\Models\CronJob; use App\Models\Server; +use Closure; +use Illuminate\Contracts\Cache\LockTimeoutException; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; class SyncCronJobs { + /** + * @template T + * @param Closure(): T $callback + * @return T + */ + public function run(Server $server, Closure $callback, bool $background = false): mixed + { + try { + return Cache::lock('cron-jobs:server-'.$server->id, 600)->block(0, fn () => DB::transaction($callback)); + } catch (LockTimeoutException $e) { + if ($background) { + throw $e; + } + throw ValidationException::withMessages(['name' => 'Another cron job operation is in progress. Please retry.']); + } + } + /** * @throws SSHError */ - public function sync(Server $server): void + public function sync(Server $server, bool $locked = false): void { + if (! $locked) { + $this->run($server, fn () => $this->sync($server, locked: true)); + + return; + } $users = $server->getSshUsers(); foreach ($users as $user) { @@ -30,11 +57,11 @@ private function syncUserCronJobs(Server $server, string $user): void $crontabOutput = $this->getUserCrontab($server, $user); // Get all Vito-managed cronjobs for this user (including both server-level and site-level) - $vitoCronJobs = $server->cronJobs() + $vitoCronJobs = $server->cronJobs()->whereNull('component_id') ->where('user', $user) ->get(); - $serverLevelCronJobs = $vitoCronJobs->where('site_id', null); + $serverLevelCronJobs = $vitoCronJobs->where('site_id', null)->where('component_id', null); if (empty($crontabOutput)) { // If crontab is empty, mark all server-level Vito cronjobs as disabled @@ -90,6 +117,10 @@ private function syncUserCronJobs(Server $server, string $user): void $frequency = $this->normalizeFrequency(implode(' ', array_slice($parts, 0, 5))); $command = $this->normalizeCommand($parts[5]); + if (preg_match('/ # vito-component-cron:\d+$/', $command)) { + continue; + } + $serverCronJobs[] = [ 'frequency' => $frequency, 'command' => $command, @@ -105,7 +136,7 @@ private function syncUserCronJobs(Server $server, string $user): void $foundCronJobs[] = $matchingCronJob->id; // Update status based on comment state (only for server-level cronjobs) - if ($matchingCronJob->site_id === null) { + if ($matchingCronJob->site_id === null && $matchingCronJob->component_id === null) { if ($isCommented && $matchingCronJob->status === CronjobStatus::READY) { $matchingCronJob->update(['status' => CronjobStatus::DISABLED]); } elseif (! $isCommented && $matchingCronJob->status === CronjobStatus::DISABLED) { diff --git a/app/Actions/Environment/UpdateEnvironment.php b/app/Actions/Environment/UpdateEnvironment.php index b34e0fc88..f5c11314d 100644 --- a/app/Actions/Environment/UpdateEnvironment.php +++ b/app/Actions/Environment/UpdateEnvironment.php @@ -2,10 +2,14 @@ namespace App\Actions\Environment; +use App\Actions\Component\BroadcastComponentUpdate; use App\Enums\ApplicationStatus; use App\Enums\ComponentStatus; use App\Enums\ComponentType; use App\Enums\EnvironmentStatus; +use App\Enums\ServiceStatus; +use App\Jobs\Component\SyncComponentRuntimeJob; +use App\Models\Server; use App\Models\Application; use App\Models\Environment; use Illuminate\Support\Facades\DB; @@ -29,8 +33,9 @@ public function update(Environment $environment, array $input): Environment throw ValidationException::withMessages(['name' => 'The environment cannot be edited during teardown.']); } - if (($input['runtime_version'] ?? null) !== $environment->runtime_version && $environment->components()->exists()) { - throw ValidationException::withMessages(['runtime_version' => 'The runtime version cannot change while the environment has components.']); + $runtimeChanged = ($input['runtime_version'] ?? null) !== $environment->runtime_version; + if ($runtimeChanged && $environment->components()->where('status', '!=', ComponentStatus::READY)->exists()) { + throw ValidationException::withMessages(['runtime_version' => 'All components must be ready before changing the runtime version.']); } Validator::make($input, [ @@ -49,6 +54,28 @@ public function update(Environment $environment, array $input): Environment } $environment->save(); + if ($runtimeChanged) { + $components = $environment->components()->with('capabilities', 'server')->get(); + Server::query()->whereKey($components->pluck('server_id')->unique())->orderBy('id')->lockForUpdate()->get(); + foreach ($components as $component) { + $component->setRelation('environment', $environment); + foreach ($application->preset()->requiredServices($environment->runtime_version ?? '') as $type => $version) { + if (! $component->server->services()->where('type', $type)->when($version !== null, fn ($query) => $query->where('version', $version)) + ->whereIn('status', [ServiceStatus::READY, ServiceStatus::STOPPED, ServiceStatus::DISABLED])->exists()) { + throw ValidationException::withMessages(['runtime_version' => 'Install the required runtime service on every component server first.']); + } + } + foreach ($component->capabilities as $capability) { + $capability->handler()->validate($component, $component->server); + } + $component->status = ComponentStatus::PENDING; + $component->needs_redeploy = true; + $component->save(); + DB::afterCommit(fn () => app(BroadcastComponentUpdate::class)->broadcast($component)); + SyncComponentRuntimeJob::dispatch($component->id, $component->server_id)->afterCommit(); + } + } + return $environment; }); } diff --git a/app/Actions/Worker/CreateWorker.php b/app/Actions/Worker/CreateWorker.php index b37da2ca3..6df405a27 100644 --- a/app/Actions/Worker/CreateWorker.php +++ b/app/Actions/Worker/CreateWorker.php @@ -2,6 +2,9 @@ namespace App\Actions\Worker; +use App\Actions\Component\ValidateComponentProcess; +use App\Models\Component; +use App\Models\ComponentCapability; use App\Enums\WorkerStatus; use App\Jobs\Worker\CreateJob; use App\Models\Server; @@ -18,46 +21,59 @@ class CreateWorker * * @throws ValidationException */ - public function create(Server $server, array $input, ?Site $site = null): Worker + public function create(Server $server, array $input, ?Site $site = null, ?Component $component = null, ?ComponentCapability $managedBy = null): Worker { - $this->validate($server, $input, $site); + $worker = app(ValidateComponentProcess::class)->run($managedBy ? null : $component, function () use ($server, $input, $site, $component, $managedBy): Worker { + app(ValidateComponentProcess::class)->validate($server, $input, $component, 'worker', managedBy: $managedBy); + if ($component) { + $input['user'] = $component->user; + } + $this->validate($server, $input, $site, $component); - // Determine site_id: use provided site or from input - $siteId = $site?->id; - if (! $site && isset($input['site_id']) && ! empty($input['site_id'])) { - $siteId = (int) $input['site_id']; - } + $siteId = $site?->id; + if (! $site && isset($input['site_id']) && ! empty($input['site_id'])) { + $siteId = (int) $input['site_id']; + } + + $worker = new Worker([ + 'server_id' => $server->id, + 'site_id' => $siteId, + 'name' => $input['name'], + 'command' => $input['command'], + 'user' => $input['user'], + 'auto_start' => $input['auto_start'] ? 1 : 0, + 'auto_restart' => $input['auto_restart'] ? 1 : 0, + 'numprocs' => $input['numprocs'], + 'environment' => isset($input['environment']) + ? UpdateWorkerEnvironment::processVariables($input['environment'], null) + : null, + 'status' => WorkerStatus::CREATING, + ]); + $worker->component_id = $component?->id; + $worker->component_capability_id = $managedBy?->id; + $worker->save(); - $worker = new Worker([ - 'server_id' => $server->id, - 'site_id' => $siteId, - 'name' => $input['name'], - 'command' => $input['command'], - 'user' => $input['user'], - 'auto_start' => $input['auto_start'] ? 1 : 0, - 'auto_restart' => $input['auto_restart'] ? 1 : 0, - 'numprocs' => $input['numprocs'], - 'environment' => isset($input['environment']) - ? UpdateWorkerEnvironment::processVariables($input['environment'], null) - : null, - 'status' => WorkerStatus::CREATING, - ]); - $worker->save(); + return $worker; + }); - dispatch(new CreateJob($worker))->onQueue('ssh'); + if ($managedBy) { + app(InstallWorker::class)->install($worker); + } else { + dispatch(new CreateJob($worker))->onQueue('ssh')->afterCommit(); + } return $worker; } - private function validate(Server $server, array $input, ?Site $site = null): void + private function validate(Server $server, array $input, ?Site $site = null, ?Component $component = null): void { $rules = [ 'name' => [ 'required', 'string', 'max:255', - Rule::unique('workers')->where(function ($query) use ($server, $site) { - return $query->where('server_id', $server->id) + Rule::unique('workers')->where(function ($query) use ($server, $site, $component) { + return $query->where('server_id', $server->id)->where('component_id', $component?->id) ->where(function ($query) use ($site) { if ($site) { $query->where('site_id', $site->id); @@ -66,11 +82,11 @@ private function validate(Server $server, array $input, ?Site $site = null): voi }), ], 'command' => [ - 'required', + 'required', 'string', 'max:65535', 'not_regex:/\x00/', ], 'user' => [ 'required', - Rule::in($site?->getSshUsers() ?? $server->getSshUsers()), + Rule::in($component ? [$component->user] : ($site?->getSshUsers() ?? $server->getSshUsers())), ], 'auto_start' => [ 'required', @@ -94,7 +110,6 @@ private function validate(Server $server, array $input, ?Site $site = null): voi ...UpdateWorkerEnvironment::nestedRules('environment'), ]; - // Add site_id validation if provided in input if (isset($input['site_id']) && ! empty($input['site_id'])) { $rules['site_id'] = [ 'required', diff --git a/app/Actions/Worker/DeleteWorker.php b/app/Actions/Worker/DeleteWorker.php index 4aa39ed55..7e18b6807 100644 --- a/app/Actions/Worker/DeleteWorker.php +++ b/app/Actions/Worker/DeleteWorker.php @@ -2,6 +2,8 @@ namespace App\Actions\Worker; +use App\Actions\Component\ValidateComponentProcess; +use App\Models\Component; use App\Models\Worker; use Illuminate\Validation\ValidationException; @@ -10,14 +12,36 @@ class DeleteWorker /** * @throws ValidationException */ - public function delete(Worker $worker): void + public function delete(Worker $worker, ?Component $component = null): void { - if ($worker->isSiteBootstrap()) { - throw ValidationException::withMessages([ - 'name' => 'This worker is managed by its site. Delete the site to remove it.', - ]); + $component ??= $worker->component; + app(ValidateComponentProcess::class)->run($component, function () use ($worker, $component): void { + $worker->refresh(); + app(ValidateComponentProcess::class)->validate($worker->server, [], $component, 'worker', $worker); + if ($worker->isSiteBootstrap()) { + throw ValidationException::withMessages([ + 'name' => 'This worker is managed by its site. Delete the site to remove it.', + ]); + } + + $this->remove($worker); + }); + } + + public function deleteForComponent(Component $component): void + { + foreach ($component->workers()->whereNull('component_capability_id')->get() as $worker) { + $this->remove($worker); } + } + public function remove(Worker $worker): void + { + if ($worker->component_id !== null) { + /** @var \App\Services\ProcessManager\ProcessManager $handler */ + $handler = $worker->server->processManager()->handler(); + $handler->delete($worker->id); + } $worker->delete(); } } diff --git a/app/Actions/Worker/EditWorker.php b/app/Actions/Worker/EditWorker.php index fadcbe866..8ace3561e 100644 --- a/app/Actions/Worker/EditWorker.php +++ b/app/Actions/Worker/EditWorker.php @@ -2,6 +2,8 @@ namespace App\Actions\Worker; +use App\Actions\Component\ValidateComponentProcess; +use App\Models\Component; use App\Enums\WorkerStatus; use App\Jobs\Worker\EditJob; use App\Models\Site; @@ -17,35 +19,45 @@ class EditWorker * * @throws ValidationException */ - public function edit(Worker $worker, array $input): Worker + public function edit(Worker $worker, array $input, ?Component $component = null): Worker { - if ($worker->isSiteBootstrap()) { - throw ValidationException::withMessages([ - 'name' => "This worker is managed by its site. Edit the Start command on the site's Application page instead.", - ]); - } + $component ??= $worker->component; + $worker = app(ValidateComponentProcess::class)->run($component, function () use ($worker, $input, $component): Worker { + $worker->refresh(); + app(ValidateComponentProcess::class)->validate($worker->server, $input, $component, 'worker', $worker); + if ($component) { + $input['user'] = $component->user; + } + if ($worker->isSiteBootstrap()) { + throw ValidationException::withMessages([ + 'name' => "This worker is managed by its site. Edit the Start command on the site's Application page instead.", + ]); + } - $this->validate($worker, $input, $worker->site); + $this->validate($worker, $input, $worker->site); - $siteId = $worker->site_id; - if (isset($input['site_id'])) { - $siteId = ! empty($input['site_id']) ? (int) $input['site_id'] : null; - } + $siteId = $worker->site_id; + if (isset($input['site_id'])) { + $siteId = ! empty($input['site_id']) ? (int) $input['site_id'] : null; + } - $worker->fill([ - 'site_id' => $siteId, - 'name' => $input['name'], - 'command' => $input['command'], - 'user' => $input['user'], - 'auto_start' => $input['auto_start'] ? 1 : 0, - 'auto_restart' => $input['auto_restart'] ? 1 : 0, - 'numprocs' => $input['numprocs'], - 'status' => WorkerStatus::RESTARTING, - ]); - $worker->error = null; - $worker->save(); + $worker->fill([ + 'site_id' => $siteId, + 'name' => $input['name'], + 'command' => $input['command'], + 'user' => $input['user'], + 'auto_start' => $input['auto_start'] ? 1 : 0, + 'auto_restart' => $input['auto_restart'] ? 1 : 0, + 'numprocs' => $input['numprocs'], + 'status' => WorkerStatus::RESTARTING, + ]); + $worker->error = null; + $worker->save(); + + return $worker; + }); - dispatch(new EditJob($worker))->onQueue('ssh'); + dispatch(new EditJob($worker))->onQueue('ssh')->afterCommit(); return $worker; } @@ -58,7 +70,7 @@ private function validate(Worker $worker, array $input, ?Site $site = null): voi 'string', 'max:255', Rule::unique('workers')->where(function ($query) use ($worker, $site) { - return $query->where('server_id', $worker->server_id) + return $query->where('server_id', $worker->server_id)->where('component_id', $worker->component_id) ->where(function ($query) use ($site) { if ($site) { $query->where('site_id', $site->id); @@ -68,11 +80,11 @@ private function validate(Worker $worker, array $input, ?Site $site = null): voi ->ignore($worker->id), ], 'command' => [ - 'required', + 'required', 'string', 'max:65535', 'not_regex:/\x00/', ], 'user' => [ 'required', - Rule::in($site?->getSshUsers() ?? $worker->server->getSshUsers()), + Rule::in($worker->component ? [$worker->component->user] : ($site?->getSshUsers() ?? $worker->server->getSshUsers())), ], 'auto_start' => [ 'required', diff --git a/app/Actions/Worker/InstallWorker.php b/app/Actions/Worker/InstallWorker.php new file mode 100644 index 000000000..2b8c07099 --- /dev/null +++ b/app/Actions/Worker/InstallWorker.php @@ -0,0 +1,42 @@ +refresh(); + if ($worker->component && $worker->component_capability_id === null) { + app(ValidateComponentProcess::class)->validate($worker->server, [], $worker->component, 'worker', $worker); + } + /** @var ProcessManager $handler */ + $handler = $worker->server->processManager()->handler(); + $configuration = clone $worker; + if ($worker->component) { + $configuration->auto_start = $worker->auto_start && app(HasComponentRelease::class)->check($worker->component); + } + if ($replace) { + $handler->delete($worker->id, $worker->site_id); + } + $handler->create($configuration); + $worker->status = $worker->component_id !== null && ! $configuration->auto_start ? WorkerStatus::STOPPED : WorkerStatus::RUNNING; + $worker->error = null; + $worker->save(); + $this->broadcastWorkerUpdate($worker); + + if ($worker->site) { + app(BroadcastSiteUpdate::class)->broadcast($worker->site); + } + } +} diff --git a/app/Actions/Worker/ManageWorker.php b/app/Actions/Worker/ManageWorker.php index adba57106..01bbb4f9c 100644 --- a/app/Actions/Worker/ManageWorker.php +++ b/app/Actions/Worker/ManageWorker.php @@ -2,6 +2,9 @@ namespace App\Actions\Worker; +use App\Actions\Component\ValidateComponentProcess; +use App\Actions\Component\HasComponentRelease; +use Illuminate\Validation\ValidationException; use App\Enums\WorkerStatus; use App\Jobs\Worker\ManageJob; use App\Models\Worker; @@ -10,6 +13,7 @@ class ManageWorker { public function start(Worker $worker): void { + $this->validate($worker, true); $worker->status = WorkerStatus::STARTING; $worker->error = null; $worker->save(); @@ -18,6 +22,7 @@ public function start(Worker $worker): void public function stop(Worker $worker): void { + $this->validate($worker, false); $worker->status = WorkerStatus::STOPPING; $worker->error = null; $worker->save(); @@ -26,9 +31,17 @@ public function stop(Worker $worker): void public function restart(Worker $worker): void { + $this->validate($worker, true); $worker->status = WorkerStatus::RESTARTING; $worker->error = null; $worker->save(); dispatch(new ManageJob($worker, 'restart', WorkerStatus::RUNNING))->onQueue('ssh'); } + public function validate(Worker $worker, bool $starting): void + { + app(ValidateComponentProcess::class)->validate($worker->server, [], $worker->component, 'worker', $worker); + if ($starting && $worker->component && ! app(HasComponentRelease::class)->check($worker->component)) { + throw ValidationException::withMessages(['name' => 'Deploy this component before starting its processes.']); + } + } } diff --git a/app/Actions/Worker/RestartAllWorkers.php b/app/Actions/Worker/RestartAllWorkers.php index 118c3d156..3f93f39c6 100644 --- a/app/Actions/Worker/RestartAllWorkers.php +++ b/app/Actions/Worker/RestartAllWorkers.php @@ -13,6 +13,7 @@ class RestartAllWorkers public function restart(Server $server, ?Site $site = null): void { $server->workers() + ->whereNull('component_id') ->when($site, fn ($query) => $query->where('site_id', $site->id)) ->whereNotIn('status', [WorkerStatus::CREATING, WorkerStatus::DELETING]) ->get() diff --git a/app/Actions/Worker/RestartSiteWorkers.php b/app/Actions/Worker/RestartSiteWorkers.php index 7b1429196..31b76094c 100644 --- a/app/Actions/Worker/RestartSiteWorkers.php +++ b/app/Actions/Worker/RestartSiteWorkers.php @@ -3,155 +3,19 @@ namespace App\Actions\Worker; use App\Actions\Site\BroadcastSiteUpdate; -use App\Enums\WorkerStatus; use App\Models\ServerLog; use App\Models\Site; -use App\Models\Worker; -use App\Services\ProcessManager\ProcessManager; -use App\Traits\HandlesWorkerFailure; -use Throwable; class RestartSiteWorkers { - use HandlesWorkerFailure; - - private const LOG_TYPE = 'deploy-restart-worker-failed'; - public function restart(Site $site, ?ServerLog $log = null): void { - /** @var ProcessManager $handler */ - $handler = $site->server->processManager()->handler(); - $workers = $site->loadMissing('workers.site')->workers; - if ($workers->isEmpty()) { return; } - try { - $workers->each(fn (Worker $worker) => $handler->writeConfig($worker)); - $output = $handler->restartMany($workers->pluck('id')->all(), $site->id); - } catch (Throwable $e) { - $workers->each(fn (Worker $worker) => $this->markWorkerFailed($worker, $e, self::LOG_TYPE)); - app(BroadcastSiteUpdate::class)->broadcast($site); - $log?->write('Failed to restart workers. See worker statuses for details.'); - - return; - } - - $statuses = $this->parseStatuses($output); - - $failed = $workers->reject(fn (Worker $worker): bool => $this->settle($worker, $statuses[$worker->id] ?? [])); - + app(RestartWorkers::class)->restart($site->server, $workers, $log, $site->id); app(BroadcastSiteUpdate::class)->broadcast($site); - - if ($failed->isNotEmpty()) { - $log?->write('Failed to restart worker(s): '.$failed->pluck('id')->implode(', ')); - } - } - - /** - * @param array $processes process token => last reported status - */ - private function settle(Worker $worker, array $processes): bool - { - if ($processes === []) { - $this->fail($worker, 'Unable to restart'); - - return false; - } - - $errors = $this->errorLines($processes); - - if ($errors !== []) { - $this->fail($worker, mb_substr(implode("\n", $errors), 0, 500)); - - return false; - } - - if (! $this->allStarted($processes)) { - $this->fail($worker, 'Unable to restart (stopped)', WorkerStatus::STOPPED); - - return false; - } - - $worker->status = WorkerStatus::RUNNING; - $worker->error = null; - $worker->save(); - - $this->broadcastWorkerUpdate($worker); - - return true; - } - - private function fail(Worker $worker, string $error, WorkerStatus $status = WorkerStatus::FAILED): void - { - $this->failWorker($worker, $error, self::LOG_TYPE, $error, $status); - } - - /** - * @return array> - */ - private function parseStatuses(string $output): array - { - $statuses = []; - - foreach (explode("\n", $output) as $line) { - $line = trim($line); - - if (! str_contains($line, ': ')) { - continue; - } - - [$process, $status] = explode(': ', $line, 2); - $group = strtok($process, ':'); - - if ($group === false || ! ctype_digit($group)) { - continue; - } - - $statuses[(int) $group][$process] = $status; - } - - return $statuses; - } - - /** - * @param array $processes - * @return array - */ - private function errorLines(array $processes): array - { - $errors = []; - - foreach ($processes as $process => $status) { - if (str_starts_with($status, 'ERROR') && ! $this->isBenignSupervisorStatus($status)) { - $errors[] = $process.': '.$status; - } - } - - return $errors; - } - - /** - * @param array $processes - */ - private function allStarted(array $processes): bool - { - $started = false; - - foreach ($processes as $status) { - if ($this->isBenignSupervisorStatus($status)) { - continue; - } - - if ($status !== 'started') { - return false; - } - - $started = true; - } - - return $started; } } diff --git a/app/Actions/Worker/RestartWorkers.php b/app/Actions/Worker/RestartWorkers.php new file mode 100644 index 000000000..bc8b6e6a1 --- /dev/null +++ b/app/Actions/Worker/RestartWorkers.php @@ -0,0 +1,156 @@ + $workers + */ + public function restart(Server $server, Collection $workers, ?ServerLog $log = null, ?int $siteId = null): void + { + if ($workers->isEmpty()) { + return; + } + + try { + $service = $server->processManager() ?? throw new RuntimeException('Process manager service not found.'); + /** @var ProcessManager $handler */ + $handler = $service->handler(); + $workers->each(fn (Worker $worker) => $handler->writeConfig($worker)); + $output = $handler->restartMany($workers->pluck('id')->all(), $siteId); + } catch (Throwable $e) { + $workers->each(fn (Worker $worker) => $this->markWorkerFailed($worker, $e, self::LOG_TYPE)); + $log?->write('Failed to restart workers. See worker statuses for details.'); + + return; + } + + $statuses = $this->parseStatuses($output); + + $failed = $workers->reject(fn (Worker $worker): bool => $this->settle($worker, $statuses[$worker->id] ?? [])); + + if ($failed->isNotEmpty()) { + $log?->write('Failed to restart worker(s): '.$failed->pluck('id')->implode(', ')); + } + } + + /** + * @param array $processes process token => last reported status + */ + private function settle(Worker $worker, array $processes): bool + { + if ($processes === []) { + $this->fail($worker, 'Unable to restart'); + + return false; + } + + $errors = $this->errorLines($processes); + + if ($errors !== []) { + $this->fail($worker, mb_substr(implode("\n", $errors), 0, 500)); + + return false; + } + + if (! $this->allStarted($processes)) { + $this->fail($worker, 'Unable to restart (stopped)', WorkerStatus::STOPPED); + + return false; + } + + $worker->status = WorkerStatus::RUNNING; + $worker->error = null; + $worker->save(); + + $this->broadcastWorkerUpdate($worker); + + return true; + } + + private function fail(Worker $worker, string $error, WorkerStatus $status = WorkerStatus::FAILED): void + { + $this->failWorker($worker, $error, self::LOG_TYPE, $error, $status); + } + + /** + * @return array> + */ + private function parseStatuses(string $output): array + { + $statuses = []; + + foreach (explode("\n", $output) as $line) { + $line = trim($line); + + if (! str_contains($line, ': ')) { + continue; + } + + [$process, $status] = explode(': ', $line, 2); + $group = strtok($process, ':'); + + if ($group === false || ! ctype_digit($group)) { + continue; + } + + $statuses[(int) $group][$process] = $status; + } + + return $statuses; + } + + /** + * @param array $processes + * @return array + */ + private function errorLines(array $processes): array + { + $errors = []; + + foreach ($processes as $process => $status) { + if (str_starts_with($status, 'ERROR') && ! $this->isBenignSupervisorStatus($status)) { + $errors[] = $process.': '.$status; + } + } + + return $errors; + } + + /** + * @param array $processes + */ + private function allStarted(array $processes): bool + { + $started = false; + + foreach ($processes as $status) { + if ($this->isBenignSupervisorStatus($status)) { + continue; + } + + if ($status !== 'started') { + return false; + } + + $started = true; + } + + return $started; + } +} diff --git a/app/Actions/Worker/SyncWorkerStatuses.php b/app/Actions/Worker/SyncWorkerStatuses.php index 5eeecbcc6..7d70f5050 100644 --- a/app/Actions/Worker/SyncWorkerStatuses.php +++ b/app/Actions/Worker/SyncWorkerStatuses.php @@ -17,7 +17,7 @@ class SyncWorkerStatuses private const LOG_TYPE = 'sync-worker-statuses-failed'; - public function sync(Server $server, ?Site $site = null): int + public function sync(Server $server, ?Site $site = null, bool $excludeComponents = false): int { /** @var Service $service */ $service = $server->processManager(); @@ -25,6 +25,7 @@ public function sync(Server $server, ?Site $site = null): int $handler = $service->handler(); $workers = $server->workers() + ->when($excludeComponents, fn ($query) => $query->whereNull('component_id')) ->when($site, fn ($query) => $query->where('site_id', $site->id)) ->whereNotIn('status', [WorkerStatus::CREATING, WorkerStatus::DELETING]) ->get(); diff --git a/app/Actions/Worker/UpdateWorkerEnvironment.php b/app/Actions/Worker/UpdateWorkerEnvironment.php index b0a1e3540..e421288b3 100644 --- a/app/Actions/Worker/UpdateWorkerEnvironment.php +++ b/app/Actions/Worker/UpdateWorkerEnvironment.php @@ -18,6 +18,10 @@ class UpdateWorkerEnvironment */ public function update(Worker $worker, array $input): WorkerEnvironmentUpdateResult { + if ($worker->component_id !== null) { + throw ValidationException::withMessages(['variables' => 'Component processes use environment variables from their environment only.']); + } + $validated = Validator::make($input, [ ...self::rules(), 'restart' => ['sometimes', 'boolean'], diff --git a/app/ComponentCapabilities/AbstractComponentCapability.php b/app/ComponentCapabilities/AbstractComponentCapability.php index 9ee925a69..34a96f33d 100644 --- a/app/ComponentCapabilities/AbstractComponentCapability.php +++ b/app/ComponentCapabilities/AbstractComponentCapability.php @@ -39,6 +39,10 @@ public function validate(Component $component, Server $server): void app(ValidateCapabilityServices::class)->validate($this->capability, $component, $server); } + public function install(Component $component): void {} + + public function uninstall(Component $component): void {} + /** * @return list */ diff --git a/app/ComponentCapabilities/Scheduler.php b/app/ComponentCapabilities/Scheduler.php index ef8b891a6..719c3c0d8 100644 --- a/app/ComponentCapabilities/Scheduler.php +++ b/app/ComponentCapabilities/Scheduler.php @@ -33,6 +33,16 @@ public function validate(Component $component, Server $server): void app(ValidateSchedulerCapability::class)->validate($this->capability, $component); } + public function install(Component $component): void + { + app(\App\Actions\ComponentCapability\InstallSchedulerCapability::class)->install($this->capability, $component); + } + + public function uninstall(Component $component): void + { + app(\App\Actions\ComponentCapability\InstallSchedulerCapability::class)->uninstall($this->capability, $component); + } + public function surfaces(): array { return ['cron_jobs']; diff --git a/app/ComponentCapabilities/Web.php b/app/ComponentCapabilities/Web.php index 3692dd60d..5486bd206 100644 --- a/app/ComponentCapabilities/Web.php +++ b/app/ComponentCapabilities/Web.php @@ -43,9 +43,19 @@ public function validate(Component $component, Server $server): void app(ValidateWebCapability::class)->validate($this->capability, $component, $server); } + public function install(Component $component): void + { + app(\App\Actions\ComponentCapability\InstallWebCapability::class)->install($this->capability, $component); + } + + public function uninstall(Component $component): void + { + app(\App\Actions\ComponentCapability\InstallWebCapability::class)->uninstall($this->capability, $component); + } + public function surfaces(): array { - return ['domains']; + return isset($this->capability->config['start_command']) ? ['domains', 'workers'] : ['domains']; } public function form(AbstractRuntimePreset $preset): DynamicForm diff --git a/app/ComponentCapabilities/Worker.php b/app/ComponentCapabilities/Worker.php index 25553150b..771e2fe53 100644 --- a/app/ComponentCapabilities/Worker.php +++ b/app/ComponentCapabilities/Worker.php @@ -26,6 +26,11 @@ public function requiredServices(AbstractRuntimePreset $preset): array return ['process_manager']; } + public function uninstall(\App\Models\Component $component): void + { + app(\App\Actions\Worker\DeleteWorker::class)->deleteForComponent($component); + } + public function surfaces(): array { return ['workers']; diff --git a/app/DTOs/ToolingTargetDTO.php b/app/DTOs/ToolingTargetDTO.php index 9771fc8ac..0cc6b4123 100644 --- a/app/DTOs/ToolingTargetDTO.php +++ b/app/DTOs/ToolingTargetDTO.php @@ -2,6 +2,7 @@ namespace App\DTOs; +use App\Models\Component; use App\Models\IsolatedUser; use App\Models\Server; use App\Models\Site; @@ -17,6 +18,17 @@ public function __construct( public ?string $configDir = null, ) {} + public static function forComponent(Component $component): self + { + return new self( + server: $component->server, + user: $component->user, + isolatedUser: $component->isolatedUser, + phpVersion: $component->environment->runtime_version, + configDir: $component->path, + ); + } + public static function forSite(Site $site): self { return new self( diff --git a/app/Helpers/ComponentShell.php b/app/Helpers/ComponentShell.php new file mode 100644 index 000000000..0a0d7365a --- /dev/null +++ b/app/Helpers/ComponentShell.php @@ -0,0 +1,31 @@ +environment->application->preset()->tooling(); + $paths = $toolId === null ? [] : (ToolingRegistry::find($toolId)?->pathContributions($target) ?? []); + $environment['PATH'] = implode(':', [...$paths, $environment['PATH'] ?? "/usr/local/bin:/usr/bin:/bin:/home/{$component->user}/.local/bin"]); + + $script = ''; + foreach ($environment as $key => $value) { + $script .= 'export '.$key.'='.escapeshellarg($value).'; '; + } + if ($cwd === null) { + $script .= '[ -d '.escapeshellarg($component->currentPath()).' ] || exit 0; '; + } + $script .= 'cd -- '.escapeshellarg($cwd ?? $component->currentPath()).' || exit 1; '; + $script .= 'exec bash -c '.escapeshellarg($command); + + return 'bash -c '.escapeshellarg('exec bash -c "$(echo '.base64_encode($script).' | base64 -d)"'); + } +} diff --git a/app/Http/Controllers/Applications/ComponentController.php b/app/Http/Controllers/Applications/ComponentController.php index 7caff27e5..ff66b772f 100644 --- a/app/Http/Controllers/Applications/ComponentController.php +++ b/app/Http/Controllers/Applications/ComponentController.php @@ -6,6 +6,10 @@ use App\Actions\Component\DeleteComponent; use App\Actions\Component\GetComponentOptions; use App\Actions\Component\RetryComponentInstall; +use App\Actions\Component\UpdateComponentCapabilities; +use App\Tables\Applications\ComponentWorkerTable; +use App\Tables\Applications\ComponentCronJobTable; +use Spatie\RouteAttributes\Attributes\Put; use App\Http\Controllers\Controller; use App\Http\Resources\ComponentResource; use App\Models\Application; @@ -57,6 +61,9 @@ public function show(Application $application, Environment $environment, Compone return Inertia::render('applications/environments/component', [ 'component' => new ComponentResource($component->load('server', 'capabilities')), + 'options' => app(GetComponentOptions::class)->get($environment), + 'workers' => ComponentWorkerTable::make($component->workers())->identifier('workers')->simplePaginate(), + 'cronjobs' => ComponentCronJobTable::make($component->cronJobs())->identifier('cronjobs')->simplePaginate(), 'primaryCandidates' => app(GetComponentOptions::class)->primaryCandidates($environment), 'can' => [ 'update' => user()->can('update', [$component, $environment, $application]), @@ -65,6 +72,16 @@ public function show(Application $application, Environment $environment, Compone ]); } + #[Put('/{component}/capabilities', name: 'components.capabilities.update')] + public function capabilities(Request $request, Application $application, Environment $environment, Component $component): RedirectResponse + { + $this->authorize('update', [$component, $environment, $application]); + + app(UpdateComponentCapabilities::class)->update($component, $request->all()); + + return back()->with('success', 'Capability changes queued.'); + } + #[Post('/{component}/retry', name: 'components.retry')] public function retry(Application $application, Environment $environment, Component $component): RedirectResponse { diff --git a/app/Http/Controllers/Applications/ComponentCronJobController.php b/app/Http/Controllers/Applications/ComponentCronJobController.php new file mode 100644 index 000000000..79bb51d28 --- /dev/null +++ b/app/Http/Controllers/Applications/ComponentCronJobController.php @@ -0,0 +1,78 @@ +authorize('create', [CronJob::class, $component->server, null, $component, $environment, $application]); + + app(CreateCronJob::class)->create($component->server, $request->all(), component: $component); + + return back()->with('success', 'Cron job has been created.'); + } + + #[Put('/{cronJob}', name: 'components.cronjobs.update')] + public function update(Request $request, Application $application, Environment $environment, Component $component, CronJob $cronJob): RedirectResponse + { + $this->authorize('update', [$cronJob, $component->server, null, $component, $environment, $application]); + + app(EditCronJob::class)->edit($component->server, $cronJob, $request->all(), component: $component); + + return back()->with('success', 'Cron job has been updated.'); + } + + #[Post('/{cronJob}/enable', name: 'components.cronjobs.enable')] + public function enable(Application $application, Environment $environment, Component $component, CronJob $cronJob): RedirectResponse + { + $this->authorize('update', [$cronJob, $component->server, null, $component, $environment, $application]); + + app(EnableCronJob::class)->enable($component->server, $cronJob); + + return back()->with('success', 'Cron job has been enabled.'); + } + + #[Post('/{cronJob}/disable', name: 'components.cronjobs.disable')] + public function disable(Application $application, Environment $environment, Component $component, CronJob $cronJob): RedirectResponse + { + $this->authorize('update', [$cronJob, $component->server, null, $component, $environment, $application]); + + app(DisableCronJob::class)->disable($component->server, $cronJob); + + return back()->with('success', 'Cron job has been disabled.'); + } + + #[Delete('/{cronJob}', name: 'components.cronjobs.destroy')] + public function destroy(Application $application, Environment $environment, Component $component, CronJob $cronJob): RedirectResponse + { + $this->authorize('delete', [$cronJob, $component->server, null, $component, $environment, $application]); + + app(DeleteCronJob::class)->delete($component->server, $cronJob, $component); + + return back()->with('success', 'Cron job has been deleted.'); + } +} diff --git a/app/Http/Controllers/Applications/ComponentWorkerController.php b/app/Http/Controllers/Applications/ComponentWorkerController.php new file mode 100644 index 000000000..e999c1b30 --- /dev/null +++ b/app/Http/Controllers/Applications/ComponentWorkerController.php @@ -0,0 +1,87 @@ +authorize('create', [Worker::class, $component->server, null, $component, $environment, $application]); + + app(CreateWorker::class)->create($component->server, $request->all(), component: $component); + + return back()->with('info', 'Worker is being created.'); + } + + #[Put('/{worker}', name: 'components.workers.update')] + public function update(Request $request, Application $application, Environment $environment, Component $component, Worker $worker): RedirectResponse + { + $this->authorize('update', [$worker, $component->server, null, $component, $environment, $application]); + + app(EditWorker::class)->edit($worker, $request->all(), $component); + + return back()->with('info', 'Worker is being updated.'); + } + + #[Post('/{worker}/start', name: 'components.workers.start')] + public function start(Application $application, Environment $environment, Component $component, Worker $worker): RedirectResponse + { + $this->authorize('update', [$worker, $component->server, null, $component, $environment, $application]); + + app(ManageWorker::class)->start($worker); + + return back()->with('info', 'Worker is being started.'); + } + + #[Post('/{worker}/stop', name: 'components.workers.stop')] + public function stop(Application $application, Environment $environment, Component $component, Worker $worker): RedirectResponse + { + $this->authorize('update', [$worker, $component->server, null, $component, $environment, $application]); + + app(ManageWorker::class)->stop($worker); + + return back()->with('info', 'Worker is being stopped.'); + } + + #[Post('/{worker}/restart', name: 'components.workers.restart')] + public function restart(Application $application, Environment $environment, Component $component, Worker $worker): RedirectResponse + { + $this->authorize('update', [$worker, $component->server, null, $component, $environment, $application]); + + app(ManageWorker::class)->restart($worker); + + return back()->with('info', 'Worker is being restarted.'); + } + + #[Delete('/{worker}', name: 'components.workers.destroy')] + public function destroy(Application $application, Environment $environment, Component $component, Worker $worker): RedirectResponse + { + $this->authorize('delete', [$worker, $component->server, null, $component, $environment, $application]); + + app(DeleteWorker::class)->delete($worker, $component); + + return back()->with('info', 'Worker has been deleted.'); + } +} diff --git a/app/Http/Controllers/CronJobController.php b/app/Http/Controllers/CronJobController.php index a200df4b3..012d73d9f 100644 --- a/app/Http/Controllers/CronJobController.php +++ b/app/Http/Controllers/CronJobController.php @@ -34,7 +34,7 @@ public function index(Server $server): Response $this->authorize('viewAny', [CronJob::class, $server]); return Inertia::render('cronjobs/index', [ - 'cronjobs' => CronJobResource::collection($server->cronJobs()->where('hidden', false)->latest()->simplePaginate(config('web.pagination_size'))), + 'cronjobs' => CronJobResource::collection($server->cronJobs()->with('component.environment')->where('hidden', false)->latest()->simplePaginate(config('web.pagination_size'))), 'sites' => $server->sites()->select('id', 'domain')->get(), ]); } diff --git a/app/Http/Controllers/WorkerController.php b/app/Http/Controllers/WorkerController.php index 22227051c..88d173262 100644 --- a/app/Http/Controllers/WorkerController.php +++ b/app/Http/Controllers/WorkerController.php @@ -42,7 +42,7 @@ public function index(Server $server): Response return Inertia::render('workers/index', [ 'workers' => WorkerResource::collection( $server->workers() - ->with('site:id,server_id,type_data') + ->with('site:id,server_id,type_data', 'component.environment') ->latest() ->simplePaginate(config('web.pagination_size')) ), @@ -58,7 +58,7 @@ public function site(Server $server, Site $site): Response return Inertia::render('workers/index', [ 'workers' => WorkerResource::collection( $site->workers() - ->with('site:id,server_id,type_data') + ->with('site:id,server_id,type_data', 'component.environment') ->latest() ->simplePaginate(config('web.pagination_size')) ), diff --git a/app/Http/Resources/ComponentResource.php b/app/Http/Resources/ComponentResource.php index 3b10d2fb9..086f68862 100644 --- a/app/Http/Resources/ComponentResource.php +++ b/app/Http/Resources/ComponentResource.php @@ -3,6 +3,7 @@ namespace App\Http\Resources; use App\Models\Component; +use App\Models\ComponentCapability; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -32,6 +33,15 @@ public function toArray(Request $request): array 'needs_redeploy' => $this->needs_redeploy, 'ssh_key' => $this->ssh_key, 'capabilities' => $this->whenLoaded('capabilities', fn (): array => $this->capabilities->pluck('capability')->all()), + 'capability_configs' => $this->whenLoaded('capabilities', fn (): array => $this->capabilities->map(fn (ComponentCapability $capability): array => [ + 'capability' => $capability->capability, + 'config' => $capability->config ?? [], + 'status' => $capability->status->getText(), + 'status_color' => $capability->status->getColor(), + ])->all()), + 'surfaces' => $this->whenLoaded('capabilities', fn (): array => $this->capabilities + ->filter(fn (ComponentCapability $capability): bool => isset(config('component.capabilities', [])[$capability->capability])) + ->flatMap(fn (ComponentCapability $capability): array => $capability->handler()->surfaces())->unique()->values()->all()), 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/app/Http/Resources/CronJobResource.php b/app/Http/Resources/CronJobResource.php index a2084657a..239e056e9 100644 --- a/app/Http/Resources/CronJobResource.php +++ b/app/Http/Resources/CronJobResource.php @@ -10,7 +10,7 @@ class CronJobResource extends JsonResource { /** - * @return array + * @return array */ public function toArray(Request $request): array { @@ -19,6 +19,11 @@ public function toArray(Request $request): array 'name' => $this->name, 'server_id' => $this->server_id, 'site_id' => $this->site_id, + $this->mergeWhen(! $request->is('api/*'), [ + 'component_id' => $this->component_id, + 'managed' => $this->component_capability_id !== null, + 'component_url' => $this->whenLoaded('component', fn (): string => route('components.show', [$this->component->environment->application_id, $this->component->environment_id, $this->component->id], false)), + ]), 'command' => $this->command, 'user' => $this->user, 'frequency' => $this->frequency, diff --git a/app/Http/Resources/WorkerResource.php b/app/Http/Resources/WorkerResource.php index 9de655cab..df2f42ca9 100644 --- a/app/Http/Resources/WorkerResource.php +++ b/app/Http/Resources/WorkerResource.php @@ -10,7 +10,7 @@ class WorkerResource extends JsonResource { /** - * @return array + * @return array */ public function toArray(Request $request): array { @@ -18,6 +18,11 @@ public function toArray(Request $request): array 'id' => $this->id, 'server_id' => $this->server_id, 'site_id' => $this->site_id, + $this->mergeWhen(! $request->is('api/*'), [ + 'component_id' => $this->component_id, + 'managed' => $this->component_capability_id !== null, + 'component_url' => $this->whenLoaded('component', fn (): string => route('components.show', [$this->component->environment->application_id, $this->component->environment_id, $this->component->id], false)), + ]), 'name' => $this->name, 'command' => $this->command, 'user' => $this->user, diff --git a/app/Jobs/Component/SyncComponentCapabilitiesJob.php b/app/Jobs/Component/SyncComponentCapabilitiesJob.php new file mode 100644 index 000000000..ebb6f5796 --- /dev/null +++ b/app/Jobs/Component/SyncComponentCapabilitiesJob.php @@ -0,0 +1,31 @@ +onQueue('ssh'); + } + + public function handle(): void + { + $this->run("server-{$this->serverId}", fn () => app(SyncComponentCapabilities::class)->handle($this->componentId)); + } + + public function failed(Exception $e): void + { + app(InstallComponent::class)->failed($this->componentId); + } +} diff --git a/app/Jobs/Component/SyncComponentRuntimeJob.php b/app/Jobs/Component/SyncComponentRuntimeJob.php new file mode 100644 index 000000000..ce5fe4038 --- /dev/null +++ b/app/Jobs/Component/SyncComponentRuntimeJob.php @@ -0,0 +1,31 @@ +onQueue('ssh'); + } + + public function handle(): void + { + $this->run("server-{$this->serverId}", fn () => app(SyncComponentRuntime::class)->handle($this->componentId)); + } + + public function failed(Exception $e): void + { + app(InstallComponent::class)->failed($this->componentId); + } +} diff --git a/app/Jobs/Worker/CreateJob.php b/app/Jobs/Worker/CreateJob.php index 7a66d77e3..9eeb7bd94 100644 --- a/app/Jobs/Worker/CreateJob.php +++ b/app/Jobs/Worker/CreateJob.php @@ -3,10 +3,8 @@ namespace App\Jobs\Worker; use App\Actions\Site\BroadcastSiteUpdate; -use App\Enums\WorkerStatus; -use App\Models\Service; +use App\Actions\Worker\InstallWorker; use App\Models\Worker; -use App\Services\ProcessManager\ProcessManager; use App\Traits\HandlesWorkerFailure; use App\Traits\UniqueQueue; use Illuminate\Contracts\Queue\ShouldQueue; @@ -24,19 +22,7 @@ public function __construct(protected Worker $worker) {} public function handle(): void { $this->run("server-{$this->worker->server_id}", function () { - /** @var Service $service */ - $service = $this->worker->server->processManager(); - /** @var ProcessManager $processManager */ - $processManager = $service->handler(); - $processManager->create($this->worker); - $this->worker->status = WorkerStatus::RUNNING; - $this->worker->error = null; - $this->worker->save(); - $this->broadcastWorkerUpdate($this->worker); - - if ($this->worker->site) { - app(BroadcastSiteUpdate::class)->broadcast($this->worker->site); - } + app(InstallWorker::class)->install($this->worker); }); } diff --git a/app/Jobs/Worker/EditJob.php b/app/Jobs/Worker/EditJob.php index 9c4cdcb12..fb9e60a55 100644 --- a/app/Jobs/Worker/EditJob.php +++ b/app/Jobs/Worker/EditJob.php @@ -3,10 +3,8 @@ namespace App\Jobs\Worker; use App\Actions\Site\BroadcastSiteUpdate; -use App\Enums\WorkerStatus; -use App\Models\Service; +use App\Actions\Worker\InstallWorker; use App\Models\Worker; -use App\Services\ProcessManager\ProcessManager; use App\Traits\HandlesWorkerFailure; use App\Traits\UniqueQueue; use Illuminate\Contracts\Queue\ShouldQueue; @@ -24,21 +22,7 @@ public function __construct(protected Worker $worker) {} public function handle(): void { $this->run("server-{$this->worker->server_id}", function () { - /** @var Service $service */ - $service = $this->worker->server->processManager(); - /** @var ProcessManager $processManager */ - $processManager = $service->handler(); - $processManager->delete($this->worker->id, $this->worker->site_id); - - $processManager->create($this->worker); - $this->worker->status = WorkerStatus::RUNNING; - $this->worker->error = null; - $this->worker->save(); - $this->broadcastWorkerUpdate($this->worker); - - if ($this->worker->site) { - app(BroadcastSiteUpdate::class)->broadcast($this->worker->site); - } + app(InstallWorker::class)->install($this->worker, replace: true); }); } diff --git a/app/Jobs/Worker/ManageJob.php b/app/Jobs/Worker/ManageJob.php index 932e81e69..9af274d2d 100644 --- a/app/Jobs/Worker/ManageJob.php +++ b/app/Jobs/Worker/ManageJob.php @@ -28,6 +28,8 @@ public function __construct( public function handle(): void { $this->run("server-{$this->worker->server_id}", function () { + $this->worker->refresh(); + app(\App\Actions\Worker\ManageWorker::class)->validate($this->worker, $this->action !== 'stop'); /** @var Service $service */ $service = $this->worker->server->processManager(); /** @var ProcessManager $handler */ diff --git a/app/Jobs/Worker/RestartAllJob.php b/app/Jobs/Worker/RestartAllJob.php index abdb8044e..7ea41f963 100644 --- a/app/Jobs/Worker/RestartAllJob.php +++ b/app/Jobs/Worker/RestartAllJob.php @@ -36,13 +36,14 @@ public function handle(): void $handler = $service->handler(); $handler->restartAll($this->site?->id); - app(SyncWorkerStatuses::class)->sync($this->server, $this->site); + app(SyncWorkerStatuses::class)->sync($this->server, $this->site, excludeComponents: true); }); } public function failed(Throwable $e): void { $failed = $this->server->workers() + ->whereNull('component_id') ->when($this->site, fn ($query) => $query->where('site_id', $this->site->id)) ->where('status', WorkerStatus::RESTARTING) ->get() diff --git a/app/Models/Component.php b/app/Models/Component.php index 65c9ecc68..2c79df905 100644 --- a/app/Models/Component.php +++ b/app/Models/Component.php @@ -20,6 +20,7 @@ * @property string $path * @property ComponentStatus $status * @property ?string $last_error + * @property ?string $installed_runtime_version * @property bool $needs_redeploy * @property ?string $deploy_key_id * @property ?string $ssh_key diff --git a/app/Models/CronJob.php b/app/Models/CronJob.php index ecf4e01e0..2775ca770 100755 --- a/app/Models/CronJob.php +++ b/app/Models/CronJob.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\CronjobStatus; +use App\Helpers\ComponentShell; use App\Traits\HasExclusiveOwner; use Database\Factories\CronJobFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -12,6 +13,7 @@ * @property int $server_id * @property ?int $site_id * @property ?int $component_id + * @property ?int $component_capability_id * @property string $command * @property string $user * @property string $frequency @@ -33,7 +35,6 @@ class CronJob extends AbstractModel protected $fillable = [ 'server_id', 'site_id', - 'component_id', 'command', 'user', 'frequency', @@ -45,6 +46,7 @@ class CronJob extends AbstractModel 'server_id' => 'integer', 'site_id' => 'integer', 'component_id' => 'integer', + 'component_capability_id' => 'integer', 'hidden' => 'boolean', 'status' => CronjobStatus::class, ]; @@ -100,7 +102,7 @@ public function getServerIdAttribute(?int $value): ?int public static function crontab(Server $server, string $user): string { $data = ''; - $cronJobs = $server->cronJobs() + $cronJobs = $server->cronJobs()->with('component.environment.application', 'component.server', 'component.isolatedUser') ->where('user', $user) ->whereIn('status', [ CronjobStatus::READY, @@ -111,7 +113,7 @@ public static function crontab(Server $server, string $user): string ->get(); /** @var CronJob $cronJob */ foreach ($cronJobs as $key => $cronJob) { - $command = $user === 'root' ? $cronJob->command : self::wrapCommand($cronJob->command); + $command = $cronJob->component ? $cronJob->wrappedCommand() : ($user === 'root' ? $cronJob->command : self::wrapCommand($cronJob->command)); $data .= $cronJob->frequency.' '.$command; if ($key != count($cronJobs) - 1) { $data .= "\n"; @@ -121,6 +123,13 @@ public static function crontab(Server $server, string $user): string return $data; } + public function wrappedCommand(): string + { + return $this->component + ? ComponentShell::wrap($this->component, $this->command).' # vito-component-cron:'.$this->id + : self::wrapCommand($this->command); + } + public static function wrapCommand(string $command): string { return 'bash -lc '.escapeshellarg($command); diff --git a/app/Models/IsolatedUser.php b/app/Models/IsolatedUser.php index b852ea8a8..7e6e62141 100644 --- a/app/Models/IsolatedUser.php +++ b/app/Models/IsolatedUser.php @@ -87,7 +87,7 @@ public function fpmPoolUsedByOthers(string $phpVersion, Site|Component $except): ->when($except instanceof Component, fn ($query) => $query->whereKeyNot($except->id)) ->with(['environment.application', 'capabilities']) ->get() - ->contains(fn (Component $component): bool => $component->environment->runtime_version === $phpVersion + ->contains(fn (Component $component): bool => ($component->environment->runtime_version === $phpVersion || $component->installed_runtime_version === $phpVersion) && $component->hasCapability('web') && $component->environment->application->preset()->webMode() === WebMode::FPM); } diff --git a/app/Models/Server.php b/app/Models/Server.php index ae26d1415..cad7fcc7b 100755 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -262,21 +262,25 @@ public function componentsUsing(Service $service): Collection return $this->components()->with(['environment.application', 'capabilities'])->get() ->filter(function (Component $component) use ($service): bool { $preset = $component->environment->application->preset(); - $version = $component->environment->runtime_version; - $services = $preset->requiredServices($version ?? ''); - - if (array_key_exists($service->type, $services) - && ($services[$service->type] === null || $services[$service->type] === $service->version)) { - return true; + $versions = array_unique([ + $component->environment->runtime_version ?? '', + $component->installed_runtime_version ?? $component->environment->runtime_version ?? '', + ]); + foreach ($versions as $version) { + $services = $preset->requiredServices($version); + if (array_key_exists($service->type, $services) + && ($services[$service->type] === null || $services[$service->type] === $service->version)) { + return true; + } } - return $component->capabilities->contains(function (ComponentCapability $capability) use ($service, $preset, $version): bool { + return $component->capabilities->contains(function (ComponentCapability $capability) use ($service, $preset, $versions): bool { if (! in_array($service->type, $capability->handler()->requiredServices($preset), true)) { return false; } if ($capability->capability === 'web' && $service->type === 'php' && $preset->webMode() === WebMode::FPM) { - return $version === $service->version; + return in_array($service->version, $versions, true); } return true; diff --git a/app/Models/Worker.php b/app/Models/Worker.php index 691d1107d..3caef23e8 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -17,6 +17,7 @@ * @property int $server_id * @property ?int $site_id * @property ?int $component_id + * @property ?int $component_capability_id * @property string $command * @property string $user * @property bool $auto_start @@ -42,7 +43,6 @@ class Worker extends AbstractModel protected $fillable = [ 'server_id', 'site_id', - 'component_id', 'command', 'user', 'auto_start', @@ -59,6 +59,7 @@ class Worker extends AbstractModel 'server_id' => 'integer', 'site_id' => 'integer', 'component_id' => 'integer', + 'component_capability_id' => 'integer', 'auto_start' => 'boolean', 'auto_restart' => 'boolean', 'numprocs' => 'integer', @@ -85,6 +86,9 @@ public static function boot(): void parent::boot(); static::deleting(function (Worker $worker): void { + if ($worker->component_id !== null) { + return; + } try { /** @var Service $service */ $service = $worker->server->processManager(); diff --git a/app/Policies/CronJobPolicy.php b/app/Policies/CronJobPolicy.php index 3fe0513b8..1099173ee 100644 --- a/app/Policies/CronJobPolicy.php +++ b/app/Policies/CronJobPolicy.php @@ -2,7 +2,10 @@ namespace App\Policies; +use App\Models\Application; +use App\Models\Component; use App\Models\CronJob; +use App\Models\Environment; use App\Models\Server; use App\Models\Site; use App\Models\User; @@ -14,45 +17,37 @@ class CronJobPolicy use HandlesAuthorization; use HasRolePolicies; - public function viewAny(User $user, Server $server, ?Site $site = null): bool + public function viewAny(User $user, Server $server, ?Site $site = null, ?Component $component = null, ?Environment $environment = null, ?Application $application = null): bool { return $this->hasReadAccess($user, $server->project) && $server->isReady() && - (! $site || $site->server_id === $server->id); + (! $site || $site->server_id === $server->id) && + (! $component || ($component->server_id === $server->id && $component->project()->id === $server->project_id && + $user->can('view', [$component, $environment ?? $component->environment, $application ?? $component->environment->application]))); } - public function view(User $user, CronJob $cronjob, Server $server, ?Site $site = null): bool + public function view(User $user, CronJob $cronjob, Server $server, ?Site $site = null, ?Component $component = null, ?Environment $environment = null, ?Application $application = null): bool { - $cronJobServer = $cronjob->server; - - return $this->hasReadAccess($user, $cronJobServer->project) && - $cronJobServer->isReady() && - $cronjob->server_id === $server->id && - (! $site || ($site->server_id === $server->id && $cronjob->site_id === $site->id)); + return $cronjob->server_id === $server->id && + (! $site || $cronjob->site_id === $site->id) && + (! $component || $cronjob->component_id === $component->id) && + $this->viewAny($user, $server, $site, $component ?? $cronjob->component, $environment, $application); } - public function create(User $user, Server $server, ?Site $site = null): bool + public function create(User $user, Server $server, ?Site $site = null, ?Component $component = null, ?Environment $environment = null, ?Application $application = null): bool { - return $this->hasWriteAccess($user, $server->project) && $server->isReady() && - (! $site || $site->server_id === $server->id); + return $this->hasWriteAccess($user, $server->project) && + $this->viewAny($user, $server, $site, $component, $environment, $application); } - public function update(User $user, CronJob $cronjob, Server $server, ?Site $site = null): bool + public function update(User $user, CronJob $cronjob, Server $server, ?Site $site = null, ?Component $component = null, ?Environment $environment = null, ?Application $application = null): bool { - $cronJobServer = $cronjob->server; - - return $this->hasWriteAccess($user, $cronJobServer->project) && - $cronJobServer->isReady() && - $cronjob->server_id === $server->id && - (! $site || ($site->server_id === $server->id && $cronjob->site_id === $site->id)); + return $cronjob->component_capability_id === null && + $this->hasWriteAccess($user, $server->project) && + $this->view($user, $cronjob, $server, $site, $component, $environment, $application); } - public function delete(User $user, CronJob $cronjob, Server $server, ?Site $site = null): bool + public function delete(User $user, CronJob $cronjob, Server $server, ?Site $site = null, ?Component $component = null, ?Environment $environment = null, ?Application $application = null): bool { - $cronJobServer = $cronjob->server; - - return $this->hasWriteAccess($user, $cronJobServer->project) && - $cronJobServer->isReady() && - $cronjob->server_id === $server->id && - (! $site || ($site->server_id === $server->id && $cronjob->site_id === $site->id)); + return $this->update($user, $cronjob, $server, $site, $component, $environment, $application); } } diff --git a/app/Policies/WorkerPolicy.php b/app/Policies/WorkerPolicy.php index 265e09796..0fdd7ede5 100644 --- a/app/Policies/WorkerPolicy.php +++ b/app/Policies/WorkerPolicy.php @@ -2,6 +2,9 @@ namespace App\Policies; +use App\Models\Application; +use App\Models\Component; +use App\Models\Environment; use App\Models\Server; use App\Models\Site; use App\Models\User; @@ -14,58 +17,45 @@ class WorkerPolicy use HandlesAuthorization; use HasRolePolicies; - public function viewAny(User $user, Server $server, ?Site $site = null): bool + public function viewAny(User $user, Server $server, ?Site $site = null, ?Component $component = null, ?Environment $environment = null, ?Application $application = null): bool { return $this->hasReadAccess($user, $server->project) && $server->isReady() && (! $site || $site->server_id === $server->id) && + (! $component || ($component->server_id === $server->id && $component->project()->id === $server->project_id && + $user->can('view', [$component, $environment ?? $component->environment, $application ?? $component->environment->application]))) && $server->processManager(); } - public function view(User $user, Worker $worker, Server $server, ?Site $site = null): bool + public function view(User $user, Worker $worker, Server $server, ?Site $site = null, ?Component $component = null, ?Environment $environment = null, ?Application $application = null): bool { - return $this->hasReadAccess($user, $server->project) && - $server->isReady() && - (! $site || $site->server_id === $server->id) && - $worker->server_id === $server->id && + return $worker->server_id === $server->id && (! $site || $worker->site_id === $site->id) && - $server->processManager(); + (! $component || $worker->component_id === $component->id) && + $this->viewAny($user, $server, $site, $component ?? $worker->component, $environment, $application); } - public function create(User $user, Server $server, ?Site $site = null): bool + public function create(User $user, Server $server, ?Site $site = null, ?Component $component = null, ?Environment $environment = null, ?Application $application = null): bool { return $this->hasWriteAccess($user, $server->project) && - $server->isReady() && - (! $site || $site->server_id === $server->id) && - $server->processManager(); + $this->viewAny($user, $server, $site, $component, $environment, $application); } public function manage(User $user, Server $server, ?Site $site = null): bool { - return $this->hasWriteAccess($user, $server->project) && - $server->isReady() && - (! $site || $site->server_id === $server->id) && - $server->processManager(); + return $this->create($user, $server, $site); } - public function update(User $user, Worker $worker, Server $server, ?Site $site = null): bool + public function update(User $user, Worker $worker, Server $server, ?Site $site = null, ?Component $component = null, ?Environment $environment = null, ?Application $application = null): bool { - return $this->hasWriteAccess($user, $server->project) && - $server->isReady() && - (! $site || $site->server_id === $server->id) && - $worker->server_id === $server->id && - (! $site || $worker->site_id === $site->id) && - $server->processManager(); + return $worker->component_capability_id === null && + $this->hasWriteAccess($user, $server->project) && + $this->view($user, $worker, $server, $site, $component, $environment, $application); } - public function delete(User $user, Worker $worker, Server $server, ?Site $site = null): bool + public function delete(User $user, Worker $worker, Server $server, ?Site $site = null, ?Component $component = null, ?Environment $environment = null, ?Application $application = null): bool { - return $this->hasWriteAccess($user, $server->project) && - $server->isReady() && - (! $site || $site->server_id === $server->id) && - $worker->server_id === $server->id && - (! $site || $worker->site_id === $site->id) && - $server->processManager() && - ! $worker->isSiteBootstrap(); + return ! $worker->isSiteBootstrap() && + $this->update($user, $worker, $server, $site, $component, $environment, $application); } } diff --git a/app/Services/ProcessManager/Supervisor.php b/app/Services/ProcessManager/Supervisor.php index 9dcc87932..663bc2701 100644 --- a/app/Services/ProcessManager/Supervisor.php +++ b/app/Services/ProcessManager/Supervisor.php @@ -5,8 +5,10 @@ use App\DTOs\ServiceLog; use App\Enums\WorkerStatus; use App\Exceptions\SSHError; +use App\Helpers\ComponentShell; use App\Models\Worker; use App\Services\HasLogs; +use Illuminate\Support\HtmlString; use Throwable; class Supervisor extends AbstractProcessManager implements HasLogs @@ -78,14 +80,14 @@ public function writeConfig(Worker $worker): void "/etc/supervisor/conf.d/{$worker->id}.conf", view('ssh.services.process-manager.supervisor.worker', [ 'name' => (string) $worker->id, - 'directory' => $worker->site?->path, - 'command' => $worker->command, + 'directory' => $worker->component_id === null ? $worker->site?->path : null, + 'command' => $worker->component ? new HtmlString(ComponentShell::wrap($worker->component, $worker->command)) : $worker->command, 'user' => $worker->user, 'autoStart' => var_export($worker->auto_start, true), 'autoRestart' => var_export($worker->auto_restart, true), 'numprocs' => (string) $worker->numprocs, 'logFile' => $worker->getLogFile(), - 'environment' => self::formatEnvironment($worker->effectiveEnvironment()), + 'environment' => $worker->component_id === null ? self::formatEnvironment($worker->effectiveEnvironment()) : '', ]), 'root' ); @@ -101,6 +103,7 @@ public function create(Worker $worker): void $this->service->server->ssh()->exec( view('ssh.services.process-manager.supervisor.create-worker', [ 'id' => $worker->id, + 'start' => $worker->component_id === null || $worker->auto_start, 'logFile' => $worker->getLogFile(), 'user' => $worker->user, ]), @@ -117,6 +120,7 @@ public function delete(int $id, ?int $siteId = null): void $this->service->server->ssh()->exec( view('ssh.services.process-manager.supervisor.delete-worker', [ 'id' => $id, + 'component' => $this->service->server->workers()->whereKey($id)->whereNotNull('component_id')->exists(), ]), 'delete-worker', $siteId @@ -191,9 +195,10 @@ public function restartMany(array $ids, ?int $siteId = null): string */ public function restartAll(?int $siteId = null): void { - if ($siteId !== null) { + if ($siteId !== null || $this->service->server->components()->exists()) { $ids = $this->service->server->workers() - ->where('site_id', $siteId) + ->whereNull('component_id') + ->when($siteId !== null, fn ($query) => $query->where('site_id', $siteId)) ->whereNotIn('status', [WorkerStatus::CREATING, WorkerStatus::DELETING]) ->pluck('id') ->all(); diff --git a/app/Tables/Applications/ComponentCronJobTable.php b/app/Tables/Applications/ComponentCronJobTable.php new file mode 100644 index 000000000..129051557 --- /dev/null +++ b/app/Tables/Applications/ComponentCronJobTable.php @@ -0,0 +1,39 @@ +perPage = config('web.pagination_size'); + $this->query->with('component.environment')->latest(); + } + + protected function columns(): array + { + return [ + TextColumn::make('name', 'Name')->sortable(), + TextColumn::make('command', 'Command'), + TextColumn::make('frequency', 'Frequency'), + BooleanColumn::make('managed', 'Managed')->value(fn (CronJob $cronJob): bool => $cronJob->component_capability_id !== null), + EnumColumn::make('status', 'Status'), + Column::data('cronjob', fn (CronJob $cronJob): array => (new CronJobResource($cronJob))->resolve()), + ActionsColumn::make(), + ]; + } + + protected function searchable(): array + { + return ['name', 'command']; + } +} diff --git a/app/Tables/Applications/ComponentWorkerTable.php b/app/Tables/Applications/ComponentWorkerTable.php new file mode 100644 index 000000000..7c65604dc --- /dev/null +++ b/app/Tables/Applications/ComponentWorkerTable.php @@ -0,0 +1,39 @@ +perPage = config('web.pagination_size'); + $this->query->with('component.environment', 'site')->latest(); + } + + protected function columns(): array + { + return [ + TextColumn::make('name', 'Name')->sortable(), + TextColumn::make('command', 'Command'), + TextColumn::make('numprocs', 'Processes'), + BooleanColumn::make('managed', 'Managed')->value(fn (Worker $worker): bool => $worker->component_capability_id !== null), + EnumColumn::make('status', 'Status'), + Column::data('worker', fn (Worker $worker): array => (new WorkerResource($worker))->resolve()), + ActionsColumn::make(), + ]; + } + + protected function searchable(): array + { + return ['name', 'command']; + } +} diff --git a/app/Traits/HandlesWorkerFailure.php b/app/Traits/HandlesWorkerFailure.php index 0a96f3372..a5f7c4da6 100644 --- a/app/Traits/HandlesWorkerFailure.php +++ b/app/Traits/HandlesWorkerFailure.php @@ -25,6 +25,10 @@ protected function markWorkerFailed(Worker $worker, Throwable $e, string $logTyp protected function failWorker(Worker $worker, ?string $error, string $logType, string $logMessage, WorkerStatus $status = WorkerStatus::FAILED): void { + if ($worker->component_id !== null) { + $error = 'The component process operation failed. Check the command, runtime and server connectivity, then retry.'; + $logMessage = $error; + } $worker->error = $error; $worker->status = $status; $worker->save(); diff --git a/app/Traits/UniqueQueue.php b/app/Traits/UniqueQueue.php index 456fa7085..29591ac33 100644 --- a/app/Traits/UniqueQueue.php +++ b/app/Traits/UniqueQueue.php @@ -3,6 +3,7 @@ namespace App\Traits; use Closure; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Database\DetectsConcurrencyErrors; use Illuminate\Support\Facades\Cache; use Throwable; @@ -33,6 +34,11 @@ public function run(string $key, Closure $callback): void try { $callback(); } catch (Throwable $e) { + if ($e instanceof LockTimeoutException) { + $this->release(30); + + return; + } if ($this->isTransientDatabaseError($e) && $this->attempts() < $this->tries) { $this->release(min(30, $this->attempts() * 2)); diff --git a/database/migrations/2026_10_18_083748_add_component_capability_process_ownership.php b/database/migrations/2026_10_18_083748_add_component_capability_process_ownership.php new file mode 100644 index 000000000..e03fae1f3 --- /dev/null +++ b/database/migrations/2026_10_18_083748_add_component_capability_process_ownership.php @@ -0,0 +1,34 @@ +foreignId('component_capability_id')->nullable()->constrained()->cascadeOnDelete(); + }); + } + + Schema::table('components', function (Blueprint $table): void { + $table->string('installed_runtime_version')->nullable(); + }); + } + + public function down(): void + { + foreach (['workers', 'cron_jobs'] as $table) { + Schema::table($table, function (Blueprint $table): void { + $table->dropConstrainedForeignId('component_capability_id'); + }); + } + + Schema::table('components', function (Blueprint $table): void { + $table->dropColumn('installed_runtime_version'); + }); + } +}; diff --git a/resources/js/components/dialogs/registry.ts b/resources/js/components/dialogs/registry.ts index c6b6a36dd..b635477d0 100644 --- a/resources/js/components/dialogs/registry.ts +++ b/resources/js/components/dialogs/registry.ts @@ -46,6 +46,7 @@ import EnvironmentForm from '@/pages/applications/components/environment-form'; import DeleteApplicationResource from '@/pages/applications/components/delete-dialog'; import AddComponent from '@/pages/applications/components/add-component'; import DeleteComponent from '@/pages/applications/components/delete-component'; +import CapabilitiesDialog from '@/pages/applications/components/capabilities-dialog'; export type DialogControlProps = { open: boolean; onOpenChange: (open: boolean) => void }; @@ -110,6 +111,7 @@ export const dialogs = { deleteApplicationResource: DeleteApplicationResource, addComponent: AddComponent, deleteComponent: DeleteComponent, + componentCapabilities: CapabilitiesDialog, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as const satisfies Record>; diff --git a/resources/js/components/ui/dynamic-field.tsx b/resources/js/components/ui/dynamic-field.tsx index 81db55c63..d756ed3e9 100644 --- a/resources/js/components/ui/dynamic-field.tsx +++ b/resources/js/components/ui/dynamic-field.tsx @@ -28,9 +28,11 @@ interface DynamicFieldProps { onChange: (value: string | number | boolean | string[]) => void; config: DynamicFieldConfig; error?: string; + idPrefix?: string; } -export default function DynamicField({ value, onChange, config, error }: DynamicFieldProps) { +export default function DynamicField({ value, onChange, config, error, idPrefix }: DynamicFieldProps) { + const fieldId = `${config.type === 'checkbox' ? 'switch' : 'field'}-${idPrefix ? `${idPrefix}-` : ''}${config.name}`; const defaultLabel = config.name.replaceAll('_', ' '); const label = config?.label || defaultLabel; const [initialValue, setInitialValue] = useState(false); @@ -99,8 +101,8 @@ export default function DynamicField({ value, onChange, config, error }: Dynamic return (
- - + + {config.description &&

{config.description}

}
@@ -112,11 +114,11 @@ export default function DynamicField({ value, onChange, config, error }: Dynamic if (config?.type === 'select' && config.options) { return ( -