From 5ed164e13ea57644b87114403ca2c3a5dfcfd275 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 28 Jan 2018 17:14:14 -0600 Subject: [PATCH] Implement server creation though the API. Also implements auto-deployment to specific locations and ports. --- .../AllocationRepositoryInterface.php | 22 +++ .../Repository/NodeRepositoryInterface.php | 12 ++ .../NoViableAllocationException.php | 9 + .../Deployment/NoViableNodeException.php | 9 + .../Controllers/Admin/ServersController.php | 7 +- .../Application/Servers/ServerController.php | 40 +++- .../Servers/StoreServerRequest.php | 148 +++++++++++++++ app/Models/Objects/DeploymentObject.php | 78 ++++++++ app/Models/Server.php | 7 +- .../Eloquent/AllocationRepository.php | 78 ++++++++ app/Repositories/Eloquent/NodeRepository.php | 25 +++ .../Allocations/AssignmentService.php | 2 +- .../Deployment/AllocationSelectionService.php | 123 ++++++++++++ .../Deployment/FindViableNodesService.php | 121 ++++++++++++ .../Servers/ServerCreationService.php | 179 +++++++++++++----- .../Servers/ServerDeletionService.php | 15 +- .../Servers/VariableValidatorService.php | 38 ++-- app/Traits/Services/HasUserLevels.php | 3 + resources/lang/en/exceptions.php | 4 + .../pterodactyl/admin/servers/new.blade.php | 8 +- routes/api-application.php | 1 + .../Servers/ServerCreationServiceTest.php | 54 +++--- .../Servers/ServerDeletionServiceTest.php | 156 ++++++--------- .../Servers/VariableValidatorServiceTest.php | 11 +- 24 files changed, 927 insertions(+), 223 deletions(-) create mode 100644 app/Exceptions/Service/Deployment/NoViableAllocationException.php create mode 100644 app/Exceptions/Service/Deployment/NoViableNodeException.php create mode 100644 app/Http/Requests/Api/Application/Servers/StoreServerRequest.php create mode 100644 app/Models/Objects/DeploymentObject.php create mode 100644 app/Services/Deployment/AllocationSelectionService.php create mode 100644 app/Services/Deployment/FindViableNodesService.php diff --git a/app/Contracts/Repository/AllocationRepositoryInterface.php b/app/Contracts/Repository/AllocationRepositoryInterface.php index 47dfe9475..ef69965b1 100644 --- a/app/Contracts/Repository/AllocationRepositoryInterface.php +++ b/app/Contracts/Repository/AllocationRepositoryInterface.php @@ -57,4 +57,26 @@ interface AllocationRepositoryInterface extends RepositoryInterface * @return array */ public function getAssignedAllocationIds(int $server): array; + + /** + * Return a concated result set of node ips that already have at least one + * server assigned to that IP. This allows for filtering out sets for + * dedicated allocation IPs. + * + * If an array of nodes is passed the results will be limited to allocations + * in those nodes. + * + * @param array $nodes + * @return array + */ + public function getDiscardableDedicatedAllocations(array $nodes = []): array; + + /** + * Return a single allocation from those meeting the requirements. + * + * @param array $nodes + * @param array $ports + * @param bool $dedicated + * @return \Pterodactyl\Models\Allocation|null + public function getRandomAllocation(array $nodes, array $ports, bool $dedicated = false); } diff --git a/app/Contracts/Repository/NodeRepositoryInterface.php b/app/Contracts/Repository/NodeRepositoryInterface.php index 49db33be8..0ebcbe3a0 100644 --- a/app/Contracts/Repository/NodeRepositoryInterface.php +++ b/app/Contracts/Repository/NodeRepositoryInterface.php @@ -2,6 +2,7 @@ namespace Pterodactyl\Contracts\Repository; +use Generator; use Pterodactyl\Models\Node; use Illuminate\Support\Collection; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -62,4 +63,15 @@ interface NodeRepositoryInterface extends RepositoryInterface, SearchableInterfa * @return \Illuminate\Support\Collection */ public function getNodesForServerCreation(): Collection; + + /** + * Return the IDs of all nodes that exist in the provided locations and have the space + * available to support the additional disk and memory provided. + * + * @param array $locations + * @param int $disk + * @param int $memory + * @return \Generator + */ + public function getNodesWithResourceUse(array $locations, int $disk, int $memory): Generator; } diff --git a/app/Exceptions/Service/Deployment/NoViableAllocationException.php b/app/Exceptions/Service/Deployment/NoViableAllocationException.php new file mode 100644 index 000000000..5a8871c8c --- /dev/null +++ b/app/Exceptions/Service/Deployment/NoViableAllocationException.php @@ -0,0 +1,9 @@ +service->create($request->except('_token')); + $server = $this->service->handle($request->except('_token')); $this->alert->success(trans('admin/server.alerts.server_created'))->flash(); return redirect()->route('admin.servers.view', $server->id); diff --git a/app/Http/Controllers/Api/Application/Servers/ServerController.php b/app/Http/Controllers/Api/Application/Servers/ServerController.php index 9d9a7474f..fd6d62f89 100644 --- a/app/Http/Controllers/Api/Application/Servers/ServerController.php +++ b/app/Http/Controllers/Api/Application/Servers/ServerController.php @@ -4,15 +4,23 @@ namespace Pterodactyl\Http\Controllers\Api\Application\Servers; use Illuminate\Http\Response; use Pterodactyl\Models\Server; +use Illuminate\Http\JsonResponse; +use Pterodactyl\Services\Servers\ServerCreationService; use Pterodactyl\Services\Servers\ServerDeletionService; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; use Pterodactyl\Transformers\Api\Application\ServerTransformer; use Pterodactyl\Http\Requests\Api\Application\Servers\GetServersRequest; use Pterodactyl\Http\Requests\Api\Application\Servers\ServerWriteRequest; +use Pterodactyl\Http\Requests\Api\Application\Servers\StoreServerRequest; use Pterodactyl\Http\Controllers\Api\Application\ApplicationApiController; class ServerController extends ApplicationApiController { + /** + * @var \Pterodactyl\Services\Servers\ServerCreationService + */ + private $creationService; + /** * @var \Pterodactyl\Services\Servers\ServerDeletionService */ @@ -26,13 +34,18 @@ class ServerController extends ApplicationApiController /** * ServerController constructor. * + * @param \Pterodactyl\Services\Servers\ServerCreationService $creationService * @param \Pterodactyl\Services\Servers\ServerDeletionService $deletionService * @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository */ - public function __construct(ServerDeletionService $deletionService, ServerRepositoryInterface $repository) - { + public function __construct( + ServerCreationService $creationService, + ServerDeletionService $deletionService, + ServerRepositoryInterface $repository + ) { parent::__construct(); + $this->creationService = $creationService; $this->deletionService = $deletionService; $this->repository = $repository; } @@ -52,6 +65,29 @@ class ServerController extends ApplicationApiController ->toArray(); } + /** + * Create a new server on the system. + * + * @param \Pterodactyl\Http\Requests\Api\Application\Servers\StoreServerRequest $request + * @return \Illuminate\Http\JsonResponse + * + * @throws \Illuminate\Validation\ValidationException + * @throws \Pterodactyl\Exceptions\DisplayException + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + * @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableAllocationException + * @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableNodeException + */ + public function store(StoreServerRequest $request): JsonResponse + { + $server = $this->creationService->handle($request->validated(), $request->getDeploymentObject()); + + return $this->fractal->item($server) + ->transformWith($this->getTransformer(ServerTransformer::class)) + ->respond(201); + } + /** * Show a single server transformed for the application API. * diff --git a/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php b/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php new file mode 100644 index 000000000..2c95ab52b --- /dev/null +++ b/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php @@ -0,0 +1,148 @@ + $rules['name'], + 'description' => array_merge(['nullable'], $rules['description']), + 'user' => $rules['owner_id'], + 'egg' => $rules['egg_id'], + 'pack' => $rules['pack_id'], + 'docker_image' => $rules['image'], + 'startup' => $rules['startup'], + 'environment' => 'required|array', + 'skip_scripts' => 'sometimes|boolean', + + // Resource limitations + 'limits' => 'required|array', + 'limits.memory' => $rules['memory'], + 'limits.swap' => $rules['swap'], + 'limits.disk' => $rules['disk'], + 'limits.io' => $rules['io'], + 'limits.cpu' => $rules['cpu'], + + // Automatic deployment rules + 'deploy' => 'sometimes|required|array', + 'deploy.locations' => 'array', + 'deploy.locations.*' => 'integer|min:1', + 'deploy.dedicated_ip' => 'required_with:deploy,boolean', + 'deploy.port_range' => 'array', + 'deploy.port_range.*' => 'string', + + 'start_on_completion' => 'sometimes|boolean', + ]; + } + + /** + * Normalize the data into a format that can be consumed by the service. + * + * @return array + */ + public function validated() + { + $data = parent::validated(); + + return [ + 'name' => array_get($data, 'name'), + 'description' => array_get($data, 'description'), + 'owner_id' => array_get($data, 'user'), + 'egg_id' => array_get($data, 'egg'), + 'pack_id' => array_get($data, 'pack'), + 'image' => array_get($data, 'docker_image'), + 'startup' => array_get($data, 'startup'), + 'environment' => array_get($data, 'environment'), + 'memory' => array_get($data, 'limits.memory'), + 'swap' => array_get($data, 'limits.swap'), + 'disk' => array_get($data, 'limits.disk'), + 'io' => array_get($data, 'limits.io'), + 'cpu' => array_get($data, 'limits.cpu'), + 'skip_scripts' => array_get($data, 'skip_scripts', false), + 'allocation_id' => array_get($data, 'allocation.default'), + 'allocation_additional' => array_get($data, 'allocation.additional'), + 'start_on_completion' => array_get($data, 'start_on_completion', false), + ]; + } + + /* + * Run validation after the rules above have been applied. + * + * @param \Illuminate\Contracts\Validation\Validator $validator + */ + public function withValidator(Validator $validator) + { + $validator->sometimes('allocation.default', [ + 'required', 'integer', 'bail', + Rule::exists('allocations', 'id')->where(function ($query) { + $query->where('node_id', $this->input('node_id')); + $query->whereNull('server_id'); + }), + ], function ($input) { + return ! ($input->deploy); + }); + + $validator->sometimes('allocation.additional.*', [ + 'integer', + Rule::exists('allocations', 'id')->where(function ($query) { + $query->where('node_id', $this->input('node_id')); + $query->whereNull('server_id'); + }), + ], function ($input) { + return ! ($input->deploy); + }); + + $validator->sometimes('deploy.locations', 'present', function ($input) { + return $input->deploy; + }); + + $validator->sometimes('deploy.port_range', 'present', function ($input) { + return $input->deploy; + }); + } + + /** + * Return a deployment object that can be passed to the server creation service. + * + * @return \Pterodactyl\Models\Objects\DeploymentObject|null + */ + public function getDeploymentObject() + { + if (is_null($this->input('deploy'))) { + return null; + } + + $object = new DeploymentObject; + $object->setDedicated($this->input('deploy.dedicated_ip', false)); + $object->setLocations($this->input('deploy.locations', [])); + $object->setPorts($this->input('deploy.port_range', [])); + + return $object; + } +} diff --git a/app/Models/Objects/DeploymentObject.php b/app/Models/Objects/DeploymentObject.php new file mode 100644 index 000000000..52857410f --- /dev/null +++ b/app/Models/Objects/DeploymentObject.php @@ -0,0 +1,78 @@ +dedicated; + } + + /** + * @param bool $dedicated + * @return $this + */ + public function setDedicated(bool $dedicated) + { + $this->dedicated = $dedicated; + + return $this; + } + + /** + * @return array + */ + public function getLocations(): array + { + return $this->locations; + } + + /** + * @param array $locations + * @return $this + */ + public function setLocations(array $locations) + { + $this->locations = $locations; + + return $this; + } + + /** + * @return array + */ + public function getPorts(): array + { + return $this->ports; + } + + /** + * @param array $ports + * @return $this + */ + public function setPorts(array $ports) + { + $this->ports = $ports; + + return $this; + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index 0d029cc61..ac3bd64f3 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -66,13 +66,15 @@ class Server extends Model implements CleansAttributes, ValidableContract 'allocation_id' => 'required', 'pack_id' => 'sometimes', 'skip_scripts' => 'sometimes', + 'image' => 'required', + 'startup' => 'required', ]; /** * @var array */ protected static $dataIntegrityRules = [ - 'owner_id' => 'exists:users,id', + 'owner_id' => 'integer|exists:users,id', 'name' => 'string|min:1|max:255', 'node_id' => 'exists:nodes,id', 'description' => 'string', @@ -85,8 +87,9 @@ class Server extends Model implements CleansAttributes, ValidableContract 'nest_id' => 'exists:nests,id', 'egg_id' => 'exists:eggs,id', 'pack_id' => 'nullable|numeric|min:0', - 'startup' => 'nullable|string', + 'startup' => 'string', 'skip_scripts' => 'boolean', + 'image' => 'string|max:255', ]; /** diff --git a/app/Repositories/Eloquent/AllocationRepository.php b/app/Repositories/Eloquent/AllocationRepository.php index 82fe860d8..a47134e4c 100644 --- a/app/Repositories/Eloquent/AllocationRepository.php +++ b/app/Repositories/Eloquent/AllocationRepository.php @@ -4,6 +4,7 @@ namespace Pterodactyl\Repositories\Eloquent; use Illuminate\Support\Collection; use Pterodactyl\Models\Allocation; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface; @@ -94,4 +95,81 @@ class AllocationRepository extends EloquentRepository implements AllocationRepos return $results->pluck('id')->toArray(); } + + /** + * Return a concated result set of node ips that already have at least one + * server assigned to that IP. This allows for filtering out sets for + * dedicated allocation IPs. + * + * If an array of nodes is passed the results will be limited to allocations + * in those nodes. + * + * @param array $nodes + * @return array + */ + public function getDiscardableDedicatedAllocations(array $nodes = []): array + { + $instance = $this->getBuilder()->select( + $this->getBuilder()->raw('CONCAT_WS("-", node_id, ip) as result') + ); + + if (! empty($nodes)) { + $instance->whereIn('node_id', $nodes); + } + + $results = $instance->whereNotNull('server_id') + ->groupBy($this->getBuilder()->raw('CONCAT(node_id, ip)')) + ->get(); + + return $results->pluck('result')->toArray(); + } + + /** + * Return a single allocation from those meeting the requirements. + * + * @param array $nodes + * @param array $ports + * @param bool $dedicated + * @return \Pterodactyl\Models\Allocation|null + */ + public function getRandomAllocation(array $nodes, array $ports, bool $dedicated = false) + { + $instance = $this->getBuilder()->whereNull('server_id'); + + if (! empty($nodes)) { + $instance->whereIn('node_id', $nodes); + } + + if (! empty($ports)) { + $instance->where(function (Builder $query) use ($ports) { + $whereIn = []; + foreach ($ports as $port) { + if (is_array($port)) { + $query->orWhereBetween('port', $port); + continue; + } + + $whereIn[] = $port; + } + + if (! empty($whereIn)) { + $query->orWhereIn('port', $whereIn); + } + }); + } + + // If this allocation should not be shared with any other servers get + // the data and modify the query as necessary, + if ($dedicated) { + $discard = $this->getDiscardableDedicatedAllocations($nodes); + + if (! empty($discard)) { + $instance->whereNotIn( + $this->getBuilder()->raw('CONCAT_WS("-", node_id, ip)'), $discard + ); + } + } + + return $instance->inRandomOrder()->first(); + } } diff --git a/app/Repositories/Eloquent/NodeRepository.php b/app/Repositories/Eloquent/NodeRepository.php index 61d93927e..8e0b44ca7 100644 --- a/app/Repositories/Eloquent/NodeRepository.php +++ b/app/Repositories/Eloquent/NodeRepository.php @@ -2,6 +2,7 @@ namespace Pterodactyl\Repositories\Eloquent; +use Generator; use Pterodactyl\Models\Node; use Illuminate\Support\Collection; use Pterodactyl\Repositories\Concerns\Searchable; @@ -157,4 +158,28 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa ]; })->values(); } + + /** + * Return the IDs of all nodes that exist in the provided locations and have the space + * available to support the additional disk and memory provided. + * + * @param array $locations + * @param int $disk + * @param int $memory + * @return \Generator + */ + public function getNodesWithResourceUse(array $locations, int $disk, int $memory): Generator + { + $instance = $this->getBuilder() + ->select(['nodes.id', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate']) + ->selectRaw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk') + ->join('servers', 'servers.node_id', '=', 'nodes.id') + ->where('nodes.public', 1); + + if (! empty($locations)) { + $instance->whereIn('nodes.location_id', $locations); + } + + return $instance->cursor(); + } } diff --git a/app/Services/Allocations/AssignmentService.php b/app/Services/Allocations/AssignmentService.php index 3a9cc776b..10d58ef40 100644 --- a/app/Services/Allocations/AssignmentService.php +++ b/app/Services/Allocations/AssignmentService.php @@ -70,7 +70,7 @@ class AssignmentService $this->connection->beginTransaction(); foreach (Network::parse(gethostbyname($data['allocation_ip'])) as $ip) { foreach ($data['allocation_ports'] as $port) { - if (! ctype_digit($port) && ! preg_match(self::PORT_RANGE_REGEX, $port)) { + if (! is_digit($port) && ! preg_match(self::PORT_RANGE_REGEX, $port)) { throw new DisplayException(trans('exceptions.allocations.invalid_mapping', ['port' => $port])); } diff --git a/app/Services/Deployment/AllocationSelectionService.php b/app/Services/Deployment/AllocationSelectionService.php new file mode 100644 index 000000000..633ba1f5e --- /dev/null +++ b/app/Services/Deployment/AllocationSelectionService.php @@ -0,0 +1,123 @@ +repository = $repository; + } + + /** + * Toggle if the selected allocation should be the only allocation belonging + * to the given IP address. If true an allocation will not be selected if an IP + * already has another server set to use on if its allocations. + * + * @param bool $dedicated + * @return $this + */ + public function setDedicated(bool $dedicated) + { + $this->dedicated = $dedicated; + + return $this; + } + + /** + * A list of node IDs that should be used when selecting an allocation. If empty, all + * nodes will be used to filter with. + * + * @param array $nodes + * @return $this + */ + public function setNodes(array $nodes) + { + $this->nodes = $nodes; + + return $this; + } + + /** + * An array of individual ports or port ranges to use when selecting an allocation. If + * empty, all ports will be considered when finding an allocation. If set, only ports appearing + * in the array or range will be used. + * + * @param array $ports + * @return $this + * + * @throws \Pterodactyl\Exceptions\DisplayException + */ + public function setPorts(array $ports) + { + $stored = []; + foreach ($ports as $port) { + if (is_digit($port)) { + $stored[] = $port; + } + + // Ranges are stored in the ports array as an array which can be + // better processed in the repository. + if (preg_match(AssignmentService::PORT_RANGE_REGEX, $port, $matches)) { + if (abs($matches[2] - $matches[1]) > AssignmentService::PORT_RANGE_LIMIT) { + throw new DisplayException(trans('exceptions.allocations.too_many_ports')); + } + + $stored[] = [$matches[1], $matches[2]]; + } + } + + $this->ports = $stored; + + return $this; + } + + /** + * Return a single allocation that should be used as the default allocation for a server. + * + * @return \Pterodactyl\Models\Allocation + * + * @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableAllocationException + */ + public function handle(): Allocation + { + $allocation = $this->repository->getRandomAllocation($this->nodes, $this->ports, $this->dedicated); + + if (is_null($allocation)) { + throw new NoViableAllocationException(trans('exceptions.deployment.no_viable_allocations')); + } + + return $allocation; + } +} diff --git a/app/Services/Deployment/FindViableNodesService.php b/app/Services/Deployment/FindViableNodesService.php new file mode 100644 index 000000000..973d7fc71 --- /dev/null +++ b/app/Services/Deployment/FindViableNodesService.php @@ -0,0 +1,121 @@ +repository = $repository; + } + + /** + * Set the locations that should be searched through to locate available nodes. + * + * @param array $locations + * @return $this + */ + public function setLocations(array $locations): self + { + $this->locations = $locations; + + return $this; + } + + /** + * Set the amount of disk that will be used by the server being created. Nodes will be + * filtered out if they do not have enough available free disk space for this server + * to be placed on. + * + * @param int $disk + * @return $this + */ + public function setDisk(int $disk): self + { + $this->disk = $disk; + + return $this; + } + + /** + * Set the amount of memory that this server will be using. As with disk space, nodes that + * do not have enough free memory will be filtered out. + * + * @param int $memory + * @return $this + */ + public function setMemory(int $memory): self + { + $this->memory = $memory; + + return $this; + } + + /** + * Returns an array of nodes that meet the provided requirements and can then + * be passed to the AllocationSelectionService to return a single allocation. + * + * This functionality is used for automatic deployments of servers and will + * attempt to find all nodes in the defined locations that meet the disk and + * memory availability requirements. Any nodes not meeting those requirements + * are tossed out, as are any nodes marked as non-public, meaning automatic + * deployments should not be done aganist them. + * + * @return int[] + * @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableNodeException + */ + public function handle(): array + { + Assert::integer($this->disk, 'Calls to ' . __METHOD__ . ' must have the disk space set as an integer, received %s'); + Assert::integer($this->memory, 'Calls to ' . __METHOD__ . ' must have the memory usage set as an integer, received %s'); + + $nodes = $this->repository->getNodesWithResourceUse($this->locations, $this->disk, $this->memory); + $viable = []; + + foreach ($nodes as $node) { + $memoryLimit = $node->memory * (1 + ($node->memory_overallocate / 100)); + $diskLimit = $node->disk * (1 + ($node->disk_overallocate / 100)); + + if (($node->sum_memory + $this->memory) > $memoryLimit || ($node->sum_disk + $this->disk) > $diskLimit) { + continue; + } + + $viable[] = $node->id; + } + + if (empty($viable)) { + throw new NoViableNodeException(trans('exceptions.deployment.no_viable_nodes')); + } + + return $viable; + } +} diff --git a/app/Services/Servers/ServerCreationService.php b/app/Services/Servers/ServerCreationService.php index 1859ec77a..4b2f01bd1 100644 --- a/app/Services/Servers/ServerCreationService.php +++ b/app/Services/Servers/ServerCreationService.php @@ -5,11 +5,16 @@ namespace Pterodactyl\Services\Servers; use Ramsey\Uuid\Uuid; use Pterodactyl\Models\Node; use Pterodactyl\Models\User; +use Pterodactyl\Models\Server; +use Illuminate\Support\Collection; +use Pterodactyl\Models\Allocation; use GuzzleHttp\Exception\RequestException; use Illuminate\Database\ConnectionInterface; -use Pterodactyl\Contracts\Repository\NodeRepositoryInterface; -use Pterodactyl\Contracts\Repository\UserRepositoryInterface; +use Pterodactyl\Models\Objects\DeploymentObject; +use Pterodactyl\Services\Deployment\FindViableNodesService; +use Pterodactyl\Contracts\Repository\EggRepositoryInterface; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; +use Pterodactyl\Services\Deployment\AllocationSelectionService; use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface; use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; use Pterodactyl\Contracts\Repository\ServerVariableRepositoryInterface; @@ -22,6 +27,11 @@ class ServerCreationService */ private $allocationRepository; + /** + * @var \Pterodactyl\Services\Deployment\AllocationSelectionService + */ + private $allocationSelectionService; + /** * @var \Pterodactyl\Services\Servers\ServerConfigurationStructureService */ @@ -38,9 +48,14 @@ class ServerCreationService private $daemonServerRepository; /** - * @var \Pterodactyl\Contracts\Repository\NodeRepositoryInterface + * @var \Pterodactyl\Contracts\Repository\EggRepositoryInterface */ - private $nodeRepository; + private $eggRepository; + + /** + * @var \Pterodactyl\Services\Deployment\FindViableNodesService + */ + private $findViableNodesService; /** * @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface @@ -52,11 +67,6 @@ class ServerCreationService */ private $serverVariableRepository; - /** - * @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface - */ - private $userRepository; - /** * @var \Pterodactyl\Services\Servers\VariableValidatorService */ @@ -66,60 +76,139 @@ class ServerCreationService * CreationService constructor. * * @param \Pterodactyl\Contracts\Repository\AllocationRepositoryInterface $allocationRepository + * @param \Pterodactyl\Services\Deployment\AllocationSelectionService $allocationSelectionService * @param \Illuminate\Database\ConnectionInterface $connection * @param \Pterodactyl\Contracts\Repository\Daemon\ServerRepositoryInterface $daemonServerRepository - * @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $nodeRepository + * @param \Pterodactyl\Contracts\Repository\EggRepositoryInterface $eggRepository + * @param \Pterodactyl\Services\Deployment\FindViableNodesService $findViableNodesService * @param \Pterodactyl\Services\Servers\ServerConfigurationStructureService $configurationStructureService * @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository * @param \Pterodactyl\Contracts\Repository\ServerVariableRepositoryInterface $serverVariableRepository - * @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $userRepository * @param \Pterodactyl\Services\Servers\VariableValidatorService $validatorService */ public function __construct( AllocationRepositoryInterface $allocationRepository, + AllocationSelectionService $allocationSelectionService, ConnectionInterface $connection, DaemonServerRepositoryInterface $daemonServerRepository, - NodeRepositoryInterface $nodeRepository, + EggRepositoryInterface $eggRepository, + FindViableNodesService $findViableNodesService, ServerConfigurationStructureService $configurationStructureService, ServerRepositoryInterface $repository, ServerVariableRepositoryInterface $serverVariableRepository, - UserRepositoryInterface $userRepository, VariableValidatorService $validatorService ) { + $this->allocationSelectionService = $allocationSelectionService; $this->allocationRepository = $allocationRepository; $this->configurationStructureService = $configurationStructureService; $this->connection = $connection; $this->daemonServerRepository = $daemonServerRepository; - $this->nodeRepository = $nodeRepository; + $this->eggRepository = $eggRepository; + $this->findViableNodesService = $findViableNodesService; $this->repository = $repository; $this->serverVariableRepository = $serverVariableRepository; - $this->userRepository = $userRepository; $this->validatorService = $validatorService; } /** - * Create a server on both the panel and daemon. + * Create a server on the Panel and trigger a request to the Daemon to begin the server + * creation process. * - * @param array $data - * @return mixed + * @param array $data + * @param \Pterodactyl\Models\Objects\DeploymentObject|null $deployment + * @return \Pterodactyl\Models\Server * * @throws \Pterodactyl\Exceptions\DisplayException + * @throws \Illuminate\Validation\ValidationException * @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - * @throws \Illuminate\Validation\ValidationException + * @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableNodeException + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + * @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableAllocationException */ - public function create(array $data) + public function handle(array $data, DeploymentObject $deployment = null): Server { - // @todo auto-deployment - $this->connection->beginTransaction(); - $server = $this->repository->create([ + + // If a deployment object has been passed we need to get the allocation + // that the server should use, and assign the node from that allocation. + if ($deployment instanceof DeploymentObject) { + $allocation = $this->configureDeployment($data, $deployment); + $data['allocation_id'] = $allocation->id; + $data['node_id'] = $allocation->node_id; + } + + if (is_null(array_get($data, 'nest_id'))) { + $egg = $this->eggRepository->setColumns(['id', 'nest_id'])->find(array_get($data, 'egg_id')); + $data['nest_id'] = $egg->nest_id; + } + + $eggVariableData = $this->validatorService + ->setUserLevel(User::USER_LEVEL_ADMIN) + ->handle(array_get($data, 'egg_id'), array_get($data, 'environment', [])); + + // Create the server and assign any additional allocations to it. + $server = $this->createModel($data); + $this->storeAssignedAllocations($server, $data); + $this->storeEggVariables($server, $eggVariableData); + + $structure = $this->configurationStructureService->handle($server); + + try { + $this->daemonServerRepository->setServer($server)->create($structure, [ + 'start_on_completion' => (bool) array_get($data, 'start_on_completion', false), + ]); + + $this->connection->commit(); + } catch (RequestException $exception) { + $this->connection->rollBack(); + throw new DaemonConnectionException($exception); + } + + return $server; + } + + /** + * Gets an allocation to use for automatic deployment. + * + * @param array $data + * @param \Pterodactyl\Models\Objects\DeploymentObject $deployment + * + * @return \Pterodactyl\Models\Allocation + * @throws \Pterodactyl\Exceptions\DisplayException + * @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableAllocationException + * @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableNodeException + */ + private function configureDeployment(array $data, DeploymentObject $deployment): Allocation + { + $nodes = $this->findViableNodesService->setLocations($deployment->getLocations()) + ->setDisk(array_get($data, 'disk')) + ->setMemory(array_get($data, 'memory')) + ->handle(); + + return $this->allocationSelectionService->setDedicated($deployment->isDedicated()) + ->setNodes($nodes) + ->setPorts($deployment->getPorts()) + ->handle(); + } + + /** + * Store the server in the database and return the model. + * + * @param array $data + * @return \Pterodactyl\Models\Server + * + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + */ + private function createModel(array $data): Server + { + return $this->repository->create([ 'uuid' => Uuid::uuid4()->toString(), 'uuidShort' => str_random(8), 'node_id' => array_get($data, 'node_id'), 'name' => array_get($data, 'name'), 'description' => array_get($data, 'description') ?? '', - 'skip_scripts' => isset($data['skip_scripts']), + 'skip_scripts' => array_get($data, 'skip_scripts') ?? isset($data['skip_scripts']), 'suspended' => false, 'owner_id' => array_get($data, 'owner_id'), 'memory' => array_get($data, 'memory'), @@ -134,22 +223,35 @@ class ServerCreationService 'pack_id' => (! isset($data['pack_id']) || $data['pack_id'] == 0) ? null : $data['pack_id'], 'startup' => array_get($data, 'startup'), 'daemonSecret' => str_random(Node::DAEMON_SECRET_LENGTH), - 'image' => array_get($data, 'docker_image'), + 'image' => array_get($data, 'image'), ]); + } - // Process allocations and assign them to the server in the database. + /** + * Configure the allocations assigned to this server. + * + * @param \Pterodactyl\Models\Server $server + * @param array $data + */ + private function storeAssignedAllocations(Server $server, array $data) + { $records = [$data['allocation_id']]; if (isset($data['allocation_additional']) && is_array($data['allocation_additional'])) { $records = array_merge($records, $data['allocation_additional']); } $this->allocationRepository->assignAllocationsToServer($server->id, $records); + } - // Process the passed variables and store them in the database. - $this->validatorService->setUserLevel(User::USER_LEVEL_ADMIN); - $results = $this->validatorService->handle(array_get($data, 'egg_id'), array_get($data, 'environment', [])); - - $records = $results->map(function ($result) use ($server) { + /** + * Process environment variables passed for this server and store them in the database. + * + * @param \Pterodactyl\Models\Server $server + * @param \Illuminate\Support\Collection $variables + */ + private function storeEggVariables(Server $server, Collection $variables) + { + $records = $variables->map(function ($result) use ($server) { return [ 'server_id' => $server->id, 'variable_id' => $result->id, @@ -160,20 +262,5 @@ class ServerCreationService if (! empty($records)) { $this->serverVariableRepository->insert($records); } - $structure = $this->configurationStructureService->handle($server); - - // Create the server on the daemon & commit it to the database. - $node = $this->nodeRepository->find($server->node_id); - try { - $this->daemonServerRepository->setNode($node)->create($structure, [ - 'start_on_completion' => (bool) array_get($data, 'start_on_completion', false), - ]); - $this->connection->commit(); - } catch (RequestException $exception) { - $this->connection->rollBack(); - throw new DaemonConnectionException($exception); - } - - return $server; } } diff --git a/app/Services/Servers/ServerDeletionService.php b/app/Services/Servers/ServerDeletionService.php index e6c098165..37481c27e 100644 --- a/app/Services/Servers/ServerDeletionService.php +++ b/app/Services/Servers/ServerDeletionService.php @@ -13,10 +13,10 @@ use Illuminate\Log\Writer; use Pterodactyl\Models\Server; use GuzzleHttp\Exception\RequestException; use Illuminate\Database\ConnectionInterface; -use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Services\Databases\DatabaseManagementService; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; use Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface; +use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; use Pterodactyl\Contracts\Repository\Daemon\ServerRepositoryInterface as DaemonServerRepositoryInterface; class ServerDeletionService @@ -101,28 +101,21 @@ class ServerDeletionService * @param int|\Pterodactyl\Models\Server $server * * @throws \Pterodactyl\Exceptions\DisplayException - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function handle($server) { - if (! $server instanceof Server) { - $server = $this->repository->setColumns(['id', 'node_id', 'uuid'])->find($server); - } - try { $this->daemonServerRepository->setServer($server)->delete(); } catch (RequestException $exception) { $response = $exception->getResponse(); if (is_null($response) || (! is_null($response) && $response->getStatusCode() !== 404)) { - $this->writer->warning($exception); - // If not forcing the deletion, throw an exception, otherwise just log it and // continue with server deletion process in the panel. if (! $this->force) { - throw new DisplayException(trans('admin/server.exceptions.daemon_exception', [ - 'code' => is_null($response) ? 'E_CONN_REFUSED' : $response->getStatusCode(), - ])); + throw new DaemonConnectionException($exception); + } else { + $this->writer->warning($exception); } } } diff --git a/app/Services/Servers/VariableValidatorService.php b/app/Services/Servers/VariableValidatorService.php index 31ca3728b..54183f492 100644 --- a/app/Services/Servers/VariableValidatorService.php +++ b/app/Services/Servers/VariableValidatorService.php @@ -73,29 +73,27 @@ class VariableValidatorService public function handle(int $egg, array $fields = []): Collection { $variables = $this->optionVariableRepository->findWhere([['egg_id', '=', $egg]]); - $messages = $this->validator->make([], []); - $response = $variables->map(function ($item) use ($fields, $messages) { - // Skip doing anything if user is not an admin and - // variable is not user viewable or editable. + $data = $rules = $customAttributes = []; + foreach ($variables as $variable) { + $data['environment'][$variable->env_variable] = array_get($fields, $variable->env_variable); + $rules['environment.' . $variable->env_variable] = $variable->rules; + $customAttributes['environment.' . $variable->env_variable] = trans('validation.internal.variable_value', ['env' => $variable->name]); + } + + $validator = $this->validator->make($data, $rules, [], $customAttributes); + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $response = $variables->filter(function ($item) { + // Skip doing anything if user is not an admin and variable is not user viewable or editable. if (! $this->isUserLevel(User::USER_LEVEL_ADMIN) && (! $item->user_editable || ! $item->user_viewable)) { return false; } - $v = $this->validator->make([ - 'variable_value' => array_get($fields, $item->env_variable), - ], [ - 'variable_value' => $item->rules, - ], [], [ - 'variable_value' => trans('validation.internal.variable_value', ['env' => $item->name]), - ]); - - if ($v->fails()) { - foreach ($v->getMessageBag()->all() as $message) { - $messages->getMessageBag()->add($item->env_variable, $message); - } - } - + return true; + })->map(function ($item) use ($fields) { return (object) [ 'id' => $item->id, 'key' => $item->env_variable, @@ -105,10 +103,6 @@ class VariableValidatorService return is_object($item); }); - if (! empty($messages->getMessageBag()->all())) { - throw new ValidationException($messages); - } - return $response; } } diff --git a/app/Traits/Services/HasUserLevels.php b/app/Traits/Services/HasUserLevels.php index d2d95e233..29e49e8e6 100644 --- a/app/Traits/Services/HasUserLevels.php +++ b/app/Traits/Services/HasUserLevels.php @@ -15,10 +15,13 @@ trait HasUserLevels * Set the access level for running this function. * * @param int $level + * @return $this */ public function setUserLevel(int $level) { $this->userLevel = $level; + + return $this; } /** diff --git a/resources/lang/en/exceptions.php b/resources/lang/en/exceptions.php index 373451a7b..712ad92d7 100644 --- a/resources/lang/en/exceptions.php +++ b/resources/lang/en/exceptions.php @@ -56,4 +56,8 @@ return [ 'users' => [ 'node_revocation_failed' => 'Failed to revoke keys on Node #:node. :error', ], + 'deployment' => [ + 'no_viable_nodes' => 'No nodes satisfying the requirements specified for automatic deployment could be found.', + 'no_viable_allocations' => 'No allocations satisfying the requirements for automatic deployment were found.', + ], ]; diff --git a/resources/themes/pterodactyl/admin/servers/new.blade.php b/resources/themes/pterodactyl/admin/servers/new.blade.php index bbed3de6c..bfb6760b4 100644 --- a/resources/themes/pterodactyl/admin/servers/new.blade.php +++ b/resources/themes/pterodactyl/admin/servers/new.blade.php @@ -91,12 +91,6 @@

Additional allocations to assign to this server on creation.

- @@ -202,7 +196,7 @@
- +

This is the default Docker image that will be used to run this server.

diff --git a/routes/api-application.php b/routes/api-application.php index 9649a4306..35cda6fe8 100644 --- a/routes/api-application.php +++ b/routes/api-application.php @@ -77,6 +77,7 @@ Route::group(['prefix' => '/servers'], function () { Route::patch('/{server}/details', 'Servers\ServerDetailsController@details')->name('api.application.servers.details'); Route::patch('/{server}/build', 'Servers\ServerDetailsController@build')->name('api.application.servers.build'); + Route::post('/', 'Servers\ServerController@store'); Route::post('/{server}/suspend', 'Servers\ServerManagementController@suspend')->name('api.application.servers.suspend'); Route::post('/{server}/unsuspend', 'Servers\ServerManagementController@unsuspend')->name('api.application.servers.unsuspend'); Route::post('/{server}/reinstall', 'Servers\ServerManagementController@reinstall')->name('api.application.servers.reinstall'); diff --git a/tests/Unit/Services/Servers/ServerCreationServiceTest.php b/tests/Unit/Services/Servers/ServerCreationServiceTest.php index 51ed61912..5f6c61d1a 100644 --- a/tests/Unit/Services/Servers/ServerCreationServiceTest.php +++ b/tests/Unit/Services/Servers/ServerCreationServiceTest.php @@ -9,16 +9,15 @@ use Pterodactyl\Models\User; use Tests\Traits\MocksUuids; use Pterodactyl\Models\Server; use Tests\Traits\MocksRequestException; -use GuzzleHttp\Exception\RequestException; use Illuminate\Database\ConnectionInterface; -use Pterodactyl\Exceptions\PterodactylException; use Pterodactyl\Services\Servers\ServerCreationService; use Pterodactyl\Services\Servers\VariableValidatorService; -use Pterodactyl\Contracts\Repository\NodeRepositoryInterface; +use Pterodactyl\Services\Deployment\FindViableNodesService; +use Pterodactyl\Contracts\Repository\EggRepositoryInterface; use Pterodactyl\Contracts\Repository\UserRepositoryInterface; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; +use Pterodactyl\Services\Deployment\AllocationSelectionService; use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface; -use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; use Pterodactyl\Services\Servers\ServerConfigurationStructureService; use Pterodactyl\Contracts\Repository\ServerVariableRepositoryInterface; use Pterodactyl\Contracts\Repository\Daemon\ServerRepositoryInterface as DaemonServerRepositoryInterface; @@ -35,6 +34,11 @@ class ServerCreationServiceTest extends TestCase */ private $allocationRepository; + /** + * @var \Pterodactyl\Services\Deployment\AllocationSelectionService + */ + private $allocationSelectionService; + /** * @var \Pterodactyl\Services\Servers\ServerConfigurationStructureService|\Mockery\Mock */ @@ -51,14 +55,14 @@ class ServerCreationServiceTest extends TestCase private $daemonServerRepository; /** - * @var \GuzzleHttp\Exception\RequestException|\Mockery\Mock + * @var \Pterodactyl\Contracts\Repository\EggRepositoryInterface */ - private $exception; + private $eggRepository; /** - * @var \Pterodactyl\Contracts\Repository\NodeRepositoryInterface|\Mockery\Mock + * @var \Pterodactyl\Services\Deployment\FindViableNodesService */ - private $nodeRepository; + private $findViableNodesService; /** * @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface|\Mockery\Mock @@ -88,11 +92,12 @@ class ServerCreationServiceTest extends TestCase parent::setUp(); $this->allocationRepository = m::mock(AllocationRepositoryInterface::class); + $this->allocationSelectionService = m::mock(AllocationSelectionService::class); $this->configurationStructureService = m::mock(ServerConfigurationStructureService::class); $this->connection = m::mock(ConnectionInterface::class); $this->daemonServerRepository = m::mock(DaemonServerRepositoryInterface::class); - $this->exception = m::mock(RequestException::class); - $this->nodeRepository = m::mock(NodeRepositoryInterface::class); + $this->eggRepository = m::mock(EggRepositoryInterface::class); + $this->findViableNodesService = m::mock(FindViableNodesService::class); $this->repository = m::mock(ServerRepositoryInterface::class); $this->serverVariableRepository = m::mock(ServerVariableRepositoryInterface::class); $this->userRepository = m::mock(UserRepositoryInterface::class); @@ -119,7 +124,7 @@ class ServerCreationServiceTest extends TestCase $this->allocationRepository->shouldReceive('assignAllocationsToServer')->with($model->id, [$model->allocation_id])->once()->andReturn(1); - $this->validatorService->shouldReceive('setUserLevel')->with(User::USER_LEVEL_ADMIN)->once()->andReturnNull(); + $this->validatorService->shouldReceive('setUserLevel')->with(User::USER_LEVEL_ADMIN)->once()->andReturnSelf(); $this->validatorService->shouldReceive('handle')->with($model->egg_id, [])->once()->andReturn( collect([(object) ['id' => 123, 'value' => 'var1-value']]) ); @@ -133,20 +138,19 @@ class ServerCreationServiceTest extends TestCase ])->once()->andReturn(true); $this->configurationStructureService->shouldReceive('handle')->with($model)->once()->andReturn(['test' => 'struct']); - $node = factory(Node::class)->make(); - $this->nodeRepository->shouldReceive('find')->with($model->node_id)->once()->andReturn($node); - - $this->daemonServerRepository->shouldReceive('setNode')->with($node)->once()->andReturnSelf(); + $this->daemonServerRepository->shouldReceive('setServer')->with($model)->once()->andReturnSelf(); $this->daemonServerRepository->shouldReceive('create')->with(['test' => 'struct'], ['start_on_completion' => false])->once(); $this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull(); - $response = $this->getService()->create($model->toArray()); + $response = $this->getService()->handle($model->toArray()); $this->assertSame($model, $response); } /** * Test handling of node timeout or other daemon error. + * + * @expectedException \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function testExceptionShouldBeThrownIfTheRequestFails() { @@ -159,21 +163,14 @@ class ServerCreationServiceTest extends TestCase $this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); $this->repository->shouldReceive('create')->once()->andReturn($model); $this->allocationRepository->shouldReceive('assignAllocationsToServer')->once()->andReturn(1); - $this->validatorService->shouldReceive('setUserLevel')->once()->andReturnNull(); + $this->validatorService->shouldReceive('setUserLevel')->once()->andReturnSelf(); $this->validatorService->shouldReceive('handle')->once()->andReturn(collect([])); $this->configurationStructureService->shouldReceive('handle')->once()->andReturn([]); - $node = factory(Node::class)->make(); - $this->nodeRepository->shouldReceive('find')->with($model->node_id)->once()->andReturn($node); - $this->daemonServerRepository->shouldReceive('setNode')->with($node)->once()->andThrow($this->exception); + $this->daemonServerRepository->shouldReceive('setServer')->with($model)->once()->andThrow($this->getExceptionMock()); $this->connection->shouldReceive('rollBack')->withNoArgs()->once()->andReturnNull(); - try { - $this->getService()->create($model->toArray()); - } catch (PterodactylException $exception) { - $this->assertInstanceOf(DaemonConnectionException::class, $exception); - $this->assertInstanceOf(RequestException::class, $exception->getPrevious()); - } + $this->getService()->handle($model->toArray()); } /** @@ -185,13 +182,14 @@ class ServerCreationServiceTest extends TestCase { return new ServerCreationService( $this->allocationRepository, + $this->allocationSelectionService, $this->connection, $this->daemonServerRepository, - $this->nodeRepository, + $this->eggRepository, + $this->findViableNodesService, $this->configurationStructureService, $this->repository, $this->serverVariableRepository, - $this->userRepository, $this->validatorService ); } diff --git a/tests/Unit/Services/Servers/ServerDeletionServiceTest.php b/tests/Unit/Services/Servers/ServerDeletionServiceTest.php index eb5ffc244..d93d2e985 100644 --- a/tests/Unit/Services/Servers/ServerDeletionServiceTest.php +++ b/tests/Unit/Services/Servers/ServerDeletionServiceTest.php @@ -1,23 +1,14 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ namespace Tests\Unit\Services\Servers; -use Exception; use Mockery as m; use Tests\TestCase; use Illuminate\Log\Writer; use GuzzleHttp\Psr7\Response; use Pterodactyl\Models\Server; -use GuzzleHttp\Exception\RequestException; +use Tests\Traits\MocksRequestException; use Illuminate\Database\ConnectionInterface; -use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Services\Servers\ServerDeletionService; use Pterodactyl\Services\Databases\DatabaseManagementService; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; @@ -26,50 +17,37 @@ use Pterodactyl\Contracts\Repository\Daemon\ServerRepositoryInterface as DaemonS class ServerDeletionServiceTest extends TestCase { - /** - * @var \Illuminate\Database\ConnectionInterface - */ - protected $connection; + use MocksRequestException; /** - * @var \Pterodactyl\Contracts\Repository\Daemon\ServerRepositoryInterface + * @var \Illuminate\Database\ConnectionInterface|\Mockery\Mock */ - protected $daemonServerRepository; + private $connection; /** - * @var \Pterodactyl\Services\Databases\DatabaseManagementService + * @var \Pterodactyl\Contracts\Repository\Daemon\ServerRepositoryInterface|\Mockery\Mock */ - protected $databaseManagementService; + private $daemonServerRepository; /** - * @var \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface + * @var \Pterodactyl\Services\Databases\DatabaseManagementService|\Mockery\Mock */ - protected $databaseRepository; + private $databaseManagementService; /** - * @var \GuzzleHttp\Exception\RequestException + * @var \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface|\Mockery\Mock */ - protected $exception; + private $databaseRepository; /** - * @var \Pterodactyl\Models\Server + * @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface|\Mockery\Mock */ - protected $model; + private $repository; /** - * @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface + * @var \Illuminate\Log\Writer|\Mockery\Mock */ - protected $repository; - - /** - * @var \Pterodactyl\Services\Servers\ServerDeletionService - */ - protected $service; - - /** - * @var \Illuminate\Log\Writer - */ - protected $writer; + private $writer; /** * Setup tests. @@ -82,19 +60,8 @@ class ServerDeletionServiceTest extends TestCase $this->daemonServerRepository = m::mock(DaemonServerRepositoryInterface::class); $this->databaseRepository = m::mock(DatabaseRepositoryInterface::class); $this->databaseManagementService = m::mock(DatabaseManagementService::class); - $this->exception = m::mock(RequestException::class); - $this->model = factory(Server::class)->make(); $this->repository = m::mock(ServerRepositoryInterface::class); $this->writer = m::mock(Writer::class); - - $this->service = new ServerDeletionService( - $this->connection, - $this->daemonServerRepository, - $this->databaseRepository, - $this->databaseManagementService, - $this->repository, - $this->writer - ); } /** @@ -102,7 +69,7 @@ class ServerDeletionServiceTest extends TestCase */ public function testForceParameterCanBeSet() { - $response = $this->service->withForce(true); + $response = $this->getService()->withForce(true); $this->assertInstanceOf(ServerDeletionService::class, $response); } @@ -112,20 +79,22 @@ class ServerDeletionServiceTest extends TestCase */ public function testServerCanBeDeletedWithoutForce() { - $this->daemonServerRepository->shouldReceive('setServer')->with($this->model)->once()->andReturnSelf() - ->shouldReceive('delete')->withNoArgs()->once()->andReturn(new Response); + $model = factory(Server::class)->make(); - $this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); - $this->databaseRepository->shouldReceive('setColumns')->with('id')->once()->andReturnSelf() - ->shouldReceive('findWhere')->with([ - ['server_id', '=', $this->model->id], - ])->once()->andReturn(collect([(object) ['id' => 50]])); + $this->daemonServerRepository->shouldReceive('setServer')->once()->with($model)->andReturnSelf(); + $this->daemonServerRepository->shouldReceive('delete')->once()->withNoArgs()->andReturn(new Response); - $this->databaseManagementService->shouldReceive('delete')->with(50)->once()->andReturnNull(); - $this->repository->shouldReceive('delete')->with($this->model->id)->once()->andReturn(1); - $this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull(); + $this->connection->shouldReceive('beginTransaction')->once()->withNoArgs()->andReturnNull(); + $this->databaseRepository->shouldReceive('setColumns')->once()->with('id')->andReturnSelf(); + $this->databaseRepository->shouldReceive('findWhere')->once()->with([ + ['server_id', '=', $model->id], + ])->andReturn(collect([(object) ['id' => 50]])); - $this->service->handle($this->model); + $this->databaseManagementService->shouldReceive('delete')->once()->with(50)->andReturnNull(); + $this->repository->shouldReceive('delete')->once()->with($model->id)->andReturn(1); + $this->connection->shouldReceive('commit')->once()->withNoArgs()->andReturnNull(); + + $this->getService()->handle($model); } /** @@ -133,64 +102,55 @@ class ServerDeletionServiceTest extends TestCase */ public function testServerShouldBeDeletedEvenWhenFailureOccursIfForceIsSet() { - $this->daemonServerRepository->shouldReceive('setServer')->with($this->model)->once()->andReturnSelf() - ->shouldReceive('delete')->withNoArgs()->once()->andThrow($this->exception); + $this->configureExceptionMock(); + $model = factory(Server::class)->make(); + + $this->daemonServerRepository->shouldReceive('setServer')->once()->with($model)->andReturnSelf(); + $this->daemonServerRepository->shouldReceive('delete')->once()->withNoArgs()->andThrow($this->getExceptionMock()); - $this->exception->shouldReceive('getResponse')->withNoArgs()->once()->andReturnNull(); $this->writer->shouldReceive('warning')->with($this->exception)->once()->andReturnNull(); $this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); - $this->databaseRepository->shouldReceive('setColumns')->with('id')->once()->andReturnSelf() - ->shouldReceive('findWhere')->with([ - ['server_id', '=', $this->model->id], - ])->once()->andReturn(collect([(object) ['id' => 50]])); + $this->databaseRepository->shouldReceive('setColumns')->with('id')->once()->andReturnSelf(); + $this->databaseRepository->shouldReceive('findWhere')->with([ + ['server_id', '=', $model->id], + ])->once()->andReturn(collect([(object) ['id' => 50]])); $this->databaseManagementService->shouldReceive('delete')->with(50)->once()->andReturnNull(); - $this->repository->shouldReceive('delete')->with($this->model->id)->once()->andReturn(1); + $this->repository->shouldReceive('delete')->with($model->id)->once()->andReturn(1); $this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull(); - $this->service->withForce()->handle($this->model); + $this->getService()->withForce()->handle($model); } /** * Test that an exception is thrown if a server cannot be deleted from the node and force is not set. + * + * @expectedException \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function testExceptionShouldBeThrownIfDaemonReturnsAnErrorAndForceIsNotSet() { - $this->daemonServerRepository->shouldReceive('setServer->delete')->once()->andThrow($this->exception); - $this->exception->shouldReceive('getResponse')->withNoArgs()->once()->andReturnNull(); - $this->writer->shouldReceive('warning')->with($this->exception)->once()->andReturnNull(); + $this->configureExceptionMock(); + $model = factory(Server::class)->make(); - try { - $this->service->handle($this->model); - } catch (Exception $exception) { - $this->assertInstanceOf(DisplayException::class, $exception); - $this->assertEquals(trans('admin/server.exceptions.daemon_exception', [ - 'code' => 'E_CONN_REFUSED', - ]), $exception->getMessage()); - } + $this->daemonServerRepository->shouldReceive('setServer->delete')->once()->andThrow($this->getExceptionMock()); + + $this->getService()->handle($model); } /** - * Test that an integer can be passed in place of the Server model. + * Return an instance of the class with mocked dependencies. + * + * @return \Pterodactyl\Services\Servers\ServerDeletionService */ - public function testIntegerCanBePassedInPlaceOfServerModel() + private function getService(): ServerDeletionService { - $this->repository->shouldReceive('setColumns')->with(['id', 'node_id', 'uuid'])->once()->andReturnSelf() - ->shouldReceive('find')->with($this->model->id)->once()->andReturn($this->model); - - $this->daemonServerRepository->shouldReceive('setServer')->with($this->model)->once()->andReturnSelf() - ->shouldReceive('delete')->withNoArgs()->once()->andReturn(new Response); - - $this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); - $this->databaseRepository->shouldReceive('setColumns')->with('id')->once()->andReturnSelf() - ->shouldReceive('findWhere')->with([ - ['server_id', '=', $this->model->id], - ])->once()->andReturn(collect([(object) ['id' => 50]])); - - $this->databaseManagementService->shouldReceive('delete')->with(50)->once()->andReturnNull(); - $this->repository->shouldReceive('delete')->with($this->model->id)->once()->andReturn(1); - $this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull(); - - $this->service->handle($this->model->id); + return new ServerDeletionService( + $this->connection, + $this->daemonServerRepository, + $this->databaseRepository, + $this->databaseManagementService, + $this->repository, + $this->writer + ); } } diff --git a/tests/Unit/Services/Servers/VariableValidatorServiceTest.php b/tests/Unit/Services/Servers/VariableValidatorServiceTest.php index 5f5294e53..5af49f436 100644 --- a/tests/Unit/Services/Servers/VariableValidatorServiceTest.php +++ b/tests/Unit/Services/Servers/VariableValidatorServiceTest.php @@ -128,10 +128,13 @@ class VariableValidatorServiceTest extends TestCase $messages = $exception->validator->getMessageBag()->all(); $this->assertNotEmpty($messages); - $this->assertSame(1, count($messages)); - $this->assertSame(trans('validation.required', [ - 'attribute' => trans('validation.internal.variable_value', ['env' => $variables[0]->name]), - ]), $messages[0]); + $this->assertSame(4, count($messages)); + + for ($i = 0; $i < 4; $i++) { + $this->assertSame(trans('validation.required', [ + 'attribute' => trans('validation.internal.variable_value', ['env' => $variables[$i]->name]), + ]), $messages[$i]); + } } }