fix(resources): clarify build server hosting restrictions (#10961)
This commit is contained in:
parent
7d699818e8
commit
913d033c75
25 changed files with 621 additions and 70 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
440
tests/Feature/ResourceHostingServerTest.php
Normal file
440
tests/Feature/ResourceHostingServerTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue