diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index 09b1e9421..c6aa2dd90 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -28,6 +28,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Sanitize branch name for Docker tag + id: sanitize + run: | + # Replace slashes and other invalid characters with dashes + SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') + echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: @@ -50,8 +57,8 @@ jobs: platforms: linux/amd64 push: true tags: | - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} aarch64: runs-on: [self-hosted, arm64] @@ -61,6 +68,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Sanitize branch name for Docker tag + id: sanitize + run: | + # Replace slashes and other invalid characters with dashes + SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') + echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: @@ -83,8 +97,8 @@ jobs: platforms: linux/aarch64 push: true tags: | - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 merge-manifest: runs-on: ubuntu-latest @@ -95,6 +109,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Sanitize branch name for Docker tag + id: sanitize + run: | + # Replace slashes and other invalid characters with dashes + SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') + echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT + - uses: docker/setup-buildx-action@v3 - name: Login to ${{ env.GITHUB_REGISTRY }} @@ -114,14 +135,14 @@ jobs: - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} run: | docker buildx imagetools create \ - --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \ - --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \ + --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} run: | docker buildx imagetools create \ - --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \ - --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} - uses: sarisia/actions-status-discord@v1 if: always() diff --git a/app/Console/Commands/ClearGlobalSearchCache.php b/app/Console/Commands/ClearGlobalSearchCache.php new file mode 100644 index 000000000..a368b0bad --- /dev/null +++ b/app/Console/Commands/ClearGlobalSearchCache.php @@ -0,0 +1,83 @@ +option('all')) { + return $this->clearAllTeamsCache(); + } + + if ($teamId = $this->option('team')) { + return $this->clearTeamCache($teamId); + } + + // If no options provided, clear cache for current user's team + if (! auth()->check()) { + $this->error('No authenticated user found. Use --team=ID or --all option.'); + + return Command::FAILURE; + } + + $teamId = auth()->user()->currentTeam()->id; + + return $this->clearTeamCache($teamId); + } + + private function clearTeamCache(int $teamId): int + { + $team = Team::find($teamId); + + if (! $team) { + $this->error("Team with ID {$teamId} not found."); + + return Command::FAILURE; + } + + GlobalSearch::clearTeamCache($teamId); + $this->info("✓ Cleared global search cache for team: {$team->name} (ID: {$teamId})"); + + return Command::SUCCESS; + } + + private function clearAllTeamsCache(): int + { + $teams = Team::all(); + + if ($teams->isEmpty()) { + $this->warn('No teams found.'); + + return Command::SUCCESS; + } + + $count = 0; + foreach ($teams as $team) { + GlobalSearch::clearTeamCache($team->id); + $count++; + } + + $this->info("✓ Cleared global search cache for {$count} team(s)"); + + return Command::SUCCESS; + } +} diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 87008e45e..680ac7701 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -617,7 +617,14 @@ private function loadSearchableItems() 'type' => 'navigation', 'description' => 'Manage private keys and API tokens', 'link' => route('security.private-key.index'), - 'search_text' => 'security private keys ssh api tokens', + 'search_text' => 'security private keys ssh api tokens cloud-init scripts', + ], + [ + 'name' => 'Cloud-Init Scripts', + 'type' => 'navigation', + 'description' => 'Manage reusable cloud-init scripts', + 'link' => route('security.cloud-init-scripts'), + 'search_text' => 'cloud-init scripts cloud init cloudinit initialization startup server setup', ], [ 'name' => 'Sources', diff --git a/app/Livewire/Security/CloudInitScriptForm.php b/app/Livewire/Security/CloudInitScriptForm.php new file mode 100644 index 000000000..33beff334 --- /dev/null +++ b/app/Livewire/Security/CloudInitScriptForm.php @@ -0,0 +1,101 @@ +scriptId = $scriptId; + $cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); + $this->authorize('update', $cloudInitScript); + + $this->name = $cloudInitScript->name; + $this->script = $cloudInitScript->script; + } else { + $this->authorize('create', CloudInitScript::class); + } + } + + protected function rules(): array + { + return [ + 'name' => 'required|string|max:255', + 'script' => ['required', 'string', new \App\Rules\ValidCloudInitYaml], + ]; + } + + protected function messages(): array + { + return [ + 'name.required' => 'Script name is required.', + 'name.max' => 'Script name cannot exceed 255 characters.', + 'script.required' => 'Cloud-init script content is required.', + ]; + } + + public function save() + { + $this->validate(); + + try { + if ($this->scriptId) { + // Update existing script + $cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($this->scriptId); + $this->authorize('update', $cloudInitScript); + + $cloudInitScript->update([ + 'name' => $this->name, + 'script' => $this->script, + ]); + + $message = 'Cloud-init script updated successfully.'; + } else { + // Create new script + $this->authorize('create', CloudInitScript::class); + + CloudInitScript::create([ + 'team_id' => currentTeam()->id, + 'name' => $this->name, + 'script' => $this->script, + ]); + + $message = 'Cloud-init script created successfully.'; + } + + // Only reset fields if creating (not editing) + if (! $this->scriptId) { + $this->reset(['name', 'script']); + } + + $this->dispatch('scriptSaved'); + $this->dispatch('success', $message); + + if ($this->modal_mode) { + $this->dispatch('closeModal'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-init-script-form'); + } +} diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php new file mode 100644 index 000000000..13bcf2caa --- /dev/null +++ b/app/Livewire/Security/CloudInitScripts.php @@ -0,0 +1,52 @@ +authorize('viewAny', CloudInitScript::class); + $this->loadScripts(); + } + + public function getListeners() + { + return [ + 'scriptSaved' => 'loadScripts', + ]; + } + + public function loadScripts() + { + $this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get(); + } + + public function deleteScript(int $scriptId) + { + try { + $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); + $this->authorize('delete', $script); + + $script->delete(); + $this->loadScripts(); + + $this->dispatch('success', 'Cloud-init script deleted successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.cloud-init-scripts'); + } +} diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 696c4ead8..f3368b4eb 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 ?int $selected_cloud_init_script_id = 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 [ @@ -85,6 +103,10 @@ public function resetSelection() { $this->selected_token_id = null; $this->current_step = 1; + $this->cloud_init_script = null; + $this->save_cloud_init_script = false; + $this->cloud_init_script_name = null; + $this->selected_cloud_init_script_id = null; } public function loadTokens() @@ -135,6 +157,10 @@ protected function rules(): array 'selectedHetznerSshKeyIds.*' => 'integer', 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', + 'cloud_init_script' => ['nullable', 'string', new \App\Rules\ValidCloudInitYaml], + 'save_cloud_init_script' => 'boolean', + 'cloud_init_script_name' => 'nullable|string|max:255', + 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id', ]); } @@ -372,6 +398,23 @@ public function updatedSelectedImage($value) ray('Image selected', $value); } + public function updatedSelectedCloudInitScriptId($value) + { + if ($value) { + $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); + $this->cloud_init_script = $script->script; + $this->cloud_init_script_name = $script->name; + } + } + + public function clearCloudInitScript() + { + $this->selected_cloud_init_script_id = null; + $this->cloud_init_script = ''; + $this->cloud_init_script_name = ''; + $this->save_cloud_init_script = false; + } + private function createHetznerServer(string $token): array { $hetznerService = new HetznerService($token); @@ -439,6 +482,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 +508,17 @@ 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) && ! empty($this->cloud_init_script_name)) { + $this->authorize('create', CloudInitScript::class); + + CloudInitScript::create([ + 'team_id' => currentTeam()->id, + 'name' => $this->cloud_init_script_name, + 'script' => $this->cloud_init_script, + ]); + } + $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..2c78cc582 --- /dev/null +++ b/app/Models/CloudInitScript.php @@ -0,0 +1,33 @@ + '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/app/Rules/ValidCloudInitYaml.php b/app/Rules/ValidCloudInitYaml.php new file mode 100644 index 000000000..8116e1161 --- /dev/null +++ b/app/Rules/ValidCloudInitYaml.php @@ -0,0 +1,55 @@ +getMessage()); + } + + return; + } + + // If it doesn't start with #! or #cloud-config, try to parse as YAML + // (some users might omit the #cloud-config header) + try { + Yaml::parse($script); + } catch (ParseException $e) { + $fail('The :attribute must be either a valid bash script (starting with #!) or valid cloud-config YAML. YAML parse error: '.$e->getMessage()); + } + } +} diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index 95cd1e8e8..aa6de3897 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -108,8 +108,17 @@ public function uploadSshKey(string $name, string $publicKey): array public function createServer(array $params): array { + ray('Hetzner createServer request', [ + 'endpoint' => '/servers', + 'params' => $params, + ]); + $response = $this->request('post', '/servers', $params); + ray('Hetzner createServer response', [ + 'response' => $response, + ]); + return $response['server'] ?? []; } 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..fe216a57d --- /dev/null +++ b/database/migrations/2025_10_10_120000_create_cloud_init_scripts_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('team_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->text('script'); // Encrypted in the model + $table->timestamps(); + + $table->index('team_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cloud_init_scripts'); + } +}; diff --git a/resources/views/components/security/navbar.blade.php b/resources/views/components/security/navbar.blade.php index b0dfdd242..425c96d74 100644 --- a/resources/views/components/security/navbar.blade.php +++ b/resources/views/components/security/navbar.blade.php @@ -11,6 +11,11 @@ @endcan + @can('viewAny', App\Models\CloudInitScript::class) + + + + @endcan diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index d8cbcf2d8..06da31354 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -14,6 +14,11 @@ return []; } + // Don't execute search if data is still loading + if (this.isLoadingInitialData) { + return []; + } + const query = this.searchQuery.toLowerCase().trim(); const results = this.allSearchableItems.filter(item => { @@ -28,6 +33,12 @@ if (!this.searchQuery || this.searchQuery.length < 1) { return []; } + + // Don't execute search if data is still loading + if (this.isLoadingInitialData) { + return []; + } + const query = this.searchQuery.toLowerCase().trim(); if (query === 'new') { @@ -239,7 +250,8 @@ class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen pt-[10vh]">
- - - {{--
-
-

- ✓ Data loaded successfully! -

-

- searchable items available -

-

- Start typing to search... -

-
-
--}} -
@@ -311,8 +307,8 @@ class="mt-2 bg-white dark:bg-coolgray-100 rounded-lg shadow-xl ring-1 ring-neutr class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"> - +
@@ -327,13 +323,11 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@if ($loadingServers) -
- - +
+ + @@ -343,8 +337,7 @@ class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg
@elseif (count($availableServers) > 0) @foreach ($availableServers as $index => $server) - @@ -388,10 +380,10 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
@@ -406,13 +398,11 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@if ($loadingDestinations) -
- - +
+ + @@ -422,24 +412,22 @@ class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg
@elseif (count($availableDestinations) > 0) @foreach ($availableDestinations as $index => $destination) - @@ -461,10 +449,10 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
@@ -479,13 +467,11 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@if ($loadingProjects) -
- - +
+ + @@ -495,8 +481,7 @@ class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg
@elseif (count($availableProjects) > 0) @foreach ($availableProjects as $index => $project) - @@ -540,10 +524,10 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
@@ -558,13 +542,11 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@if ($loadingEnvironments) -
- - +
+ + @@ -574,8 +556,7 @@ class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg
@elseif (count($availableEnvironments) > 0) @foreach ($availableEnvironments as $index => $environment) - @@ -636,8 +616,7 @@ class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-cool
- + {{ $result['name'] }} +
{{ $result['project'] }} / {{ $result['environment'] }}
@endif @if (!empty($result['description'])) -
+
{{ Str::limit($result['description'], 80) }}
@endif @@ -674,8 +651,8 @@ class="text-sm text-neutral-600 dark:text-neutral-400"> - +
@@ -705,16 +682,15 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-yellow-50 da
- + class="h-5 w-5 text-yellow-600 dark:text-yellow-400" fill="none" + viewBox="0 0 24 24" stroke="currentColor"> +
-
+
{{ $item['name'] }}
@if (isset($item['quickcommand'])) @@ -722,8 +698,7 @@ class="font-medium text-neutral-900 dark:text-white truncate"> class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0">{{ $item['quickcommand'] }} @endif
-
+
{{ $item['description'] }}
@@ -731,8 +706,8 @@ class="text-sm text-neutral-600 dark:text-neutral-400 truncate"> - +
@@ -817,8 +792,7 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-yellow-50 da class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center"> + fill="none" viewBox="0 0 24 24" stroke="currentColor"> @@ -854,7 +828,7 @@ class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"
-
+
\ No newline at end of file diff --git a/resources/views/livewire/security/cloud-init-script-form.blade.php b/resources/views/livewire/security/cloud-init-script-form.blade.php new file mode 100644 index 000000000..83bedffab --- /dev/null +++ b/resources/views/livewire/security/cloud-init-script-form.blade.php @@ -0,0 +1,17 @@ +
+ + + + +
+ @if ($modal_mode) + + Cancel + + @endif + + {{ $scriptId ? 'Update Script' : 'Create Script' }} + +
+ \ No newline at end of file diff --git a/resources/views/livewire/security/cloud-init-scripts.blade.php b/resources/views/livewire/security/cloud-init-scripts.blade.php new file mode 100644 index 000000000..e2013a4fb --- /dev/null +++ b/resources/views/livewire/security/cloud-init-scripts.blade.php @@ -0,0 +1,50 @@ +
+ +
+

Cloud-Init Scripts

+ @can('create', App\Models\CloudInitScript::class) + + + + @endcan +
+
Manage reusable cloud-init scripts for server initialization. Currently working only with Hetzner's integration.
+ +
+ @forelse ($scripts as $script) +
+
+
+
{{ $script->name }}
+
+ Created {{ $script->created_at->diffForHumans() }} +
+
+
+ +
+ @can('update', $script) + + + + @endcan + + @can('delete', $script) + + @endcan +
+
+ @empty +
No cloud-init scripts found. Create one to get started.
+ @endforelse +
+
diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php index 8f03bc9b7..4e9bcedc2 100644 --- a/resources/views/livewire/server/new/by-hetzner.blade.php +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -18,7 +18,8 @@
- + Continue
@@ -48,8 +49,7 @@
- + @foreach ($locations as $location)
- + @@ -111,8 +111,7 @@ class="p-4 border border-yellow-500 dark:border-yellow-600 rounded bg-yellow-50

No private keys found. You need to create a private key to continue.

- +
@@ -156,6 +155,35 @@ class="p-4 border border-yellow-500 dark:border-yellow-600 rounded bg-yellow-50 +
+
+ + @if ($saved_cloud_init_scripts->count() > 0) +
+ + + @foreach ($saved_cloud_init_scripts as $script) + + @endforeach + + + Clear + +
+ @endif +
+ + +
+ +
+ +
+
+
+
Back @@ -169,4 +197,4 @@ class="p-4 border border-yellow-500 dark:border-yellow-600 rounded bg-yellow-50 @endif @endif @endif -
+ \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 312bfb193..fd185496d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,6 +34,7 @@ use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow; use App\Livewire\Project\Show as ProjectShow; use App\Livewire\Security\ApiTokens; +use App\Livewire\Security\CloudInitScripts; use App\Livewire\Security\CloudTokens; use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex; use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow; @@ -275,6 +276,7 @@ Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show'); Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens'); + Route::get('/security/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts'); Route::get('/security/api-tokens', ApiTokens::class)->name('security.api-tokens'); }); diff --git a/tests/Feature/CloudInitScriptTest.php b/tests/Feature/CloudInitScriptTest.php new file mode 100644 index 000000000..881f0071c --- /dev/null +++ b/tests/Feature/CloudInitScriptTest.php @@ -0,0 +1,101 @@ + 'test-server', + 'server_type' => 'cx11', + 'image' => 1, + 'location' => 'nbg1', + 'start_after_create' => true, + 'ssh_keys' => [123], + 'public_net' => [ + 'enable_ipv4' => true, + 'enable_ipv6' => true, + ], + ]; + + // Add cloud-init script if provided + if (! empty($cloudInitScript)) { + $params['user_data'] = $cloudInitScript; + } + + expect($params) + ->toHaveKey('user_data') + ->and($params['user_data'])->toBe("#!/bin/bash\necho 'Hello World'"); +}); + +it('validates cloud-init script is not included when empty', function () { + $cloudInitScript = null; + $params = [ + 'name' => 'test-server', + 'server_type' => 'cx11', + 'image' => 1, + 'location' => 'nbg1', + 'start_after_create' => true, + 'ssh_keys' => [123], + 'public_net' => [ + 'enable_ipv4' => true, + 'enable_ipv6' => true, + ], + ]; + + // Add cloud-init script if provided + if (! empty($cloudInitScript)) { + $params['user_data'] = $cloudInitScript; + } + + expect($params)->not->toHaveKey('user_data'); +}); + +it('validates cloud-init script is not included when empty string', function () { + $cloudInitScript = ''; + $params = [ + 'name' => 'test-server', + 'server_type' => 'cx11', + 'image' => 1, + 'location' => 'nbg1', + 'start_after_create' => true, + 'ssh_keys' => [123], + 'public_net' => [ + 'enable_ipv4' => true, + 'enable_ipv6' => true, + ], + ]; + + // Add cloud-init script if provided + if (! empty($cloudInitScript)) { + $params['user_data'] = $cloudInitScript; + } + + expect($params)->not->toHaveKey('user_data'); +}); + +it('validates cloud-init script with multiline content', function () { + $cloudInitScript = "#cloud-config\n\npackages:\n - nginx\n - git\n\nruncmd:\n - systemctl start nginx"; + $params = [ + 'name' => 'test-server', + 'server_type' => 'cx11', + 'image' => 1, + 'location' => 'nbg1', + 'start_after_create' => true, + 'ssh_keys' => [123], + 'public_net' => [ + 'enable_ipv4' => true, + 'enable_ipv6' => true, + ], + ]; + + // Add cloud-init script if provided + if (! empty($cloudInitScript)) { + $params['user_data'] = $cloudInitScript; + } + + expect($params) + ->toHaveKey('user_data') + ->and($params['user_data'])->toContain('#cloud-config') + ->and($params['user_data'])->toContain('packages:') + ->and($params['user_data'])->toContain('runcmd:'); +}); diff --git a/tests/Unit/CloudInitScriptValidationTest.php b/tests/Unit/CloudInitScriptValidationTest.php new file mode 100644 index 000000000..bb4657502 --- /dev/null +++ b/tests/Unit/CloudInitScriptValidationTest.php @@ -0,0 +1,76 @@ +toBeFalse() + ->and($hasValue)->toBeFalse(); +}); + +it('validates cloud-init script name is required when saving', function () { + $saveScript = true; + $scriptName = 'My Installation Script'; + + $isNameRequired = $saveScript; + $hasName = ! empty($scriptName); + + expect($isNameRequired)->toBeTrue() + ->and($hasName)->toBeTrue(); +}); + +it('validates cloud-init script description is optional', function () { + $scriptDescription = null; + + $isDescriptionRequired = false; + $hasDescription = ! empty($scriptDescription); + + expect($isDescriptionRequired)->toBeFalse() + ->and($hasDescription)->toBeFalse(); +}); + +it('validates save_cloud_init_script must be boolean', function () { + $saveCloudInitScript = true; + + expect($saveCloudInitScript)->toBeBool(); +}); + +it('validates save_cloud_init_script defaults to false', function () { + $saveCloudInitScript = false; + + expect($saveCloudInitScript)->toBeFalse(); +}); + +it('validates cloud-init script can be a bash script', function () { + $cloudInitScript = "#!/bin/bash\napt-get update\napt-get install -y nginx"; + + expect($cloudInitScript)->toBeString() + ->and($cloudInitScript)->toContain('#!/bin/bash'); +}); + +it('validates cloud-init script can be cloud-config yaml', function () { + $cloudInitScript = "#cloud-config\npackages:\n - nginx\n - git"; + + expect($cloudInitScript)->toBeString() + ->and($cloudInitScript)->toContain('#cloud-config'); +}); + +it('validates script name max length is 255 characters', function () { + $scriptName = str_repeat('a', 255); + + expect(strlen($scriptName))->toBe(255) + ->and(strlen($scriptName))->toBeLessThanOrEqual(255); +}); + +it('validates script name exceeding 255 characters should be invalid', function () { + $scriptName = str_repeat('a', 256); + + $isValid = strlen($scriptName) <= 255; + + expect($isValid)->toBeFalse() + ->and(strlen($scriptName))->toBeGreaterThan(255); +}); diff --git a/tests/Unit/Rules/ValidCloudInitYamlTest.php b/tests/Unit/Rules/ValidCloudInitYamlTest.php new file mode 100644 index 000000000..f3ea906af --- /dev/null +++ b/tests/Unit/Rules/ValidCloudInitYamlTest.php @@ -0,0 +1,174 @@ +validate('script', $script, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +}); + +it('accepts valid cloud-config YAML without header', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + + $script = <<<'YAML' +users: + - name: demo + groups: sudo +packages: + - nginx +YAML; + + $rule->validate('script', $script, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +}); + +it('accepts valid bash script with shebang', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + + $script = <<<'BASH' +#!/bin/bash +apt update +apt install -y nginx +systemctl start nginx +BASH; + + $rule->validate('script', $script, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +}); + +it('accepts empty or null script', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + + $rule->validate('script', '', function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); + + $rule->validate('script', null, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +}); + +it('rejects invalid YAML format', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + $errorMessage = ''; + + $script = <<<'YAML' +#cloud-config +users: + - name: demo + groups: sudo + invalid_indentation +packages: + - nginx +YAML; + + $rule->validate('script', $script, function ($message) use (&$valid, &$errorMessage) { + $valid = false; + $errorMessage = $message; + }); + + expect($valid)->toBeFalse(); + expect($errorMessage)->toContain('YAML'); +}); + +it('rejects script that is neither bash nor valid YAML', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + $errorMessage = ''; + + $script = <<<'INVALID' +this is not valid YAML + and has invalid indentation: + - item + without proper structure { +INVALID; + + $rule->validate('script', $script, function ($message) use (&$valid, &$errorMessage) { + $valid = false; + $errorMessage = $message; + }); + + expect($valid)->toBeFalse(); + expect($errorMessage)->toContain('bash script'); +}); + +it('accepts complex cloud-config with multiple sections', function () { + $rule = new ValidCloudInitYaml; + $valid = true; + + $script = <<<'YAML' +#cloud-config +users: + - name: coolify + groups: sudo, docker + shell: /bin/bash + sudo: ['ALL=(ALL) NOPASSWD:ALL'] + ssh_authorized_keys: + - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... + +packages: + - docker.io + - docker-compose + - git + - curl + +package_update: true +package_upgrade: true + +runcmd: + - systemctl enable docker + - systemctl start docker + - usermod -aG docker coolify + - echo "Server setup complete" + +write_files: + - path: /etc/docker/daemon.json + content: | + { + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } + } +YAML; + + $rule->validate('script', $script, function ($message) use (&$valid) { + $valid = false; + }); + + expect($valid)->toBeTrue(); +});