diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 696c4ead8..788144417 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server\New; use App\Enums\ProxyTypes; +use App\Models\CloudInitScript; use App\Models\CloudProviderToken; use App\Models\PrivateKey; use App\Models\Server; @@ -62,16 +63,33 @@ class ByHetzner extends Component public bool $enable_ipv6 = true; + public ?string $cloud_init_script = null; + + public bool $save_cloud_init_script = false; + + public ?string $cloud_init_script_name = null; + + public ?string $cloud_init_script_description = null; + + #[Locked] + public Collection $saved_cloud_init_scripts; + public function mount() { $this->authorize('viewAny', CloudProviderToken::class); $this->loadTokens(); + $this->loadSavedCloudInitScripts(); $this->server_name = generate_random_name(); if ($this->private_keys->count() > 0) { $this->private_key_id = $this->private_keys->first()->id; } } + public function loadSavedCloudInitScripts() + { + $this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get(); + } + public function getListeners() { return [ @@ -135,6 +153,10 @@ protected function rules(): array 'selectedHetznerSshKeyIds.*' => 'integer', 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', + 'cloud_init_script' => 'nullable|string', + 'save_cloud_init_script' => 'boolean', + 'cloud_init_script_name' => 'required_if:save_cloud_init_script,true|nullable|string|max:255', + 'cloud_init_script_description' => 'nullable|string', ]); } @@ -372,6 +394,14 @@ public function updatedSelectedImage($value) ray('Image selected', $value); } + public function loadCloudInitScript(?int $scriptId) + { + if ($scriptId) { + $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); + $this->cloud_init_script = $script->script; + } + } + private function createHetznerServer(string $token): array { $hetznerService = new HetznerService($token); @@ -439,6 +469,11 @@ private function createHetznerServer(string $token): array ], ]; + // Add cloud-init script if provided + if (! empty($this->cloud_init_script)) { + $params['user_data'] = $this->cloud_init_script; + } + ray('Server creation parameters', $params); // Create server on Hetzner @@ -460,6 +495,18 @@ public function submit() return $this->dispatch('error', 'You have reached the server limit for your subscription.'); } + // Save cloud-init script if requested + if ($this->save_cloud_init_script && ! empty($this->cloud_init_script)) { + $this->authorize('create', CloudInitScript::class); + + CloudInitScript::create([ + 'team_id' => currentTeam()->id, + 'name' => $this->cloud_init_script_name, + 'script' => $this->cloud_init_script, + 'description' => $this->cloud_init_script_description, + ]); + } + $hetznerToken = $this->getHetznerToken(); // Create server on Hetzner diff --git a/app/Models/CloudInitScript.php b/app/Models/CloudInitScript.php new file mode 100644 index 000000000..8d2cf72a6 --- /dev/null +++ b/app/Models/CloudInitScript.php @@ -0,0 +1,34 @@ + 'encrypted', + ]; + } + + public function team() + { + return $this->belongsTo(Team::class); + } + + public static function ownedByCurrentTeam(array $select = ['*']) + { + $selectArray = collect($select)->concat(['id']); + + return self::whereTeamId(currentTeam()->id)->select($selectArray->all()); + } +} diff --git a/app/Policies/CloudInitScriptPolicy.php b/app/Policies/CloudInitScriptPolicy.php new file mode 100644 index 000000000..0be4f2662 --- /dev/null +++ b/app/Policies/CloudInitScriptPolicy.php @@ -0,0 +1,65 @@ +isAdmin(); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, CloudInitScript $cloudInitScript): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, CloudInitScript $cloudInitScript): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, CloudInitScript $cloudInitScript): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, CloudInitScript $cloudInitScript): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, CloudInitScript $cloudInitScript): bool + { + return $user->isAdmin(); + } +} diff --git a/database/migrations/2025_10_10_120000_create_cloud_init_scripts_table.php b/database/migrations/2025_10_10_120000_create_cloud_init_scripts_table.php new file mode 100644 index 000000000..15985d986 --- /dev/null +++ b/database/migrations/2025_10_10_120000_create_cloud_init_scripts_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('team_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->text('script'); // Encrypted in the model + $table->text('description')->nullable(); + $table->timestamps(); + + $table->index('team_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cloud_init_scripts'); + } +}; diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php index 8f03bc9b7..775aed601 100644 --- a/resources/views/livewire/server/new/by-hetzner.blade.php +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -156,6 +156,39 @@ class="p-4 border border-yellow-500 dark:border-yellow-600 rounded bg-yellow-50 +