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) { if (! $server) {
return response()->json(['message' => 'Server not found.'], 404); 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(); $destinations = $server->destinations();
if ($destinations->count() == 0) { if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400); 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) { if (! $server) {
return response()->json(['message' => 'Server not found.'], 404); 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(); $destinations = $server->destinations();
if ($destinations->count() == 0) { if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400); return response()->json(['message' => 'Server has no destinations.'], 400);

View file

@ -736,6 +736,13 @@ public function update_server(Request $request)
], 422); ], 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); $server->update($updateFields);
if ($request->has('is_build_server')) { if ($request->has('is_build_server')) {
$server->settings()->update([ $server->settings()->update([

View file

@ -444,6 +444,12 @@ public function create_service(Request $request)
if (! $server) { if (! $server) {
return response()->json(['message' => 'Server not found.'], 404); 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(); $destinations = $server->destinations();
if ($destinations->count() == 0) { if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400); 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)) { if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
data_set($servicePayload, 'connect_to_docker_network', true); 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->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
$service->description = $request->description; $service->description = $request->description;
if ($request->has('is_container_label_escape_enabled')) { if ($request->has('is_container_label_escape_enabled')) {
@ -639,6 +646,12 @@ public function create_service(Request $request)
if (! $server) { if (! $server) {
return response()->json(['message' => 'Server not found.'], 404); 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(); $destinations = $server->destinations();
if ($destinations->count() == 0) { if ($destinations->count() == 0) {
return response()->json(['message' => 'Server has no destinations.'], 400); 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 $selectedServer = null;
public ?int $selectedDestination = null; public ?string $selectedDestination = null;
public ?Server $server = null; public ?Server $server = null;
@ -76,9 +76,9 @@ public function render()
return view('livewire.project.clone-me'); 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->selectedServer = null;
$this->selectedDestination = null; $this->selectedDestination = null;
$this->server = null; $this->server = null;
@ -86,7 +86,7 @@ public function selectServer($server_id, $destination_id)
return; return;
} }
$this->selectedServer = $server_id; $this->selectedServer = $server_id;
$this->selectedDestination = $destination_id; $this->selectedDestination = $destination_uuid;
$this->server = $this->servers->where('id', $server_id)->first(); $this->server = $this->servers->where('id', $server_id)->first();
} }
@ -98,6 +98,10 @@ public function clone(string $type)
'selectedDestination' => 'required', 'selectedDestination' => 'required',
'newName' => ValidationPatterns::nameRules(), 'newName' => ValidationPatterns::nameRules(),
]); ]);
$selectedDestination = find_resource_destination_for_current_team($this->selectedDestination);
if (! $selectedDestination) {
throw new \Exception('Destination not found.');
}
if ($type === 'project') { if ($type === 'project') {
$foundProject = Project::where('name', $this->newName)->first(); $foundProject = Project::where('name', $this->newName)->first();
if ($foundProject) { if ($foundProject) {
@ -130,7 +134,6 @@ public function clone(string $type)
$databases = $this->environment->databases(); $databases = $this->environment->databases();
$services = $this->environment->services; $services = $this->environment->services;
foreach ($applications as $application) { foreach ($applications as $application) {
$selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations())->where('id', $this->selectedDestination)->first();
clone_application($application, $selectedDestination, [ clone_application($application, $selectedDestination, [
'environment_id' => $environment->id, 'environment_id' => $environment->id,
], $this->cloneVolumeData); ], $this->cloneVolumeData);
@ -147,7 +150,8 @@ public function clone(string $type)
'status' => 'exited', 'status' => 'exited',
'started_at' => null, 'started_at' => null,
'environment_id' => $environment->id, 'environment_id' => $environment->id,
'destination_id' => $this->selectedDestination, 'destination_id' => $selectedDestination->id,
'destination_type' => $selectedDestination->getMorphClass(),
]); ]);
$newDatabase->save(); $newDatabase->save();
@ -265,7 +269,9 @@ public function clone(string $type)
])->fill([ ])->fill([
'uuid' => $uuid, 'uuid' => $uuid,
'environment_id' => $environment->id, 'environment_id' => $environment->id,
'destination_id' => $this->selectedDestination, 'destination_id' => $selectedDestination->id,
'destination_type' => $selectedDestination->getMorphClass(),
'server_id' => $selectedDestination->server_id,
]); ]);
$newService->save(); $newService->save();

View file

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

View file

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

View file

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

View file

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

View file

@ -290,7 +290,7 @@ public function submit()
$project_uuid = $this->parameters['project_uuid']; $project_uuid = $this->parameters['project_uuid'];
$environment_uuid = $this->parameters['environment_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) { if (! $destination) {
throw new \Exception('Destination not found.'); throw new \Exception('Destination not found.');
} }
@ -336,7 +336,8 @@ public function submit()
$application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['docker_compose_location'] = $this->docker_compose_location;
$application_init['base_directory'] = $this->base_directory; $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->is_static = $this->isStatic;
$application->settings->save(); $application->settings->save();

View file

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

View file

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

View file

@ -33,7 +33,7 @@ public function mount()
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }
if (isset($type) && isset($destination_uuid)) { 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) { if (! $destination) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }
@ -96,7 +96,8 @@ public function mount()
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) { if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
data_set($service_payload, 'connect_to_docker_network', true); 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->name = "$oneClickServiceName-".$service->uuid;
$service->save(); $service->save();
if ($oneClickDotEnvs?->count() > 0) { 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); $server = Server::ownedByCurrentTeam()->findOrFail($server_id);
$network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id); $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->resource->getConnection()->transaction(function () use ($network, $server) { $this->resource->getConnection()->transaction(function () use ($network, $server) {
$main_destination = $this->resource->destination; $mainDestination = $this->resource->destination;
$this->resource->update([ $this->resource->update([
'destination_id' => $network->id, 'destination_id' => $network->id,
'destination_type' => StandaloneDocker::class, 'destination_type' => StandaloneDocker::class,
@ -128,7 +127,7 @@ public function promote(int $network_id, int $server_id)
$this->resource->additional_networks() $this->resource->additional_networks()
->wherePivot('server_id', $server->id) ->wherePivot('server_id', $server->id)
->detach($network->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->resource->refresh();
$this->refreshServers(); $this->refreshServers();

View file

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

View file

@ -202,7 +202,7 @@ public function mount(string $server_uuid)
try { try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->syncData(); $this->syncData();
if (! $this->server->isEmpty()) { if (! $this->server->isBuildServer() && ! $this->server->isEmpty()) {
$this->isBuildServerLocked = true; $this->isBuildServerLocked = true;
} }
// Load saved Hetzner status and validation state // Load saved Hetzner status and validation state
@ -409,6 +409,12 @@ public function updatedIsBuildServer($value)
{ {
try { try {
$this->authorize('update', $this->server); $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) { if ($value === true && $this->isSentinelEnabled) {
$this->isSentinelEnabled = false; $this->isSentinelEnabled = false;
$this->isMetricsEnabled = 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() public function settings()

View file

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

View file

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

View file

@ -535,6 +535,17 @@ function find_destination_for_current_team(?string $uuid): StandaloneDocker|Swar
?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first(); ?? 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 function showBoarding(): bool
{ {
if (isDev()) { if (isDev()) {

View file

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

View file

@ -433,28 +433,40 @@ function searchResources() {
server. <a class="underline dark:text-white" href="/servers" {{ wireNavigate() }}> server. <a class="underline dark:text-white" href="/servers" {{ wireNavigate() }}>
Go to servers page Go to servers page
</a> </div> </a> </div>
@else @endif
@forelse($servers as $server) @forelse($servers as $server)
<div class="w-full coolbox group" wire:click="setServer({{ $server }})"> <div class="w-full coolbox group" wire:click="setServer({{ $server }})">
<div class="flex flex-col mx-6"> <div class="flex flex-col mx-6">
<div class="box-title"> <div class="box-title">
{{ $server->name }} {{ $server->name }}
</div> </div>
<div class="box-description"> <div class="box-description">
{{ $server->description }} {{ $server->description }}
</div>
</div> </div>
</div> </div>
@empty </div>
@empty
@if ($buildServers?->isEmpty() && ! $onlyBuildServerAvailable)
<div> <div>
<div>No validated & reachable servers found. <a class="underline dark:text-white" <div>No validated & reachable servers found. <a class="underline dark:text-white"
href="/servers" {{ wireNavigate() }}> href="/servers" {{ wireNavigate() }}>
Go to servers page Go to servers page
</a></div> </a></div>
</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> </div>
@endif @endif
@if ($current_step === 'destinations') @if ($current_step === 'destinations')

View file

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

View file

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