wowowowowo
Some checks failed
CI / Lint (push) Failing after 22s
CI / Tests (push) Failing after 33s

This commit is contained in:
2026-05-28 15:15:41 +01:00
parent 8f603122e2
commit 5b977c1f41
129 changed files with 9943 additions and 722 deletions

View File

@@ -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;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Models\BuildArtifact;
use App\Models\Organisation;
use Illuminate\Http\Request;
use Inertia\Response;
class BuildArtifactController 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('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,
]);
}
}

View File

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

View File

@@ -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<int, int>
*/
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<int, array{attachment_id: int, caddyfile: string}>
*/
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<int, string>
*/
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();
}
}

View File

@@ -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<int, int>
*/
private function serverIdsFor(Environment $environment): Collection
{
return $environment->services
->flatMap(fn ($service) => [
$service->server_id,
...$service->replicas->pluck('server_id')->all(),
])
->filter()
->unique()
->values();
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Models\Organisation;
use Inertia\Response;
class EnvironmentIndexController extends Controller
{
public function __invoke(Organisation $organisation): Response
{
$applications = $organisation->applications()
->with([
'environments' => fn ($query) => $query
->withCount(['services', 'attachments', 'variables', 'buildArtifacts'])
->latest(),
])
->get();
return inertia('environments/Index', [
'applications' => $applications,
]);
}
}

View File

@@ -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<string, string>
*/
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;
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Environments\AttachManagedService;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\ServiceType;
use App\Http\Requests\StoreGatewayRouteRequest;
use App\Http\Requests\UpdateGatewayRouteRequest;
use App\Models\EnvironmentAttachment;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class GatewayRouteController extends Controller
{
public function index(Request $request): Response
{
$organisation = Organisation::findOrFail($request->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),
];
}
}

View File

@@ -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)];

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Operation;
use App\Models\Organisation;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceReplica;
use App\Models\ServiceSlice;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class OperationController extends Controller
{
public function index(Request $request, Organisation $organisation): Response
{
$applicationIds = $organisation->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,
};
}
}

View File

@@ -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(),
],
]);
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Http\Controllers;
use App\Enums\OrganisationRole;
use App\Http\Requests\StoreOrganisationMemberRequest;
use App\Http\Requests\UpdateOrganisationInvitationRequest;
use App\Http\Requests\UpdateOrganisationMemberRequest;
use App\Models\Organisation;
use App\Models\OrganisationInvitation;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Response;
class OrganisationMemberController extends Controller
{
public function index(Organisation $organisation): Response
{
return inertia('organisation-members/Index', [
'organisation' => $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.');
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers;
use App\Enums\ProviderType;
use App\Http\Requests\StoreProviderRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class ProviderController extends Controller
{
public function create(Request $request): Response
{
Organisation::findOrFail($request->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.');
}
}

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers;
use App\Actions\FirewallRules\UninstallFirewallRule;
use App\Http\Requests\StoreServerFirewallRuleRequest;
use App\Models\FirewallRule;
use App\Models\Organisation;
use App\Models\Server;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ServerFirewallRuleController extends Controller
{
public function store(StoreServerFirewallRuleRequest $request, Organisation $organisation, Server $server): RedirectResponse
{
abort_unless((int) $server->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.');
}
}

View File

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

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Models\Organisation;
use App\Models\ServiceReplica;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class ServiceReplicaController extends Controller
{
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'));
$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,
]);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreServiceSliceRequest;
use App\Models\Organisation;
use App\Models\ServiceSlice;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class ServiceSliceController extends Controller
{
public function index(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'));
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.');
}
}

View File

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

View File

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

View File

@@ -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')))

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ImportEnvironmentVariablesRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'contents' => ['required', 'string', 'max:20000'],
'overridable' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -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'],

View File

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

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEnvironmentDeploymentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'target_commit' => ['nullable', 'string', 'size:40', 'regex:/^[a-fA-F0-9]{40}$/'],
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEnvironmentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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'],
];
}
}

View File

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

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreGatewayRouteRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Enums\OrganisationRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreOrganisationMemberRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
'role' => ['required', Rule::enum(OrganisationRole::class)],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use App\Enums\ProviderType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProviderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::enum(ProviderType::class)],
'token' => ['required', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use App\Enums\FirewallRuleType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreServerFirewallRuleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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:.\/-]+$/'],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreServiceSliceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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'],
];
}
}

View File

@@ -21,6 +21,7 @@ class StoreServiceUpdateRequest extends FormRequest
return [
'image_digest' => ['required', 'string', 'starts_with:sha256:'],
'backup_requested' => ['sometimes', 'boolean'],
'confirmation' => ['required', 'string'],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use App\Enums\RepositoryType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateApplicationRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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._\/-]+$/'],
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests;
use App\Enums\EnvironmentAttachmentRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateEnvironmentAttachmentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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'],
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests;
use App\Enums\BuildStrategy;
use App\Enums\SchedulerMode;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateEnvironmentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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'],
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEnvironmentVariableRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
'value' => ['nullable', 'string'],
'overridable' => ['boolean'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateGatewayRouteRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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'],
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use App\Enums\OrganisationRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateOrganisationInvitationRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'role' => ['required', Rule::enum(OrganisationRole::class)],
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use App\Enums\OrganisationRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateOrganisationMemberRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'role' => ['required', Rule::enum(OrganisationRole::class)],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use App\Enums\RegistryType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateRegistryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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'],
];
}
}

View File

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

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use App\Enums\SourceProviderType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateSourceProviderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::enum(SourceProviderType::class)],
'url' => ['nullable', 'string', 'max:255'],
];
}
}

View File

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

View File

@@ -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);

View File

@@ -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);

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use App\Enums\OrganisationRole;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OrganisationInvitation extends Model
{
/** @use HasFactory<\Database\Factories\OrganisationInvitationFactory> */
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');
}
}

View File

@@ -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)

View File

@@ -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);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Support;
use App\Models\EnvironmentAttachment;
class CaddyRouteRenderer
{
/**
* @param array<int, string> $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),
' }',
'}',
]);
}
}