diff --git a/.env.example b/.env.example index 35db1dd..0ecd1ac 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,5 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +HETZNER_KEY= diff --git a/app/Http/Controllers/ApplicationController.php b/app/Http/Controllers/ApplicationController.php index 2af0ecc..0578d12 100644 --- a/app/Http/Controllers/ApplicationController.php +++ b/app/Http/Controllers/ApplicationController.php @@ -8,6 +8,7 @@ use App\Actions\Applications\VerifyRepositoryAccess; use App\Enums\RepositoryType; use App\Enums\ServerStatus; use App\Http\Requests\StoreApplicationRequest; +use App\Http\Requests\UpdateApplicationRequest; use App\Models\Application; use App\Models\Organisation; use Illuminate\Http\RedirectResponse; @@ -27,9 +28,12 @@ class ApplicationController extends Controller public function create(Request $request): Response { - Organisation::findOrFail($request->route('organisation')); + $organisation = Organisation::findOrFail($request->route('organisation')); - return inertia('applications/Create'); + return inertia('applications/Create', [ + 'sourceProviders' => $organisation->sourceProviders()->get(), + 'repositoryTypes' => RepositoryType::toArray(), + ]); } public function store(StoreApplicationRequest $request): RedirectResponse @@ -38,8 +42,9 @@ class ApplicationController extends Controller $application = $organisation->applications()->create([ 'name' => $request->string('name')->toString(), + 'source_provider_id' => $this->sourceProviderIdFor($organisation, $request->integer('source_provider_id') ?: null), 'repository_url' => $request->string('repository_url')->toString(), - 'repository_type' => RepositoryType::GIT, + 'repository_type' => $request->enum('repository_type', RepositoryType::class), 'default_branch' => $request->string('default_branch')->toString(), ]); @@ -56,14 +61,24 @@ class ApplicationController extends Controller $id = $request->route('application'); $organisation = Organisation::findOrFail($request->route('organisation')); $application = Application::with([ + 'environments.buildArtifacts' => fn ($query) => $query->latest()->limit(5), + 'environments.operations' => fn ($query) => $query->latest()->limit(1), + 'environments.services.replicas', + 'environments.services.endpoints', 'environments.services.slices', 'environments.attachments.service', 'environments.variables', 'organisation', + 'sourceProvider', ])->whereBelongsTo($organisation)->findOrFail($id); return inertia('applications/Show', [ 'application' => $application, + 'deploymentRequirements' => [ + 'registryRequired' => $organisation->servers()->count() > 1 && $organisation->registries()->doesntExist(), + 'registryCount' => $organisation->registries()->count(), + 'serverCount' => $organisation->servers()->count(), + ], 'servers' => inertia()->optional(function () use ($application) { return $application ->organisation @@ -75,6 +90,51 @@ class ApplicationController extends Controller ]); } + public function edit(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + + return inertia('applications/Edit', [ + 'application' => $application, + 'repositoryTypes' => RepositoryType::toArray(), + 'sourceProviders' => $organisation->sourceProviders()->get(), + ]); + } + + public function update(UpdateApplicationRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + + $application->update([ + 'name' => $request->string('name')->toString(), + 'source_provider_id' => $this->sourceProviderIdFor($organisation, $request->integer('source_provider_id') ?: null), + 'repository_type' => $request->enum('repository_type', RepositoryType::class), + 'repository_url' => $request->string('repository_url')->toString(), + 'default_branch' => $request->string('default_branch')->toString(), + ]); + + return redirect() + ->route('applications.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + ]) + ->with('success', 'Application updated.'); + } + + public function destroy(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + + $application->delete(); + + return redirect() + ->route('applications.index', ['organisation' => $organisation->id]) + ->with('success', 'Application deleted.'); + } + public function verifyRepository(Request $request): RedirectResponse { $organisation = Organisation::findOrFail($request->route('organisation')); @@ -86,4 +146,23 @@ class ApplicationController extends Controller return back()->with('success', 'Repository access verified.'); } + + public function rotateDeployKey(Request $request, GenerateDeployKey $generateDeployKey): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + + $generateDeployKey->execute($application); + + return back()->with('success', 'Deploy key rotated. Install the new public key before verifying access.'); + } + + private function sourceProviderIdFor(Organisation $organisation, ?int $sourceProviderId): ?int + { + if ($sourceProviderId === null) { + return null; + } + + return $organisation->sourceProviders()->findOrFail($sourceProviderId)->id; + } } diff --git a/app/Http/Controllers/BuildArtifactController.php b/app/Http/Controllers/BuildArtifactController.php new file mode 100644 index 0000000..0888064 --- /dev/null +++ b/app/Http/Controllers/BuildArtifactController.php @@ -0,0 +1,44 @@ +route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + + return inertia('build-artifacts/Index', [ + 'application' => $application, + 'environment' => $environment, + 'artifacts' => $environment->buildArtifacts() + ->with(['builtByOperation', 'builtByService']) + ->latest() + ->paginate(30), + ]); + } + + public function show(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + /** @var BuildArtifact $artifact */ + $artifact = $environment->buildArtifacts() + ->with(['builtByOperation.steps', 'builtByService']) + ->findOrFail($request->route('artifact')); + + return inertia('build-artifacts/Show', [ + 'application' => $application, + 'environment' => $environment, + 'artifact' => $artifact, + ]); + } +} diff --git a/app/Http/Controllers/EnvironmentAttachmentController.php b/app/Http/Controllers/EnvironmentAttachmentController.php index 0b10019..c68d9ea 100644 --- a/app/Http/Controllers/EnvironmentAttachmentController.php +++ b/app/Http/Controllers/EnvironmentAttachmentController.php @@ -6,6 +6,7 @@ use App\Actions\Environments\AttachManagedService; use App\Enums\EnvironmentAttachmentRole; use App\Enums\ServiceType; use App\Http\Requests\StoreEnvironmentAttachmentRequest; +use App\Http\Requests\UpdateEnvironmentAttachmentRequest; use App\Models\Organisation; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -27,6 +28,12 @@ class EnvironmentAttachmentController extends Controller ->orderBy('name') ->get(['id', 'name', 'type', 'category']), 'roles' => array_values(EnvironmentAttachmentRole::toArray()), + 'compatibility' => [ + EnvironmentAttachmentRole::DATABASE->value => [ServiceType::POSTGRES->value], + EnvironmentAttachmentRole::CACHE->value => [ServiceType::VALKEY->value], + EnvironmentAttachmentRole::QUEUE->value => [ServiceType::VALKEY->value], + EnvironmentAttachmentRole::GATEWAY->value => [ServiceType::CADDY->value], + ], ]); } @@ -37,7 +44,7 @@ class EnvironmentAttachmentController extends Controller $environment = $application->environments()->findOrFail($request->route('environment')); $service = $organisation->services()->findOrFail($request->integer('service_id')); - app(AttachManagedService::class)->execute( + $attachment = app(AttachManagedService::class)->execute( environment: $environment, service: $service, role: $request->enum('role', EnvironmentAttachmentRole::class), @@ -46,6 +53,17 @@ class EnvironmentAttachmentController extends Controller isPrimary: $request->boolean('is_primary', true), ); + if ($request->enum('role', EnvironmentAttachmentRole::class) === EnvironmentAttachmentRole::GATEWAY && $attachment->serviceSlice) { + $attachment->serviceSlice->update([ + 'config' => [ + ...($attachment->serviceSlice->config ?? []), + 'domain' => $request->filled('domain') ? $request->string('domain')->toString() : null, + 'path_prefix' => $request->filled('path_prefix') ? $request->string('path_prefix')->toString() : '/', + 'tls_enabled' => $request->boolean('tls_enabled', true), + ], + ]); + } + return redirect() ->route('environments.show', [ 'organisation' => $organisation->id, @@ -54,4 +72,73 @@ class EnvironmentAttachmentController extends Controller ]) ->with('success', 'Managed service attached.'); } + + public function edit(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + $attachment = $environment->attachments() + ->with(['service', 'serviceSlice']) + ->findOrFail($request->route('attachment')); + + return inertia('environment-attachments/Edit', [ + 'application' => $application, + 'environment' => $environment, + 'attachment' => $attachment, + 'roles' => array_values(EnvironmentAttachmentRole::toArray()), + ]); + } + + public function update(UpdateEnvironmentAttachmentRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + $attachment = $environment->attachments()->findOrFail($request->route('attachment')); + + $attachment->update([ + 'role' => $request->enum('role', EnvironmentAttachmentRole::class), + 'env_prefix' => $request->filled('env_prefix') ? $request->string('env_prefix')->toString() : null, + 'is_primary' => $request->boolean('is_primary'), + ]); + + if ($attachment->serviceSlice && $request->enum('role', EnvironmentAttachmentRole::class) === EnvironmentAttachmentRole::GATEWAY) { + $attachment->serviceSlice->update([ + 'config' => [ + ...($attachment->serviceSlice->config ?? []), + 'domain' => $request->filled('domain') ? $request->string('domain')->toString() : null, + 'path_prefix' => $request->filled('path_prefix') ? $request->string('path_prefix')->toString() : '/', + 'tls_enabled' => $request->boolean('tls_enabled', true), + 'certificate_status' => $request->filled('certificate_status') ? $request->string('certificate_status')->toString() : null, + ], + ]); + } + + return redirect() + ->route('environments.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]) + ->with('success', 'Attachment updated.'); + } + + public function destroy(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + $attachment = $environment->attachments()->findOrFail($request->route('attachment')); + + $attachment->delete(); + + return redirect() + ->route('environments.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]) + ->with('success', 'Attachment detached.'); + } } diff --git a/app/Http/Controllers/EnvironmentController.php b/app/Http/Controllers/EnvironmentController.php index 3eaf463..9db464f 100644 --- a/app/Http/Controllers/EnvironmentController.php +++ b/app/Http/Controllers/EnvironmentController.php @@ -2,12 +2,54 @@ namespace App\Http\Controllers; +use App\Actions\Applications\CreateLaravelEnvironment; +use App\Enums\BuildStrategy; +use App\Enums\EnvironmentAttachmentRole; +use App\Enums\SchedulerMode; +use App\Enums\ServiceType; +use App\Http\Requests\StoreEnvironmentRequest; +use App\Http\Requests\UpdateEnvironmentRequest; +use App\Models\Environment; use App\Models\Organisation; +use App\Support\CaddyRouteRenderer; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Inertia\Response; class EnvironmentController extends Controller { + public function create(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + + return inertia('environments/Create', [ + 'application' => $application, + ]); + } + + public function store(StoreEnvironmentRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + + $environment = app(CreateLaravelEnvironment::class)->execute( + application: $application, + name: $request->string('name')->toString(), + branch: $request->string('branch')->toString(), + phpVersion: $request->string('php_version')->toString(), + ); + + return redirect() + ->route('environments.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]) + ->with('success', 'Environment created.'); + } + public function show(Request $request): Response { $organisation = Organisation::findOrFail($request->route('organisation')); @@ -15,18 +57,151 @@ class EnvironmentController extends Controller $environment = $application->environments() ->with([ 'services.replicas', + 'services.endpoints', 'services.slices', 'services.operations.steps', 'attachments.service', 'attachments.serviceSlice', 'variables', + 'buildArtifacts.builtByService', 'operations.steps', + 'operations.children.target', ]) ->findOrFail($request->route('environment')); + $serverCount = $this->serverIdsFor($environment)->count(); + return inertia('environments/Show', [ 'application' => $application, 'environment' => $environment, + 'deploymentRequirements' => [ + 'registryRequired' => $organisation->registries()->doesntExist() && $serverCount > 1, + 'registryCount' => $organisation->registries()->count(), + 'serverCount' => $serverCount, + ], + 'gatewayRoutePreviews' => $this->gatewayRoutePreviews($environment), ]); } + + public function edit(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments() + ->with('services') + ->findOrFail($request->route('environment')); + + return inertia('environments/Edit', [ + 'application' => $application, + 'environment' => $environment, + 'schedulerModes' => array_values(SchedulerMode::toArray()), + 'buildStrategies' => array_values(BuildStrategy::toArray()), + ]); + } + + public function update(UpdateEnvironmentRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments() + ->with('services') + ->findOrFail($request->route('environment')); + + $schedulerTargetServiceId = $request->integer('scheduler_target_service_id') ?: null; + + if ($schedulerTargetServiceId !== null) { + abort_unless($environment->services()->whereKey($schedulerTargetServiceId)->exists(), 422); + } + + $environment->update([ + 'name' => $request->string('name')->toString(), + 'branch' => $request->string('branch')->toString(), + 'status' => $request->string('status')->toString(), + 'scheduler_enabled' => $request->boolean('scheduler_enabled'), + 'scheduler_target_service_id' => $schedulerTargetServiceId, + 'scheduler_mode' => $request->enum('scheduler_mode', SchedulerMode::class), + 'build_config' => [ + ...($environment->build_config ?? []), + 'build_strategy' => $request->enum('build_strategy', BuildStrategy::class)?->value, + 'php_version' => $request->string('php_version')->toString(), + 'document_root' => $request->string('document_root')->toString(), + 'health_path' => $request->string('health_path')->toString(), + 'js_package_manager' => $request->string('js_package_manager')->toString(), + 'js_build_command' => $request->filled('js_build_command') + ? $request->string('js_build_command')->toString() + : null, + ], + ]); + + return redirect() + ->route('environments.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]) + ->with('success', 'Environment updated.'); + } + + public function destroy(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + + $environment->delete(); + + return redirect() + ->route('applications.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + ]) + ->with('success', 'Environment deleted.'); + } + + /** + * @return \Illuminate\Support\Collection + */ + private function serverIdsFor(Environment $environment): Collection + { + return $environment->services + ->flatMap(fn ($service) => [ + $service->server_id, + ...$service->replicas->pluck('server_id')->all(), + ]) + ->filter() + ->unique() + ->values(); + } + + /** + * @return Collection + */ + private function gatewayRoutePreviews(Environment $environment): Collection + { + $upstreams = $this->previewGatewayUpstreams($environment); + $renderer = app(CaddyRouteRenderer::class); + + return $environment->attachments + ->filter(fn ($attachment): bool => $attachment->role === EnvironmentAttachmentRole::GATEWAY) + ->map(fn ($attachment): array => [ + 'attachment_id' => $attachment->id, + 'caddyfile' => $renderer->render($attachment, $upstreams), + ]) + ->values(); + } + + /** + * @return array + */ + private function previewGatewayUpstreams(Environment $environment): array + { + return $environment->services + ->filter(fn ($service): bool => $service->type === ServiceType::LARAVEL && in_array('web', $service->process_roles ?? [], true)) + ->flatMap(fn ($service) => $service->replicas->map( + fn ($replica): string => ($replica->internal_host ?: $replica->container_name).':'.($replica->internal_port ?: 80) + )) + ->values() + ->whenEmpty(fn ($upstreams) => $upstreams->push('web:80')) + ->all(); + } } diff --git a/app/Http/Controllers/EnvironmentDeploymentController.php b/app/Http/Controllers/EnvironmentDeploymentController.php index 379cea7..cec1d07 100644 --- a/app/Http/Controllers/EnvironmentDeploymentController.php +++ b/app/Http/Controllers/EnvironmentDeploymentController.php @@ -2,15 +2,17 @@ namespace App\Http\Controllers; +use App\Http\Requests\StoreEnvironmentDeploymentRequest; use App\Jobs\Environments\DeployEnvironment; use App\Models\Application; use App\Models\Environment; use App\Models\Organisation; use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Collection; class EnvironmentDeploymentController extends Controller { - public function store(Organisation $organisation, Application $application, Environment $environment): RedirectResponse + public function store(StoreEnvironmentDeploymentRequest $request, Organisation $organisation, Application $application, Environment $environment): RedirectResponse { abort_unless( (int) $application->organisation_id === (int) $organisation->id @@ -18,7 +20,16 @@ class EnvironmentDeploymentController extends Controller 404, ); - dispatch(new DeployEnvironment($environment)); + $environment->loadMissing('services.replicas'); + + if ($organisation->registries()->doesntExist() && $this->serverIdsFor($environment)->count() > 1) { + return back()->with('error', 'Configure a registry before deploying this environment to multiple servers.'); + } + + dispatch(new DeployEnvironment( + environment: $environment, + targetCommit: $request->validated('target_commit') ?: null, + )); return redirect()->route('environments.show', [ 'organisation' => $organisation->id, @@ -26,4 +37,19 @@ class EnvironmentDeploymentController extends Controller 'environment' => $environment->id, ]); } + + /** + * @return \Illuminate\Support\Collection + */ + private function serverIdsFor(Environment $environment): Collection + { + return $environment->services + ->flatMap(fn ($service) => [ + $service->server_id, + ...$service->replicas->pluck('server_id')->all(), + ]) + ->filter() + ->unique() + ->values(); + } } diff --git a/app/Http/Controllers/EnvironmentIndexController.php b/app/Http/Controllers/EnvironmentIndexController.php new file mode 100644 index 0000000..46830e3 --- /dev/null +++ b/app/Http/Controllers/EnvironmentIndexController.php @@ -0,0 +1,24 @@ +applications() + ->with([ + 'environments' => fn ($query) => $query + ->withCount(['services', 'attachments', 'variables', 'buildArtifacts']) + ->latest(), + ]) + ->get(); + + return inertia('environments/Index', [ + 'applications' => $applications, + ]); + } +} diff --git a/app/Http/Controllers/EnvironmentVariableController.php b/app/Http/Controllers/EnvironmentVariableController.php index b54412e..d68ddc7 100644 --- a/app/Http/Controllers/EnvironmentVariableController.php +++ b/app/Http/Controllers/EnvironmentVariableController.php @@ -3,7 +3,9 @@ namespace App\Http\Controllers; use App\Enums\EnvironmentVariableSource; +use App\Http\Requests\ImportEnvironmentVariablesRequest; use App\Http\Requests\StoreEnvironmentVariableRequest; +use App\Http\Requests\UpdateEnvironmentVariableRequest; use App\Models\Organisation; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -11,6 +13,22 @@ use Inertia\Response; class EnvironmentVariableController extends Controller { + public function index(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + + return inertia('environment-variables/Index', [ + 'application' => $application, + 'environment' => $environment, + 'variables' => $environment->variables() + ->with('serviceSlice') + ->orderBy('key') + ->get(), + ]); + } + public function create(Request $request): Response { $organisation = Organisation::findOrFail($request->route('organisation')); @@ -35,11 +53,136 @@ class EnvironmentVariableController extends Controller 'value' => $request->string('value')->toString(), 'source' => EnvironmentVariableSource::USER, 'service_slice_id' => null, - 'overridable' => true, + 'overridable' => $request->boolean('overridable', true), ]); return redirect() - ->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id]) + ->route('environments.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]) ->with('success', 'Environment variable saved.'); } + + public function import(ImportEnvironmentVariablesRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + $count = 0; + + foreach ($this->parseDotEnv($request->string('contents')->toString()) as $key => $value) { + $environment->variables()->updateOrCreate([ + 'key' => $key, + ], [ + 'value' => $value, + 'source' => EnvironmentVariableSource::USER, + 'service_slice_id' => null, + 'overridable' => $request->boolean('overridable', true), + ]); + + $count++; + } + + return redirect() + ->route('environment-variables.index', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]) + ->with('success', "{$count} environment variables imported."); + } + + public function edit(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + $variable = $environment->variables()->with('serviceSlice')->findOrFail($request->route('variable')); + + return inertia('environment-variables/Edit', [ + 'application' => $application, + 'environment' => $environment, + 'variable' => $variable, + ]); + } + + public function update(UpdateEnvironmentVariableRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + $variable = $environment->variables()->findOrFail($request->route('variable')); + + $variable->update([ + 'key' => $request->string('key')->toString(), + 'value' => $request->string('value')->toString(), + 'overridable' => $request->boolean('overridable'), + ]); + + return redirect() + ->route('environment-variables.index', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]) + ->with('success', 'Environment variable updated.'); + } + + public function destroy(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + $variable = $environment->variables()->findOrFail($request->route('variable')); + + $variable->delete(); + + return redirect() + ->route('environment-variables.index', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]) + ->with('success', 'Environment variable deleted.'); + } + + /** + * @return array + */ + private function parseDotEnv(string $contents): array + { + return collect(preg_split('/\R/', $contents) ?: []) + ->map(fn (string $line): string => trim($line)) + ->reject(fn (string $line): bool => $line === '' || str_starts_with($line, '#')) + ->mapWithKeys(function (string $line): array { + if (str_starts_with($line, 'export ')) { + $line = trim(substr($line, 7)); + } + + [$key, $value] = array_pad(explode('=', $line, 2), 2, ''); + $key = trim($key); + + if (! preg_match('/^[A-Z][A-Z0-9_]*$/', $key)) { + return []; + } + + return [$key => $this->unquoteDotEnvValue(trim($value))]; + }) + ->all(); + } + + private function unquoteDotEnvValue(string $value): string + { + if (str_starts_with($value, '"') && str_ends_with($value, '"')) { + return stripcslashes(substr($value, 1, -1)); + } + + if (str_starts_with($value, "'") && str_ends_with($value, "'")) { + return substr($value, 1, -1); + } + + return $value; + } } diff --git a/app/Http/Controllers/GatewayRouteController.php b/app/Http/Controllers/GatewayRouteController.php new file mode 100644 index 0000000..7471e84 --- /dev/null +++ b/app/Http/Controllers/GatewayRouteController.php @@ -0,0 +1,162 @@ +route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments() + ->with(['attachments.service', 'attachments.serviceSlice']) + ->findOrFail($request->route('environment')); + + return inertia('gateway-routes/Index', [ + 'application' => $application, + 'environment' => $environment, + 'routes' => $environment->attachments + ->filter(fn (EnvironmentAttachment $attachment): bool => $attachment->role === EnvironmentAttachmentRole::GATEWAY) + ->values(), + ]); + } + + public function create(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + + return inertia('gateway-routes/Create', [ + 'application' => $application, + 'environment' => $environment, + 'services' => $organisation->services() + ->where('type', ServiceType::CADDY->value) + ->orderBy('name') + ->get(['id', 'name', 'type', 'category']), + ]); + } + + public function store(StoreGatewayRouteRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + $service = $organisation->services() + ->where('type', ServiceType::CADDY->value) + ->findOrFail($request->integer('service_id')); + + $attachment = app(AttachManagedService::class)->execute( + environment: $environment, + service: $service, + role: EnvironmentAttachmentRole::GATEWAY, + name: $request->string('name')->toString(), + isPrimary: true, + ); + + $attachment->serviceSlice?->update([ + 'config' => [ + ...($attachment->serviceSlice->config ?? []), + ...$this->routeConfig($request), + 'certificate_status' => $request->boolean('tls_enabled', true) ? 'pending' : 'disabled', + ], + ]); + + return redirect() + ->route('gateway.routes.index', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]) + ->with('success', 'Gateway route created.'); + } + + public function edit(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + $route = $environment->attachments() + ->with(['service', 'serviceSlice']) + ->where('role', EnvironmentAttachmentRole::GATEWAY->value) + ->findOrFail($request->route('route')); + + return inertia('gateway-routes/Edit', [ + 'application' => $application, + 'environment' => $environment, + 'routeAttachment' => $route, + ]); + } + + public function update(UpdateGatewayRouteRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + $route = $environment->attachments() + ->with('serviceSlice') + ->where('role', EnvironmentAttachmentRole::GATEWAY->value) + ->findOrFail($request->route('route')); + + $route->serviceSlice?->update([ + 'config' => [ + ...($route->serviceSlice->config ?? []), + ...$this->routeConfig($request), + 'certificate_status' => $request->filled('certificate_status') + ? $request->string('certificate_status')->toString() + : ($request->boolean('tls_enabled', true) ? 'pending' : 'disabled'), + ], + ]); + + return redirect() + ->route('gateway.routes.index', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]) + ->with('success', 'Gateway route updated.'); + } + + public function destroy(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + $route = $environment->attachments() + ->where('role', EnvironmentAttachmentRole::GATEWAY->value) + ->findOrFail($request->route('route')); + + $route->delete(); + + return redirect() + ->route('gateway.routes.index', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]) + ->with('success', 'Gateway route removed.'); + } + + /** + * @return array{domain: string, path_prefix: string, tls_enabled: bool} + */ + private function routeConfig(StoreGatewayRouteRequest|UpdateGatewayRouteRequest $request): array + { + return [ + 'domain' => $request->string('domain')->toString(), + 'path_prefix' => $request->string('path_prefix')->toString(), + 'tls_enabled' => $request->boolean('tls_enabled', true), + ]; + } +} diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php index 7ca2923..259bab1 100644 --- a/app/Http/Controllers/OnboardingController.php +++ b/app/Http/Controllers/OnboardingController.php @@ -11,6 +11,10 @@ class OnboardingController extends Controller { $organisation->loadCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']); + $applicationNeedingDeployKey = $organisation->applications() + ->whereNull('deploy_key_installed_at') + ->first(); + $steps = [ [ 'key' => 'organisation', @@ -48,6 +52,17 @@ class OnboardingController extends Controller 'complete' => $organisation->applications_count > 0, 'href' => route('applications.create', ['organisation' => $organisation->id]), ], + [ + 'key' => 'deploy-key', + 'label' => 'Deploy key', + 'complete' => $organisation->applications_count === 0 || $applicationNeedingDeployKey === null, + 'href' => $applicationNeedingDeployKey + ? route('applications.show', [ + 'organisation' => $organisation->id, + 'application' => $applicationNeedingDeployKey->id, + ]) + : route('applications.index', ['organisation' => $organisation->id]), + ], ]; $next = collect($steps)->firstWhere('complete', false) ?? $steps[array_key_last($steps)]; diff --git a/app/Http/Controllers/OperationController.php b/app/Http/Controllers/OperationController.php new file mode 100644 index 0000000..50edc5a --- /dev/null +++ b/app/Http/Controllers/OperationController.php @@ -0,0 +1,171 @@ +applications()->pluck('id'); + $environmentIds = Environment::query() + ->whereIn('application_id', $applicationIds) + ->pluck('id'); + $serverIds = $organisation->servers()->pluck('id'); + $serviceIds = $organisation->services()->pluck('id'); + $replicaIds = ServiceReplica::query() + ->whereIn('service_id', $serviceIds) + ->pluck('id'); + $sliceIds = ServiceSlice::query() + ->whereIn('service_id', $serviceIds) + ->pluck('id'); + + $operations = Operation::query() + ->with(['target', 'parent', 'children.target']) + ->withCount('steps', 'children') + ->where(function (Builder $query) use ($applicationIds, $environmentIds, $serverIds, $serviceIds, $replicaIds, $sliceIds): void { + $query + ->where(function (Builder $query) use ($applicationIds): void { + $query->where('target_type', (new Application)->getMorphClass()) + ->whereIn('target_id', $applicationIds); + }) + ->orWhere(function (Builder $query) use ($environmentIds): void { + $query->where('target_type', (new Environment)->getMorphClass()) + ->whereIn('target_id', $environmentIds); + }) + ->orWhere(function (Builder $query) use ($serverIds): void { + $query->where('target_type', (new Server)->getMorphClass()) + ->whereIn('target_id', $serverIds); + }) + ->orWhere(function (Builder $query) use ($serviceIds): void { + $query->where('target_type', (new Service)->getMorphClass()) + ->whereIn('target_id', $serviceIds); + }) + ->orWhere(function (Builder $query) use ($replicaIds): void { + $query->where('target_type', (new ServiceReplica)->getMorphClass()) + ->whereIn('target_id', $replicaIds); + }) + ->orWhere(function (Builder $query) use ($sliceIds): void { + $query->where('target_type', (new ServiceSlice)->getMorphClass()) + ->whereIn('target_id', $sliceIds); + }); + }) + ->when($request->filled('kind'), fn (Builder $query) => $query->where('kind', $request->string('kind')->toString())) + ->when($request->filled('status'), fn (Builder $query) => $query->where('status', $request->string('status')->toString())) + ->latest() + ->paginate(30) + ->withQueryString(); + + return inertia('operations/Index', [ + 'operations' => $operations, + 'filters' => $request->only(['kind', 'status']), + 'operationKinds' => OperationKind::toArray(), + 'operationStatuses' => OperationStatus::toArray(), + ]); + } + + public function show(Organisation $organisation, Operation $operation): Response + { + abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404); + + $operation->load([ + 'target', + 'parent.target', + 'children.target', + 'children.steps', + 'steps', + ]); + + return inertia('operations/Show', [ + 'operation' => $operation, + ]); + } + + public function retry(Organisation $organisation, Operation $operation): RedirectResponse + { + abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404); + + $operation->update([ + 'status' => OperationStatus::PENDING, + 'started_at' => null, + 'finished_at' => null, + ]); + + return redirect() + ->route('operations.show', [ + 'organisation' => $organisation->id, + 'operation' => $operation->id, + ]) + ->with('success', 'Operation queued to run again.'); + } + + public function cancel(Organisation $organisation, Operation $operation): RedirectResponse + { + abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404); + + $operation->update([ + 'status' => OperationStatus::CANCELLED, + 'finished_at' => now(), + ]); + + return redirect() + ->route('operations.show', [ + 'organisation' => $organisation->id, + 'operation' => $operation->id, + ]) + ->with('success', 'Operation cancelled.'); + } + + public function downloadLogs(Organisation $organisation, Operation $operation): StreamedResponse + { + abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404); + + $operation->load('steps'); + + return response()->streamDownload(function () use ($operation): void { + foreach ($operation->steps as $step) { + echo "# {$step->name}\n\n"; + + if ($step->logs) { + echo "## Logs\n{$step->logs}\n\n"; + } + + if ($step->error_logs) { + echo "## Error Logs\n{$step->error_logs}\n\n"; + } + } + }, "operation-{$operation->hash}.log", [ + 'Content-Type' => 'text/plain', + ]); + } + + private function operationBelongsToOrganisation(Operation $operation, Organisation $organisation): bool + { + $target = $operation->target; + + return match (true) { + $target instanceof Application => $target->organisation_id === $organisation->id, + $target instanceof Environment => $target->application()->where('organisation_id', $organisation->id)->exists(), + $target instanceof Server => $target->organisation_id === $organisation->id, + $target instanceof Service => $target->organisation_id === $organisation->id, + $target instanceof ServiceReplica => $target->service()->where('organisation_id', $organisation->id)->exists(), + $target instanceof ServiceSlice => $target->service()->where('organisation_id', $organisation->id)->exists(), + default => false, + }; + } +} diff --git a/app/Http/Controllers/OrganisationController.php b/app/Http/Controllers/OrganisationController.php index b3bf5d0..724e670 100644 --- a/app/Http/Controllers/OrganisationController.php +++ b/app/Http/Controllers/OrganisationController.php @@ -2,8 +2,11 @@ namespace App\Http\Controllers; +use App\Enums\ServiceStatus; +use App\Models\Operation; use App\Models\Organisation; use App\Models\Provider; +use App\Models\Service; use Illuminate\Http\Request; use Inertia\Inertia; @@ -15,7 +18,23 @@ class OrganisationController extends Controller 'providers' => Inertia::lazy(fn () => Provider::whereOrganisationId($request->route('organisation'))->get()), 'registries' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->registries()->get()), 'sourceProviders' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->sourceProviders()->get()), - 'organisation' => Organisation::withCount('servers', 'applications', 'members')->findOrFail($request->route('organisation')), + 'organisation' => Organisation::with('members') + ->withCount('servers', 'applications', 'members', 'providers', 'sourceProviders', 'registries') + ->findOrFail($request->route('organisation')), + 'health' => [ + 'unhealthy_services' => Service::query() + ->where('organisation_id', $request->route('organisation')) + ->whereNot('status', ServiceStatus::RUNNING) + ->count(), + 'failed_operations' => Operation::query() + ->whereHasMorph('target', [Service::class], fn ($query) => $query->where('organisation_id', $request->route('organisation'))) + ->where('status', 'failed') + ->count(), + 'locked_variables' => Organisation::findOrFail($request->route('organisation')) + ->applications() + ->whereHas('environments.variables', fn ($query) => $query->where('overridable', false)) + ->count(), + ], ]); } } diff --git a/app/Http/Controllers/OrganisationMemberController.php b/app/Http/Controllers/OrganisationMemberController.php new file mode 100644 index 0000000..f7c0623 --- /dev/null +++ b/app/Http/Controllers/OrganisationMemberController.php @@ -0,0 +1,120 @@ + $organisation->load(['members', 'invitations.invitedBy']), + 'roles' => array_values(OrganisationRole::toArray()), + ]); + } + + public function store(StoreOrganisationMemberRequest $request, Organisation $organisation): RedirectResponse + { + $email = Str::lower($request->string('email')->toString()); + $user = User::query() + ->where('email', $email) + ->first(); + + if ($user === null) { + abort_if( + $organisation->invitations()->where('email', $email)->whereNull('accepted_at')->exists(), + 422, + 'This email already has a pending invitation.' + ); + + $organisation->invitations()->create([ + 'email' => $email, + 'role' => $request->enum('role', OrganisationRole::class), + 'token' => Str::random(40), + 'invited_by_user_id' => $request->user()?->id, + 'expires_at' => now()->addDays(14), + ]); + + return redirect() + ->route('organisation-members.index', ['organisation' => $organisation->id]) + ->with('success', 'Invitation created.'); + } + + $organisation->members()->syncWithoutDetaching([ + $user->id => ['role' => $request->enum('role', OrganisationRole::class)], + ]); + + $organisation->invitations() + ->where('email', $email) + ->delete(); + + return redirect() + ->route('organisation-members.index', ['organisation' => $organisation->id]) + ->with('success', 'Member added.'); + } + + public function update(UpdateOrganisationMemberRequest $request, Organisation $organisation, User $member): RedirectResponse + { + abort_unless($organisation->members()->whereKey($member->id)->exists(), 404); + + $organisation->members()->updateExistingPivot($member->id, [ + 'role' => $request->enum('role', OrganisationRole::class), + ]); + + return redirect() + ->route('organisation-members.index', ['organisation' => $organisation->id]) + ->with('success', 'Member role updated.'); + } + + public function updateInvitation( + UpdateOrganisationInvitationRequest $request, + Organisation $organisation, + OrganisationInvitation $invitation + ): RedirectResponse { + abort_unless($invitation->organisation_id === $organisation->id, 404); + + $invitation->update([ + 'role' => $request->enum('role', OrganisationRole::class), + ]); + + return redirect() + ->route('organisation-members.index', ['organisation' => $organisation->id]) + ->with('success', 'Invitation role updated.'); + } + + public function destroy(Request $request, Organisation $organisation, User $member): RedirectResponse + { + abort_if($organisation->owner_id === $member->id, 422, 'The organisation owner cannot be removed.'); + + $organisation->members()->detach($member->id); + + return redirect() + ->route('organisation-members.index', ['organisation' => $organisation->id]) + ->with('success', 'Member removed.'); + } + + public function destroyInvitation( + Request $request, + Organisation $organisation, + OrganisationInvitation $invitation + ): RedirectResponse { + abort_unless($invitation->organisation_id === $organisation->id, 404); + + $invitation->delete(); + + return redirect() + ->route('organisation-members.index', ['organisation' => $organisation->id]) + ->with('success', 'Invitation cancelled.'); + } +} diff --git a/app/Http/Controllers/ProviderController.php b/app/Http/Controllers/ProviderController.php new file mode 100644 index 0000000..b2a8d89 --- /dev/null +++ b/app/Http/Controllers/ProviderController.php @@ -0,0 +1,49 @@ +route('organisation')); + + return inertia('providers/Create', [ + 'providerTypes' => array_values(ProviderType::toArray()), + ]); + } + + public function store(StoreProviderRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + + $organisation->providers()->create([ + 'name' => $request->string('name')->toString(), + 'type' => $request->enum('type', ProviderType::class), + 'token' => $request->string('token')->toString(), + ]); + + return redirect() + ->route('organisations.show', ['organisation' => $organisation->id]) + ->with('success', 'Server provider created.'); + } + + public function destroy(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $provider = $organisation->providers()->findOrFail($request->route('provider')); + + $provider->delete(); + + return redirect() + ->route('organisations.show', ['organisation' => $organisation->id]) + ->with('success', 'Server provider deleted.'); + } +} diff --git a/app/Http/Controllers/RegistryController.php b/app/Http/Controllers/RegistryController.php index adf39da..cca929e 100644 --- a/app/Http/Controllers/RegistryController.php +++ b/app/Http/Controllers/RegistryController.php @@ -4,13 +4,27 @@ namespace App\Http\Controllers; use App\Enums\RegistryType; use App\Http\Requests\StoreRegistryRequest; +use App\Http\Requests\UpdateRegistryRequest; +use App\Models\BuildArtifact; use App\Models\Organisation; +use App\Models\Registry; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Response; class RegistryController extends Controller { + public function index(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + + return inertia('registries/Index', [ + 'registries' => $organisation->registries() + ->latest() + ->get(), + ]); + } + public function create(Request $request): Response { Organisation::findOrFail($request->route('organisation')); @@ -38,4 +52,75 @@ class RegistryController extends Controller ->route('organisations.show', ['organisation' => $organisation->id]) ->with('success', 'Registry created.'); } + + public function show(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + /** @var Registry $registry */ + $registry = $organisation->registries()->findOrFail($request->route('registry')); + $registryUrl = rtrim((string) $registry->url, '/'); + $artifacts = BuildArtifact::query() + ->with(['environment.application', 'builtByService']) + ->whereHas('environment.application', fn ($query) => $query->where('organisation_id', $organisation->id)) + ->when($registryUrl !== '', fn ($query) => $query->where('registry_ref', 'like', $registryUrl.'%')); + + return inertia('registries/Show', [ + 'registry' => $registry, + 'artifactCount' => (clone $artifacts)->count(), + 'environmentCount' => (clone $artifacts)->distinct('environment_id')->count('environment_id'), + 'artifacts' => $artifacts + ->latest() + ->paginate(20), + ]); + } + + public function edit(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $registry = $organisation->registries()->findOrFail($request->route('registry')); + + return inertia('registries/Edit', [ + 'registry' => $registry, + 'registryTypes' => array_values(RegistryType::toArray()), + ]); + } + + public function update(UpdateRegistryRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + /** @var Registry $registry */ + $registry = $organisation->registries()->findOrFail($request->route('registry')); + + $credentials = $registry->credentials ?? []; + $username = $request->string('username')->toString(); + + if ($request->filled('password')) { + $credentials['password'] = $request->string('password')->toString(); + } + + $credentials['username'] = $username; + + $registry->update([ + 'name' => $request->string('name')->toString(), + 'type' => $request->enum('type', RegistryType::class), + 'url' => rtrim($request->string('url')->toString(), '/'), + 'credentials' => $credentials, + ]); + + return redirect() + ->route('organisations.show', ['organisation' => $organisation->id]) + ->with('success', 'Registry updated.'); + } + + public function destroy(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $registry = $organisation->registries()->findOrFail($request->route('registry')); + + $registry->delete(); + + return redirect() + ->route('organisations.show', ['organisation' => $organisation->id]) + ->with('success', 'Registry deleted.'); + } } diff --git a/app/Http/Controllers/ServerController.php b/app/Http/Controllers/ServerController.php index 77a25e1..5fcbda1 100644 --- a/app/Http/Controllers/ServerController.php +++ b/app/Http/Controllers/ServerController.php @@ -3,26 +3,33 @@ namespace App\Http\Controllers; use App\Actions\GenerateRandomSlug; +use App\Enums\OperationKind; +use App\Enums\OperationStatus; use App\Enums\ServerStatus; use App\Jobs\Servers\WaitForServerToConnect; use App\Models\Organisation; use App\Models\Provider; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; +use Inertia\Response; class ServerController extends Controller { - public function index(Request $request) + public function index(Request $request): Response { $organisation = Organisation::findOrFail($request->route('organisation')); return inertia('servers/Index', [ 'servers' => $organisation->servers()->paginate(30), + 'networks' => $organisation->networks() + ->with(['servers' => fn ($query) => $query->select('id', 'network_id', 'name', 'private_ip', 'status')]) + ->get(), ]); } - public function create(Request $request) + public function create(Request $request): Response { $organisation = Organisation::findOrFail($request->route('organisation')); @@ -55,7 +62,7 @@ class ServerController extends Controller ]); } - public function store(Request $request) + public function store(Request $request): RedirectResponse { $request->validate([ 'provider' => ['required', 'exists:providers,id'], @@ -135,13 +142,63 @@ class ServerController extends Controller return redirect()->route('servers.show', ['organisation' => $organisation->id, 'server' => $server->id]); } - public function show(Request $request) + public function show(Request $request): Response { $organisation = Organisation::findOrFail($request->route('organisation')); $server = $organisation->servers()->findOrFail($request->route('server')); return inertia('servers/Show', [ - 'server' => $server->load('services.slices', 'serviceOperations.steps', 'serviceOperations.target'), + 'server' => $server->load( + 'firewallRules', + 'network', + 'operations.steps', + 'operations.children.target', + 'services.slices', + 'services.endpoints', + 'serviceOperations.steps', + 'serviceOperations.children.target', + 'serviceOperations.target', + ), ]); } + + public function destroy(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $server = $organisation->servers()->findOrFail($request->route('server')); + + $server->delete(); + + return redirect() + ->route('servers.index', ['organisation' => $organisation->id]) + ->with('success', 'Server deleted.'); + } + + public function heal(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $server = $organisation->servers()->findOrFail($request->route('server')); + + $operation = $server->operations()->create([ + 'kind' => OperationKind::SERVER_PROVISION, + 'status' => OperationStatus::PENDING, + ]); + + foreach ([ + 'Check server shell' => 'true', + 'Check Docker' => 'docker --version && docker compose version', + 'Check Keystone directories' => 'test -d /home/keystone && test -d /home/keystone/services', + ] as $order => $script) { + $operation->steps()->create([ + 'name' => $order, + 'order' => $operation->steps()->count() + 1, + 'status' => OperationStatus::PENDING, + 'script' => $script, + ]); + } + + return redirect() + ->route('servers.show', ['organisation' => $organisation->id, 'server' => $server->id]) + ->with('success', 'Server heal operation queued.'); + } } diff --git a/app/Http/Controllers/ServerFirewallRuleController.php b/app/Http/Controllers/ServerFirewallRuleController.php new file mode 100644 index 0000000..66c4511 --- /dev/null +++ b/app/Http/Controllers/ServerFirewallRuleController.php @@ -0,0 +1,41 @@ +organisation_id === (int) $organisation->id, 404); + + $server->firewallRules()->create($request->validated()); + + return redirect() + ->route('servers.show', [$organisation, $server]) + ->with('success', 'Firewall rule queued for installation.'); + } + + public function destroy(Request $request, Organisation $organisation, Server $server, FirewallRule $firewallRule, UninstallFirewallRule $uninstallFirewallRule): RedirectResponse + { + abort_unless( + (int) $server->organisation_id === (int) $organisation->id + && (int) $firewallRule->server_id === (int) $server->id, + 404, + ); + + $uninstallFirewallRule->execute($firewallRule); + $firewallRule->delete(); + + return redirect() + ->route('servers.show', [$organisation, $server]) + ->with('success', 'Firewall rule removed.'); + } +} diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index 7e15a0f..4315fd5 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -3,10 +3,12 @@ namespace App\Http\Controllers; use App\Actions\Services\CreateService; +use App\Enums\DeployPolicy; use App\Enums\ServiceCategory; use App\Enums\ServiceType; use App\Http\Requests\StoreServiceRequest; use App\Http\Requests\UpdateServiceRequest; +use App\Models\Organisation; use App\Models\Server; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -49,7 +51,7 @@ class ServiceController extends Controller { $server = Server::findOrFail($request->route('server')); $service = $server->services() - ->with(['replicas', 'slices', 'operations.steps', 'environment.application']) + ->with(['replicas', 'slices', 'endpoints', 'operations.steps', 'operations.children.target', 'environment.application']) ->findOrFail($request->route('service')); return inertia('services/Show', [ @@ -58,6 +60,23 @@ class ServiceController extends Controller ]); } + public function showForEnvironment(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + $service = $environment->services() + ->with(['server', 'replicas', 'slices', 'endpoints', 'operations.steps', 'operations.children.target', 'environment.application']) + ->findOrFail($request->route('service')); + + return inertia('services/Show', [ + 'server' => $service->server, + 'service' => $service, + 'environment' => $environment, + 'application' => $application, + ]); + } + public function edit(Request $request): Response { $server = Server::findOrFail($request->route('server')); @@ -66,6 +85,7 @@ class ServiceController extends Controller return inertia('services/Edit', [ 'server' => $server, 'service' => $service, + 'deployPolicies' => array_values(DeployPolicy::toArray()), ]); } @@ -74,7 +94,31 @@ class ServiceController extends Controller $server = Server::findOrFail($request->route('server')); $service = $server->services()->findOrFail($request->route('service')); - $service->update($request->validated()); + $validated = $request->validated(); + + $service->update([ + 'name' => $validated['name'], + 'desired_replicas' => $validated['desired_replicas'], + 'default_cpu_limit' => $validated['default_cpu_limit'] ?? null, + 'default_memory_limit_mb' => $validated['default_memory_limit_mb'] ?? null, + 'deploy_policy' => $request->enum('deploy_policy', DeployPolicy::class) ?? $service->deploy_policy, + 'version_track' => $validated['version_track'] ?? $service->version_track, + 'available_image_digest' => $validated['available_image_digest'] ?? null, + 'process_roles' => collect(explode(',', $validated['process_roles'] ?? '')) + ->map(fn (string $role): string => trim($role)) + ->filter() + ->values() + ->all(), + 'config' => [ + ...($service->config ?? []), + 'migration_mode' => $validated['migration_mode'] ?? null, + 'migration_timing' => $validated['migration_timing'] ?? null, + 'migration_command' => $validated['migration_command'] ?? null, + 'health_path' => $validated['health_path'] ?? null, + 'backup_enabled' => $request->boolean('backup_enabled'), + 'backup_command' => $validated['backup_command'] ?? null, + ], + ]); return redirect() ->route('services.show', [ @@ -84,4 +128,19 @@ class ServiceController extends Controller ]) ->with('success', 'Service updated.'); } + + public function destroy(Request $request): RedirectResponse + { + $server = Server::findOrFail($request->route('server')); + $service = $server->services()->findOrFail($request->route('service')); + + $service->delete(); + + return redirect() + ->route('servers.show', [ + 'organisation' => $server->organisation_id, + 'server' => $server->id, + ]) + ->with('success', 'Service deleted.'); + } } diff --git a/app/Http/Controllers/ServiceReplicaController.php b/app/Http/Controllers/ServiceReplicaController.php new file mode 100644 index 0000000..611c1a9 --- /dev/null +++ b/app/Http/Controllers/ServiceReplicaController.php @@ -0,0 +1,102 @@ +route('organisation')); + $server = $organisation->servers()->findOrFail($request->route('server')); + $service = $server->services()->with('environment.application')->findOrFail($request->route('service')); + $replica = $service->replicas() + ->with(['server', 'operation.steps', 'operations.steps', 'operations.children.target']) + ->findOrFail($request->route('replica')); + + return inertia('service-replicas/Show', [ + 'server' => $server, + 'service' => $service, + 'replica' => $replica, + ]); + } + + public function restart(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $server = $organisation->servers()->findOrFail($request->route('server')); + $service = $server->services()->findOrFail($request->route('service')); + $replica = $service->replicas()->findOrFail($request->route('replica')); + + $this->queueLifecycleOperation($replica, 'Restart replica', "docker restart {$replica->container_name}"); + + return redirect() + ->route('service-replicas.show', [ + 'organisation' => $organisation->id, + 'server' => $server->id, + 'service' => $service->id, + 'replica' => $replica->id, + ]) + ->with('success', 'Replica restart queued.'); + } + + public function start(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $server = $organisation->servers()->findOrFail($request->route('server')); + $service = $server->services()->findOrFail($request->route('service')); + $replica = $service->replicas()->findOrFail($request->route('replica')); + + $this->queueLifecycleOperation($replica, 'Start replica', "docker start {$replica->container_name}"); + + return redirect() + ->route('service-replicas.show', [ + 'organisation' => $organisation->id, + 'server' => $server->id, + 'service' => $service->id, + 'replica' => $replica->id, + ]) + ->with('success', 'Replica start queued.'); + } + + public function stop(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $server = $organisation->servers()->findOrFail($request->route('server')); + $service = $server->services()->findOrFail($request->route('service')); + $replica = $service->replicas()->findOrFail($request->route('replica')); + + $this->queueLifecycleOperation($replica, 'Stop replica', "docker stop {$replica->container_name}"); + + return redirect() + ->route('service-replicas.show', [ + 'organisation' => $organisation->id, + 'server' => $server->id, + 'service' => $service->id, + 'replica' => $replica->id, + ]) + ->with('success', 'Replica stop queued.'); + } + + private function queueLifecycleOperation(ServiceReplica $replica, string $name, string $script): void + { + $operation = $replica->operations()->create([ + 'kind' => OperationKind::REPLICA_DEPLOY, + 'status' => OperationStatus::PENDING, + ]); + + $operation->steps()->create([ + 'name' => $name, + 'order' => 1, + 'status' => OperationStatus::PENDING, + 'script' => $script, + ]); + } +} diff --git a/app/Http/Controllers/ServiceSliceController.php b/app/Http/Controllers/ServiceSliceController.php new file mode 100644 index 0000000..f7c5e6a --- /dev/null +++ b/app/Http/Controllers/ServiceSliceController.php @@ -0,0 +1,104 @@ +route('organisation')); + $server = $organisation->servers()->findOrFail($request->route('server')); + $service = $server->services() + ->with('environment.application') + ->findOrFail($request->route('service')); + + return inertia('service-slices/Index', [ + 'server' => $server, + 'service' => $service, + 'slices' => $service->slices() + ->with(['environment.application', 'attachments', 'operations.steps', 'operations.children.target']) + ->latest() + ->get(), + ]); + } + + public function show(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $server = $organisation->servers()->findOrFail($request->route('server')); + $service = $server->services()->with('environment.application')->findOrFail($request->route('service')); + $slice = $service->slices() + ->with(['environment.application', 'attachments.environment.application', 'operations.steps', 'operations.children.target']) + ->findOrFail($request->route('slice')); + + return inertia('service-slices/Show', [ + 'server' => $server, + 'service' => $service, + 'slice' => $slice, + ]); + } + + public function create(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $server = $organisation->servers()->findOrFail($request->route('server')); + $service = $server->services()->findOrFail($request->route('service')); + + return inertia('service-slices/Create', [ + 'server' => $server, + 'service' => $service, + 'environments' => $organisation->applications() + ->with('environments') + ->get() + ->pluck('environments') + ->flatten() + ->values(), + ]); + } + + public function store(StoreServiceSliceRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $server = $organisation->servers()->findOrFail($request->route('server')); + $service = $server->services()->findOrFail($request->route('service')); + $environmentId = $request->integer('environment_id') ?: null; + + if ($environmentId !== null) { + $belongsToOrganisation = $organisation->applications() + ->whereHas('environments', fn ($query) => $query->whereKey($environmentId)) + ->exists(); + + abort_unless($belongsToOrganisation, 422); + } + + $config = $request->filled('config') + ? json_decode($request->string('config')->toString(), true, flags: JSON_THROW_ON_ERROR) + : []; + + /** @var ServiceSlice $slice */ + $slice = $service->slices()->create([ + 'environment_id' => $environmentId, + 'name' => $request->string('name')->toString(), + 'type' => $request->string('type')->toString(), + 'status' => $request->string('status')->toString(), + 'config' => $config, + 'credentials' => [], + ]); + + return redirect() + ->route('service-slices.show', [ + 'organisation' => $organisation->id, + 'server' => $server->id, + 'service' => $service->id, + 'slice' => $slice->id, + ]) + ->with('success', 'Slice created.'); + } +} diff --git a/app/Http/Controllers/ServiceUpdateController.php b/app/Http/Controllers/ServiceUpdateController.php index 6b7df7d..474204d 100644 --- a/app/Http/Controllers/ServiceUpdateController.php +++ b/app/Http/Controllers/ServiceUpdateController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Actions\Services\CreateStatefulServiceUpdateOperation; +use App\Actions\Services\ResolveServiceImageDigest; use App\Enums\ServiceType; use App\Http\Requests\StoreServiceUpdateRequest; use App\Models\Organisation; @@ -33,6 +34,7 @@ class ServiceUpdateController extends Controller CreateStatefulServiceUpdateOperation $createStatefulServiceUpdateOperation, ): RedirectResponse { abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404); + abort_unless($request->string('confirmation')->toString() === $service->name, 422); $createStatefulServiceUpdateOperation->execute( service: $service, @@ -45,4 +47,26 @@ class ServiceUpdateController extends Controller 'server' => $server->id, ]); } + + public function resolve( + Organisation $organisation, + Server $server, + Service $service, + ResolveServiceImageDigest $resolveServiceImageDigest, + ): RedirectResponse { + abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404); + abort_unless(in_array($service->type, [ServiceType::POSTGRES, ServiceType::VALKEY], true), 404); + + $service->update([ + 'available_image_digest' => $resolveServiceImageDigest->execute($service), + ]); + + return redirect() + ->route('service-updates.create', [ + 'organisation' => $organisation->id, + 'server' => $server->id, + 'service' => $service->id, + ]) + ->with('success', 'Latest image digest resolved.'); + } } diff --git a/app/Http/Controllers/SourceProviderController.php b/app/Http/Controllers/SourceProviderController.php index 9139aa2..1591383 100644 --- a/app/Http/Controllers/SourceProviderController.php +++ b/app/Http/Controllers/SourceProviderController.php @@ -4,13 +4,26 @@ namespace App\Http\Controllers; use App\Enums\SourceProviderType; use App\Http\Requests\StoreSourceProviderRequest; +use App\Http\Requests\UpdateSourceProviderRequest; use App\Models\Organisation; +use App\Models\SourceProvider; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Response; class SourceProviderController extends Controller { + public function index(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + + return inertia('source-providers/Index', [ + 'sourceProviders' => $organisation->sourceProviders() + ->latest() + ->get(), + ]); + } + public function create(Request $request): Response { Organisation::findOrFail($request->route('organisation')); @@ -35,4 +48,44 @@ class SourceProviderController extends Controller ->route('organisations.show', ['organisation' => $organisation->id]) ->with('success', 'Source provider created.'); } + + public function edit(Request $request): Response + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $sourceProvider = $organisation->sourceProviders()->findOrFail($request->route('source_provider')); + + return inertia('source-providers/Edit', [ + 'sourceProvider' => $sourceProvider, + 'sourceProviderTypes' => array_values(SourceProviderType::toArray()), + ]); + } + + public function update(UpdateSourceProviderRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + /** @var SourceProvider $sourceProvider */ + $sourceProvider = $organisation->sourceProviders()->findOrFail($request->route('source_provider')); + + $sourceProvider->update([ + 'name' => $request->string('name')->toString(), + 'type' => $request->enum('type', SourceProviderType::class), + 'url' => $request->filled('url') ? rtrim($request->string('url')->toString(), '/') : null, + ]); + + return redirect() + ->route('organisations.show', ['organisation' => $organisation->id]) + ->with('success', 'Source provider updated.'); + } + + public function destroy(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $sourceProvider = $organisation->sourceProviders()->findOrFail($request->route('source_provider')); + + $sourceProvider->delete(); + + return redirect() + ->route('organisations.show', ['organisation' => $organisation->id]) + ->with('success', 'Source provider deleted.'); + } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 384a4fa..a06e7b9 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -31,7 +31,9 @@ class HandleInertiaRequests extends Middleware ...parent::share($request), 'name' => config('app.name'), 'organisation' => $request->route('organisation') - ? Organisation::with('applications')->findOrFail($this->routeKey($request->route('organisation'))) + ? Organisation::with('applications.environments') + ->withCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']) + ->findOrFail($this->routeKey($request->route('organisation'))) : null, 'application' => $request->route('application') ? Application::with('environments')->findOrFail($this->routeKey($request->route('application'))) diff --git a/app/Http/Requests/ImportEnvironmentVariablesRequest.php b/app/Http/Requests/ImportEnvironmentVariablesRequest.php new file mode 100644 index 0000000..158a0c4 --- /dev/null +++ b/app/Http/Requests/ImportEnvironmentVariablesRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'contents' => ['required', 'string', 'max:20000'], + 'overridable' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/StoreApplicationRequest.php b/app/Http/Requests/StoreApplicationRequest.php index 86cebf3..9487e0c 100644 --- a/app/Http/Requests/StoreApplicationRequest.php +++ b/app/Http/Requests/StoreApplicationRequest.php @@ -2,7 +2,9 @@ namespace App\Http\Requests; +use App\Enums\RepositoryType; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class StoreApplicationRequest extends FormRequest { @@ -23,6 +25,8 @@ class StoreApplicationRequest extends FormRequest { return [ 'name' => ['required', 'string', 'max:255'], + 'source_provider_id' => ['nullable', 'integer', 'exists:source_providers,id'], + 'repository_type' => ['required', Rule::enum(RepositoryType::class)], 'repository_url' => ['required', 'string', 'max:255', 'regex:/^(git@[^:]+:.+|ssh:\/\/.+)$/i'], 'default_branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'], 'environment_name' => ['required', 'string', 'max:255'], diff --git a/app/Http/Requests/StoreEnvironmentAttachmentRequest.php b/app/Http/Requests/StoreEnvironmentAttachmentRequest.php index bcefaa2..e9b9f39 100644 --- a/app/Http/Requests/StoreEnvironmentAttachmentRequest.php +++ b/app/Http/Requests/StoreEnvironmentAttachmentRequest.php @@ -29,6 +29,9 @@ class StoreEnvironmentAttachmentRequest extends FormRequest 'name' => ['nullable', 'string', 'max:255'], 'env_prefix' => ['nullable', 'string', 'max:32', 'regex:/^[A-Z][A-Z0-9_]*$/'], 'is_primary' => ['boolean'], + 'domain' => ['nullable', 'string', 'max:255'], + 'path_prefix' => ['nullable', 'string', 'max:255'], + 'tls_enabled' => ['boolean'], ]; } } diff --git a/app/Http/Requests/StoreEnvironmentDeploymentRequest.php b/app/Http/Requests/StoreEnvironmentDeploymentRequest.php new file mode 100644 index 0000000..20250f3 --- /dev/null +++ b/app/Http/Requests/StoreEnvironmentDeploymentRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'target_commit' => ['nullable', 'string', 'size:40', 'regex:/^[a-fA-F0-9]{40}$/'], + ]; + } +} diff --git a/app/Http/Requests/StoreEnvironmentRequest.php b/app/Http/Requests/StoreEnvironmentRequest.php new file mode 100644 index 0000000..ea2211f --- /dev/null +++ b/app/Http/Requests/StoreEnvironmentRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'], + 'php_version' => ['required', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/StoreEnvironmentVariableRequest.php b/app/Http/Requests/StoreEnvironmentVariableRequest.php index bad499d..9c8a89d 100644 --- a/app/Http/Requests/StoreEnvironmentVariableRequest.php +++ b/app/Http/Requests/StoreEnvironmentVariableRequest.php @@ -24,6 +24,7 @@ class StoreEnvironmentVariableRequest extends FormRequest return [ 'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'], 'value' => ['nullable', 'string'], + 'overridable' => ['boolean'], ]; } } diff --git a/app/Http/Requests/StoreGatewayRouteRequest.php b/app/Http/Requests/StoreGatewayRouteRequest.php new file mode 100644 index 0000000..cd645e7 --- /dev/null +++ b/app/Http/Requests/StoreGatewayRouteRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + return [ + 'service_id' => ['required', 'integer', 'exists:services,id'], + 'name' => ['required', 'string', 'max:255'], + 'domain' => ['required', 'string', 'max:255'], + 'path_prefix' => ['required', 'string', 'max:255', 'regex:/^\//'], + 'tls_enabled' => ['boolean'], + ]; + } +} diff --git a/app/Http/Requests/StoreOrganisationMemberRequest.php b/app/Http/Requests/StoreOrganisationMemberRequest.php new file mode 100644 index 0000000..e301102 --- /dev/null +++ b/app/Http/Requests/StoreOrganisationMemberRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email', 'max:255'], + 'role' => ['required', Rule::enum(OrganisationRole::class)], + ]; + } +} diff --git a/app/Http/Requests/StoreProviderRequest.php b/app/Http/Requests/StoreProviderRequest.php new file mode 100644 index 0000000..0363477 --- /dev/null +++ b/app/Http/Requests/StoreProviderRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::enum(ProviderType::class)], + 'token' => ['required', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/StoreServerFirewallRuleRequest.php b/app/Http/Requests/StoreServerFirewallRuleRequest.php new file mode 100644 index 0000000..e6cc4cc --- /dev/null +++ b/app/Http/Requests/StoreServerFirewallRuleRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + return [ + 'type' => ['required', Rule::enum(FirewallRuleType::class)], + 'ports' => ['required', 'string', 'max:50', 'regex:/^[A-Za-z0-9:\/,-]+$/'], + 'from' => ['nullable', 'string', 'max:255', 'regex:/^[A-Fa-f0-9:.\/-]+$/'], + ]; + } +} diff --git a/app/Http/Requests/StoreServiceSliceRequest.php b/app/Http/Requests/StoreServiceSliceRequest.php new file mode 100644 index 0000000..13f5881 --- /dev/null +++ b/app/Http/Requests/StoreServiceSliceRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'string', 'max:255'], + 'environment_id' => ['nullable', 'integer'], + 'status' => ['required', 'string', 'max:255'], + 'config' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/StoreServiceUpdateRequest.php b/app/Http/Requests/StoreServiceUpdateRequest.php index 0c90c0e..4abac14 100644 --- a/app/Http/Requests/StoreServiceUpdateRequest.php +++ b/app/Http/Requests/StoreServiceUpdateRequest.php @@ -21,6 +21,7 @@ class StoreServiceUpdateRequest extends FormRequest return [ 'image_digest' => ['required', 'string', 'starts_with:sha256:'], 'backup_requested' => ['sometimes', 'boolean'], + 'confirmation' => ['required', 'string'], ]; } } diff --git a/app/Http/Requests/UpdateApplicationRequest.php b/app/Http/Requests/UpdateApplicationRequest.php new file mode 100644 index 0000000..b4e9f80 --- /dev/null +++ b/app/Http/Requests/UpdateApplicationRequest.php @@ -0,0 +1,34 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'source_provider_id' => ['nullable', 'integer', 'exists:source_providers,id'], + 'repository_type' => ['required', Rule::enum(RepositoryType::class)], + 'repository_url' => ['required', 'string', 'max:255', 'regex:/^(git@[^:]+:.+|ssh:\/\/.+)$/i'], + 'default_branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'], + ]; + } +} diff --git a/app/Http/Requests/UpdateEnvironmentAttachmentRequest.php b/app/Http/Requests/UpdateEnvironmentAttachmentRequest.php new file mode 100644 index 0000000..f00805c --- /dev/null +++ b/app/Http/Requests/UpdateEnvironmentAttachmentRequest.php @@ -0,0 +1,36 @@ +|string> + */ + public function rules(): array + { + return [ + 'role' => ['required', Rule::enum(EnvironmentAttachmentRole::class)], + 'env_prefix' => ['nullable', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'], + 'is_primary' => ['boolean'], + 'domain' => ['nullable', 'string', 'max:255'], + 'path_prefix' => ['nullable', 'string', 'max:255'], + 'tls_enabled' => ['boolean'], + 'certificate_status' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/UpdateEnvironmentRequest.php b/app/Http/Requests/UpdateEnvironmentRequest.php new file mode 100644 index 0000000..6a78c3e --- /dev/null +++ b/app/Http/Requests/UpdateEnvironmentRequest.php @@ -0,0 +1,42 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'], + 'status' => ['required', 'string', 'max:255'], + 'scheduler_enabled' => ['boolean'], + 'scheduler_target_service_id' => ['nullable', 'integer'], + 'scheduler_mode' => ['required', Rule::enum(SchedulerMode::class)], + 'build_strategy' => ['required', Rule::enum(BuildStrategy::class)], + 'php_version' => ['nullable', 'string', 'max:20'], + 'document_root' => ['nullable', 'string', 'max:255'], + 'health_path' => ['nullable', 'string', 'max:255'], + 'js_package_manager' => ['nullable', 'string', 'max:50'], + 'js_build_command' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/UpdateEnvironmentVariableRequest.php b/app/Http/Requests/UpdateEnvironmentVariableRequest.php new file mode 100644 index 0000000..c66829e --- /dev/null +++ b/app/Http/Requests/UpdateEnvironmentVariableRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'], + 'value' => ['nullable', 'string'], + 'overridable' => ['boolean'], + ]; + } +} diff --git a/app/Http/Requests/UpdateGatewayRouteRequest.php b/app/Http/Requests/UpdateGatewayRouteRequest.php new file mode 100644 index 0000000..21588da --- /dev/null +++ b/app/Http/Requests/UpdateGatewayRouteRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'domain' => ['required', 'string', 'max:255'], + 'path_prefix' => ['required', 'string', 'max:255', 'regex:/^\//'], + 'tls_enabled' => ['boolean'], + 'certificate_status' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/UpdateOrganisationInvitationRequest.php b/app/Http/Requests/UpdateOrganisationInvitationRequest.php new file mode 100644 index 0000000..32b5536 --- /dev/null +++ b/app/Http/Requests/UpdateOrganisationInvitationRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'role' => ['required', Rule::enum(OrganisationRole::class)], + ]; + } +} diff --git a/app/Http/Requests/UpdateOrganisationMemberRequest.php b/app/Http/Requests/UpdateOrganisationMemberRequest.php new file mode 100644 index 0000000..b92e17d --- /dev/null +++ b/app/Http/Requests/UpdateOrganisationMemberRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'role' => ['required', Rule::enum(OrganisationRole::class)], + ]; + } +} diff --git a/app/Http/Requests/UpdateRegistryRequest.php b/app/Http/Requests/UpdateRegistryRequest.php new file mode 100644 index 0000000..ff38d5c --- /dev/null +++ b/app/Http/Requests/UpdateRegistryRequest.php @@ -0,0 +1,34 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::enum(RegistryType::class)], + 'url' => ['required', 'string', 'max:255'], + 'username' => ['nullable', 'string', 'max:255'], + 'password' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/UpdateServiceRequest.php b/app/Http/Requests/UpdateServiceRequest.php index 1a7ffe4..7adfb89 100644 --- a/app/Http/Requests/UpdateServiceRequest.php +++ b/app/Http/Requests/UpdateServiceRequest.php @@ -2,7 +2,9 @@ namespace App\Http\Requests; +use App\Enums\DeployPolicy; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class UpdateServiceRequest extends FormRequest { @@ -26,6 +28,16 @@ class UpdateServiceRequest extends FormRequest 'desired_replicas' => ['required', 'integer', 'min:0', 'max:25'], 'default_cpu_limit' => ['nullable', 'numeric', 'min:0.125', 'max:64'], 'default_memory_limit_mb' => ['nullable', 'integer', 'min:64', 'max:1048576'], + 'deploy_policy' => ['nullable', Rule::enum(DeployPolicy::class)], + 'version_track' => ['nullable', 'string', 'max:255'], + 'available_image_digest' => ['nullable', 'string', 'max:255'], + 'process_roles' => ['nullable', 'string', 'max:255'], + 'migration_mode' => ['nullable', 'string', 'max:255'], + 'migration_timing' => ['nullable', 'string', 'max:255'], + 'migration_command' => ['nullable', 'string', 'max:255'], + 'health_path' => ['nullable', 'string', 'max:255'], + 'backup_enabled' => ['sometimes', 'boolean'], + 'backup_command' => ['nullable', 'string', 'max:1000'], ]; } } diff --git a/app/Http/Requests/UpdateSourceProviderRequest.php b/app/Http/Requests/UpdateSourceProviderRequest.php new file mode 100644 index 0000000..bdbced9 --- /dev/null +++ b/app/Http/Requests/UpdateSourceProviderRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::enum(SourceProviderType::class)], + 'url' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Jobs/Environments/DeployEnvironment.php b/app/Jobs/Environments/DeployEnvironment.php index afeaacb..8bd2fa4 100644 --- a/app/Jobs/Environments/DeployEnvironment.php +++ b/app/Jobs/Environments/DeployEnvironment.php @@ -18,6 +18,7 @@ use App\Models\Operation; use App\Models\Service; use App\Models\ServiceReplica; use App\Services\Compose\ComposeRenderer; +use App\Support\CaddyRouteRenderer; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use InvalidArgumentException; @@ -29,6 +30,7 @@ class DeployEnvironment implements ShouldQueue public function __construct( public Environment $environment, + public ?string $targetCommit = null, ) { // } @@ -51,7 +53,7 @@ class DeployEnvironment implements ShouldQueue 'started_at' => now(), ]); - $commitSha = app(ResolveEnvironmentCommit::class)->execute($this->environment); + $commitSha = $this->targetCommit ?? app(ResolveEnvironmentCommit::class)->execute($this->environment); $services = $this->servicesNeedingDeployment($plan->services, $commitSha); if ($services === []) { @@ -378,15 +380,25 @@ class DeployEnvironment implements ShouldQueue private function gatewayCutoverSteps(EnvironmentAttachment $attachment): array { $containerName = $attachment->service->replicas()->first()?->container_name; + $config = $attachment->serviceSlice?->config ?? []; + $domain = $config['domain'] ?? null; + $tlsEnabled = $config['tls_enabled'] ?? true; $reloadCommand = $containerName ? 'docker exec '.escapeshellarg($containerName).' caddy reload --config /etc/caddy/Caddyfile' : "docker compose -f /home/keystone/services/{$attachment->service_id}/compose.yml exec -T {$this->serviceKey($attachment->service)} caddy reload --config /etc/caddy/Caddyfile"; + $certificateCheck = $tlsEnabled && $domain + ? 'curl --fail --silent --show-error --head https://'.escapeshellarg($domain).' >/dev/null' + : 'true # TLS disabled or no domain configured for this route'; return [ [ 'name' => 'Validate Caddy route configuration', 'script' => 'test -s /home/keystone/gateway/Caddyfile', ], + [ + 'name' => 'Check TLS certificate status', + 'script' => $certificateCheck, + ], [ 'name' => 'Reload Caddy', 'script' => $reloadCommand, @@ -406,15 +418,13 @@ class DeployEnvironment implements ShouldQueue private function configureCaddyRouteScript(EnvironmentAttachment $attachment): string { - $route = $attachment->serviceSlice?->name ?? $this->environment->name; $upstreams = $this->gatewayUpstreams($attachment); + $caddyfile = app(CaddyRouteRenderer::class)->render($attachment, $upstreams); return implode("\n", [ 'mkdir -p /home/keystone/gateway/Caddyfile.d', "cat > /home/keystone/gateway/Caddyfile.d/{$attachment->id}.caddy <<'KEYSTONE_CADDY_ROUTE'", - "{$route} {", - ' reverse_proxy '.implode(' ', $upstreams), - '}', + $caddyfile, 'KEYSTONE_CADDY_ROUTE', 'cat /home/keystone/gateway/Caddyfile.d/*.caddy > /home/keystone/gateway/Caddyfile', ]); diff --git a/app/Models/Application.php b/app/Models/Application.php index af96f43..fc444b0 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -29,6 +29,11 @@ class Application extends Model return $this->belongsTo(Organisation::class); } + public function sourceProvider(): BelongsTo + { + return $this->belongsTo(SourceProvider::class); + } + public function environments(): HasMany { return $this->hasMany(Environment::class); diff --git a/app/Models/Organisation.php b/app/Models/Organisation.php index 73a3f4e..701d19b 100644 --- a/app/Models/Organisation.php +++ b/app/Models/Organisation.php @@ -30,6 +30,11 @@ class Organisation extends Model ->withTimestamps(); } + public function invitations(): HasMany + { + return $this->hasMany(OrganisationInvitation::class); + } + public function servers(): HasMany { return $this->hasMany(Server::class); diff --git a/app/Models/OrganisationInvitation.php b/app/Models/OrganisationInvitation.php new file mode 100644 index 0000000..f7026e1 --- /dev/null +++ b/app/Models/OrganisationInvitation.php @@ -0,0 +1,35 @@ + */ + use HasFactory; + + protected $guarded = []; + + protected function casts(): array + { + return [ + 'role' => OrganisationRole::class, + 'accepted_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + } + + public function organisation(): BelongsTo + { + return $this->belongsTo(Organisation::class); + } + + public function invitedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by_user_id'); + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index 3fb80ab..46c8fa4 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Spatie\Ssh\Ssh; class Server extends Model @@ -69,6 +70,11 @@ class Server extends Model )->where('target_type', (new Service)->getMorphClass()); } + public function operations(): MorphMany + { + return $this->morphMany(Operation::class, 'target'); + } + public function sshClient(string $user = 'root'): Ssh { return Ssh::create($user, $this->ipv4) diff --git a/app/Models/SourceProvider.php b/app/Models/SourceProvider.php index 709092b..fb26ab9 100644 --- a/app/Models/SourceProvider.php +++ b/app/Models/SourceProvider.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Enums\SourceProviderType; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class SourceProvider extends Model { @@ -22,4 +23,9 @@ class SourceProvider extends Model { return $this->belongsTo(Organisation::class); } + + public function applications(): HasMany + { + return $this->hasMany(Application::class); + } } diff --git a/app/Support/CaddyRouteRenderer.php b/app/Support/CaddyRouteRenderer.php new file mode 100644 index 0000000..ee7df00 --- /dev/null +++ b/app/Support/CaddyRouteRenderer.php @@ -0,0 +1,38 @@ + $upstreams + */ + public function render(EnvironmentAttachment $attachment, array $upstreams): string + { + $config = $attachment->serviceSlice?->config ?? []; + $domain = $config['domain'] ?? $attachment->serviceSlice?->name ?? $attachment->environment->name; + $pathPrefix = $config['path_prefix'] ?? '/'; + $siteAddress = ($config['tls_enabled'] ?? true) ? $domain : "http://{$domain}"; + $upstreamTargets = $upstreams === [] ? ['web:80'] : $upstreams; + + if ($pathPrefix === '/') { + return implode("\n", [ + "{$siteAddress} {", + ' reverse_proxy '.implode(' ', $upstreamTargets), + '}', + ]); + } + + $normalizedPath = rtrim($pathPrefix, '/'); + + return implode("\n", [ + "{$siteAddress} {", + " handle_path {$normalizedPath}* {", + ' reverse_proxy '.implode(' ', $upstreamTargets), + ' }', + '}', + ]); + } +} diff --git a/bun.lockb b/bun.lockb index 4befe71..32ec0e9 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/composer.lock b/composer.lock index add056b..aa14e52 100644 --- a/composer.lock +++ b/composer.lock @@ -9819,5 +9819,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/database/factories/OrganisationInvitationFactory.php b/database/factories/OrganisationInvitationFactory.php new file mode 100644 index 0000000..8f99d86 --- /dev/null +++ b/database/factories/OrganisationInvitationFactory.php @@ -0,0 +1,32 @@ + + */ +class OrganisationInvitationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'organisation_id' => Organisation::factory(), + 'invited_by_user_id' => User::factory(), + 'email' => $this->faker->unique()->safeEmail(), + 'role' => OrganisationRole::MEMBER, + 'token' => Str::random(40), + 'expires_at' => now()->addDays(14), + ]; + } +} diff --git a/database/migrations/2026_05_24_141635_add_source_provider_id_to_applications_table.php b/database/migrations/2026_05_24_141635_add_source_provider_id_to_applications_table.php new file mode 100644 index 0000000..09d268d --- /dev/null +++ b/database/migrations/2026_05_24_141635_add_source_provider_id_to_applications_table.php @@ -0,0 +1,32 @@ +foreignId('source_provider_id') + ->nullable() + ->after('organisation_id') + ->constrained('source_providers') + ->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropConstrainedForeignId('source_provider_id'); + }); + } +}; diff --git a/database/migrations/2026_05_24_155558_create_organisation_invitations_table.php b/database/migrations/2026_05_24_155558_create_organisation_invitations_table.php new file mode 100644 index 0000000..9f4227d --- /dev/null +++ b/database/migrations/2026_05_24_155558_create_organisation_invitations_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignIdFor(Organisation::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(User::class, 'invited_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('email'); + $table->string('role'); + $table->string('token')->unique(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->unique(['organisation_id', 'email']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('organisation_invitations'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e7932aa..33bec8a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -65,7 +65,6 @@ class DatabaseSeeder extends Seeder $application->environments()->create([ 'name' => 'Dev', 'branch' => 'main', - 'url' => 'https://dev.clipbin.hjb.dev', 'status' => 'active', ]); } diff --git a/docs/managed-registry.md b/docs/managed-registry.md new file mode 100644 index 0000000..c0d0051 --- /dev/null +++ b/docs/managed-registry.md @@ -0,0 +1,296 @@ +# Managed Registry Plan + +Keystone should be self-hosted first. A fresh install should include a working build and image pipeline without requiring the user to bring an external Docker registry, S3 bucket, or separate build server. + +## Product Principles + +- The Keystone control node is the default build node. +- Keystone provides a first-party managed Docker registry by default. +- The managed registry stores images on local disk first. +- The registry storage path must be configurable for mounted VPS volumes. +- External registries, S3-backed storage, and dedicated build nodes are optional advanced features. +- Multi-server deployments should work out of the box after Keystone is installed. +- Registry credentials must not be persisted in operation scripts, logs, or UI-visible output. +- Old build artifacts should be pruned automatically, retaining the latest 3 successful artifacts per environment by default. +- Build and deploy should be separate phases, even when started by one user action. +- Users should be able to connect an existing Ubuntu server as a Keystone node without using a cloud provider integration. + +## Default Self-Hosted Shape + +When Keystone is installed on a server, that server becomes the control node. The install process should prepare: + +- Keystone application services. +- Docker and Docker Compose. +- A managed `registry:2` service. +- Local registry storage. +- Generated registry credentials. +- A default build capability on the control node. + +This is separate from server provisioning. Keystone needs two scripts/flows: + +- `install-keystone.sh` installs Keystone itself on the control node. +- The remote provisioning script prepares other servers so they can be managed by Keystone. + +Remote provisioning should continue to install Docker, configure SSH access, prepare the `keystone` user, and link the server back to Keystone. It should not be responsible for installing the Keystone application itself. + +Default settings: + +```text +Build node: Keystone control node +Registry: registry:2 managed by Keystone +Registry storage driver: local +Registry storage path: /home/keystone/registry/data +Image retention: latest 3 successful artifacts per environment +Auth: generated htpasswd credentials managed by Keystone +``` + +The install flow should allow overriding the storage path, for example: + +```text +/mnt/keystone-registry +``` + +This lets users place registry image data on a mounted VPS volume while keeping Keystone's default behavior simple. + +## Default Image Flow + +```text +Git repository + -> Keystone control node builds Docker image + -> Keystone pushes image to the managed registry + -> Target servers pull image from the managed registry + -> Target servers run containers +``` + +The build node and registry are separate concepts: + +- Build node: where `git clone`, `docker build`, and `docker push` run. +- Registry: where built images are stored and later pulled from. + +The control node is the default build node, but users should later be able to add a dedicated build node from Keystone settings. + +The running Keystone server is the control node. This does not necessarily need to be represented as a normal deploy target server at first. A lightweight installation/control-node setting may be enough until Keystone needs HA control-plane support. + +If Keystone later supports HA control planes, the control node concept should become more explicit so the app can distinguish between: + +- The current web/queue/scheduler node. +- The active registry host. +- The default build node. +- Runtime nodes used for deployed applications. + +## Registry Exposure + +The managed registry should be exposed over HTTPS where possible, ideally behind the control node's web proxy, for example: + +```text +registry.example.com +``` + +Avoid defaulting to a plain `host:5000` registry if possible. Plain HTTP registries require Docker daemon insecure-registry configuration on every build and target server, which adds onboarding friction. + +Target servers must be able to reach the registry URL before they can deploy images built by Keystone. + +## Authentication + +Use `registry:2` htpasswd authentication for the first version. + +Keystone should: + +- Generate registry credentials. +- Write the registry htpasswd file during provisioning. +- Store credentials encrypted. +- Configure build and target servers for registry access. +- Use `docker login --password-stdin` when login is needed. + +Do not inline registry passwords into persisted operation scripts. Operation steps are stored and may be visible in the UI or logs. + +Preferred approaches: + +- Configure Docker auth on each server through a separate secure action. +- Or write root-owned / user-owned credential files on the server and have deployment scripts read from those files. + +Token auth can be considered later if Keystone needs per-repository or per-server scoped credentials. It should not be part of the first implementation. + +## Build Planning + +Build planning should assume a default managed registry exists after install. + +For the default path: + +- Build strategy: build on control node. +- Registry: managed local registry. +- Artifact reference: full managed registry image reference. + +Multi-server deploys should no longer block because the user has not configured an external registry. They should only block if the managed registry is missing, unhealthy, or unreachable. + +External registries should remain available as an advanced override. + +Build strategy should not be exposed to users as low-level values such as `target_server`, `dedicated_builder`, or `external_registry`. The UI should expose intent instead: + +- Default build node. +- Specific build node. +- External registry override. + +Internally, build planning can still map those choices to implementation strategies. + +## Build Execution + +The default build execution should: + +1. Select the configured build node, defaulting to the control node. +2. Clone the application repository. +3. Render the Keystone Dockerfile. +4. Log in to the managed registry. +5. Build the image. +6. Tag the image using the managed registry reference. +7. Push the image. +8. Resolve and store the registry manifest digest. + +Example flow: + +```bash +docker login registry.example.com --username keystone --password-stdin +docker build --file Dockerfile.keystone --tag registry.example.com/application:aaaaaaaaaaaa . +docker push registry.example.com/application:aaaaaaaaaaaa +docker manifest inspect registry.example.com/application:aaaaaaaaaaaa +``` + +The stored digest must be the registry manifest digest, not a local image ID. Digest-based pulls and registry manifest deletion depend on this being correct. + +Build execution should create a build operation that can succeed or fail independently from deployment. A deployment can then depend on a successful build artifact. + +## Deploy Execution + +Target servers should pull immutable image references from the managed registry. + +Deploy execution should: + +1. Ensure the target server has registry auth configured. +2. Pull the exact image digest. +3. Render Compose with the full registry image reference. +4. Start or update containers. + +Example pull reference: + +```text +registry.example.com/application@sha256:... +``` + +Compose should use the full registry reference, not only `sha256:...`. + +Deploy execution should be a separate operation phase from build execution. The deploy phase should consume a completed build artifact and should not be responsible for building the artifact itself. + +Operations should have explicit execution targets. Inferring the SSH target only from the operation target model becomes fragile once Keystone has build nodes, registry maintenance, and runtime deployment steps. + +Each operation or operation step should be able to declare where it runs: + +- Control node. +- Build node. +- Runtime server. +- Specific server. + +## Pruning And Retention + +Default retention should keep the latest 3 successful build artifacts per environment. + +Pruning should also retain: + +- Any artifact currently referenced by a service's available image digest. +- Any artifact currently referenced by a service's current image digest. +- Any artifact needed for an active deployment operation. + +Pruning should remove old registry manifests first, then run registry garbage collection to remove unreferenced blobs from local disk. + +`registry:2` requires deletion to be enabled: + +```text +REGISTRY_STORAGE_DELETE_ENABLED=true +``` + +Garbage collection is safest when the registry is not accepting writes. The first implementation should run cleanup during a controlled maintenance window, using a lock so pruning does not race with active builds or pushes. + +Suggested cleanup flow: + +1. Acquire a registry maintenance lock. +2. Find prunable artifacts by environment retention rules. +3. Delete old manifests through the registry API. +4. Stop the registry or put it in a safe maintenance state. +5. Run registry garbage collection. +6. Restart the registry. +7. Mark artifacts as pruned or delete their records. +8. Release the lock. + +## Future Extensions + +These should be optional settings, not onboarding requirements: + +- Dedicated build nodes. +- S3-compatible registry storage. +- External registries such as GHCR, Gitea, Docker Hub, or generic registries. +- Separate push and pull credentials. +- Credential rotation. +- Per-server or per-repository scoped auth. +- Configurable retention per application or environment. + +The first version should optimize for a self-hosted user installing Keystone on a VPS and being able to deploy with minimal additional setup. + +## Existing Server Provisioning + +Keystone should support connecting an existing Ubuntu server as a managed node. This is important for users running VPSs, Proxmox VMs, homelab hardware, or manually provisioned servers. + +The flow should be: + +1. User creates a server record in Keystone as an existing server. +2. Keystone shows a one-time provisioning command. +3. User runs the command on the server as root or a sudo-capable user. +4. The script installs Docker and required packages. +5. The script creates/configures the `keystone` user. +6. The script installs Keystone's management SSH key. +7. The script calls back to Keystone with a one-time token. +8. Keystone marks the server active. + +This should sit alongside cloud-provider provisioning. Cloud providers can create the VM automatically, but the same remote preparation logic should be reused where possible. + +Provisioning callbacks should not authenticate only by `server_id` or IP address. They should use a short-lived, single-use provisioning token tied to the server record. + +Avoid passing sensitive values such as sudo passwords in URL query strings. Safer options include: + +- Generate a short-lived provisioning token and pass only that in the URL. +- Store sensitive bootstrap data server-side and let the provisioning script exchange the one-time token for the data it needs. +- Prefer SSH key-based provider bootstrap where available instead of root password bootstrap. +- If a password must be used, pass it over SSH stdin or an encrypted job payload, not through a script URL. + +The remote provisioning script can still be downloaded from Keystone, but the URL should not contain long-lived secrets or reusable credentials. + +### Sudo Password Handling + +Keep the current Forge-like user model for now: + +- Provisioned servers have a `keystone` user. +- SSH login is key-only. +- The generated sudo password is for the human user to SSH in and run elevated commands manually. +- Keystone automation continues to use SSH key access and Docker/sudo-capable permissions as required. + +This model is acceptable, but sudo password delivery should be hardened. + +Laravel protections help with some leak paths: + +- `ShouldBeEncrypted` protects queued job payloads. +- Encrypted casts protect stored secrets. +- Hidden model attributes avoid accidental serialization. +- PHP `#[\SensitiveParameter]` can prevent secret values appearing in stack traces. + +These protections do not cover query strings, shell process arguments, rendered scripts left on disk, reverse-proxy logs, or third-party request logging. + +Minimal hardening plan: + +1. Keep generating a sudo password for the provisioned `keystone` user. +2. Keep flashing the sudo password to the user once after server creation. +3. Add `#[\SensitiveParameter]` to job constructor parameters such as `rootPassword` and `sudoPassword`. +4. Stop passing `sudo_password` in the provision script URL. +5. Use a short-lived, single-use provisioning token in the URL instead. +6. Store the sudo password encrypted server-side until the provisioning script is rendered or exchanged. +7. Ensure the remote provisioning script deletes itself at the end of provisioning. +8. Avoid writing the plaintext sudo password to logs or long-lived files. + +The goal is to preserve the simple human-admin UX while removing avoidable secret exposure from URLs and leftover bootstrap artifacts. diff --git a/docs/ui-review.md b/docs/ui-review.md index be32731..fc0e3db 100644 --- a/docs/ui-review.md +++ b/docs/ui-review.md @@ -15,14 +15,14 @@ Conventions: ## 1. Global Navigation & Information Architecture -- **Partial — sidebar only exposes Dashboard + Servers.** `resources/js/components/AppSidebar.vue:19-37` builds a `mainNavItems` array with only `Dashboard` and `Servers`. There are no entries for `Applications`, `Operations`, `Onboarding`, or organisation `Settings`. The spec frames environments as the primary surface (§20 Phase 6: "Present environments as the primary application surface"). Add at minimum `Applications` and `Operations` items, plus a context switcher / link to onboarding while incomplete. -- **Missing — no organisation switcher.** Multiple organisations are modeled (`Organisation::members()` on `app/Models/Organisation.php`), and the dashboard already supports multiple orgs (`resources/js/pages/Dashboard.vue:8-13`). After picking an org there is no way to switch without going back to `/dashboard`. Add a switcher in the sidebar header. +- **Partial — active header navigation is still not environment-first.** The active `AppLayout` uses `resources/js/layouts/app/AppHeaderLayout.vue`, whose header navigation (`resources/js/components/AppHeader.vue`) exposes organisation, `Applications`, and `Servers` when an organisation is selected. There are still no entries for `Operations` or `Onboarding`, and environments are only reachable after choosing an application. The inactive sidebar layout (`resources/js/components/AppSidebar.vue:19-37`) is even narrower, exposing only `Dashboard` and `Servers`. The spec frames environments as the primary surface (§20 Phase 6: "Present environments as the primary application surface"). Add an environment-primary route/navigation surface, plus `Operations` and onboarding access while setup is incomplete. +- **Partial — organisation switcher exists only in the active header chrome.** Multiple organisations are modeled (`Organisation::members()` on `app/Models/Organisation.php`), and the active header (`resources/js/components/AppSidebarHeader.vue`) includes organisation/application/environment dropdowns. The inactive sidebar layout has no equivalent, and onboarding/operations are still absent from the switcher flow. Keep the header switcher if `AppHeaderLayout` remains the canonical layout; otherwise add parity to the sidebar chrome. - **Missing — no Operations index.** Operations are the spec's audit/execution backbone (§3) but the UI only surfaces them inline on `environments/Show.vue` and `servers/Show.vue`. There is no organisation-wide operations feed for triage. Add an `operations.index` view with filters (kind, status, target). -- **Missing — no global empty/help state.** A fresh org with no servers/apps has no "Get started" CTA in the sidebar; user must guess to visit `/onboarding`. Promote the onboarding link until all onboarding steps are complete. +- **Missing — no global empty/help state.** A fresh org with no servers/apps has no "Get started" CTA in the primary app chrome; user must guess to visit `/onboarding`. Promote the onboarding link until all onboarding steps are complete. ## 2. Onboarding (Spec §19) -- **Partial — onboarding page exists but is unreachable from primary nav.** `resources/js/pages/onboarding/Show.vue` is only reachable via the URL `/organisations/{id}/onboarding`. There is no link from `Dashboard`, `AppSidebar`, or `organisations/Show.vue`. Surface a persistent banner or sidebar entry while `nextStep` is non-terminal. +- **Partial — onboarding page exists but is unreachable from primary nav.** `resources/js/pages/onboarding/Show.vue` is only reachable via the URL `/organisations/{id}/onboarding`. There is no link from `Dashboard`, the active header navigation, `AppSidebar`, or `organisations/Show.vue`. Surface a persistent banner or primary-nav entry while `nextStep` is non-terminal. - **Partial — onboarding "Provider" step routes to organisation show.** `app/Http/Controllers/OnboardingController.php:25` sets the Provider step `href` to `organisations.show`, but the Server Providers list there (`resources/js/pages/organisations/Show.vue:202-220`) has no Add button. There is no `providers.create` route or page. Either add a `ProviderController@create` + Vue page or make the step open an inline dialog. - **Missing — registry/source/server-create steps don't enforce a single org-level "default" once configured.** Spec §19 says registry is required for multi-server. The UI never blocks deployment on this — see Deployment Flow gap below. - **Missing — onboarding doesn't reflect deploy-key install step (§5).** The spec lists "Deploy key installation and verification" as a discrete step; onboarding shows none. Add a step gated on `applications.deploy_key_installed_at`. @@ -39,7 +39,7 @@ Conventions: - **Partial — deploy-key card disappears once installed.** `resources/js/pages/applications/Show.vue:46-75` only shows the deploy-key card when `application.deploy_key_public && !application.deploy_key_installed_at`. After install there is no way to view or rotate the key. Show key + `deploy_key_fingerprint` and a "Rotate" action permanently in an Application Settings tab. - **Missing — no fingerprint display.** The model stores `deploy_key_fingerprint` (`app/Models/Application.php`), but the UI never renders it. Surface beside the public key for verification. - **Missing — no way to re-run `git ls-remote` verification after install.** Verify button is gated by the same conditional in `applications/Show.vue:46`. Move it to an always-available action; spec §5 implies verification can be re-run. -- **Missing — application creation does not pick a source provider.** `resources/js/pages/applications/Create.vue` collects `repository_url` as a free string. Source providers exist (§5: Gitea/GitHub/generic Git) but the form never references them — users have no guidance for which provider corresponds to the URL, and `application.source_provider_id` is not captured. +- **Missing — application creation does not pick a source provider.** `resources/js/pages/applications/Create.vue` collects `repository_url` as a free string. Source providers exist (§5: Gitea/GitHub/generic Git) but the form never references them — users have no guidance for which provider corresponds to the URL, and the application schema/UI currently has no source-provider association. - **Missing — repository type selector.** Spec lists `repository_type` (§2 Application). UI hardcodes `RepositoryType::GIT` (`app/Http/Controllers/ApplicationController.php:39`). Even if Git is the only v1 type, the form should display the resolved type. ## 5. Applications & Environments (Spec §2, §6, §17) @@ -107,7 +107,7 @@ Conventions: ## 9. Servers (Spec §4) -- **Broken — `