Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c5961a6
phase 1
saeedvaziry Oct 17, 2026
8cc9fc2
phase 2
saeedvaziry Oct 17, 2026
ebc5d0b
phase 3
saeedvaziry Oct 17, 2026
8440a7f
phase 4
saeedvaziry Oct 17, 2026
c4328c3
phase 5
saeedvaziry Oct 17, 2026
6e099e3
phase 6
saeedvaziry Oct 18, 2026
f6df5ae
phase 7
saeedvaziry Oct 18, 2026
dbfd63f
phase 8
saeedvaziry Oct 18, 2026
e399727
phase 9
saeedvaziry Oct 18, 2026
5fac979
phase 10
saeedvaziry Oct 19, 2026
2f6605e
phase 11
saeedvaziry Oct 19, 2026
20c4322
phase 12
saeedvaziry Oct 19, 2026
6360f35
phase 13
saeedvaziry Oct 19, 2026
c10ec6f
phase 14
saeedvaziry Oct 20, 2026
0538272
style: fix Applications code style violations
saeedvaziry Oct 20, 2026
5238d18
fix: Applications issues found in isolated E2E testing
saeedvaziry Oct 20, 2026
2b140b0
style: fix Pint violation in SiteGroundworkTest
saeedvaziry Oct 20, 2026
5e53e9b
fix: isolate component PHP tooling and guard repository changes
saeedvaziry Oct 21, 2026
2a8c473
feat: redesign Applications around an environment canvas
saeedvaziry Oct 22, 2026
38c7a19
feat: open environment variables, settings and deployments as canvas …
saeedvaziry Oct 22, 2026
82546d9
fix: Applications issues found in multi-server showcase testing
saeedvaziry Oct 22, 2026
1751338
style: apply pre-commit formatting
saeedvaziry Oct 22, 2026
8806c2a
fix: animate canvas sheets closed and keep the node panel open under …
saeedvaziry Oct 22, 2026
81c462e
fix: stop root from following user-planted symlinks in worker and ser…
saeedvaziry Oct 22, 2026
59b71e2
fix: Applications issues found in multi-server failure testing
saeedvaziry Oct 22, 2026
fb1273d
style: format deployment panel and sort test imports
saeedvaziry Oct 22, 2026
72f07ac
fix: preserve worker logs and make API site cleanup opt-in
saeedvaziry Oct 23, 2026
41a43f6
fix: harden deployments, cron sync and server compatibility
saeedvaziry Oct 23, 2026
f7beb23
style: fix hosted domain query indentation
saeedvaziry Oct 23, 2026
e9c6078
fix: keep existing plugins compatible and resolve E2E regressions
saeedvaziry Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
10 changes: 10 additions & 0 deletions .github/instructions/php-laravel.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>` 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

Expand Down Expand Up @@ -78,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.
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ opencode.json
!.claude/commands/
.opencode
.agents/
skills-lock.json
skills-lock.json
.plans/
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions app/Actions/Application/CreateApplication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Actions\Application;

use App\Actions\Environment\CreateEnvironment;
use App\Models\Application;
use App\Models\Project;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;

class CreateApplication
{
/**
* @param array<string, mixed> $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;
});
}
}
113 changes: 113 additions & 0 deletions app/Actions/Application/DeleteApplication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

namespace App\Actions\Application;

use App\Actions\Environment\DeleteEnvironment;
use App\Actions\EnvironmentDeployment\AssertEnvironmentIdle;
use App\Actions\GitHook\SyncRepositoryGitHook;
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 App\Models\EnvironmentAttachment;
use App\Models\Ssl;
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
{
/**
* @param array<string, mixed> $input
*/
public function delete(Application $application, array $input): void
{
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.']);
}

foreach ($application->environments()->orderBy('id')->lockForUpdate()->get() as $environment) {
app(AssertEnvironmentIdle::class)->validate($environment, 'name');
}

if (! Component::query()->whereHas('environment', fn ($query) => $query->where('application_id', $application->id))->exists()
&& ! EnvironmentAttachment::query()->whereHas('environment', fn ($query) => $query->where('application_id', $application->id))->exists()
&& ! Ssl::query()->whereHas('environment', fn ($query) => $query->where('application_id', $application->id))->exists()) {
foreach ($application->environments()->get() as $environment) {
app(SyncRepositoryGitHook::class)->disable($environment);
}
$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();
});
foreach ($application->environments()->get() as $environment) {
app(SyncRepositoryGitHook::class)->disable($environment);
}
});
}

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));
}
}
17 changes: 17 additions & 0 deletions app/Actions/Application/GetApplicationRuntime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace App\Actions\Application;

use App\Models\Application;
use App\RuntimePresets\AbstractRuntimePreset;
use Illuminate\Validation\ValidationException;

class GetApplicationRuntime
{
public function get(Application $application, string $field = 'runtime'): AbstractRuntimePreset
{
return $application->presetOrNull() ?? throw ValidationException::withMessages([
$field => 'The runtime plugin is missing. Restore it before changing runtime configuration or deploying.',
]);
}
}
37 changes: 37 additions & 0 deletions app/Actions/Application/GetApplicationSourceControl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace App\Actions\Application;

use App\Models\Application;
use App\Models\SourceControl;
use App\SourceControlProviders\SupportsApplications;
use Illuminate\Validation\ValidationException;

class GetApplicationSourceControl
{
public function get(Application $application): SourceControl
{
$source = $application->sourceControl()->where(fn ($query) => $query
->whereNull('project_id')->orWhere('project_id', $application->project_id))->first();

if (! $source) {
throw ValidationException::withMessages(['deployment' => 'Select an available source control in this project before deploying.']);
}

$this->provider($source);

return $source;
}

public function provider(SourceControl $sourceControl): SupportsApplications
{
$provider = $sourceControl->provider();
if (! $provider instanceof SupportsApplications) {
throw ValidationException::withMessages([
'source_control_id' => 'This source-control provider supports Sites only. Update its plugin to support Applications.',
]);
}

return $provider;
}
}
77 changes: 77 additions & 0 deletions app/Actions/Application/UpdateApplication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

namespace App\Actions\Application;

use App\Actions\Component\BroadcastComponentUpdate;
use App\Actions\EnvironmentDeployment\AssertEnvironmentIdle;
use App\Actions\GitHook\SyncRepositoryGitHook;
use App\Enums\ApplicationStatus;
use App\Enums\ComponentStatus;
use App\Enums\ComponentType;
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
{
/**
* @param array<string, mixed> $input
*/
public function update(Application $application, array $input, User $actor): Application
{
app(ValidateApplication::class)->validate($application->project, $input, $actor, $application);

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.']);
}

$oldSource = $application->sourceControl;
$oldRepository = $application->repository;
$changed = $application->source_control_id !== (int) $input['source_control_id'] || $application->repository !== $input['repository'];
if ($changed) {
foreach ($application->environments()->orderBy('id')->lockForUpdate()->get() as $environment) {
app(AssertEnvironmentIdle::class)->validate($environment, 'repository');
if ($environment->components()->where('type', ComponentType::APP)
->whereIn('status', [ComponentStatus::PENDING, ComponentStatus::INSTALLING])->exists()) {
throw ValidationException::withMessages(['repository' => 'Wait for component installation or synchronization to finish before changing the repository or source control.']);
}
}
}
$application->update([
'name' => $input['name'],
'source_control_id' => $input['source_control_id'],
'repository' => $input['repository'],
]);

$application->unsetRelation('sourceControl');
if ($changed) {
Component::query()->whereHas('environment', fn ($query) => $query->where('application_id', $application->id))
->where('type', ComponentType::APP)->where('status', ComponentStatus::READY)->each(function (Component $component): void {
$component->status = ComponentStatus::PENDING;
$component->needs_redeploy = true;
$component->save();
DB::afterCommit(fn () => app(BroadcastComponentUpdate::class)->broadcast($component));
SyncComponentDeployKeysJob::dispatch($component->id, $component->server_id)->afterCommit();
});
}
DB::afterCommit(function () use ($application, $oldSource, $oldRepository, $changed): void {
try {
if ($changed && $oldSource) {
app(SyncRepositoryGitHook::class)->sync($oldSource, $oldRepository);
}
} finally {
if ($application->sourceControl) {
app(SyncRepositoryGitHook::class)->sync($application->sourceControl, $application->repository);
}
}
});

return $application;
});
}
}
54 changes: 54 additions & 0 deletions app/Actions/Application/ValidateApplication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace App\Actions\Application;

use App\Exceptions\RepositoryNotFound;
use App\Exceptions\RepositoryPermissionDenied;
use App\Exceptions\SourceControlIsNotConnected;
use App\Models\Application;
use App\Models\Project;
use App\Models\SourceControl;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;

class ValidateApplication
{
/**
* @param array<string, mixed> $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 = app(GetApplicationSourceControl::class)->provider($sourceControl);

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',
]);
}
}
}
Loading
Loading