fix(resources): clarify build server hosting restrictions (#10961)

This commit is contained in:
Andras Bacsai 2026-07-16 21:35:23 +02:00 committed by GitHub
parent 7d699818e8
commit 913d033c75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 621 additions and 70 deletions

View file

@ -1237,6 +1237,12 @@ private function create_application(Request $request, $type)
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if (! $server->canHostResources()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
], 422);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);

View file

@ -1813,6 +1813,12 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if (! $server->canHostResources()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
], 422);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);

View file

@ -736,6 +736,13 @@ public function update_server(Request $request)
], 422);
}
if ($request->boolean('is_build_server') && ! $server->isBuildServer() && ! $server->isEmpty()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_build_server' => ['A server with existing resources cannot be configured as a build server.']],
], 422);
}
$server->update($updateFields);
if ($request->has('is_build_server')) {
$server->settings()->update([

View file

@ -444,6 +444,12 @@ public function create_service(Request $request)
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if (! $server->canHostResources()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
], 422);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);
@ -501,7 +507,8 @@ public function create_service(Request $request)
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
data_set($servicePayload, 'connect_to_docker_network', true);
}
$service = Service::create($servicePayload);
$service = new Service($servicePayload);
$service->save();
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
$service->description = $request->description;
if ($request->has('is_container_label_escape_enabled')) {
@ -639,6 +646,12 @@ public function create_service(Request $request)
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if (! $server->canHostResources()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
], 422);
}
$destinations = $server->destinations();
if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400);

View file

@ -34,7 +34,7 @@ class CloneMe extends Component
public ?int $selectedServer = null;
public ?int $selectedDestination = null;
public ?string $selectedDestination = null;
public ?Server $server = null;
@ -76,9 +76,9 @@ public function render()
return view('livewire.project.clone-me');
}
public function selectServer($server_id, $destination_id)
public function selectServer($server_id, $destination_uuid)
{
if ($server_id == $this->selectedServer && $destination_id == $this->selectedDestination) {
if ($server_id == $this->selectedServer && $destination_uuid === $this->selectedDestination) {
$this->selectedServer = null;
$this->selectedDestination = null;
$this->server = null;
@ -86,7 +86,7 @@ public function selectServer($server_id, $destination_id)
return;
}
$this->selectedServer = $server_id;
$this->selectedDestination = $destination_id;
$this->selectedDestination = $destination_uuid;
$this->server = $this->servers->where('id', $server_id)->first();
}
@ -98,6 +98,10 @@ public function clone(string $type)
'selectedDestination' => 'required',
'newName' => ValidationPatterns::nameRules(),
]);
$selectedDestination = find_resource_destination_for_current_team($this->selectedDestination);
if (! $selectedDestination) {
throw new \Exception('Destination not found.');
}
if ($type === 'project') {
$foundProject = Project::where('name', $this->newName)->first();
if ($foundProject) {
@ -130,7 +134,6 @@ public function clone(string $type)
$databases = $this->environment->databases();
$services = $this->environment->services;
foreach ($applications as $application) {
$selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations())->where('id', $this->selectedDestination)->first();
clone_application($application, $selectedDestination, [
'environment_id' => $environment->id,
], $this->cloneVolumeData);
@ -147,7 +150,8 @@ public function clone(string $type)
'status' => 'exited',
'started_at' => null,
'environment_id' => $environment->id,
'destination_id' => $this->selectedDestination,
'destination_id' => $selectedDestination->id,
'destination_type' => $selectedDestination->getMorphClass(),
]);
$newDatabase->save();
@ -265,7 +269,9 @@ public function clone(string $type)
])->fill([
'uuid' => $uuid,
'environment_id' => $environment->id,
'destination_id' => $this->selectedDestination,
'destination_id' => $selectedDestination->id,
'destination_type' => $selectedDestination->getMorphClass(),
'server_id' => $selectedDestination->server_id,
]);
$newService->save();

View file

@ -47,19 +47,20 @@ public function submit()
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
$service = Service::create([
$service = new Service([
'docker_compose_raw' => $this->dockerComposeRaw,
'environment_id' => $environment->id,
'server_id' => $destination->server_id,
'destination_id' => $destination->id,
'destination_type' => $destination_class,
]);
$service->save();
$variables = parseEnvFormatToArray($this->envFile);
foreach ($variables as $key => $data) {

View file

@ -115,7 +115,7 @@ public function submit()
$parser->parse($dockerImage);
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
@ -133,7 +133,7 @@ public function submit()
// Determine the image tag based on whether it's a hash or regular tag
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
$application = Application::create([
$application = new Application([
'name' => 'docker-image-'.new_public_id(),
'repository_project_id' => 0,
'git_repository' => 'coollabsio/coolify',
@ -147,6 +147,7 @@ public function submit()
'destination_type' => $destination_class,
'health_check_enabled' => false,
]);
$application->save();
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
$application->update([

View file

@ -192,7 +192,7 @@ public function submit()
}
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
@ -201,7 +201,7 @@ public function submit()
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$application = Application::create([
$application = new Application([
'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name),
'repository_project_id' => $this->selected_repository_id,
'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(),
@ -216,6 +216,7 @@ public function submit()
'source_id' => $this->github_app->id,
'source_type' => $this->github_app->getMorphClass(),
]);
$application->save();
$application->settings->is_static = $this->is_static;
$application->settings->save();

View file

@ -136,7 +136,7 @@ public function submit()
$this->validate();
try {
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
@ -185,7 +185,8 @@ public function submit()
$application_init['docker_compose_location'] = $this->docker_compose_location;
$application_init['base_directory'] = $this->base_directory;
}
$application = Application::create($application_init);
$application = new Application($application_init);
$application->save();
$application->settings->is_static = $this->is_static;
$application->settings->save();

View file

@ -290,7 +290,7 @@ public function submit()
$project_uuid = $this->parameters['project_uuid'];
$environment_uuid = $this->parameters['environment_uuid'];
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
@ -336,7 +336,8 @@ public function submit()
$application_init['docker_compose_location'] = $this->docker_compose_location;
$application_init['base_directory'] = $this->base_directory;
}
$application = Application::create($application_init);
$application = new Application($application_init);
$application->save();
$application->settings->is_static = $this->isStatic;
$application->settings->save();

View file

@ -24,6 +24,8 @@ class Select extends Component
public Collection|null|Server $servers;
public ?Collection $buildServers = null;
public bool $onlyBuildServerAvailable = false;
public ?Collection $standaloneDockers;
@ -380,7 +382,7 @@ public function setType(string $type)
return;
}
if (count($this->servers) === 1) {
if (count($this->servers) === 1 && $this->buildServers?->isEmpty()) {
$server = $this->servers->first();
if ($server instanceof Server) {
$this->setServer($server);
@ -452,12 +454,8 @@ public function whatToDoNext()
public function loadServers()
{
$this->servers = Server::isUsable()->get()->sortBy('name');
$this->allServers = $this->servers;
if ($this->allServers && $this->allServers->isNotEmpty()) {
$this->onlyBuildServerAvailable = $this->allServers->every(function ($server) {
return $server->isBuildServer();
});
}
$this->buildServers = Server::isUsableBuildServer()->get()->sortBy('name');
$this->allServers = $this->servers->concat($this->buildServers);
$this->onlyBuildServerAvailable = $this->servers->isEmpty() && $this->buildServers->isNotEmpty();
}
}

View file

@ -38,7 +38,7 @@ public function submit()
'dockerfile' => 'required',
]);
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
@ -51,7 +51,7 @@ public function submit()
if (! $port) {
$port = 80;
}
$application = Application::create([
$application = new Application([
'name' => 'dockerfile-'.new_public_id(),
'repository_project_id' => 0,
'git_repository' => 'coollabsio/coolify',
@ -66,6 +66,7 @@ public function submit()
'source_id' => 0,
'source_type' => GithubApp::class,
]);
$application->save();
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
$application->update([

View file

@ -33,7 +33,7 @@ public function mount()
return redirect()->route('dashboard');
}
if (isset($type) && isset($destination_uuid)) {
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
return redirect()->route('dashboard');
}
@ -96,7 +96,8 @@ public function mount()
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
data_set($service_payload, 'connect_to_docker_network', true);
}
$service = Service::create($service_payload);
$service = new Service($service_payload);
$service->save();
$service->name = "$oneClickServiceName-".$service->uuid;
$service->save();
if ($oneClickDotEnvs?->count() > 0) {

View file

@ -118,9 +118,8 @@ public function promote(int $network_id, int $server_id)
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
$network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
$this->authorize('update', $this->resource);
$this->resource->getConnection()->transaction(function () use ($network, $server) {
$main_destination = $this->resource->destination;
$mainDestination = $this->resource->destination;
$this->resource->update([
'destination_id' => $network->id,
'destination_type' => StandaloneDocker::class,
@ -128,7 +127,7 @@ public function promote(int $network_id, int $server_id)
$this->resource->additional_networks()
->wherePivot('server_id', $server->id)
->detach($network->id);
$this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
$this->resource->additional_networks()->attach($mainDestination->id, ['server_id' => $mainDestination->server->id]);
});
$this->resource->refresh();
$this->refreshServers();

View file

@ -11,7 +11,6 @@
use App\Models\Environment;
use App\Models\Project;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
@ -19,7 +18,6 @@
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\SwarmDocker;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -37,6 +35,8 @@ class ResourceOperations extends Component
public $servers;
public $buildServers;
public bool $cloneVolumeData = false;
public function mount()
@ -45,7 +45,9 @@ public function mount()
$this->projectUuid = data_get($parameters, 'project_uuid');
$this->environmentUuid = data_get($parameters, 'environment_uuid');
$this->projects = Project::ownedByCurrentTeamCached();
$this->servers = currentTeam()->servers->filter(fn ($server) => ! $server->isBuildServer());
$servers = currentTeam()->servers()->get();
$this->servers = $servers->reject(fn ($server) => $server->isBuildServer());
$this->buildServers = $servers->filter(fn ($server) => $server->isBuildServer());
}
public function toggleVolumeCloning(bool $value)
@ -53,20 +55,20 @@ public function toggleVolumeCloning(bool $value)
$this->cloneVolumeData = $value;
}
public function cloneTo($destination_id)
public function cloneTo($destination_uuid)
{
try {
$this->authorize('update', $this->resource);
$new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id);
if (! $new_destination) {
$new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id);
}
$new_destination = find_resource_destination_for_current_team($destination_uuid);
if (! $new_destination) {
return $this->addError('destination_id', 'Destination not found.');
}
$uuid = new_public_id();
$server = $new_destination->server;
if (! $server->canHostResources()) {
return $this->addError('destination_id', 'The selected server cannot host resources.');
}
if ($this->resource->getMorphClass() === Application::class) {
$new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData);
@ -99,6 +101,7 @@ public function cloneTo($destination_id)
'status' => 'exited',
'started_at' => null,
'destination_id' => $new_destination->id,
'destination_type' => $new_destination->getMorphClass(),
]);
$new_resource->save();

View file

@ -202,7 +202,7 @@ public function mount(string $server_uuid)
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->syncData();
if (! $this->server->isEmpty()) {
if (! $this->server->isBuildServer() && ! $this->server->isEmpty()) {
$this->isBuildServerLocked = true;
}
// Load saved Hetzner status and validation state
@ -409,6 +409,12 @@ public function updatedIsBuildServer($value)
{
try {
$this->authorize('update', $this->server);
if ($value === true && ! $this->server->isEmpty()) {
$this->isBuildServer = false;
$this->dispatch('error', 'A server with existing resources cannot be configured as a build server.');
return;
}
if ($value === true && $this->isSentinelEnabled) {
$this->isSentinelEnabled = false;
$this->isMetricsEnabled = false;

View file

@ -509,9 +509,29 @@ public static function ownedByCurrentTeamCached()
});
}
public static function isUsable()
public static function isUsable(): Builder
{
return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_swarm_worker', false)->whereRelation('settings', 'is_build_server', false)->whereRelation('settings', 'force_disabled', false);
return self::usableByBuildServerStatus(false);
}
public static function isUsableBuildServer(): Builder
{
return self::usableByBuildServerStatus(true);
}
private static function usableByBuildServerStatus(bool $isBuildServer): Builder
{
return Server::ownedByCurrentTeam()
->whereRelation('settings', 'is_reachable', true)
->whereRelation('settings', 'is_usable', true)
->whereRelation('settings', 'is_swarm_worker', false)
->whereRelation('settings', 'is_build_server', $isBuildServer)
->whereRelation('settings', 'force_disabled', false);
}
public function canHostResources(): bool
{
return ! $this->isBuildServer();
}
public function settings()

View file

@ -109,6 +109,7 @@ class ServerSetting extends Model
'sentinel_token' => 'encrypted',
'is_reachable' => 'boolean',
'is_usable' => 'boolean',
'is_build_server' => 'boolean',
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
'connection_timeout' => 'integer',

View file

@ -220,6 +220,7 @@ function clone_application(Application $source, $destination, array $overrides =
'fqdn' => $url,
'status' => 'exited',
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
], $overrides));
$newApplication->save();

View file

@ -535,6 +535,17 @@ function find_destination_for_current_team(?string $uuid): StandaloneDocker|Swar
?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first();
}
function find_resource_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null
{
$destination = find_destination_for_current_team($uuid);
if (! $destination?->server?->canHostResources()) {
return null;
}
return $destination;
}
function showBoarding(): bool
{
if (isDev()) {

View file

@ -25,13 +25,13 @@
@foreach ($servers->sortBy('id') as $server)
@foreach ($server->destinations() as $destination)
<tr class="cursor-pointer hover:bg-coolgray-50 dark:hover:bg-coolgray-200"
wire:click="selectServer('{{ $server->id }}', '{{ $destination->id }}')">
wire:click="selectServer('{{ $server->id }}', '{{ $destination->uuid }}')">
<td class="px-5 py-4 text-sm whitespace-nowrap dark:text-white"
:class="'{{ $selectedDestination === $destination->id }}' ?
:class="'{{ $selectedDestination === $destination->uuid }}' ?
'bg-coollabs text-white' : 'dark:bg-coolgray-100 bg-white'">
{{ $server->name }}</td>
<td class="px-5 py-4 text-sm whitespace-nowrap dark:text-white "
:class="'{{ $selectedDestination === $destination->id }}' ?
:class="'{{ $selectedDestination === $destination->uuid }}' ?
'bg-coollabs text-white' : 'dark:bg-coolgray-100 bg-white'">
{{ $destination->name }}
</td>

View file

@ -433,28 +433,40 @@ function searchResources() {
server. <a class="underline dark:text-white" href="/servers" {{ wireNavigate() }}>
Go to servers page
</a> </div>
@else
@forelse($servers as $server)
<div class="w-full coolbox group" wire:click="setServer({{ $server }})">
<div class="flex flex-col mx-6">
<div class="box-title">
{{ $server->name }}
</div>
<div class="box-description">
{{ $server->description }}
</div>
@endif
@forelse($servers as $server)
<div class="w-full coolbox group" wire:click="setServer({{ $server }})">
<div class="flex flex-col mx-6">
<div class="box-title">
{{ $server->name }}
</div>
<div class="box-description">
{{ $server->description }}
</div>
</div>
@empty
</div>
@empty
@if ($buildServers?->isEmpty() && ! $onlyBuildServerAvailable)
<div>
<div>No validated & reachable servers found. <a class="underline dark:text-white"
href="/servers" {{ wireNavigate() }}>
Go to servers page
</a></div>
</div>
@endforelse
@endif
@endif
@endforelse
@foreach($buildServers ?? [] as $buildServer)
<div class="w-full coolbox opacity-60 cursor-not-allowed">
<div class="flex flex-col mx-6">
<div class="box-title">{{ $buildServer->name }}</div>
<div class="box-description">
This server is configured as a build server and cannot host resources.
<a class="underline dark:text-white" href="{{ route('server.show', ['server_uuid' => $buildServer->uuid]) }}"
{{ wireNavigate() }}>Change server settings</a>
</div>
</div>
</div>
@endforeach
</div>
@endif
@if ($current_step === 'destinations')

View file

@ -18,6 +18,7 @@
'destinations' => $s->destinations()->map(
fn($d) => [
'id' => $d->id,
'uuid' => $d->uuid,
'name' => $d->name,
'server_id' => $s->id,
],
@ -77,6 +78,9 @@
<template x-for="server in servers" :key="server.id">
<option :value="server.id" x-text="`${server.name} (${server.ip})`"></option>
</template>
@foreach ($buildServers as $buildServer)
<option disabled>{{ $buildServer->name }} Build server cannot host resources</option>
@endforeach
</select>
</div>
@ -84,8 +88,8 @@
<label class="block text-sm font-medium mb-2">Select Network Destination</label>
<select x-model="selectedCloneDestination" :disabled="!selectedCloneServer" class="select">
<option value="">Choose a destination...</option>
<template x-for="destination in availableDestinations" :key="destination.id">
<option :value="destination.id" x-text="destination.name"></option>
<template x-for="destination in availableDestinations" :key="destination.uuid">
<option :value="destination.uuid" x-text="destination.name"></option>
</template>
</select>
</div>

View file

@ -3,21 +3,28 @@
use App\Livewire\Project\Shared\ResourceOperations;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::forceCreate(['id' => 0]);
// Team A (attacker's team)
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
$this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->firstOrFail();
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
@ -30,7 +37,7 @@
// Team B (victim's team)
$this->teamB = Team::factory()->create();
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
$this->destinationB = StandaloneDocker::where('server_id', $this->serverB->id)->firstOrFail();
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
@ -40,7 +47,7 @@
test('cloneTo rejects destination belonging to another team', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $this->destinationB->id)
->call('cloneTo', $this->destinationB->uuid)
->assertHasErrors('destination_id');
// Ensure no cross-tenant application was created
@ -48,12 +55,16 @@
});
test('cloneTo allows destination belonging to own team', function () {
$secondDestination = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
$secondDestination = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'network' => 'second-destination',
]);
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $secondDestination->id)
->assertHasNoErrors('destination_id')
->assertRedirect();
->call('cloneTo', $secondDestination->uuid)
->assertHasNoErrors('destination_id');
expect(Application::count())->toBe(2);
});
test('moveTo rejects environment belonging to another team', function () {

View file

@ -0,0 +1,440 @@
<?php
use App\Livewire\Project\CloneMe;
use App\Livewire\Project\New\Select as ResourceSelect;
use App\Livewire\Project\New\SimpleDockerfile;
use App\Livewire\Project\Shared\ResourceOperations;
use App\Livewire\Server\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Blade;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('resource-hosting-test', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => false,
'is_swarm_worker' => false,
'force_disabled' => false,
]);
$this->server->refresh()->load('settings');
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
afterEach(function () {
Server::flushIdentityMap();
});
function resourceHostingApiHeaders(string $token): array
{
return [
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
];
}
function createResourceHostingTestApplication(object $test): Application
{
return Application::factory()->create([
'environment_id' => $test->environment->id,
'destination_id' => $test->destination->id,
'destination_type' => $test->destination->getMorphClass(),
]);
}
test('only eligible deployment servers can host resources', function () {
expect($this->server->canHostResources())->toBeTrue();
$this->server->settings->update(['is_build_server' => true]);
expect($this->server->fresh()->canHostResources())->toBeFalse();
});
test('a populated build server can be changed back to a deployment server', function () {
createResourceHostingTestApplication($this);
$this->server->settings()->update(['is_build_server' => true]);
Livewire::actingAs($this->user)
->test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSet('isBuildServer', true)
->assertSet('isBuildServerLocked', false)
->set('isBuildServer', false)
->assertHasNoErrors();
expect((bool) $this->server->settings->fresh()->is_build_server)->toBeFalse();
});
test('a populated deployment server cannot be changed into a build server', function () {
createResourceHostingTestApplication($this);
Livewire::actingAs($this->user)
->test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSet('isBuildServer', false)
->assertSet('isBuildServerLocked', true)
->set('isBuildServer', true)
->assertSet('isBuildServer', false);
expect((bool) $this->server->settings->fresh()->is_build_server)->toBeFalse();
});
test('resource selection keeps excluded build servers visible for explanation', function () {
$this->actingAs($this->user);
$buildServer = Server::factory()->create(['team_id' => $this->team->id]);
$buildServer->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
'is_swarm_worker' => false,
'force_disabled' => false,
]);
$component = new ResourceSelect;
$component->loadServers();
expect($component->servers->pluck('id'))->toContain($this->server->id)
->not->toContain($buildServer->id)
->and(Server::isUsableBuildServer()->pluck('id'))->toContain($buildServer->id)
->not->toContain($this->server->id)
->and($component->buildServers->pluck('id'))->toContain($buildServer->id)
->and($component->allServers->pluck('id'))->toContain($this->server->id, $buildServer->id);
});
test('resource selection does not show the empty server message when only build servers are available', function () {
$html = Blade::render(
file_get_contents(resource_path('views/livewire/project/new/select.blade.php')),
[
'current_step' => 'servers',
'onlyBuildServerAvailable' => true,
'servers' => collect(),
'buildServers' => collect(),
],
);
expect($html)
->toContain('Only build servers are available')
->not->toContain('No validated & reachable servers found');
});
test('application API rejects build servers', function () {
$this->server->settings()->update(['is_build_server' => true]);
$this->withHeaders(resourceHostingApiHeaders($this->bearerToken))
->postJson('/api/v1/applications/dockerimage', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'docker_registry_image_name' => 'nginx',
'docker_registry_image_tag' => 'latest',
'ports_exposes' => '80',
'instant_deploy' => false,
])
->assertUnprocessable()
->assertInvalid(['server_uuid']);
expect(Application::count())->toBe(0);
});
test('database API rejects build servers', function () {
$this->server->settings()->update(['is_build_server' => true]);
$this->withHeaders(resourceHostingApiHeaders($this->bearerToken))
->postJson('/api/v1/databases/postgresql', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'instant_deploy' => false,
])
->assertUnprocessable()
->assertInvalid(['server_uuid']);
});
test('service API rejects build servers', function () {
$this->server->settings()->update(['is_build_server' => true]);
$this->withHeaders(resourceHostingApiHeaders($this->bearerToken))
->postJson('/api/v1/services', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'docker_compose_raw' => base64_encode("services:\n app:\n image: nginx:latest"),
'instant_deploy' => false,
])
->assertUnprocessable()
->assertInvalid(['server_uuid']);
});
test('server API rejects enabling build mode when resources exist', function () {
createResourceHostingTestApplication($this);
$originalName = $this->server->name;
$this->withHeaders(resourceHostingApiHeaders($this->bearerToken))
->patchJson('/api/v1/servers/'.$this->server->uuid, [
'is_build_server' => true,
'name' => 'should-not-be-saved',
])
->assertUnprocessable()
->assertInvalid(['is_build_server']);
expect((bool) $this->server->settings->fresh()->is_build_server)->toBeFalse()
->and($this->server->fresh()->name)->toBe($originalName);
});
test('server API allows keeping build mode enabled when resources exist', function () {
createResourceHostingTestApplication($this);
$this->server->settings()->update(['is_build_server' => true]);
$this->withHeaders(resourceHostingApiHeaders($this->bearerToken))
->patchJson('/api/v1/servers/'.$this->server->uuid, [
'is_build_server' => true,
])
->assertCreated();
expect((bool) $this->server->settings->fresh()->is_build_server)->toBeTrue();
});
test('server API allows disabling build mode when resources exist', function () {
createResourceHostingTestApplication($this);
$this->server->settings()->update(['is_build_server' => true]);
$this->withHeaders(resourceHostingApiHeaders($this->bearerToken))
->patchJson('/api/v1/servers/'.$this->server->uuid, [
'is_build_server' => false,
'name' => 'Deployment Server',
])
->assertCreated();
expect((bool) $this->server->settings->fresh()->is_build_server)->toBeFalse()
->and($this->server->fresh()->name)->toBe('Deployment Server');
});
test('resource APIs still accept deployment servers', function () {
$headers = resourceHostingApiHeaders($this->bearerToken);
$this->withHeaders($headers)
->postJson('/api/v1/applications/dockerimage', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'docker_registry_image_name' => 'nginx',
'docker_registry_image_tag' => 'latest',
'ports_exposes' => '80',
'instant_deploy' => false,
])
->assertCreated();
$this->withHeaders($headers)
->postJson('/api/v1/databases/postgresql', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'instant_deploy' => false,
])
->assertCreated();
$this->withHeaders($headers)
->postJson('/api/v1/services', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'docker_compose_raw' => base64_encode("services:\n app:\n image: nginx:latest"),
'instant_deploy' => false,
])
->assertCreated();
});
test('a crafted web request cannot create a resource on a build server', function () {
$this->actingAs($this->user);
$this->server->settings()->update(['is_build_server' => true]);
$url = route('project.resource.create', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
]).'?type=postgresql&destination='.$this->destination->uuid.'&server_id='.$this->server->id.'&database_image=postgres:16-alpine';
$this->get($url)->assertRedirectToRoute('dashboard');
expect(StandalonePostgresql::count())->toBe(0);
});
test('a manipulated resource form cannot submit to a build server', function () {
$this->actingAs($this->user);
$this->server->settings()->update(['is_build_server' => true]);
$routeParameters = [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
];
expect(fn () => Livewire::withUrlParams(['destination' => $this->destination->uuid])
->test(SimpleDockerfile::class, $routeParameters)
->set('dockerfile', "FROM nginx\nCMD [\"nginx\"]\n")
->call('submit'))
->toThrow(Exception::class, 'Destination not found.');
expect(Application::count())->toBe(0);
});
test('a manipulated project clone cannot target a build server', function () {
$this->actingAs($this->user);
$this->server->settings()->update(['is_build_server' => true]);
$projectCount = Project::count();
Livewire::test(CloneMe::class, [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('selectedDestination', $this->destination->uuid)
->set('newName', 'blocked-clone')
->call('clone', 'project');
expect(Project::count())->toBe($projectCount);
});
test('project clone resolves overlapping destination ids by uuid', function () {
$this->actingAs($this->user);
createResourceHostingTestApplication($this);
$swarmDestination = SwarmDocker::create([
'server_id' => $this->server->id,
'name' => 'Swarm destination',
'network' => 'swarm-network',
]);
expect($swarmDestination->id)->toBe($this->destination->id);
Livewire::test(CloneMe::class, [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('selectedDestination', $swarmDestination->uuid)
->set('newName', 'swarm-clone')
->call('clone', 'project')
->assertNotDispatched('error');
$clonedApplication = Project::where('name', 'swarm-clone')
->firstOrFail()
->environments()
->where('name', $this->environment->name)
->firstOrFail()
->applications()
->firstOrFail();
expect($clonedApplication->destination_id)->toBe($swarmDestination->id)
->and($clonedApplication->destination_type)->toBe(SwarmDocker::class)
->and($clonedApplication->destination->is($swarmDestination))->toBeTrue();
});
test('project clone assigns services to the selected server', function () {
$this->actingAs($this->user);
Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$targetServer = Server::factory()->create(['team_id' => $this->team->id]);
$targetServer->settings()->update(['is_build_server' => false]);
$targetDestination = StandaloneDocker::where('server_id', $targetServer->id)->firstOrFail();
Livewire::test(CloneMe::class, [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('selectedDestination', $targetDestination->uuid)
->set('newName', 'service-clone')
->call('clone', 'project')
->assertNotDispatched('error');
$clonedService = Project::where('name', 'service-clone')
->firstOrFail()
->environments()
->where('name', $this->environment->name)
->firstOrFail()
->services()
->firstOrFail();
expect($clonedService->server_id)->toBe($targetServer->id)
->and($clonedService->destination_id)->toBe($targetDestination->id)
->and($targetServer->fresh()->isEmpty())->toBeFalse();
});
test('a manipulated clone request cannot target a build server', function () {
$this->actingAs($this->user);
$source = createResourceHostingTestApplication($this);
$buildServer = Server::factory()->create(['team_id' => $this->team->id]);
$buildServer->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
]);
$buildDestination = StandaloneDocker::where('server_id', $buildServer->id)->firstOrFail();
Livewire::test(ResourceOperations::class, ['resource' => $source])
->call('cloneTo', $buildDestination->uuid)
->assertHasErrors(['destination_id']);
expect(Application::count())->toBe(1);
});
test('resource clone resolves overlapping destination ids by uuid', function () {
$this->actingAs($this->user);
$source = createResourceHostingTestApplication($this);
$swarmDestination = SwarmDocker::create([
'server_id' => $this->server->id,
'name' => 'Swarm clone target',
'network' => 'swarm-clone-target',
]);
expect($swarmDestination->id)->toBe($this->destination->id);
Livewire::test(ResourceOperations::class, ['resource' => $source])
->call('cloneTo', $swarmDestination->uuid);
$clone = Application::whereKeyNot($source->id)->firstOrFail();
expect($clone->destination_id)->toBe($swarmDestination->id)
->and($clone->destination_type)->toBe(SwarmDocker::class)
->and($clone->destination->is($swarmDestination))->toBeTrue();
});
test('resource operations explains why build servers cannot be clone targets', function () {
$this->actingAs($this->user);
$source = createResourceHostingTestApplication($this);
$buildServer = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'Dedicated Builder']);
$buildServer->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
]);
Livewire::test(ResourceOperations::class, ['resource' => $source])
->assertSet('buildServers', fn ($servers) => $servers->contains('id', $buildServer->id))
->assertSee('Dedicated Builder')
->assertSee('Build server — cannot host resources');
});