├── .env.example ├── src ├── Events │ ├── CreatingServerEvent.php │ ├── DeletingServerEvent.php │ ├── ServerDeletedEvent.php │ ├── ServerErroredEvent.php │ ├── ServerLimitHitEvent.php │ ├── ServerStoppedEvent.php │ ├── StoppingServerEvent.php │ ├── RebootingServerEvent.php │ ├── ServerHangingEvent.php │ └── ServerRunningEvent.php ├── ServerProviders │ ├── UpCloud │ │ ├── UpCloudServerStatus.php │ │ ├── Exceptions │ │ │ ├── CannotRebootServer.php │ │ │ └── CannotGetUpCloudServerDetails.php │ │ ├── UpCloudServer.php │ │ └── UpCloudServerProvider.php │ └── ServerProvider.php ├── Exceptions │ ├── JobDoesNotExist.php │ ├── CannotStopServer.php │ ├── CannotDetermineDefaultProviderName.php │ ├── CannotRebootServer.php │ ├── InvalidProvider.php │ ├── CannotStartServer.php │ ├── InvalidAction.php │ ├── ProviderDoesNotExist.php │ └── ServerTypeDoesNotExist.php ├── Actions │ ├── GenerateServerNameAction.php │ ├── FindServersToStopAction.php │ ├── StopServerAction.php │ ├── RebootServerAction.php │ └── StartServerAction.php ├── Facades │ └── DynamicServers.php ├── Enums │ └── ServerStatus.php ├── Jobs │ ├── StopServerJob.php │ ├── CreateServerJob.php │ ├── RebootServerJob.php │ ├── DynamicServerJob.php │ ├── DeleteServerJob.php │ ├── VerifyServerDeletedJob.php │ ├── VerifyServerRebootedJob.php │ ├── VerifyServerStartedJob.php │ └── VerifyServerStoppedJob.php ├── Support │ ├── ServerTypes │ │ ├── ServerTypes.php │ │ └── ServerType.php │ ├── Config.php │ └── DynamicServersManager.php ├── Commands │ ├── MonitorDynamicServersCommand.php │ ├── DetectHangingServersCommand.php │ └── ListDynamicServersCommand.php ├── DynamicServersServiceProvider.php └── Models │ └── Server.php ├── database ├── migrations │ └── create_dynamic_servers_table.php └── factories │ └── ServerFactory.php ├── LICENSE.md ├── resources └── stubs │ └── DynamicServersProvider.php.stub ├── composer.json ├── config └── dynamic-servers.php ├── CHANGELOG.md └── README.md /.env.example: -------------------------------------------------------------------------------- 1 | UP_CLOUD_USER_NAME= 2 | UP_CLOUD_PASSWORD= 3 | UP_CLOUD_DISK_IMAGE_UUID= 4 | -------------------------------------------------------------------------------- /src/Events/CreatingServerEvent.php: -------------------------------------------------------------------------------- 1 | type}-{$server->id}"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Events/ServerHangingEvent.php: -------------------------------------------------------------------------------- 1 | id} because it has status `{$server->status->value}`"); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Exceptions/CannotDetermineDefaultProviderName.php: -------------------------------------------------------------------------------- 1 | id} because it has status `{$server->status->value}`. Only running servers can be rebooted"); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidProvider.php: -------------------------------------------------------------------------------- 1 | id} has an invalid provider `{$server->provider}`. Make sure you have configured a provider with that name in the config file."); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Exceptions/CannotStartServer.php: -------------------------------------------------------------------------------- 1 | id} because it has status `{$server->status->value}`"); 13 | } 14 | 15 | public static function limitHit(): self 16 | { 17 | return new self("Could not start a dynamic server because we've hit the maximum number."); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/ServerProviders/UpCloud/Exceptions/CannotRebootServer.php: -------------------------------------------------------------------------------- 1 | json('error.error_message'); 16 | 17 | return new self("Could not reboot server for UpCloud server id {$server->id}: $reason"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/ServerProviders/UpCloud/Exceptions/CannotGetUpCloudServerDetails.php: -------------------------------------------------------------------------------- 1 | json('error.error_message'); 16 | 17 | return new self("Could refresh details for UpCloud server id {$server->id}: $reason"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Actions/FindServersToStopAction.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public function execute(int $numberOfServersToStop, string $type): Collection 15 | { 16 | return Server::query() 17 | ->where('status', ServerStatus::Running) 18 | ->where('type', $type) 19 | ->status(ServerStatus::Running) 20 | ->limit($numberOfServersToStop) 21 | ->get(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidAction.php: -------------------------------------------------------------------------------- 1 | getMessage()}", previous: $exception); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Enums/ServerStatus.php: -------------------------------------------------------------------------------- 1 | map(function (string $name) { 14 | return "`{$name}`"; 15 | }) 16 | ->join(', ', ' and '); 17 | 18 | return new self("There is no provider registered with name `{$providerName}`. Available providers are: {$availableNames}. You can register providers in the `providers` key of the `dynamic_servers` config file."); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Exceptions/ServerTypeDoesNotExist.php: -------------------------------------------------------------------------------- 1 | map(function (string $name) { 14 | return "`{$name}`"; 15 | }) 16 | ->join(', ', ' and '); 17 | 18 | return new self("There is no server type registered with name `{$serverTypeName}`. Available names are: {$availableNames}. You can register more using `DynamicServers::registerServerType()"); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Actions/StopServerAction.php: -------------------------------------------------------------------------------- 1 | status !== ServerStatus::Running) { 16 | throw CannotStopServer::wrongStatus($server); 17 | } 18 | 19 | /** @var class-string $stopServerJobClass */ 20 | $stopServerJobClass = Config::dynamicServerJobClass('stop_server'); 21 | 22 | dispatch(new $stopServerJobClass($server)); 23 | 24 | $server->markAs(ServerStatus::Stopping); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/ServerProviders/ServerProvider.php: -------------------------------------------------------------------------------- 1 | server = $server; 14 | 15 | return $this; 16 | } 17 | 18 | abstract public function createServer(): void; 19 | 20 | abstract public function hasStarted(): bool; 21 | 22 | abstract public function stopServer(): void; 23 | 24 | abstract public function hasStopped(): bool; 25 | 26 | abstract public function deleteServer(): void; 27 | 28 | abstract public function hasBeenDeleted(): bool; 29 | 30 | abstract public function rebootServer(): void; 31 | 32 | abstract public function currentServerCount(): int; 33 | } 34 | -------------------------------------------------------------------------------- /src/Jobs/StopServerJob.php: -------------------------------------------------------------------------------- 1 | server->serverProvider()->stopServer(); 15 | } catch (Exception $exception) { 16 | $this->server->markAsErrored($exception); 17 | 18 | report($exception); 19 | 20 | return; 21 | } 22 | 23 | event(new StoppingServerEvent($this->server)); 24 | 25 | /** @var class-string $verifyServerStoppedJob */ 26 | $verifyServerStoppedJob = Config::dynamicServerJobClass('verify_server_stopped'); 27 | 28 | dispatch(new $verifyServerStoppedJob($this->server)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Jobs/CreateServerJob.php: -------------------------------------------------------------------------------- 1 | server->serverProvider()->createServer(); 15 | } catch (Exception $exception) { 16 | $this->server->markAsErrored($exception); 17 | 18 | report($exception); 19 | 20 | return; 21 | } 22 | 23 | event(new CreatingServerEvent($this->server)); 24 | 25 | /** @var class-string $verifyServerStartedJob */ 26 | $verifyServerStartedJob = Config::dynamicServerJobClass('verify_server_started'); 27 | 28 | dispatch(new $verifyServerStartedJob($this->server)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Jobs/RebootServerJob.php: -------------------------------------------------------------------------------- 1 | server->serverProvider()->rebootServer(); 15 | } catch (Exception $exception) { 16 | $this->server->markAsErrored($exception); 17 | 18 | report($exception); 19 | 20 | return; 21 | } 22 | 23 | event(new RebootingServerEvent($this->server)); 24 | 25 | /** @var class-string $verifyServerDeletedJob */ 26 | $verifyServerRebootedJob = Config::dynamicServerJobClass('verify_server_rebooted'); 27 | 28 | dispatch(new $verifyServerRebootedJob($this->server)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Jobs/DynamicServerJob.php: -------------------------------------------------------------------------------- 1 | onQueue(config('dynamic-servers.queue')); 27 | } 28 | 29 | public function uniqueId() 30 | { 31 | return $this->server->id; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Actions/RebootServerAction.php: -------------------------------------------------------------------------------- 1 | status !== ServerStatus::Running) { 16 | throw CannotRebootServer::wrongStatus($server); 17 | } 18 | 19 | /** @var class-string $rebootServerJobClass */ 20 | $rebootServerJobClass = Config::dynamicServerJobClass('reboot_server'); 21 | 22 | dispatch(new $rebootServerJobClass($server)); 23 | 24 | $server->update([ 25 | 'reboot_requested_at' => null, 26 | ]); 27 | 28 | $server->markAs(ServerStatus::Rebooting); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Jobs/DeleteServerJob.php: -------------------------------------------------------------------------------- 1 | server->serverProvider()->deleteServer(); 16 | 17 | $this->server->markAs(ServerStatus::Deleting); 18 | } catch (Exception $exception) { 19 | $this->server->markAsErrored($exception); 20 | 21 | report($exception); 22 | 23 | return; 24 | } 25 | 26 | event(new DeletingServerEvent($this->server)); 27 | 28 | /** @var class-string $verifyServerDeletedJob */ 29 | $verifyServerDeletedJob = Config::dynamicServerJobClass('verify_server_deleted'); 30 | 31 | dispatch(new $verifyServerDeletedJob($this->server)); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Support/ServerTypes/ServerTypes.php: -------------------------------------------------------------------------------- 1 | */ 11 | protected Collection $serverTypes; 12 | 13 | public function __construct() 14 | { 15 | $this->serverTypes = collect(); 16 | } 17 | 18 | public function register(ServerType $serverType): self 19 | { 20 | $this->serverTypes->put($serverType->name, $serverType); 21 | 22 | return $this; 23 | } 24 | 25 | public function find(string $serverTypeName): ServerType 26 | { 27 | if (! $this->serverTypes->has($serverTypeName)) { 28 | throw ServerTypeDoesNotExist::make($serverTypeName); 29 | } 30 | 31 | return $this->serverTypes->get($serverTypeName); 32 | } 33 | 34 | public function allNames(): array 35 | { 36 | return $this->serverTypes->keys()->toArray(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /database/migrations/create_dynamic_servers_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | 14 | $table->string('name'); 15 | $table->string('type')->nullable(); 16 | $table->json('configuration')->nullable(); 17 | $table->string('provider')->nullable(); 18 | $table->string('status'); 19 | $table->timestamp('status_updated_at')->nullable(); 20 | $table->timestamp('reboot_requested_at')->nullable(); 21 | $table->json('meta'); 22 | $table->text('exception_class')->nullable(); 23 | $table->text('exception_message')->nullable(); 24 | $table->text('exception_trace')->nullable(); 25 | 26 | $table->timestamps(); 27 | }); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/Jobs/VerifyServerDeletedJob.php: -------------------------------------------------------------------------------- 1 | server->isProbablyHanging()) { 15 | $this->server->markAsHanging(); 16 | 17 | return; 18 | } 19 | 20 | if ($this->server->serverProvider()->hasBeenDeleted()) { 21 | $this->server->markAs(ServerStatus::Deleted); 22 | 23 | event(new ServerDeletedEvent($this->server)); 24 | 25 | return; 26 | } 27 | 28 | $this->release(20); 29 | } catch (Exception $exception) { 30 | $this->server->markAsErrored($exception); 31 | 32 | report($exception); 33 | } 34 | } 35 | 36 | public function retryUntil() 37 | { 38 | return now()->addMinutes(10); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ServerProviders/UpCloud/UpCloudServer.php: -------------------------------------------------------------------------------- 1 | where('access', 'public') 18 | ->where('family', 'IPv4') 19 | ->first()['address'] ?? ''; 20 | 21 | return new self( 22 | $payload['uuid'], 23 | $payload['title'], 24 | $ip, 25 | UpCloudServerStatus::from($payload['state']), 26 | ); 27 | } 28 | 29 | public function toArray(): array 30 | { 31 | return [ 32 | 'uuid' => $this->uuid, 33 | 'title' => $this->title, 34 | 'ip' => $this->ip, 35 | 'status' => $this->status->value, 36 | ]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) spatie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Jobs/VerifyServerRebootedJob.php: -------------------------------------------------------------------------------- 1 | server->isProbablyHanging()) { 15 | $this->server->markAsHanging(); 16 | 17 | return; 18 | } 19 | 20 | if ($this->server->serverProvider()->hasStarted()) { 21 | $previousStatus = $this->server->status; 22 | 23 | $this->server->markAs(ServerStatus::Running); 24 | 25 | event(new ServerRunningEvent($this->server, $previousStatus)); 26 | 27 | if ($this->server->rebootRequested()) { 28 | $this->server->reboot(); 29 | 30 | return; 31 | } 32 | 33 | return; 34 | } 35 | 36 | $this->release(20); 37 | } catch (Exception $exception) { 38 | $this->server->markAsErrored($exception); 39 | 40 | report($exception); 41 | } 42 | } 43 | 44 | public function retryUntil() 45 | { 46 | return now()->addMinutes(10); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Jobs/VerifyServerStartedJob.php: -------------------------------------------------------------------------------- 1 | server->isProbablyHanging()) { 15 | $this->server->markAsHanging(); 16 | 17 | return; 18 | } 19 | 20 | if ($this->server->serverProvider()->hasStarted()) { 21 | $previousStatus = $this->server->status; 22 | 23 | $this->server->markAs(ServerStatus::Running); 24 | 25 | event(new ServerRunningEvent($this->server, $previousStatus)); 26 | 27 | if ($this->server->rebootRequested()) { 28 | $this->server->reboot(); 29 | 30 | return; 31 | } 32 | 33 | return; 34 | } 35 | 36 | $this->release(20); 37 | } catch (Exception $exception) { 38 | $this->server->markAsErrored($exception); 39 | 40 | report($exception); 41 | } 42 | } 43 | 44 | public function retryUntil() 45 | { 46 | return now()->addMinutes(10); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Jobs/VerifyServerStoppedJob.php: -------------------------------------------------------------------------------- 1 | server->isProbablyHanging()) { 16 | $this->server->markAsHanging(); 17 | 18 | return; 19 | } 20 | 21 | if ($this->server->serverProvider()->hasStopped()) { 22 | $this->server->markAs(ServerStatus::Stopped); 23 | event(new ServerStoppedEvent($this->server)); 24 | 25 | /** @var class-string $deleteServerJob */ 26 | $deleteServerJob = Config::dynamicServerJobClass('delete_server'); 27 | 28 | dispatch(new $deleteServerJob($this->server)); 29 | 30 | return; 31 | } 32 | 33 | $this->release(20); 34 | } catch (Exception $exception) { 35 | $this->server->markAsErrored($exception); 36 | 37 | report($exception); 38 | } 39 | } 40 | 41 | public function retryUntil() 42 | { 43 | return now()->addMinutes(10); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Commands/MonitorDynamicServersCommand.php: -------------------------------------------------------------------------------- 1 | info('Determining new dynamic server count...'); 16 | 17 | $initialServerCounts = Server::countPerStatus(); 18 | 19 | DynamicServers::monitor(); 20 | 21 | $currentServerCounts = Server::countPerStatus(); 22 | 23 | $this->summarizeDifference($initialServerCounts, $currentServerCounts); 24 | 25 | return self::SUCCESS; 26 | } 27 | 28 | protected function summarizeDifference(array $initialCounts, array $currentCounts): self 29 | { 30 | $differences = collect($initialCounts) 31 | ->map(fn (int $count, string $status) => $currentCounts[$status] - $count) 32 | ->reject(fn (int $count) => $count < 1); 33 | 34 | if ($differences->isEmpty()) { 35 | $this->components->info('No servers started or stopped'); 36 | 37 | return $this; 38 | } 39 | 40 | $differences->each(function (int $count, string $status) { 41 | $this->components->twoColumnDetail($status, (string) $count); 42 | }); 43 | 44 | return $this; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Commands/DetectHangingServersCommand.php: -------------------------------------------------------------------------------- 1 | info('Detecting hanging servers...'); 17 | 18 | $thresholdInMinutes = config('dynamic-servers.mark_server_as_hanging_after_minutes'); 19 | 20 | /** @var \Illuminate\Support\Collection $hangingServers */ 21 | $hangingServers = Server::query() 22 | ->status( 23 | ServerStatus::New, 24 | ServerStatus::Starting, 25 | ServerStatus::Stopping, 26 | ) 27 | 28 | ->where('status_updated_at', '<=', now()->subMinutes($thresholdInMinutes)) 29 | ->get(); 30 | 31 | if ($hangingServers->isEmpty()) { 32 | $this->components->info('No hanging servers detected'); 33 | 34 | return self::SUCCESS; 35 | } 36 | 37 | $this->components->warn("Detected {$hangingServers->count()} hanging ".Str::plural('server', $hangingServers->count())); 38 | 39 | $hangingServers->each(function (Server $server) { 40 | $server->markAsHanging(); 41 | }); 42 | 43 | return self::SUCCESS; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Commands/ListDynamicServersCommand.php: -------------------------------------------------------------------------------- 1 | line(''); 16 | 17 | $headers = [ 18 | 'Name', 19 | 'Provider', 20 | 'Type', 21 | 'Status', 22 | 'Status updated at', 23 | ]; 24 | 25 | $rows = Server::query() 26 | ->whereNot('status', ServerStatus::Stopped) 27 | ->get(); 28 | 29 | if ($rows->isEmpty()) { 30 | $this->components->info('No dynamic servers found...'); 31 | 32 | return self::SUCCESS; 33 | } 34 | 35 | $rows = $rows 36 | ->map(function (Server $server) { 37 | return [ 38 | 'name' => $server->name, 39 | 'provider' => $server->provider, 40 | 'type' => $server->type, 41 | 'status' => $server->status->value, 42 | 'status_updated_at' => $server->status_updated_at?->format('Y-m-d H:i:s') ?? 'Unknown', 43 | ]; 44 | }) 45 | ->all(); 46 | 47 | $this->table($headers, $rows); 48 | 49 | $this->line(''); 50 | 51 | return self::SUCCESS; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Support/ServerTypes/ServerType.php: -------------------------------------------------------------------------------- 1 | name = $name; 34 | 35 | $this->providerName = $providerName ?? Config::defaultProviderName(); 36 | 37 | $this->configuration = $configuration ?? []; 38 | } 39 | 40 | public function provider(string $providerName): self 41 | { 42 | if (! in_array($providerName, Config::providerNames())) { 43 | throw ProviderDoesNotExist::make($providerName); 44 | } 45 | 46 | $this->providerName = $providerName; 47 | 48 | return $this; 49 | } 50 | 51 | public function configuration(array|Closure $configuration): self 52 | { 53 | $this->configuration = $configuration; 54 | 55 | return $this; 56 | } 57 | 58 | public function getConfiguration(Server $server): array 59 | { 60 | $configuration = $this->configuration; 61 | 62 | if (is_callable($configuration)) { 63 | $configuration = $configuration($server); 64 | } 65 | 66 | return $configuration; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /resources/stubs/DynamicServersProvider.php.stub: -------------------------------------------------------------------------------- 1 | provider('up_cloud') 18 | ->configuration(function (Server $server) { 19 | return [ 20 | 'server' => [ 21 | 'zone' => 'de-fra1', 22 | 'title' => $server->name, 23 | 'hostname' => Str::slug($server->name), 24 | 'plan' => '2xCPU-4GB', 25 | 'storage_devices' => [ 26 | 'storage_device' => [ 27 | [ 28 | 'action' => 'clone', 29 | 'storage' => $server->option('disk_image'), 30 | 'title' => Str::slug($server->name) . '-disk', 31 | 'tier' => 'maxiops', 32 | ], 33 | ], 34 | ], 35 | ], 36 | ]; 37 | }); 38 | 39 | DynamicServers::registerServerType($serverType); 40 | 41 | DynamicServers::determineServerCount(function (DynamicServersManager $servers) { 42 | /* 43 | * Add logic here to determine how many servers you need. 44 | * 45 | $numberOfServerNeeded = ...; 46 | 47 | $servers->ensure($numberOfServerNeeded); 48 | */ 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Support/Config.php: -------------------------------------------------------------------------------- 1 | allowedToStartServer($server)) { 18 | $server->delete(); 19 | 20 | return; 21 | } 22 | 23 | if ($server->status !== ServerStatus::New) { 24 | throw CannotStartServer::wrongStatus($server); 25 | } 26 | 27 | /** @var class-string $createServerJobClass */ 28 | $createServerJobClass = Config::dynamicServerJobClass('create_server'); 29 | 30 | dispatch(new $createServerJobClass($server)); 31 | 32 | $server->markAs(ServerStatus::Starting); 33 | } 34 | 35 | protected function allowedToStartServer(Server $server): bool 36 | { 37 | $amountOfServers = $this->getCurrentServerCount($server); 38 | 39 | $limit = config("dynamic-servers.providers.{$server->provider}.maximum_servers_in_account", 20); 40 | 41 | if ($amountOfServers < $limit) { 42 | return true; 43 | } 44 | 45 | event(new ServerLimitHitEvent($server)); 46 | 47 | if (config('dynamic-servers.throw_exception_when_hitting_maximum_server_limit')) { 48 | throw CannotStartServer::limitHit(); 49 | } 50 | 51 | return false; 52 | } 53 | 54 | protected function getCurrentServerCount(Server $server): int 55 | { 56 | $cacheKey = "server-provider-{$server->provider}-server-count"; 57 | 58 | $count = Cache::get($cacheKey, $server->serverProvider()->currentServerCount()) + 1; 59 | 60 | Cache::put($cacheKey, $count, 60); 61 | 62 | return $count; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /database/factories/ServerFactory.php: -------------------------------------------------------------------------------- 1 | 'server-name', 22 | 'type' => $serverType->name, 23 | 'provider' => $serverType->providerName, 24 | 'status' => ServerStatus::New, 25 | 'meta' => [], 26 | ]; 27 | } 28 | 29 | public function running(): Factory 30 | { 31 | return $this->state(function (array $attributes) { 32 | return [ 33 | 'status' => ServerStatus::Running->value, 34 | ]; 35 | }); 36 | } 37 | 38 | public function stopped(): Factory 39 | { 40 | return $this->state(function (array $attributes) { 41 | return [ 42 | 'status' => ServerStatus::Stopped->value, 43 | ]; 44 | }); 45 | } 46 | 47 | public function starting(): Factory 48 | { 49 | return $this->state(function (array $attributes) { 50 | return [ 51 | 'status' => ServerStatus::Starting->value, 52 | ]; 53 | }); 54 | } 55 | 56 | public function rebooting(): Factory 57 | { 58 | return $this->state(function (array $attributes) { 59 | return [ 60 | 'status' => ServerStatus::Rebooting->value, 61 | ]; 62 | }); 63 | } 64 | 65 | public function stopping(): Factory 66 | { 67 | return $this->state(function (array $attributes) { 68 | return [ 69 | 'status' => ServerStatus::Stopping->value, 70 | ]; 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/laravel-dynamic-servers", 3 | "description": "Dynamically create and destroy servers", 4 | "keywords": [ 5 | "spatie", 6 | "laravel", 7 | "laravel-dynamic-servers" 8 | ], 9 | "homepage": "https://github.com/spatie/laravel-dynamic-servers", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Rias Van der Veken", 14 | "email": "rias@spatie.be", 15 | "role": "Developer" 16 | }, 17 | { 18 | "name": "Freek Van der Herten", 19 | "email": "freek@spatie.be", 20 | "role": "Developer" 21 | } 22 | ], 23 | "require": { 24 | "php": "^8.2", 25 | "illuminate/contracts": "^10.0|^11.0", 26 | "illuminate/http": "^10.0|^11.0", 27 | "spatie/laravel-package-tools": "^1.13.3" 28 | }, 29 | "require-dev": { 30 | "guzzlehttp/guzzle": "^7.5", 31 | "nunomaduro/collision": "^7.0|^8.0", 32 | "nunomaduro/larastan": "^2.2", 33 | "orchestra/testbench": "^8.0|^9.0", 34 | "pestphp/pest": "^v2.34", 35 | "pestphp/pest-plugin-laravel": "^2.4", 36 | "phpunit/phpunit": "^9.5|^10.5|^11.0", 37 | "spatie/pest-plugin-test-time": "^1.1.1|^2.0", 38 | "vlucas/phpdotenv": "^5.4.1" 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "Spatie\\DynamicServers\\": "src", 43 | "Spatie\\DynamicServers\\Database\\Factories\\": "database/factories" 44 | } 45 | }, 46 | "autoload-dev": { 47 | "psr-4": { 48 | "Spatie\\DynamicServers\\Tests\\": "tests" 49 | } 50 | }, 51 | "scripts": { 52 | "analyse": "vendor/bin/phpstan analyse", 53 | "test": "vendor/bin/pest", 54 | "test-coverage": "vendor/bin/pest --coverage", 55 | "format": "vendor/bin/pint" 56 | }, 57 | "config": { 58 | "sort-packages": true, 59 | "allow-plugins": { 60 | "pestphp/pest-plugin": true, 61 | "phpstan/extension-installer": true 62 | } 63 | }, 64 | "extra": { 65 | "laravel": { 66 | "providers": [ 67 | "Spatie\\DynamicServers\\DynamicServersServiceProvider" 68 | ] 69 | }, 70 | "aliases": { 71 | "DynamicServers": "Spatie\\DynamicServers\\Facades\\DynamicServers" 72 | } 73 | }, 74 | "minimum-stability": "dev", 75 | "prefer-stable": true 76 | } 77 | -------------------------------------------------------------------------------- /src/DynamicServersServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-dynamic-servers') 22 | ->hasConfigFile() 23 | ->hasMigration('create_dynamic_servers_table') 24 | ->publishesServiceProvider('DynamicServersProvider') 25 | ->hasCommands( 26 | DetectHangingServersCommand::class, 27 | ListDynamicServersCommand::class, 28 | MonitorDynamicServersCommand::class, 29 | ) 30 | ->hasInstallCommand(function (InstallCommand $command) { 31 | $command 32 | ->publishConfigFile() 33 | ->copyAndRegisterServiceProviderInApp() 34 | ->publishMigrations() 35 | ->askToRunMigrations() 36 | ->askToStarRepoOnGitHub('spatie/laravel-dynamic-servers') 37 | ->endWith(function (InstallCommand $installCommand) { 38 | $installCommand->line(''); 39 | $installCommand->info("We've added app\Providers\DynamicServersProvider to your project."); 40 | $installCommand->info('Feel free to customize it to your needs.'); 41 | $installCommand->line(''); 42 | $installCommand->info('You can view all docs at https://spatie.be/docs/laravel-dynamic-servers'); 43 | $installCommand->line(''); 44 | $installCommand->info('Thank you very much for installing this package!'); 45 | }); 46 | }); 47 | } 48 | 49 | public function packageRegistered() 50 | { 51 | $this->app->singleton(DynamicServersManager::class, fn () => new DynamicServersManager); 52 | $this->app->bind('dynamicServers', DynamicServersManager::class); 53 | 54 | $this->app->singleton(ServerTypes::class, fn () => new ServerTypes); 55 | 56 | $this->registerDefaultServerType(); 57 | } 58 | 59 | protected function registerDefaultServerType(): self 60 | { 61 | $defaultType = ServerType::new('default') 62 | ->provider(Config::defaultProviderName()); 63 | 64 | app(DynamicServersManager::class)->registerServerType($defaultType); 65 | 66 | return $this; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /config/dynamic-servers.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'up_cloud' => [ 6 | 'class' => Spatie\DynamicServers\ServerProviders\UpCloud\UpCloudServerProvider::class, 7 | 'maximum_servers_in_account' => 20, 8 | 'options' => [ 9 | 'username' => env('UP_CLOUD_USER_NAME'), 10 | 'password' => env('UP_CLOUD_PASSWORD'), 11 | 'disk_image' => env('UP_CLOUD_DISK_IMAGE_UUID'), 12 | ], 13 | ], 14 | ], 15 | 16 | /* 17 | * Overriding these actions will give you fine-grained control over 18 | * how we handle your servers. In most cases, it's fine to use 19 | * the defaults. 20 | */ 21 | 'actions' => [ 22 | 'generate_server_name' => Spatie\DynamicServers\Actions\GenerateServerNameAction::class, 23 | 'start_server' => Spatie\DynamicServers\Actions\StartServerAction::class, 24 | 'stop_server' => Spatie\DynamicServers\Actions\StopServerAction::class, 25 | 'find_servers_to_stop' => Spatie\DynamicServers\Actions\FindServersToStopAction::class, 26 | 'reboot_server' => Spatie\DynamicServers\Actions\RebootServerAction::class, 27 | ], 28 | 29 | /* 30 | * Overriding these jobs will give you fine-grained control over 31 | * how we create, stop, delete and reboot your servers. In most cases, 32 | * it's fine to use the defaults. 33 | */ 34 | 'jobs' => [ 35 | 'create_server' => Spatie\DynamicServers\Jobs\CreateServerJob::class, 36 | 'verify_server_started' => Spatie\DynamicServers\Jobs\VerifyServerStartedJob::class, 37 | 'stop_server' => Spatie\DynamicServers\Jobs\StopServerJob::class, 38 | 'verify_server_stopped' => Spatie\DynamicServers\Jobs\VerifyServerStoppedJob::class, 39 | 'delete_server' => Spatie\DynamicServers\Jobs\DeleteServerJob::class, 40 | 'verify_server_deleted' => Spatie\DynamicServers\Jobs\VerifyServerDeletedJob::class, 41 | 'reboot_server' => Spatie\DynamicServers\Jobs\RebootServerJob::class, 42 | 'verify_server_rebooted' => Spatie\DynamicServers\Jobs\VerifyServerRebootedJob::class, 43 | ], 44 | 45 | /** 46 | * Which queue the server jobs should be processed on 47 | * by default this will use your default queue. 48 | */ 49 | 'queue' => null, 50 | 51 | /* 52 | * When we detect that a server is taking longer than this amount of minutes 53 | * to start or stop, we'll mark it has hanging, and will not try to use it anymore 54 | * 55 | * The `ServerHangingEvent` will be fired, that you can use to send yourself a notification, 56 | * or manually take the necessary actions to start/stop it. 57 | */ 58 | 'mark_server_as_hanging_after_minutes' => 10, 59 | 60 | /* 61 | * The dynamic_servers table holds records of all dynamic servers. 62 | * 63 | * Using Laravel's prune command all stopped servers will be deleted 64 | * after the given amount of days. 65 | */ 66 | 'prune_stopped_servers_from_local_db_after_days' => 7, 67 | 68 | 'throw_exception_when_hitting_maximum_server_limit' => false, 69 | ]; 70 | -------------------------------------------------------------------------------- /src/Support/DynamicServersManager.php: -------------------------------------------------------------------------------- 1 | determineServerCountUsing = $determineServerCountUsing; 22 | 23 | return $this; 24 | } 25 | 26 | public function monitor(): self 27 | { 28 | if (is_null($this->determineServerCountUsing)) { 29 | return $this; 30 | } 31 | 32 | ($this->determineServerCountUsing)($this); 33 | 34 | return $this; 35 | } 36 | 37 | public function ensure(int $desiredCount, string $type = 'default'): self 38 | { 39 | $provisionedServerCount = Server::query() 40 | ->where('type', $type) 41 | ->provisioned() 42 | ->count(); 43 | 44 | if ($provisionedServerCount < $desiredCount) { 45 | $extraServersNeeded = $desiredCount - $provisionedServerCount; 46 | 47 | $this->increaseCount($extraServersNeeded, $type); 48 | 49 | return $this; 50 | } 51 | 52 | if ($provisionedServerCount > $desiredCount) { 53 | $lessServersNeeded = $provisionedServerCount - $desiredCount; 54 | 55 | $this->decreaseCount($lessServersNeeded, $type); 56 | 57 | return $this; 58 | } 59 | 60 | return $this; 61 | } 62 | 63 | public function increaseCount(int $count = 1, string $type = 'default'): self 64 | { 65 | foreach (range(1, $count) as $i) { 66 | Server::prepareNew($type)->start(); 67 | } 68 | 69 | return $this; 70 | } 71 | 72 | public function increase(int $by = 1, string $type = 'default'): self 73 | { 74 | return $this->increaseCount($by, $type); 75 | } 76 | 77 | public function decreaseCount(int $by = 1, string $type = 'default'): self 78 | { 79 | /** @var FindServersToStopAction $findServersToStopAction */ 80 | $findServersToStopAction = Config::action('find_servers_to_stop'); 81 | 82 | $servers = $findServersToStopAction->execute($by, $type); 83 | 84 | $servers->each(fn (Server $server) => $server->stop()); 85 | 86 | return $this; 87 | } 88 | 89 | public function decrease(int $by = 1, string $type = 'default'): self 90 | { 91 | return $this->decreaseCount($by, $type); 92 | } 93 | 94 | public function getServerType(string $serverType): ServerType 95 | { 96 | return app(ServerTypes::class)->find($serverType); 97 | } 98 | 99 | public function registerServerType(ServerType $serverType): self 100 | { 101 | app(ServerTypes::class)->register($serverType); 102 | 103 | return $this; 104 | } 105 | 106 | public function serverTypeNames(): array 107 | { 108 | return app(ServerTypes::class)->allNames(); 109 | } 110 | 111 | public function reboot(string $type = 'default') 112 | { 113 | Server::status(ServerStatus::Starting, ServerStatus::Rebooting)->update([ 114 | 'reboot_requested_at' => now(), 115 | ]); 116 | 117 | Server::status(ServerStatus::Running)->each(fn (Server $server) => $server->reboot()); 118 | } 119 | 120 | public function provisionedCount(): int 121 | { 122 | return Server::provisioned()->count(); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/ServerProviders/UpCloud/UpCloudServerProvider.php: -------------------------------------------------------------------------------- 1 | request()->post('/server', $this->server->configuration); 17 | 18 | if (! $response->successful()) { 19 | throw new Exception($response->json('error.error_message')); 20 | } 21 | 22 | $upCloudServer = UpCloudServer::fromApiPayload($response->json('server')); 23 | 24 | $this->server->addMeta('server_properties', $upCloudServer->toArray()); 25 | } 26 | 27 | public function hasStarted(): bool 28 | { 29 | $upCloudServer = $this->getServer(); 30 | 31 | return $upCloudServer->status === UpCloudServerStatus::Started; 32 | } 33 | 34 | public function stopServer(): void 35 | { 36 | $serverUuid = $this->server->meta('server_properties.uuid'); 37 | 38 | $response = $this->request()->post("/server/{$serverUuid}/stop", [ 39 | 'stop_server' => [ 40 | 'stop_type' => 'soft', 41 | 'timeout' => 60, 42 | ], 43 | ]); 44 | 45 | if (! $response->successful()) { 46 | throw new Exception($response->json('error.error_message')); 47 | } 48 | } 49 | 50 | public function hasStopped(): bool 51 | { 52 | $upCloudServer = $this->getServer(); 53 | 54 | return $upCloudServer->status === UpCloudServerStatus::Stopped; 55 | } 56 | 57 | public function deleteServer(): void 58 | { 59 | $serverUuid = $this->server->meta('server_properties.uuid'); 60 | 61 | $response = $this->request() 62 | ->delete("/server/{$serverUuid}?storages=1&backups=delete"); 63 | 64 | if (! $response->successful()) { 65 | throw new Exception($response->json('error.error_message', 'Could not delete server')); 66 | } 67 | } 68 | 69 | public function hasBeenDeleted(): bool 70 | { 71 | $serverUuid = $this->server->meta('server_properties.uuid'); 72 | 73 | $response = $this->request()->get("/server/{$serverUuid}"); 74 | 75 | return $response->failed(); 76 | } 77 | 78 | public function getServer(): UpCloudServer 79 | { 80 | $serverUuid = $this->server->meta('server_properties.uuid'); 81 | 82 | $response = $this->request()->get("/server/{$serverUuid}"); 83 | 84 | if (! $response->successful()) { 85 | throw CannotGetUpCloudServerDetails::make($this->server, $response); 86 | } 87 | 88 | return UpCloudServer::fromApiPayload($response->json('server')); 89 | } 90 | 91 | public function rebootServer(): void 92 | { 93 | $serverUuid = $this->server->meta('server_properties.uuid'); 94 | 95 | $response = $this->request()->post("/server/{$serverUuid}/restart", [ 96 | 'stop_type' => 'soft', 97 | 'timeout' => 60, 98 | 'timeout_action' => 'destroy', // Hard stop and start again after timeout 99 | ]); 100 | 101 | if (! $response->successful()) { 102 | throw CannotRebootServer::make($this->server, $response); 103 | } 104 | } 105 | 106 | public function currentServerCount(): int 107 | { 108 | $response = $this->request()->get('server'); 109 | 110 | if (! $response->successful()) { 111 | throw CannotGetUpCloudServerDetails::make($this->server, $response); 112 | } 113 | 114 | return count($response->json('servers.server')); 115 | } 116 | 117 | protected function request(): PendingRequest 118 | { 119 | return Http::withBasicAuth( 120 | $this->server->option('username'), 121 | $this->server->option('password') 122 | )->baseUrl('https://api.upcloud.com/1.3'); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-dynamic-servers` will be documented in this file. 4 | 5 | ## 1.1.0 - 2024-05-10 6 | 7 | ### What's Changed 8 | 9 | * Add support for Laravel 11 10 | * Drop support for Laravel 9 & PHP 8.1 11 | * Bump dependabot/fetch-metadata from 1.3.5 to 1.3.6 by @dependabot in https://github.com/spatie/laravel-dynamic-servers/pull/12 12 | * Documentation fix: Replace $servers with DynamicServers facade by @georgejipa in https://github.com/spatie/laravel-dynamic-servers/pull/13 13 | * Bump aglipanci/laravel-pint-action from 2.1.0 to 2.2.0 by @dependabot in https://github.com/spatie/laravel-dynamic-servers/pull/14 14 | * Bump dependabot/fetch-metadata from 1.3.6 to 1.4.0 by @dependabot in https://github.com/spatie/laravel-dynamic-servers/pull/15 15 | * Bump dependabot/fetch-metadata from 1.4.0 to 1.5.0 by @dependabot in https://github.com/spatie/laravel-dynamic-servers/pull/16 16 | * Bump aglipanci/laravel-pint-action from 2.2.0 to 2.3.0 by @dependabot in https://github.com/spatie/laravel-dynamic-servers/pull/17 17 | * Bump dependabot/fetch-metadata from 1.5.0 to 1.5.1 by @dependabot in https://github.com/spatie/laravel-dynamic-servers/pull/18 18 | * Bump dependabot/fetch-metadata from 1.5.1 to 1.6.0 by @dependabot in https://github.com/spatie/laravel-dynamic-servers/pull/19 19 | * Bump aglipanci/laravel-pint-action from 2.3.0 to 2.3.1 by @dependabot in https://github.com/spatie/laravel-dynamic-servers/pull/22 20 | * Bump aglipanci/laravel-pint-action from 2.3.1 to 2.4 by @dependabot in https://github.com/spatie/laravel-dynamic-servers/pull/24 21 | 22 | ### New Contributors 23 | 24 | * @georgejipa made their first contribution in https://github.com/spatie/laravel-dynamic-servers/pull/13 25 | 26 | **Full Changelog**: https://github.com/spatie/laravel-dynamic-servers/compare/1.0.2...1.1.0 27 | 28 | ## 1.0.2 - 2023-01-25 29 | 30 | - support L10 31 | 32 | ## 1.0.1 - 2023-01-04 33 | 34 | ### What's Changed 35 | 36 | - Fix an issue where the TTL of the server count would reset to -1 37 | - Bump dependabot/fetch-metadata from 1.3.3 to 1.3.4 by @dependabot in https://github.com/spatie/laravel-dynamic-servers/pull/9 38 | - Bump dependabot/fetch-metadata from 1.3.4 to 1.3.5 by @dependabot in https://github.com/spatie/laravel-dynamic-servers/pull/10 39 | - Bump aglipanci/laravel-pint-action from 1.0.0 to 2.1.0 by @dependabot in https://github.com/spatie/laravel-dynamic-servers/pull/11 40 | 41 | ### New Contributors 42 | 43 | - @dependabot made their first contribution in https://github.com/spatie/laravel-dynamic-servers/pull/9 44 | 45 | **Full Changelog**: https://github.com/spatie/laravel-dynamic-servers/compare/1.0.0...1.0.1 46 | 47 | ## 1.0.0 - 2022-09-26 48 | 49 | - stable release 50 | 51 | ## 0.0.7 - 2022-09-13 52 | 53 | - Fix an error when server limit was reached 54 | 55 | **Full Changelog**: https://github.com/spatie/laravel-dynamic-servers/compare/0.0.6...0.0.7 56 | 57 | ## 0.0.6 - 2022-09-13 58 | 59 | ### What's Changed 60 | 61 | - Delete pint.json by @rubenvanerk in https://github.com/spatie/laravel-dynamic-servers/pull/6 62 | - Add queue config by @riasvdv in https://github.com/spatie/laravel-dynamic-servers/pull/7 63 | 64 | ### New Contributors 65 | 66 | - @rubenvanerk made their first contribution in https://github.com/spatie/laravel-dynamic-servers/pull/6 67 | - @riasvdv made their first contribution in https://github.com/spatie/laravel-dynamic-servers/pull/7 68 | 69 | **Full Changelog**: https://github.com/spatie/laravel-dynamic-servers/compare/0.0.5...0.0.6 70 | 71 | ## 0.0.5 - 2022-09-09 72 | 73 | **Full Changelog**: https://github.com/spatie/laravel-dynamic-servers/compare/0.0.4...0.0.5 74 | 75 | ## 0.0.3 - 2022-09-07 76 | 77 | ### What's Changed 78 | 79 | - Documentation typos by @xewl in https://github.com/spatie/laravel-dynamic-servers/pull/5 80 | 81 | ### New Contributors 82 | 83 | - @xewl made their first contribution in https://github.com/spatie/laravel-dynamic-servers/pull/5 84 | 85 | **Full Changelog**: https://github.com/spatie/laravel-dynamic-servers/compare/0.0.2...0.0.3 86 | 87 | ## 0.0.2 - 2022-09-06 88 | 89 | ### What's Changed 90 | 91 | - refactor(scopes): use server scopes for dynamic servers by @tiagomichaelsousa in https://github.com/spatie/laravel-dynamic-servers/pull/1 92 | - Add env vars to testing config to make tests pass by @abenerd in https://github.com/spatie/laravel-dynamic-servers/pull/2 93 | - Fix Larastan issues and config by @abenerd in https://github.com/spatie/laravel-dynamic-servers/pull/4 94 | 95 | ### New Contributors 96 | 97 | - @tiagomichaelsousa made their first contribution in https://github.com/spatie/laravel-dynamic-servers/pull/1 98 | - @abenerd made their first contribution in https://github.com/spatie/laravel-dynamic-servers/pull/2 99 | 100 | **Full Changelog**: https://github.com/spatie/laravel-dynamic-servers/compare/0.0.1...0.0.2 101 | 102 | ## 0.0.1 - 2022-08-27 103 | 104 | - experimental release 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Logo for laravel-dynamic-servers 6 | 7 | 8 | 9 |

Dynamically create and destroy servers

10 | 11 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/laravel-dynamic-servers.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-dynamic-servers) 12 | [![GitHub Tests Action Status](https://img.shields.io/github/workflow/status/spatie/laravel-dynamic-servers/run-tests?label=tests)](https://github.com/spatie/laravel-dynamic-servers/actions?query=workflow%3Arun-tests+branch%3Amain) 13 | [![GitHub Code Style Action Status](https://img.shields.io/github/workflow/status/spatie/laravel-dynamic-servers/Fix%20PHP%20code%20style%20issues?label=code%20style)](https://github.com/spatie/laravel-dynamic-servers/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 14 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/laravel-dynamic-servers.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-dynamic-servers) 15 | 16 |
17 | 18 | This package can help you start and stop servers when you need them. The prime use case is to spin up extra working servers that can help you process the workload on queues. 19 | 20 | You can think of this as a sort of PHP based version of Kubernetes that has 5% of its features, but covers that 80% use case. For most PHP and Laravel developers, this package will also be easier to learn and use. 21 | 22 | Typically, on your hosting provider, you would prepare a server snapshot, that will be used as a template when starting 23 | new servers. 24 | 25 | After the package is configured, spinning up an extra servers is as easy as: 26 | 27 | ```php 28 | // typically, in a service provider 29 | 30 | use Laravel\Horizon\WaitTimeCalculator; 31 | use Spatie\DynamicServers\Facades\DynamicServers; 32 | use Spatie\DynamicServers\Support\DynamicServersManager; 33 | 34 | /* 35 | * The package will call the closure passed 36 | * to `determineServerCount` every minute 37 | */ 38 | DynamicServers::determineServerCount(function(DynamicServersManager $servers) { 39 | /* 40 | * First, we'll calculate the number of servers needed. 41 | * 42 | * In this example, we will take a look at Horizon's 43 | * reported waiting time. Of course, in your project you can 44 | * calculate the number of servers needed however you want. 45 | */ 46 | $waitTimeInMinutes = app(WaitTimeCalculator::class)->calculate('default'); 47 | $numberOfServersNeeded = round($waitTimeInMinutes / 10); 48 | 49 | /* 50 | * Next, we will pass the number of servers needed to the `ensure` method. 51 | * 52 | * If there currently are less that that number of servers available, 53 | * the package will start new ones. 54 | * 55 | * If there are currently more than that number of servers running, 56 | * the package will stop a few servers. 57 | */ 58 | $servers->ensure($numberOfServersNeeded); 59 | }); 60 | ``` 61 | 62 | Out of the box, the package supports [UpCloud](https://upcloud.com). You can 63 | create [your own server provider](https://spatie.be/docs/laravel-dynamic-servers/v1/advanced-usage/creating-your-own-server-provider) 64 | to add support for your favourite hosting service. 65 | 66 | ## Support us 67 | 68 | [](https://spatie.be/github-ad-click/laravel-dynamic-servers) 69 | 70 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can 71 | support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 72 | 73 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. 74 | You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards 75 | on [our virtual postcard wall](https://spatie.be/open-source/postcards). 76 | 77 | ## Documentation 78 | 79 | All documentation is available [on our documentation site](https://spatie.be/docs/laravel-dynamic-servers/). 80 | 81 | ## Testing 82 | 83 | ```bash 84 | composer test 85 | ``` 86 | 87 | ## Changelog 88 | 89 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 90 | 91 | ## Contributing 92 | 93 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 94 | 95 | ## Security Vulnerabilities 96 | 97 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 98 | 99 | ## Credits 100 | 101 | - [Rias Van der Veken](https://twitter.com/riasvdv) 102 | - [Freek Van der herten](https://twitter.com/freekmurze) 103 | - [All Contributors](../../contributors) 104 | 105 | This idea behind this package was... spawned 🥁 by streams and a blog post by Jason McCreary on [Spawning workers based on queue workload](https://jasonmccreary.me/articles/spawing-worker-servers-job-queue-load-laravel/). 106 | 107 | ## License 108 | 109 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 110 | -------------------------------------------------------------------------------- /src/Models/Server.php: -------------------------------------------------------------------------------- 1 | 'array', 38 | 'status_updated_at' => 'datetime', 39 | 'status' => ServerStatus::class, 40 | 'meta' => AsArrayObject::class, 41 | ]; 42 | 43 | public static function booted() 44 | { 45 | self::creating(function (self $server) { 46 | if ($server->status === null) { 47 | $server->status = ServerStatus::New; 48 | $server->status_updated_at = now(); 49 | } 50 | 51 | if (empty($server->meta)) { 52 | $server->meta = new ArrayObject; 53 | } 54 | }); 55 | 56 | self::created(function (self $server) { 57 | if ($server->name === 'pending-server-name') { 58 | $server->name = $server->generateName(); 59 | } 60 | 61 | if (empty($configuration)) { 62 | $server->configuration = $server->serverType()->getConfiguration($server); 63 | } 64 | 65 | $server->saveQuietly(); 66 | }); 67 | } 68 | 69 | public function serverType(): ServerType 70 | { 71 | return DynamicServers::getServerType($this->type); 72 | } 73 | 74 | public static function prepareNew(string $type = 'default', ?string $name = null): Server 75 | { 76 | /** @var ServerType $serverType */ 77 | $serverType = DynamicServers::getServerType($type); 78 | 79 | return Server::create([ 80 | 'name' => $name ?? 'pending-server-name', 81 | 'type' => $type, 82 | 'provider' => $serverType->providerName, 83 | ]); 84 | } 85 | 86 | public function start(): self 87 | { 88 | /** @var StartServerAction $action */ 89 | $action = Config::action('start_server'); 90 | 91 | $action->execute($this); 92 | 93 | return $this; 94 | } 95 | 96 | public function stop(): self 97 | { 98 | /** @var StopServerAction $action */ 99 | $action = Config::action('stop_server'); 100 | 101 | $action->execute($this); 102 | 103 | return $this; 104 | } 105 | 106 | public function reboot(): self 107 | { 108 | /** @var RebootServerAction $action */ 109 | $action = Config::action('reboot_server'); 110 | 111 | $action->execute($this); 112 | 113 | return $this; 114 | } 115 | 116 | public function markAs(ServerStatus $status): self 117 | { 118 | $this->update([ 119 | 'status' => $status, 120 | 'status_updated_at' => now(), 121 | ]); 122 | 123 | return $this; 124 | } 125 | 126 | public function serverProvider(): ServerProvider 127 | { 128 | /** @var class-string $providerClassName */ 129 | $providerClassName = config("dynamic-servers.providers.{$this->provider}.class") ?? ''; 130 | 131 | if (! is_a($providerClassName, ServerProvider::class, true)) { 132 | throw InvalidProvider::make($this); 133 | } 134 | 135 | /** @var ServerProvider $serverProvider */ 136 | $serverProvider = app($providerClassName); 137 | 138 | $serverProvider->setServer($this); 139 | 140 | return $serverProvider; 141 | } 142 | 143 | public function markAsErrored(Exception $exception): self 144 | { 145 | $this->update([ 146 | 'status' => ServerStatus::Errored, 147 | 'status_updated_at' => now(), 148 | 'exception_class' => $exception::class, 149 | 'exception_message' => $exception->getMessage(), 150 | 'exception_trace' => $exception->getTraceAsString(), 151 | ]); 152 | 153 | event(new ServerErroredEvent($this)); 154 | 155 | return $this; 156 | } 157 | 158 | public function markAsHanging(): self 159 | { 160 | $previousStatus = $this->status; 161 | 162 | $this->markAs(ServerStatus::Hanging); 163 | 164 | event(new ServerHangingEvent($this, $previousStatus)); 165 | 166 | return $this; 167 | } 168 | 169 | public function meta(string $key, mixed $default = null) 170 | { 171 | return Arr::get($this->meta, $key) ?? $default; 172 | } 173 | 174 | public function addMeta(string $name, string|array|int|bool $value): self 175 | { 176 | $this->meta[$name] = $value; 177 | 178 | $this->save(); 179 | 180 | return $this; 181 | } 182 | 183 | public function option(string $key): mixed 184 | { 185 | return Config::providerOption($this->provider, $key); 186 | } 187 | 188 | public function scopeStatus(Builder $query, ServerStatus ...$statuses): void 189 | { 190 | $query->whereIn('status', $statuses); 191 | } 192 | 193 | public function scopeProvisioned(Builder $query): void 194 | { 195 | $this->scopeStatus($query, ...ServerStatus::provisionedStates()); 196 | } 197 | 198 | public function scopeType(Builder $query, string $type): Builder 199 | { 200 | return $query->where('type', $type); 201 | } 202 | 203 | protected function generateName(): string 204 | { 205 | /** @var GenerateServerNameAction $generateServerNameAction */ 206 | $generateServerNameAction = Config::action('generate_server_name'); 207 | 208 | return $generateServerNameAction->execute($this); 209 | } 210 | 211 | public function prunable(): Builder 212 | { 213 | $days = config('dynamic-servers.prune_stopped_servers_from_local_db_after_days'); 214 | 215 | return static::query() 216 | ->status(ServerStatus::Stopped, ServerStatus::Errored) 217 | ->where('status_updated_at', '<=', now()->addDays($days)); 218 | } 219 | 220 | public static function countPerStatus(): array 221 | { 222 | $allStatuses = collect(ServerStatus::cases())->map->value; 223 | 224 | $actualStatuses = DB::table((new self)->getTable()) 225 | ->select('status', DB::raw('count(*) as count')) 226 | ->whereIn('status', $allStatuses->toArray()) 227 | ->groupBy('status') 228 | ->get() 229 | ->mapWithKeys(fn (object $result) => [$result->status => $result->count]) 230 | ->toArray(); 231 | 232 | return $allStatuses 233 | ->mapWithKeys(function (string $status) use ($actualStatuses) { 234 | return [$status => $actualStatuses[$status] ?? 0]; 235 | }) 236 | ->toArray(); 237 | } 238 | 239 | public function isProbablyHanging(): bool 240 | { 241 | if (in_array($this->status, [ 242 | ServerStatus::Running, 243 | ServerStatus::Stopped, 244 | ServerStatus::Errored, 245 | ])) { 246 | return false; 247 | } 248 | 249 | if (! in_array($this->status, [ 250 | ServerStatus::New, 251 | ServerStatus::Starting, 252 | ServerStatus::Stopping, 253 | ])) { 254 | if (is_null($this->status_updated_at)) { 255 | return false; 256 | } 257 | } 258 | 259 | return $this->status_updated_at->diffInMinutes() >= config('dynamic-servers.mark_server_as_hanging_after_minutes'); 260 | } 261 | 262 | public function rebootRequested(): bool 263 | { 264 | return ! is_null($this->reboot_requested_at); 265 | } 266 | } 267 | --------------------------------------------------------------------------------