Add load balancer (#453)

This commit is contained in:
Saeed Vaziry
2025-01-30 23:52:51 +01:00
committed by GitHub
parent 53e20cbc2a
commit 6966f06b1a
54 changed files with 3128 additions and 1833 deletions

View File

@ -46,7 +46,6 @@ public function create(Site $site, array $input): void
$ssl->status = SslStatus::CREATED;
$ssl->save();
$webserver->updateVHost($site);
$site->type()->edit();
})->catch(function () use ($ssl) {
$ssl->status = SslStatus::FAILED;
$ssl->save();

View File

@ -24,6 +24,9 @@ public function edit(Server $server, array $input): Server
}
$server->ip = $input['ip'];
}
if (isset($input['local_ip'])) {
$server->local_ip = $input['local_ip'];
}
if (isset($input['port'])) {
if ($server->port !== $input['port']) {
$checkConnection = true;
@ -52,6 +55,10 @@ public static function rules(Server $server): array
new RestrictedIPAddressesRule,
Rule::unique('servers')->where('project_id', $server->project_id)->ignore($server->id),
],
'local_ip' => [
'string',
Rule::unique('servers')->where('project_id', $server->project_id)->ignore($server->id),
],
'port' => [
'integer',
'min:1',

View File

@ -0,0 +1,63 @@
<?php
namespace App\Actions\Site;
use App\Enums\LoadBalancerMethod;
use App\Models\LoadBalancerServer;
use App\Models\Site;
use Illuminate\Validation\Rule;
class UpdateLoadBalancer
{
public function update(Site $site, array $input): void
{
$site->loadBalancerServers()->delete();
foreach ($input['servers'] as $server) {
$loadBalancerServer = new LoadBalancerServer([
'load_balancer_id' => $site->id,
'ip' => $server['server'],
'port' => $server['port'],
'weight' => $server['weight'],
'backup' => (bool) $server['backup'],
]);
$loadBalancerServer->save();
}
$site->webserver()->updateVHost($site);
}
public static function rules(Site $site): array
{
return [
'servers' => [
'required',
'array',
],
'servers.*.server' => [
'required',
Rule::exists('servers', 'local_ip')
->where('project_id', $site->project->id),
],
'servers.*.port' => [
'required',
'numeric',
'min:1',
'max:65535',
],
'servers.*.weight' => [
'nullable',
'numeric',
'min:0',
],
'servers.*.backup' => [
'required',
'boolean',
],
'method' => [
'required',
Rule::in(LoadBalancerMethod::all()),
],
];
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Enums;
use App\Traits\Enum;
final class LoadBalancerMethod
{
use Enum;
const ROUND_ROBIN = 'round-robin';
const LEAST_CONNECTIONS = 'least-connections';
const IP_HASH = 'ip-hash';
}

View File

@ -14,20 +14,5 @@ final class SiteType
const PHPMYADMIN = 'phpmyadmin';
public static function hasWebDirectory(): array
{
return [
self::PHP,
self::PHP_BLANK,
self::LARAVEL,
];
}
public static function hasSourceControl(): array
{
return [
self::PHP,
self::LARAVEL,
];
}
const LOAD_BALANCER = 'load-balancer';
}

View File

@ -3,9 +3,10 @@
namespace App\Http\Controllers\API;
use App\Actions\Site\CreateSite;
use App\Actions\Site\UpdateLoadBalancer;
use App\Enums\LoadBalancerMethod;
use App\Enums\SiteType;
use App\Http\Controllers\Controller;
use App\Http\Resources\ServerResource;
use App\Http\Resources\SiteResource;
use App\Models\Project;
use App\Models\Server;
@ -42,7 +43,7 @@ public function index(Project $project, Server $server): ResourceCollection
#[Post('/', name: 'api.projects.servers.sites.create', middleware: 'ability:write')]
#[Endpoint(title: 'create', description: 'Create a new site.')]
#[BodyParam(name: 'type', required: true, enum: [SiteType::PHP, SiteType::PHP_BLANK, SiteType::PHPMYADMIN, SiteType::LARAVEL, SiteType::WORDPRESS])]
#[BodyParam(name: 'type', required: true, enum: [SiteType::PHP, SiteType::PHP_BLANK, SiteType::PHPMYADMIN, SiteType::LARAVEL, SiteType::WORDPRESS, SiteType::LOAD_BALANCER])]
#[BodyParam(name: 'domain', required: true)]
#[BodyParam(name: 'aliases', type: 'array')]
#[BodyParam(name: 'php_version', description: 'One of the installed PHP Versions', required: true, example: '7.4')]
@ -53,6 +54,7 @@ public function index(Project $project, Server $server): ResourceCollection
#[BodyParam(name: 'composer', type: 'boolean', description: 'Run composer if site supports composer', example: true)]
#[BodyParam(name: 'version', description: 'Version, if the site type requires a version like PHPMyAdmin', example: '5.2.1')]
#[BodyParam(name: 'user', description: 'user, to isolate the website under a new user')]
#[BodyParam(name: 'method', description: 'Load balancer method, Required if the site type is Load balancer', enum: [LoadBalancerMethod::ROUND_ROBIN, LoadBalancerMethod::LEAST_CONNECTIONS, LoadBalancerMethod::IP_HASH])]
#[ResponseFromApiResource(SiteResource::class, Site::class)]
public function create(Request $request, Project $project, Server $server): SiteResource
{
@ -70,13 +72,13 @@ public function create(Request $request, Project $project, Server $server): Site
#[Get('{site}', name: 'api.projects.servers.sites.show', middleware: 'ability:read')]
#[Endpoint(title: 'show', description: 'Get a site by ID.')]
#[ResponseFromApiResource(SiteResource::class, Site::class)]
public function show(Project $project, Server $server, Site $site): ServerResource
public function show(Project $project, Server $server, Site $site): SiteResource
{
$this->authorize('view', [$site, $server]);
$this->validateRoute($project, $server, $site);
return new ServerResource($server);
return new SiteResource($site);
}
#[Delete('{site}', name: 'api.projects.servers.sites.delete', middleware: 'ability:write')]
@ -93,6 +95,24 @@ public function delete(Project $project, Server $server, Site $site)
return response()->noContent();
}
#[Post('{site}/load-balancer', name: 'api.projects.servers.sites.load-balancer', middleware: 'ability:write')]
#[Endpoint(title: 'load-balancer', description: 'Update load balancer.')]
#[BodyParam(name: 'method', description: 'Load balancer method, Required if the site type is Load balancer', enum: [LoadBalancerMethod::ROUND_ROBIN, LoadBalancerMethod::LEAST_CONNECTIONS, LoadBalancerMethod::IP_HASH])]
#[BodyParam(name: 'servers', type: 'array', description: 'Array of servers including server, port, weight, backup. (server is the local IP of the server)')]
#[Response(status: 200)]
public function updateLoadBalancer(Request $request, Project $project, Server $server, Site $site): SiteResource
{
$this->authorize('update', [$site, $server]);
$this->validateRoute($project, $server, $site);
$this->validate($request, UpdateLoadBalancer::rules($site));
app(UpdateLoadBalancer::class)->update($site, $request->all());
return new SiteResource($site);
}
private function validateRoute(Project $project, Server $server, ?Site $site = null): void
{
if ($project->id !== $server->project_id) {

View File

@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $load_balancer_id
* @property string $ip
* @property int $port
* @property int $weight
* @property bool $backup
* @property Site $loadBalancer
*/
class LoadBalancerServer extends AbstractModel
{
use HasFactory;
protected $fillable = [
'load_balancer_id',
'ip',
'port',
'weight',
'backup',
];
protected $casts = [
'load_balancer_id' => 'integer',
'port' => 'integer',
'weight' => 'integer',
'backup' => 'boolean',
];
public function loadBalancer(): BelongsTo
{
return $this->belongsTo(Site::class, 'load_balancer_id');
}
public function server(): ?Server
{
return $this->loadBalancer->project->servers()->where('local_ip', $this->ip)->first();
}
}

View File

@ -16,6 +16,7 @@
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
@ -47,6 +48,7 @@
* @property ?Ssl $activeSsl
* @property string $ssh_key_name
* @property ?SourceControl $sourceControl
* @property Collection<LoadBalancerServer> $loadBalancerServers
*
* @TODO: Add nodejs_version column
*/
@ -332,4 +334,9 @@ public function webserver(): Webserver
return $webserver;
}
public function loadBalancerServers(): HasMany
{
return $this->hasMany(LoadBalancerServer::class, 'load_balancer_id');
}
}

View File

@ -34,7 +34,7 @@ public function data(array $input): array
public function editRules(array $input): array
{
return [];
return $this->createRules($input);
}
protected function progress(int $percentage): void

58
app/SiteTypes/LoadBalancer.php Executable file
View File

@ -0,0 +1,58 @@
<?php
namespace App\SiteTypes;
use App\Enums\LoadBalancerMethod;
use App\Enums\SiteFeature;
use App\Exceptions\SSHError;
use App\SSH\Services\Webserver\Webserver;
use Illuminate\Validation\Rule;
class LoadBalancer extends AbstractSiteType
{
public function language(): string
{
return 'yaml';
}
public function supportedFeatures(): array
{
return [
SiteFeature::SSL,
];
}
public function createRules(array $input): array
{
return [
'method' => [
'required',
Rule::in(LoadBalancerMethod::all()),
],
];
}
public function data(array $input): array
{
return [
'method' => $input['method'] ?? LoadBalancerMethod::ROUND_ROBIN,
];
}
/**
* @throws SSHError
*/
public function install(): void
{
$this->isolate();
/** @var Webserver $webserver */
$webserver = $this->site->server->webserver()->handler();
$webserver->createVHost($this->site);
}
public function edit(): void
{
//
}
}

View File

@ -3,6 +3,7 @@
namespace App\Web\Pages\Servers\Sites;
use App\Actions\Site\CreateSite;
use App\Enums\LoadBalancerMethod;
use App\Enums\SiteType;
use App\Models\Site;
use App\Models\SourceControl;
@ -133,6 +134,17 @@ protected function getHeaderActions(): array
->rules(fn (Get $get) => CreateSite::rules($this->server, $get())['version']),
// WordPress
$this->wordpressFields(),
// Load Balancer
Select::make('method')
->label('Balancing Method')
->validationAttribute('Balancing Method')
->options(
collect(LoadBalancerMethod::all())
->mapWithKeys(fn ($method) => [$method => $method])
)
->visible(fn (Get $get) => $get('type') === SiteType::LOAD_BALANCER)
->rules(fn (Get $get) => CreateSite::rules($this->server, $get())['method'] ?? []),
// User
TextInput::make('user')
->label('User')
->placeholder('vito')

View File

@ -7,6 +7,7 @@
use App\Actions\Site\UpdateDeploymentScript;
use App\Actions\Site\UpdateEnv;
use App\Enums\SiteFeature;
use App\Enums\SiteType;
use App\Web\Fields\CodeEditorField;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
@ -58,6 +59,10 @@ public function getWidgets(): array
if (in_array(SiteFeature::DEPLOYMENT, $this->site->type()->supportedFeatures())) {
$widgets[] = [Widgets\DeploymentsList::class, ['site' => $this->site]];
}
if ($this->site->type === SiteType::LOAD_BALANCER) {
$widgets[] = [Widgets\LoadBalancerServers::class, ['site' => $this->site]];
}
}
return $widgets;

View File

@ -0,0 +1,141 @@
<?php
namespace App\Web\Pages\Servers\Sites\Widgets;
use App\Actions\Site\UpdateLoadBalancer;
use App\Enums\LoadBalancerMethod;
use App\Models\LoadBalancerServer;
use App\Models\Site;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget;
use Livewire\Attributes\On;
class LoadBalancerServers extends Widget implements HasForms
{
use InteractsWithForms;
protected static string $view = 'components.form';
public Site $site;
public string $method;
public array $servers = [];
public function mount(): void
{
$this->setLoadBalancerServers();
if (empty($this->servers)) {
$this->servers = [
[
'server' => null,
'port' => 80,
'weight' => null,
'backup' => false,
],
];
}
$this->method = $this->site->type_data['method'] ?? LoadBalancerMethod::ROUND_ROBIN;
}
#[On('load-balancer-updated')]
public function setLoadBalancerServers(): void
{
$this->servers = $this->site->loadBalancerServers->map(function (LoadBalancerServer $server) {
return [
'server' => $server->ip,
'port' => $server->port,
'weight' => $server->weight,
'backup' => $server->backup,
];
})->toArray();
}
public function form(Form $form): Form
{
return $form
->schema([
Section::make()
->heading('Load Balancer Servers')
->description('You can add or remove servers from the load balancer here')
->columns(3)
->schema([
Select::make('method')
->label('Balancing Method')
->validationAttribute('Balancing Method')
->options(
collect(LoadBalancerMethod::all())
->mapWithKeys(fn ($method) => [$method => $method])
)
->rules(UpdateLoadBalancer::rules($this->site)['method']),
Repeater::make('servers')
->schema([
Select::make('server')
->placeholder('Select a server')
->searchable()
->required()
->rules(UpdateLoadBalancer::rules($this->site)['servers.*.server'])
->options(function () {
return $this->site->project->servers()
->where('id', '!=', $this->site->server_id)
->get()
->mapWithKeys(function ($server) {
return [$server->local_ip => $server->name.' ('.$server->local_ip.')'];
});
}),
TextInput::make('port')
->default(80)
->required()
->rules(UpdateLoadBalancer::rules($this->site)['servers.*.port']),
TextInput::make('weight')
->rules(UpdateLoadBalancer::rules($this->site)['servers.*.weight']),
Toggle::make('backup')
->label('Backup')
->inline(false)
->default(false),
])
->columnSpan(3)
->live()
->reorderable(false)
->columns(4)
->reorderableWithDragAndDrop(false)
->addActionLabel('Add Server'),
])
->footerActions([
Action::make('save')
->label('Save')
->action(fn () => $this->save()),
]),
]);
}
public function save(): void
{
$this->authorize('update', [$this->site, $this->site->server]);
$this->validate();
run_action($this, function () {
app(UpdateLoadBalancer::class)->update($this->site, [
'method' => $this->method,
'servers' => $this->servers,
]);
$this->dispatch('load-balancer-updated');
Notification::make()
->success()
->title('Load balancer updated!')
->send();
});
}
}

View File

@ -27,6 +27,8 @@ class UpdateServerInfo extends Widget implements HasForms
public string $ip;
public ?string $local_ip;
public string $port;
public function mount(Server $server): void
@ -34,6 +36,7 @@ public function mount(Server $server): void
$this->server = $server;
$this->name = $server->name;
$this->ip = $server->ip;
$this->local_ip = $server->local_ip;
$this->port = $server->port;
}
@ -52,6 +55,10 @@ public function form(Form $form): Form
TextInput::make('ip')
->label('IP Address')
->rules(EditServer::rules($this->server)['ip']),
TextInput::make('local_ip')
->label('Local Network IP Address')
->placeholder('10.0.0.1')
->rules(EditServer::rules($this->server)['local_ip']),
TextInput::make('port')
->label('Port')
->rules(EditServer::rules($this->server)['port']),