diff --git a/app/Exceptions/AutoDeploymentException.php b/app/Exceptions/AutoDeploymentException.php new file mode 100644 index 000000000..109fe1096 --- /dev/null +++ b/app/Exceptions/AutoDeploymentException.php @@ -0,0 +1,30 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Pterodactyl\Exceptions; + +class AutoDeploymentException extends \Exception +{ + // +} diff --git a/app/Http/Controllers/Admin/ServersController.php b/app/Http/Controllers/Admin/ServersController.php index 10c3b7d29..281152273 100644 --- a/app/Http/Controllers/Admin/ServersController.php +++ b/app/Http/Controllers/Admin/ServersController.php @@ -34,6 +34,7 @@ use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Repositories\ServerRepository; use Pterodactyl\Repositories\DatabaseRepository; +use Pterodactyl\Exceptions\AutoDeploymentException; use Pterodactyl\Exceptions\DisplayValidationException; class ServersController extends Controller @@ -97,6 +98,8 @@ class ServersController extends Controller return redirect()->route('admin.servers.new')->withErrors(json_decode($ex->getMessage()))->withInput(); } catch (DisplayException $ex) { Alert::danger($ex->getMessage())->flash(); + } catch (AutoDeploymentException $ex) { + Alert::danger('Auto-Deployment Exception: ' . $ex->getMessage())->flash(); } catch (TransferException $ex) { Log::warning($ex); Alert::danger('A TransferException was encountered while trying to contact the daemon, please ensure it is online and accessible. This error has been logged.')->flash(); diff --git a/app/Repositories/ServerRepository.php b/app/Repositories/ServerRepository.php index d3e4b0dfa..f8523d48b 100644 --- a/app/Repositories/ServerRepository.php +++ b/app/Repositories/ServerRepository.php @@ -27,8 +27,16 @@ namespace Pterodactyl\Repositories; use DB; use Crypt; use Validator; -use Pterodactyl\Models; +use Pterodactyl\Models\Pack; +use Pterodactyl\Models\User; +use Pterodactyl\Models\Node; +use Pterodactyl\Models\Server; +use Pterodactyl\Models\Service; +use Pterodactyl\Models\Allocation; use Pterodactyl\Services\UuidService; +use Pterodactyl\Models\ServiceOption; +use Pterodactyl\Models\ServerVariable; +use Pterodactyl\Models\ServiceVariable; use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\TransferException; use Pterodactyl\Services\DeploymentService; @@ -80,12 +88,11 @@ class ServerRepository * @return \Pterodactyl\Models\Server * * @throws \Pterodactyl\Exceptions\DisplayException + * @throws \Pterodactyl\Exceptions\AutoDeploymentException * @throws \Pterodactyl\Exceptions\DisplayValidationException */ public function create(array $data) { - - // Validate Fields $validator = Validator::make($data, [ 'user_id' => 'required|exists:users,id', 'name' => 'required|regex:/^([\w .-]{1,200})$/', @@ -124,25 +131,27 @@ class ServerRepository throw new DisplayValidationException(json_encode($validator->errors())); } - $user = Models\User::findOrFail($data['user_id']); + $user = User::findOrFail($data['user_id']); - $autoDeployed = false; - if (isset($data['auto_deploy']) && $data['auto_deploy']) { - // This is an auto-deployment situation - // Ignore any other passed node data - unset($data['node_id'], $data['allocation_id']); + $deployment = false; + if (isset($data['auto_deploy'])) { + $deployment = new DeploymentService; - $autoDeployed = true; - $node = DeploymentService::smartRandomNode($data['memory'], $data['disk'], $data['location_id']); - $allocation = DeploymentService::randomAllocation($node->id); - } else { - $node = Models\Node::findOrFail($data['node_id']); + if (isset($data['location_id'])) { + $deployment->setLocation($data['location_id']); + } + + $deployment->setMemory($data['memory'])->setDisk($data['disk'])->select(); } + $node = (! $deployment) ? Node::findOrFail($data['node_id']) : $deployment->node(); + // Verify IP & Port are a.) free and b.) assigned to the node. // We know the node exists because of 'exists:nodes,id' in the validation - if (! $autoDeployed) { - $allocation = Models\Allocation::where('id', $data['allocation_id'])->where('node_id', $data['node_id'])->whereNull('server_id')->first(); + if (! $deployment) { + $allocation = Allocation::where('id', $data['allocation_id'])->where('node_id', $data['node_id'])->whereNull('server_id')->first(); + } else { + $allocation = $deployment->allocation(); } // Something failed in the query, either that combo doesn't exist, or it is in use. @@ -154,7 +163,7 @@ class ServerRepository // We know the service and option exists because of the validation. // We need to verify that the option exists for the service, and then check for // any required variable fields. (fields are labeled env_) - $option = Models\ServiceOption::where('id', $data['option_id'])->where('service_id', $data['service_id'])->first(); + $option = ServiceOption::where('id', $data['option_id'])->where('service_id', $data['service_id'])->first(); if (! $option) { throw new DisplayException('The requested service option does not exist for the specified service.'); } @@ -163,17 +172,17 @@ class ServerRepository if (! isset($data['pack_id']) || (int) $data['pack_id'] < 1) { $data['pack_id'] = null; } else { - $pack = Models\Pack::where('id', $data['pack_id'])->where('option_id', $data['option_id'])->first(); + $pack = Pack::where('id', $data['pack_id'])->where('option_id', $data['option_id'])->first(); if (! $pack) { throw new DisplayException('The requested service pack does not seem to exist for this combination.'); } } // Load up the Service Information - $service = Models\Service::find($option->service_id); + $service = Service::find($option->service_id); // Check those Variables - $variables = Models\ServiceVariable::where('option_id', $data['option_id'])->get(); + $variables = ServiceVariable::where('option_id', $data['option_id'])->get(); $variableList = []; if ($variables) { foreach ($variables as $variable) { @@ -206,9 +215,9 @@ class ServerRepository } // Check Overallocation - if (! $autoDeployed) { + if (! $deployment) { if (is_numeric($node->memory_overallocate) || is_numeric($node->disk_overallocate)) { - $totals = Models\Server::select(DB::raw('SUM(memory) as memory, SUM(disk) as disk'))->where('node_id', $node->id)->first(); + $totals = Server::select(DB::raw('SUM(memory) as memory, SUM(disk) as disk'))->where('node_id', $node->id)->first(); // Check memory limits if (is_numeric($node->memory_overallocate)) { @@ -236,7 +245,7 @@ class ServerRepository $uuid = new UuidService; // Add Server to the Database - $server = new Models\Server; + $server = new Server; $genUuid = $uuid->generate('servers', 'uuid'); $genShortUuid = $uuid->generateShort('servers', 'uuidShort', $genUuid); @@ -278,7 +287,7 @@ class ServerRepository // Add Additional Allocations if (isset($data['allocation_additional']) && is_array($data['allocation_additional'])) { foreach ($data['allocation_additional'] as $allocation) { - $model = Models\Allocation::where('id', $allocation)->where('node_id', $data['node_id'])->whereNull('server_id')->first(); + $model = Allocation::where('id', $allocation)->where('node_id', $data['node_id'])->whereNull('server_id')->first(); if (! $model) { continue; } @@ -296,7 +305,7 @@ class ServerRepository foreach ($variableList as $item) { $environmentVariables[$item['env']] = $item['val']; - Models\ServerVariable::create([ + ServerVariable::create([ 'server_id' => $server->id, 'variable_id' => $item['id'], 'variable_value' => $item['val'], @@ -379,7 +388,7 @@ class ServerRepository DB::beginTransaction(); try { - $server = Models\Server::with('user')->findOrFail($id); + $server = Server::with('user')->findOrFail($id); // Update daemon secret if it was passed. if (isset($data['reset_token']) || (isset($data['owner_id']) && (int) $data['owner_id'] !== $server->user->id)) { @@ -445,7 +454,7 @@ class ServerRepository DB::beginTransaction(); try { - $server = Models\Server::findOrFail($id); + $server = Server::findOrFail($id); $server->image = $data['docker_image']; $server->save(); @@ -502,7 +511,7 @@ class ServerRepository DB::beginTransaction(); try { - $server = Models\Server::with('allocation', 'allocations')->findOrFail($id); + $server = Server::with('allocation', 'allocations')->findOrFail($id); $newBuild = []; $newAllocations = []; @@ -525,7 +534,7 @@ class ServerRepository // Add Assignments if (isset($data['add_allocations'])) { foreach ($data['add_allocations'] as $allocation) { - $model = Models\Allocation::where('id', $allocation)->whereNull('server_id')->first(); + $model = Allocation::where('id', $allocation)->whereNull('server_id')->first(); if (! $model) { continue; } @@ -555,7 +564,7 @@ class ServerRepository } $newPorts = true; - Models\Allocation::where('id', $allocation)->where('server_id', $server->id)->update([ + Allocation::where('id', $allocation)->where('server_id', $server->id)->update([ 'server_id' => null, ]); } @@ -637,7 +646,7 @@ class ServerRepository { } - protected function processVariables(Models\Server $server, $data, $admin = false) + protected function processVariables(Server $server, $data, $admin = false) { $server->load('option.variables'); @@ -671,7 +680,7 @@ class ServerRepository )); } - $svar = Models\ServerVariable::firstOrNew([ + $svar = ServerVariable::firstOrNew([ 'server_id' => $server->id, 'variable_id' => $variable->id, ]); @@ -716,7 +725,7 @@ class ServerRepository */ public function updateStartup($id, array $data, $admin = false) { - $server = Models\Server::with('variables', 'option.variables')->findOrFail($id); + $server = Server::with('variables', 'option.variables')->findOrFail($id); $hasServiceChanges = false; if ($admin) { @@ -771,7 +780,7 @@ class ServerRepository // We know the service and option exists because of the validation. // We need to verify that the option exists for the service, and then check for // any required variable fields. (fields are labeled env_) - $option = Models\ServiceOption::where('id', $data['option_id'])->where('service_id', $data['service_id'])->first(); + $option = ServiceOption::where('id', $data['option_id'])->where('service_id', $data['service_id'])->first(); if (! $option) { throw new DisplayException('The requested service option does not exist for the specified service.'); } @@ -780,7 +789,7 @@ class ServerRepository if (! isset($data['pack_id']) || (int) $data['pack_id'] < 1) { $data['pack_id'] = null; } else { - $pack = Models\Pack::where('id', $data['pack_id'])->where('option_id', $data['option_id'])->first(); + $pack = Pack::where('id', $data['pack_id'])->where('option_id', $data['option_id'])->first(); if (! $pack) { throw new DisplayException('The requested service pack does not seem to exist for this combination.'); } @@ -833,7 +842,7 @@ class ServerRepository */ public function delete($id, $force = false) { - $server = Models\Server::with('node', 'allocations', 'variables')->findOrFail($id); + $server = Server::with('node', 'allocations', 'variables')->findOrFail($id); // Due to MySQL lockouts if the daemon response fails, we need to // delete the server from the daemon first. If it succeedes and then @@ -903,7 +912,7 @@ class ServerRepository */ public function toggleInstall($id) { - $server = Models\Server::findOrFail($id); + $server = Server::findOrFail($id); if ($server->installed > 1) { throw new DisplayException('This server was marked as having a failed install or being deleted, you cannot override this.'); } @@ -921,7 +930,7 @@ class ServerRepository */ public function toggleAccess($id, $unsuspend = true) { - $server = Models\Server::with('node')->findOrFail($id); + $server = Server::with('node')->findOrFail($id); DB::transaction(function () use ($server, $unsuspend) { if ( @@ -952,7 +961,7 @@ class ServerRepository */ public function updateSFTPPassword($id, $password) { - $server = Models\Server::with('node')->findOrFail($id); + $server = Server::with('node')->findOrFail($id); $validator = Validator::make(['password' => $password], [ 'password' => 'required|regex:/^((?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,})$/', @@ -983,7 +992,7 @@ class ServerRepository */ public function reinstall($id) { - $server = Models\Server::with('node')->findOrFail($id); + $server = Server::with('node')->findOrFail($id); DB::transaction(function () use ($server) { $server->installed = 0; diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php index b0befe8c0..e9cca35a0 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -25,114 +25,230 @@ namespace Pterodactyl\Services; use DB; -use Pterodactyl\Models; -use Pterodactyl\Exceptions\DisplayException; +use Pterodactyl\Models\Node; +use Pterodactyl\Models\Server; +use Pterodactyl\Models\Location; +use Pterodactyl\Exceptions\AutoDeploymentException; class DeploymentService { /** - * Return a random location model. DO NOT USE. + * Eloquent model representing the allocation to use. * - * @return \Pterodactyl\Models\Node - * @todo Actually make this smarter. If we're selecting a random location - * but then it has no nodes we should probably continue attempting all locations - * until we hit one. + * @var \Pterodactyl\Models\Allocation */ - public static function randomLocation() + protected $allocation; + + /** + * Amount of disk to be used by the server. + * + * @var int + */ + protected $disk; + + /** + * Amount of memory to be used by the sever. + * + * @var int + */ + protected $memory; + + /** + * Eloquent model representing the location to use. + * + * @var \Pterodactyl\Models\Location + */ + protected $location; + + /** + * Eloquent model representing the node to use. + * + * @var \Pterodactyl\Models\Node + */ + protected $node; + + /** + * Set the location to use when auto-deploying. + * + * @param int|\Pterodactyl\Models\Location $location + * @return void + */ + public function setLocation($location) { - return Models\Location::inRandomOrder()->first(); + $this->location = ($location instanceof Location) ? $location : Location::with('nodes')->findOrFail($location); + if (! $this->location->relationLoaded('nodes')) { + $this->location->load('nodes'); + } + + if (count($this->location->nodes) < 1) { + throw new AutoDeploymentException('The location provided does not contain any nodes and cannot be used.'); + } + + return $this; + } + + /** + * Set the node to use when auto-deploying. + * + * @param int|\Pterodactyl\Models\Node $node + * @return void + */ + public function setNode($node) + { + $this->node = ($node instanceof Node) ? $node : Node::findOrFail($node); + if (! $this->node->relationLoaded('allocations')) { + $this->node->load('allocations'); + } + + $this->setLocation($this->node->location); + + return $this; + } + + /** + * Set the amount of disk space to be used by the new server. + * + * @param int $disk + * @return void + */ + public function setDisk(int $disk) + { + $this->disk = $disk; + + return $this; + } + + /** + * Set the amount of memory to be used by the new server. + * + * @param int $memory + * @return void + */ + public function setMemory(int $memory) + { + $this->memory = $memory; + + return $this; + } + + /** + * Return a random location model. + * + * @param array $exclude + * @return void; + */ + protected function findLocation(array $exclude = []) + { + $location = Location::with('nodes')->whereNotIn('id', $exclude)->inRandomOrder()->first(); + + if (! $location) { + throw new AutoDeploymentException('Unable to locate a suitable location to select a node from.'); + } + + if (count($location->nodes) < 1) { + return $this->findLocation(array_merge($exclude, [$location->id])); + } + + $this->setLocation($location); } /** * Return a model instance of a random node. - * @param int $location - * @param array $not - * @return \Pterodactyl\Models\Node * - * @throws \Pterodactyl\Exceptions\DisplayException + * @return void; */ - public static function randomNode($location, array $not = []) + protected function findNode(array $exclude = []) { - $useLocation = Models\Location::where('id', $location)->first(); - if (! $useLocation) { - throw new DisplayException('The location passed was not valid and could not be found.'); + if (! $this->location) { + $this->setLocation($this->findLocation()); } - $node = Models\Node::where('location_id', $useLocation->id)->where('public', 1)->whereNotIn('id', $not)->inRandomOrder()->first(); - if (! $node) { - throw new DisplayException("Unable to find a node in location {$useLocation->short} (id: {$useLocation->id}) that is available and has space."); + $select = $this->location->nodes->whereNotIn('id', $exclude); + if (count($select) < 1) { + throw new AutoDeploymentException('Unable to find a suitable node within the assigned location with enough space.'); } - return $node; + // Check usage, select new node if necessary + $this->setNode($select->random()); + if (! $this->checkNodeUsage()) { + return $this->findNode(array_merge($exclude, [$this->node()->id])); + } } /** - * Selects a random node ensuring it does not put the node - * over allocation limits. - * @param int $memory - * @param int $disk - * @param null|int $location - * @return \Pterodactyl\Models\Node + * Checks that a node's allocation limits will not be passed + * with the assigned limits. * - * @throws \Pterodactyl\Exceptions\DisplayException + * @return bool */ - public static function smartRandomNode($memory, $disk, $location = null) + protected function checkNodeUsage() { - $node = self::randomNode($location); - $notIn = []; - do { - $return = self::checkNodeAllocation($node, $memory, $disk); - if (! $return) { - $notIn = array_merge($notIn, [ - $node->id, - ]); - $node = self::randomNode($location, $notIn); - } - } while (! $return); - - return $node; - } - - /** - * Returns a random allocation for a node. - * @param int $node - * @return \Models\Pterodactyl\Allocation - */ - public static function randomAllocation($node) - { - $allocation = Models\Allocation::where('node_id', $node)->whereNull('server_id')->inRandomOrder()->first(); - if (! $allocation) { - throw new DisplayException('No available allocation could be found for the assigned node.'); + if (! $this->disk && ! $this->memory) { + return true; } - return $allocation; + $totals = Server::select(DB::raw('SUM(memory) as memory, SUM(disk) as disk'))->where('node_id', $this->node()->id)->first(); + + if ($this->memory) { + $limit = ($this->node()->memory * (1 + ($this->node()->memory_overallocate / 100))); + + if (($totals->memory + $this->memory) > $limit) { + return false; + } + } + + if ($this->disk) { + $limit = ($this->node()->disk * (1 + ($this->node()->disk_overallocate / 100))); + + if (($totals->disk + $this->disk) > $limit) { + return false; + } + } + + return true; } /** - * Checks that a node's allocation limits will not be passed with the given information. - * @param \Pterodactyl\Models\Node $node - * @param int $memory - * @param int $disk - * @return bool Returns true if this information would not put the node over it's limit. + * Return the assigned node for this auto-deployment. + * + * @return \Pterodactyl\Models\Node */ - protected static function checkNodeAllocation(Models\Node $node, $memory, $disk) - { - if (is_numeric($node->memory_overallocate) || is_numeric($node->disk_overallocate)) { - $totals = Models\Server::select(DB::raw('SUM(memory) as memory, SUM(disk) as disk'))->where('node_id', $node->id)->first(); + public function node() { + return $this->node; + } - // Check memory limits - if (is_numeric($node->memory_overallocate)) { - $limit = ($node->memory * (1 + ($node->memory_overallocate / 100))); - $memoryLimitReached = (($totals->memory + $memory) > $limit); - } + /** + * Return the assigned location for this auto-deployment. + * + * @return \Pterodactyl\Models\Location + */ + public function location() { + return $this->location; + } - // Check Disk Limits - if (is_numeric($node->disk_overallocate)) { - $limit = ($node->disk * (1 + ($node->disk_overallocate / 100))); - $diskLimitReached = (($totals->disk + $disk) > $limit); - } + /** + * Return the assigned location for this auto-deployment. + * + * @return \Pterodactyl\Models\Allocation + */ + public function allocation() { + return $this->allocation; + } - return ! $diskLimitReached && ! $memoryLimitReached; + /** + * Select and return the node to be used by the auto-deployment system. + * + * @return void + */ + public function select() { + if (! $this->node) { + $this->findNode(); + } + + // Set the Allocation + $this->allocation = $this->node()->allocations->where('server_id', null)->random(); + if (! $this->allocation) { + throw new AutoDeploymentException('Unable to find a suitable allocation to assign to this server.'); } } }