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)
-
-
@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)
-