Implement Keystone environment deployments

This commit is contained in:
2026-05-13 16:11:23 +01:00
parent 65d3142d03
commit aa680b25fd
175 changed files with 10258 additions and 740 deletions

View File

@@ -2,26 +2,65 @@
namespace App\Http\Controllers;
use App\Actions\Applications\CreateLaravelEnvironment;
use App\Actions\Applications\GenerateDeployKey;
use App\Actions\Applications\VerifyRepositoryAccess;
use App\Enums\RepositoryType;
use App\Enums\ServerStatus;
use App\Http\Requests\StoreApplicationRequest;
use App\Models\Application;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class ApplicationController extends Controller
{
public function index(Request $request)
public function index(Request $request): Response
{
$organisation = Organisation::with('applications.instances.server')->findOrFail($request->route('organisation'));
$organisation = Organisation::with('applications.environments.services')->findOrFail($request->route('organisation'));
return inertia('applications/Index', [
'applications' => $organisation->applications,
]);
}
public function show(Request $request)
public function create(Request $request): Response
{
Organisation::findOrFail($request->route('organisation'));
return inertia('applications/Create');
}
public function store(StoreApplicationRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->create([
'name' => $request->string('name')->toString(),
'repository_url' => $request->string('repository_url')->toString(),
'repository_type' => RepositoryType::GIT,
'default_branch' => $request->string('default_branch')->toString(),
]);
app(GenerateDeployKey::class)->execute($application);
app(CreateLaravelEnvironment::class)->execute($application->refresh(), $request->string('environment_name')->toString());
return redirect()
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
->with('success', 'Application created. Add the deploy key to your repository before verifying access.');
}
public function show(Request $request): Response
{
$id = $request->route('application');
$application = Application::with(['instances.server', 'organisation'])->findOrFail($id);
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = Application::with([
'environments.services.slices',
'environments.attachments.service',
'environments.variables',
'organisation',
])->whereBelongsTo($organisation)->findOrFail($id);
return inertia('applications/Show', [
'application' => $application,
@@ -35,4 +74,16 @@ class ApplicationController extends Controller
}),
]);
}
public function verifyRepository(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
if (! app(VerifyRepositoryAccess::class)->execute($application)) {
return back()->with('error', 'Repository access could not be verified.');
}
return back()->with('success', 'Repository access verified.');
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Environments\AttachManagedService;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\ServiceType;
use App\Http\Requests\StoreEnvironmentAttachmentRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class EnvironmentAttachmentController extends Controller
{
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('environment-attachments/Create', [
'application' => $application,
'environment' => $environment,
'services' => $organisation->services()
->whereIn('type', [ServiceType::POSTGRES->value, ServiceType::VALKEY->value, ServiceType::CADDY->value])
->orderBy('name')
->get(['id', 'name', 'type', 'category']),
'roles' => array_values(EnvironmentAttachmentRole::toArray()),
]);
}
public function store(StoreEnvironmentAttachmentRequest $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()->findOrFail($request->integer('service_id'));
app(AttachManagedService::class)->execute(
environment: $environment,
service: $service,
role: $request->enum('role', EnvironmentAttachmentRole::class),
name: $request->filled('name') ? $request->string('name')->toString() : null,
envPrefix: $request->filled('env_prefix') ? $request->string('env_prefix')->toString() : null,
isPrimary: $request->boolean('is_primary', true),
);
return redirect()
->route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
])
->with('success', 'Managed service attached.');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers;
use App\Models\Organisation;
use Illuminate\Http\Request;
use Inertia\Response;
class EnvironmentController extends Controller
{
public function show(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()
->with([
'services.replicas',
'services.slices',
'services.operations.steps',
'attachments.service',
'attachments.serviceSlice',
'variables',
'operations.steps',
])
->findOrFail($request->route('environment'));
return inertia('environments/Show', [
'application' => $application,
'environment' => $environment,
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\Environments\DeployEnvironment;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
class EnvironmentDeploymentController extends Controller
{
public function store(Organisation $organisation, Application $application, Environment $environment): RedirectResponse
{
abort_unless(
(int) $application->organisation_id === (int) $organisation->id
&& (int) $environment->application_id === (int) $application->id,
404,
);
dispatch(new DeployEnvironment($environment));
return redirect()->route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Environments\CreateMigrationOperation;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EnvironmentMigrationController extends Controller
{
public function store(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
app(CreateMigrationOperation::class)->execute($environment);
return redirect()
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
->with('success', 'Migration operation created.');
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use App\Enums\EnvironmentVariableSource;
use App\Http\Requests\StoreEnvironmentVariableRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class EnvironmentVariableController extends Controller
{
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('environment-variables/Create', [
'application' => $application,
'environment' => $environment,
]);
}
public function store(StoreEnvironmentVariableRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$environment->variables()->updateOrCreate([
'key' => $request->string('key')->toString(),
], [
'value' => $request->string('value')->toString(),
'source' => EnvironmentVariableSource::USER,
'service_slice_id' => null,
'overridable' => true,
]);
return redirect()
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
->with('success', 'Environment variable saved.');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Environments\CreateLaravelWorkerService;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EnvironmentWorkerController extends Controller
{
public function store(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
app(CreateLaravelWorkerService::class)->execute($environment);
return redirect()
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
->with('success', 'Worker service created.');
}
}

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Applications\CreateInstance;
use App\Models\Application;
use App\Models\Server;
use Illuminate\Http\Request;
class InstanceController extends Controller
{
public function store(Request $request, Application $application)
{
$validated = $request->validate([
'server_id' => 'required|exists:servers,id',
'branch' => 'required|string|max:255',
'config' => 'sometimes|array',
]);
$server = Server::findOrFail($validated['server_id']);
$instance = (new CreateInstance())->execute(
$application,
$server,
$validated['branch'],
$validated['config'] ?? []
);
return redirect()
->route('applications.show', [
'organisation' => $application->organisation_id,
'application' => $application->id
])
->with('success', 'Instance created successfully');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Models\Organisation;
use Inertia\Response;
class OnboardingController extends Controller
{
public function show(Organisation $organisation): Response
{
$organisation->loadCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']);
$steps = [
[
'key' => 'organisation',
'label' => 'Organisation',
'complete' => true,
'href' => route('organisations.show', ['organisation' => $organisation->id]),
],
[
'key' => 'provider',
'label' => 'Provider',
'complete' => $organisation->providers_count > 0,
'href' => route('organisations.show', ['organisation' => $organisation->id]),
],
[
'key' => 'source',
'label' => 'Source',
'complete' => $organisation->source_providers_count > 0,
'href' => route('source-providers.create', ['organisation' => $organisation->id]),
],
[
'key' => 'registry',
'label' => 'Registry',
'complete' => $organisation->registries_count > 0,
'href' => route('registries.create', ['organisation' => $organisation->id]),
],
[
'key' => 'server',
'label' => 'Server',
'complete' => $organisation->servers_count > 0,
'href' => route('servers.create', ['organisation' => $organisation->id]),
],
[
'key' => 'application',
'label' => 'Application',
'complete' => $organisation->applications_count > 0,
'href' => route('applications.create', ['organisation' => $organisation->id]),
],
];
$next = collect($steps)->firstWhere('complete', false) ?? $steps[array_key_last($steps)];
return inertia('onboarding/Show', [
'organisation' => $organisation,
'steps' => $steps,
'nextStep' => $next,
]);
}
}

View File

@@ -13,6 +13,8 @@ class OrganisationController extends Controller
{
return inertia('organisations/Show', [
'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')),
]);
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers;
use App\Enums\RegistryType;
use App\Http\Requests\StoreRegistryRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class RegistryController extends Controller
{
public function create(Request $request): Response
{
Organisation::findOrFail($request->route('organisation'));
return inertia('registries/Create', [
'registryTypes' => array_values(RegistryType::toArray()),
]);
}
public function store(StoreRegistryRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$organisation->registries()->create([
'name' => $request->string('name')->toString(),
'type' => $request->enum('type', RegistryType::class),
'url' => rtrim($request->string('url')->toString(), '/'),
'credentials' => [
'username' => $request->string('username')->toString(),
'password' => $request->string('password')->toString(),
],
]);
return redirect()
->route('organisations.show', ['organisation' => $organisation->id])
->with('success', 'Registry created.');
}
}

View File

@@ -75,12 +75,12 @@ class ServerController extends Controller
}
$networkZone = $request->network_zone ?? 'global';
// Look for an existing network with the same network_zone
$network = $provider->networks()
->where('network_zone', $networkZone)
->first();
if (! $network) {
// We need to create a network with the correct network zone
$networkName = "keystone-{$networkZone}";
@@ -141,7 +141,7 @@ class ServerController extends Controller
$server = $organisation->servers()->findOrFail($request->route('server'));
return inertia('servers/Show', [
'server' => $server->load('services.slices', 'serviceDeployments.steps', 'serviceDeployments.target'),
'server' => $server->load('services.slices', 'serviceOperations.steps', 'serviceOperations.target'),
]);
}
}

View File

@@ -5,13 +5,16 @@ namespace App\Http\Controllers;
use App\Actions\Services\CreateService;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Http\Requests\StoreServiceRequest;
use App\Http\Requests\UpdateServiceRequest;
use App\Models\Server;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Response;
class ServiceController extends Controller
{
public function create(Request $request)
public function create(Request $request): Response
{
$server = Server::findOrFail($request->route('server'));
@@ -21,19 +24,8 @@ class ServiceController extends Controller
]);
}
public function store(Request $request)
public function store(StoreServiceRequest $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'category' => ['required', Rule::enum(ServiceCategory::class)],
'type' => ['required', Rule::enum(ServiceType::class)],
'version' => ['required', 'string', function ($key, $value, $fail) use ($request) {
if (!isset(config('keystone.services')[$request->category][$request->type]['versions'][$value])) {
$fail('The selected version is invalid.');
}
}],
]);
$server = Server::findOrFail($request->route('server'));
$service = app(CreateService::class)->execute(
@@ -52,4 +44,44 @@ class ServiceController extends Controller
'service' => $service,
]);
}
public function show(Request $request): Response
{
$server = Server::findOrFail($request->route('server'));
$service = $server->services()
->with(['replicas', 'slices', 'operations.steps', 'environment.application'])
->findOrFail($request->route('service'));
return inertia('services/Show', [
'server' => $server,
'service' => $service,
]);
}
public function edit(Request $request): Response
{
$server = Server::findOrFail($request->route('server'));
$service = $server->services()->findOrFail($request->route('service'));
return inertia('services/Edit', [
'server' => $server,
'service' => $service,
]);
}
public function update(UpdateServiceRequest $request): RedirectResponse
{
$server = Server::findOrFail($request->route('server'));
$service = $server->services()->findOrFail($request->route('service'));
$service->update($request->validated());
return redirect()
->route('services.show', [
'organisation' => $server->organisation_id,
'server' => $server->id,
'service' => $service->id,
])
->with('success', 'Service updated.');
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Services\CreateStatefulServiceUpdateOperation;
use App\Enums\ServiceType;
use App\Http\Requests\StoreServiceUpdateRequest;
use App\Models\Organisation;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Http\RedirectResponse;
use Inertia\Response;
class ServiceUpdateController extends Controller
{
public function create(Organisation $organisation, Server $server, Service $service): Response
{
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);
return inertia('services/updates/Create', [
'server' => $server,
'service' => $service,
'backupAvailable' => (bool) ($service->config['backup_enabled'] ?? false),
]);
}
public function store(
StoreServiceUpdateRequest $request,
Organisation $organisation,
Server $server,
Service $service,
CreateStatefulServiceUpdateOperation $createStatefulServiceUpdateOperation,
): RedirectResponse {
abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404);
$createStatefulServiceUpdateOperation->execute(
service: $service,
imageDigest: $request->string('image_digest')->toString(),
backupRequested: $request->boolean('backup_requested'),
);
return redirect()->route('servers.show', [
'organisation' => $organisation->id,
'server' => $server->id,
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SourceProviderType;
use App\Http\Requests\StoreSourceProviderRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class SourceProviderController extends Controller
{
public function create(Request $request): Response
{
Organisation::findOrFail($request->route('organisation'));
return inertia('source-providers/Create', [
'sourceProviderTypes' => array_values(SourceProviderType::toArray()),
]);
}
public function store(StoreSourceProviderRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$organisation->sourceProviders()->create([
'name' => $request->string('name')->toString(),
'type' => $request->enum('type', SourceProviderType::class),
'url' => $request->filled('url') ? rtrim($request->string('url')->toString(), '/') : null,
'config' => [],
]);
return redirect()
->route('organisations.show', ['organisation' => $organisation->id])
->with('success', 'Source provider created.');
}
}