From 5b977c1f41c95e3e7c73d593c648f4c9bfca3809 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Thu, 28 May 2026 15:15:41 +0100 Subject: [PATCH] wowowowowo --- .env.example | 2 + .../Controllers/ApplicationController.php | 85 +++- .../Controllers/BuildArtifactController.php | 44 +++ .../EnvironmentAttachmentController.php | 89 ++++- .../Controllers/EnvironmentController.php | 175 ++++++++ .../EnvironmentDeploymentController.php | 30 +- .../EnvironmentIndexController.php | 24 ++ .../EnvironmentVariableController.php | 147 ++++++- .../Controllers/GatewayRouteController.php | 162 ++++++++ app/Http/Controllers/OnboardingController.php | 15 + app/Http/Controllers/OperationController.php | 171 ++++++++ .../Controllers/OrganisationController.php | 21 +- .../OrganisationMemberController.php | 120 ++++++ app/Http/Controllers/ProviderController.php | 49 +++ app/Http/Controllers/RegistryController.php | 85 ++++ app/Http/Controllers/ServerController.php | 67 +++- .../ServerFirewallRuleController.php | 41 ++ app/Http/Controllers/ServiceController.php | 63 ++- .../Controllers/ServiceReplicaController.php | 102 +++++ .../Controllers/ServiceSliceController.php | 104 +++++ .../Controllers/ServiceUpdateController.php | 24 ++ .../Controllers/SourceProviderController.php | 53 +++ app/Http/Middleware/HandleInertiaRequests.php | 4 +- .../ImportEnvironmentVariablesRequest.php | 29 ++ app/Http/Requests/StoreApplicationRequest.php | 4 + .../StoreEnvironmentAttachmentRequest.php | 3 + .../StoreEnvironmentDeploymentRequest.php | 28 ++ app/Http/Requests/StoreEnvironmentRequest.php | 30 ++ .../StoreEnvironmentVariableRequest.php | 1 + .../Requests/StoreGatewayRouteRequest.php | 32 ++ .../StoreOrganisationMemberRequest.php | 31 ++ app/Http/Requests/StoreProviderRequest.php | 32 ++ .../StoreServerFirewallRuleRequest.php | 32 ++ .../Requests/StoreServiceSliceRequest.php | 32 ++ .../Requests/StoreServiceUpdateRequest.php | 1 + .../Requests/UpdateApplicationRequest.php | 34 ++ .../UpdateEnvironmentAttachmentRequest.php | 36 ++ .../Requests/UpdateEnvironmentRequest.php | 42 ++ .../UpdateEnvironmentVariableRequest.php | 30 ++ .../Requests/UpdateGatewayRouteRequest.php | 31 ++ .../UpdateOrganisationInvitationRequest.php | 30 ++ .../UpdateOrganisationMemberRequest.php | 30 ++ app/Http/Requests/UpdateRegistryRequest.php | 34 ++ app/Http/Requests/UpdateServiceRequest.php | 12 + .../Requests/UpdateSourceProviderRequest.php | 32 ++ app/Jobs/Environments/DeployEnvironment.php | 20 +- app/Models/Application.php | 5 + app/Models/Organisation.php | 5 + app/Models/OrganisationInvitation.php | 35 ++ app/Models/Server.php | 6 + app/Models/SourceProvider.php | 6 + app/Support/CaddyRouteRenderer.php | 38 ++ bun.lockb | Bin 154320 -> 139651 bytes composer.lock | 2 +- .../OrganisationInvitationFactory.php | 32 ++ ...urce_provider_id_to_applications_table.php | 32 ++ ..._create_organisation_invitations_table.php | 38 ++ database/seeders/DatabaseSeeder.php | 1 - docs/managed-registry.md | 296 ++++++++++++++ docs/ui-review.md | 164 +++++++- opencode.json | 8 +- resources/js/components/AppHeader.vue | 57 ++- resources/js/components/AppSidebar.vue | 39 +- resources/js/components/AppSidebarHeader.vue | 3 + resources/js/components/RadioButton.vue | 26 +- resources/js/components/ServerSelector.vue | 23 +- .../components/environments/ServiceCard.vue | 47 +-- .../operations/OperationTimeline.vue | 149 +++++++ resources/js/pages/Dashboard.vue | 78 +++- resources/js/pages/applications/Create.vue | 75 +++- resources/js/pages/applications/Edit.vue | 142 +++++++ resources/js/pages/applications/Index.vue | 44 ++- resources/js/pages/applications/Show.vue | 321 +++++++++++---- resources/js/pages/build-artifacts/Index.vue | 90 +++++ resources/js/pages/build-artifacts/Show.vue | 70 ++++ .../pages/environment-attachments/Create.vue | 106 +++-- .../js/pages/environment-attachments/Edit.vue | 137 +++++++ .../js/pages/environment-variables/Create.vue | 33 +- .../js/pages/environment-variables/Edit.vue | 110 ++++++ .../js/pages/environment-variables/Index.vue | 225 +++++++++++ resources/js/pages/environments/Create.vue | 81 ++++ resources/js/pages/environments/Edit.vue | 213 ++++++++++ resources/js/pages/environments/Index.vue | 89 +++++ resources/js/pages/environments/Show.vue | 373 ++++++++++++++++-- resources/js/pages/gateway-routes/Create.vue | 124 ++++++ resources/js/pages/gateway-routes/Edit.vue | 124 ++++++ resources/js/pages/gateway-routes/Index.vue | 175 ++++++++ resources/js/pages/onboarding/Show.vue | 12 +- resources/js/pages/operations/Index.vue | 98 +++++ resources/js/pages/operations/Show.vue | 116 ++++++ .../js/pages/organisation-members/Index.vue | 226 +++++++++++ resources/js/pages/organisations/Show.vue | 199 +++++++++- resources/js/pages/providers/Create.vue | 77 ++++ resources/js/pages/registries/Create.vue | 11 +- resources/js/pages/registries/Edit.vue | 118 ++++++ resources/js/pages/registries/Index.vue | 118 ++++++ resources/js/pages/registries/Show.vue | 118 ++++++ resources/js/pages/servers/Create.vue | 41 +- resources/js/pages/servers/Index.vue | 111 +++++- resources/js/pages/servers/Show.vue | 283 +++++++++---- resources/js/pages/service-replicas/Show.vue | 158 ++++++++ resources/js/pages/service-slices/Create.vue | 108 +++++ resources/js/pages/service-slices/Index.vue | 135 +++++++ resources/js/pages/service-slices/Show.vue | 100 +++++ resources/js/pages/services/Create.vue | 61 ++- resources/js/pages/services/Edit.vue | 237 ++++++++--- resources/js/pages/services/Show.vue | 187 +++++++-- .../js/pages/services/updates/Create.vue | 70 +++- .../js/pages/source-providers/Create.vue | 11 +- resources/js/pages/source-providers/Edit.vue | 99 +++++ resources/js/pages/source-providers/Index.vue | 115 ++++++ routes/web.php | 152 ++++++- tests/Feature/ApplicationControllerTest.php | 72 +++- tests/Feature/CrudUiTest.php | 199 ++++++++++ tests/Feature/DatabaseSeederTest.php | 7 + tests/Feature/DeployEnvironmentJobTest.php | 1 + .../EnvironmentAttachmentControllerTest.php | 150 +++++++ tests/Feature/EnvironmentControllerTest.php | 83 ++++ .../EnvironmentDeploymentControllerTest.php | 135 +++++++ .../EnvironmentVariableControllerTest.php | 67 +++- tests/Feature/NavigationUiTest.php | 53 +++ tests/Feature/OnboardingControllerTest.php | 36 +- tests/Feature/OperationsUiTest.php | 154 ++++++++ .../OrganisationMemberControllerTest.php | 116 ++++++ tests/Feature/RegistryControllerTest.php | 52 +++ tests/Feature/ResourceDetailUiTest.php | 254 ++++++++++++ tests/Feature/ServerControllerTest.php | 348 +++++++--------- tests/Feature/ServiceControllerTest.php | 70 ++++ tests/Feature/ServiceUpdateControllerTest.php | 22 ++ 129 files changed, 9943 insertions(+), 722 deletions(-) create mode 100644 app/Http/Controllers/BuildArtifactController.php create mode 100644 app/Http/Controllers/EnvironmentIndexController.php create mode 100644 app/Http/Controllers/GatewayRouteController.php create mode 100644 app/Http/Controllers/OperationController.php create mode 100644 app/Http/Controllers/OrganisationMemberController.php create mode 100644 app/Http/Controllers/ProviderController.php create mode 100644 app/Http/Controllers/ServerFirewallRuleController.php create mode 100644 app/Http/Controllers/ServiceReplicaController.php create mode 100644 app/Http/Controllers/ServiceSliceController.php create mode 100644 app/Http/Requests/ImportEnvironmentVariablesRequest.php create mode 100644 app/Http/Requests/StoreEnvironmentDeploymentRequest.php create mode 100644 app/Http/Requests/StoreEnvironmentRequest.php create mode 100644 app/Http/Requests/StoreGatewayRouteRequest.php create mode 100644 app/Http/Requests/StoreOrganisationMemberRequest.php create mode 100644 app/Http/Requests/StoreProviderRequest.php create mode 100644 app/Http/Requests/StoreServerFirewallRuleRequest.php create mode 100644 app/Http/Requests/StoreServiceSliceRequest.php create mode 100644 app/Http/Requests/UpdateApplicationRequest.php create mode 100644 app/Http/Requests/UpdateEnvironmentAttachmentRequest.php create mode 100644 app/Http/Requests/UpdateEnvironmentRequest.php create mode 100644 app/Http/Requests/UpdateEnvironmentVariableRequest.php create mode 100644 app/Http/Requests/UpdateGatewayRouteRequest.php create mode 100644 app/Http/Requests/UpdateOrganisationInvitationRequest.php create mode 100644 app/Http/Requests/UpdateOrganisationMemberRequest.php create mode 100644 app/Http/Requests/UpdateRegistryRequest.php create mode 100644 app/Http/Requests/UpdateSourceProviderRequest.php create mode 100644 app/Models/OrganisationInvitation.php create mode 100644 app/Support/CaddyRouteRenderer.php create mode 100644 database/factories/OrganisationInvitationFactory.php create mode 100644 database/migrations/2026_05_24_141635_add_source_provider_id_to_applications_table.php create mode 100644 database/migrations/2026_05_24_155558_create_organisation_invitations_table.php create mode 100644 docs/managed-registry.md create mode 100644 resources/js/components/operations/OperationTimeline.vue create mode 100644 resources/js/pages/applications/Edit.vue create mode 100644 resources/js/pages/build-artifacts/Index.vue create mode 100644 resources/js/pages/build-artifacts/Show.vue create mode 100644 resources/js/pages/environment-attachments/Edit.vue create mode 100644 resources/js/pages/environment-variables/Edit.vue create mode 100644 resources/js/pages/environment-variables/Index.vue create mode 100644 resources/js/pages/environments/Create.vue create mode 100644 resources/js/pages/environments/Edit.vue create mode 100644 resources/js/pages/environments/Index.vue create mode 100644 resources/js/pages/gateway-routes/Create.vue create mode 100644 resources/js/pages/gateway-routes/Edit.vue create mode 100644 resources/js/pages/gateway-routes/Index.vue create mode 100644 resources/js/pages/operations/Index.vue create mode 100644 resources/js/pages/operations/Show.vue create mode 100644 resources/js/pages/organisation-members/Index.vue create mode 100644 resources/js/pages/providers/Create.vue create mode 100644 resources/js/pages/registries/Edit.vue create mode 100644 resources/js/pages/registries/Index.vue create mode 100644 resources/js/pages/registries/Show.vue create mode 100644 resources/js/pages/service-replicas/Show.vue create mode 100644 resources/js/pages/service-slices/Create.vue create mode 100644 resources/js/pages/service-slices/Index.vue create mode 100644 resources/js/pages/service-slices/Show.vue create mode 100644 resources/js/pages/source-providers/Edit.vue create mode 100644 resources/js/pages/source-providers/Index.vue create mode 100644 tests/Feature/CrudUiTest.php create mode 100644 tests/Feature/DatabaseSeederTest.php create mode 100644 tests/Feature/NavigationUiTest.php create mode 100644 tests/Feature/OperationsUiTest.php create mode 100644 tests/Feature/OrganisationMemberControllerTest.php create mode 100644 tests/Feature/ResourceDetailUiTest.php 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 4befe71f4a231f02445403b78c392d9452ac9585..32ec0e91c0c8dd354afffe30a00065ddf654bfa0 100755 GIT binary patch literal 139651 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 H;D7!PZ@fCf literal 154320 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 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 — `