Merge branch 'next' into v4.x
This commit is contained in:
commit
ac6fe136ca
22 changed files with 621 additions and 123 deletions
|
|
@ -4,6 +4,12 @@ # Changelog
|
|||
|
||||
## [unreleased]
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Update changelog
|
||||
|
||||
## [4.0.0-beta.436] - 2025-10-17
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Use tags in update
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
|
||||
class CleanupRedis extends Command
|
||||
{
|
||||
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup}';
|
||||
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks}';
|
||||
|
||||
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, and related data)';
|
||||
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
|
|
@ -56,6 +56,13 @@ public function handle()
|
|||
$deletedCount += $overlappingCleaned;
|
||||
}
|
||||
|
||||
// Clean up stale cache locks (WithoutOverlapping middleware)
|
||||
if ($this->option('clear-locks')) {
|
||||
$this->info('Cleaning up stale cache locks...');
|
||||
$locksCleaned = $this->cleanupCacheLocks($dryRun);
|
||||
$deletedCount += $locksCleaned;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
|
||||
} else {
|
||||
|
|
@ -273,4 +280,56 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun)
|
|||
|
||||
return $cleanedCount;
|
||||
}
|
||||
|
||||
private function cleanupCacheLocks(bool $dryRun): int
|
||||
{
|
||||
$cleanedCount = 0;
|
||||
|
||||
// Use the default Redis connection (database 0) where cache locks are stored
|
||||
$redis = Redis::connection('default');
|
||||
|
||||
// Get all keys matching WithoutOverlapping lock pattern
|
||||
$allKeys = $redis->keys('*');
|
||||
$lockKeys = [];
|
||||
|
||||
foreach ($allKeys as $key) {
|
||||
// Match cache lock keys: they contain 'laravel-queue-overlap'
|
||||
if (preg_match('/overlap/i', $key)) {
|
||||
$lockKeys[] = $key;
|
||||
}
|
||||
}
|
||||
if (empty($lockKeys)) {
|
||||
$this->info(' No cache locks found.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info(' Found '.count($lockKeys).' cache lock(s)');
|
||||
|
||||
foreach ($lockKeys as $lockKey) {
|
||||
// Check TTL to identify stale locks
|
||||
$ttl = $redis->ttl($lockKey);
|
||||
|
||||
// TTL = -1 means no expiration (stale lock!)
|
||||
// TTL = -2 means key doesn't exist
|
||||
// TTL > 0 means lock is valid and will expire
|
||||
if ($ttl === -1) {
|
||||
if ($dryRun) {
|
||||
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
|
||||
} else {
|
||||
$redis->del($lockKey);
|
||||
$this->info(" ✓ Deleted STALE lock: {$lockKey}");
|
||||
}
|
||||
$cleanedCount++;
|
||||
} elseif ($ttl > 0) {
|
||||
$this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($cleanedCount === 0) {
|
||||
$this->info(' No stale locks found (all locks have expiration set)');
|
||||
}
|
||||
|
||||
return $cleanedCount;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ public function handle()
|
|||
$this->cleanupUnusedNetworkFromCoolifyProxy();
|
||||
|
||||
try {
|
||||
$this->call('cleanup:redis');
|
||||
$this->call('cleanup:redis', ['--clear-locks' => true]);
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ public function middleware(): array
|
|||
{
|
||||
return [
|
||||
(new WithoutOverlapping('scheduled-job-manager'))
|
||||
->releaseAfter(60), // Release the lock after 60 seconds if job fails
|
||||
->expireAfter(60) // Lock expires after 1 minute to prevent stale locks
|
||||
->dontRelease(), // Don't re-queue on lock conflict
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,11 @@ public function checkStatus()
|
|||
}
|
||||
}
|
||||
|
||||
public function manualCheckStatus()
|
||||
{
|
||||
$this->checkStatus();
|
||||
}
|
||||
|
||||
public function force_deploy_without_cache()
|
||||
{
|
||||
$this->authorize('deploy', $this->application);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ public function checkStatus()
|
|||
}
|
||||
}
|
||||
|
||||
public function manualCheckStatus()
|
||||
{
|
||||
$this->checkStatus();
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -54,6 +54,11 @@ public function checkStatus()
|
|||
}
|
||||
}
|
||||
|
||||
public function manualCheckStatus()
|
||||
{
|
||||
$this->checkStatus();
|
||||
}
|
||||
|
||||
public function serviceChecked()
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -358,6 +358,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
{
|
||||
$uuid = data_get($resource, 'uuid');
|
||||
$compose = data_get($resource, 'docker_compose_raw');
|
||||
// Store original compose for later use to update docker_compose_raw with content removed
|
||||
$originalCompose = $compose;
|
||||
if (! $compose) {
|
||||
return collect([]);
|
||||
}
|
||||
|
|
@ -1299,9 +1301,32 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
|
||||
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
|
||||
$resource->docker_compose = $cleanedCompose;
|
||||
// Also update docker_compose_raw to remove content: from volumes
|
||||
// This prevents content from being reapplied on subsequent deployments
|
||||
$resource->docker_compose_raw = $cleanedCompose;
|
||||
|
||||
// Update docker_compose_raw to remove content: from volumes only
|
||||
// This keeps the original user input clean while preventing content reapplication
|
||||
// Parse the original compose again to create a clean version without Coolify additions
|
||||
try {
|
||||
$originalYaml = Yaml::parse($originalCompose);
|
||||
// Remove content, isDirectory, and is_directory from all volume definitions
|
||||
if (isset($originalYaml['services'])) {
|
||||
foreach ($originalYaml['services'] as $serviceName => &$service) {
|
||||
if (isset($service['volumes'])) {
|
||||
foreach ($service['volumes'] as $key => &$volume) {
|
||||
if (is_array($volume)) {
|
||||
unset($volume['content']);
|
||||
unset($volume['isDirectory']);
|
||||
unset($volume['is_directory']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
|
||||
} catch (\Exception $e) {
|
||||
// If parsing fails, keep the original docker_compose_raw unchanged
|
||||
ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage());
|
||||
}
|
||||
|
||||
data_forget($resource, 'environment_variables');
|
||||
data_forget($resource, 'environment_variables_preview');
|
||||
$resource->save();
|
||||
|
|
@ -1313,6 +1338,8 @@ function serviceParser(Service $resource): Collection
|
|||
{
|
||||
$uuid = data_get($resource, 'uuid');
|
||||
$compose = data_get($resource, 'docker_compose_raw');
|
||||
// Store original compose for later use to update docker_compose_raw with content removed
|
||||
$originalCompose = $compose;
|
||||
if (! $compose) {
|
||||
return collect([]);
|
||||
}
|
||||
|
|
@ -2226,9 +2253,32 @@ function serviceParser(Service $resource): Collection
|
|||
|
||||
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
|
||||
$resource->docker_compose = $cleanedCompose;
|
||||
// Also update docker_compose_raw to remove content: from volumes
|
||||
// This prevents content from being reapplied on subsequent deployments
|
||||
$resource->docker_compose_raw = $cleanedCompose;
|
||||
|
||||
// Update docker_compose_raw to remove content: from volumes only
|
||||
// This keeps the original user input clean while preventing content reapplication
|
||||
// Parse the original compose again to create a clean version without Coolify additions
|
||||
try {
|
||||
$originalYaml = Yaml::parse($originalCompose);
|
||||
// Remove content, isDirectory, and is_directory from all volume definitions
|
||||
if (isset($originalYaml['services'])) {
|
||||
foreach ($originalYaml['services'] as $serviceName => &$service) {
|
||||
if (isset($service['volumes'])) {
|
||||
foreach ($service['volumes'] as $key => &$volume) {
|
||||
if (is_array($volume)) {
|
||||
unset($volume['content']);
|
||||
unset($volume['isDirectory']);
|
||||
unset($volume['is_directory']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
|
||||
} catch (\Exception $e) {
|
||||
// If parsing fails, keep the original docker_compose_raw unchanged
|
||||
ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage());
|
||||
}
|
||||
|
||||
data_forget($resource, 'environment_variables');
|
||||
data_forget($resource, 'environment_variables_preview');
|
||||
$resource->save();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
'hover:border-l-red-500 cursor-not-allowed' => $upgrade,
|
||||
])>
|
||||
<div class="flex items-center">
|
||||
{{ $logo }}
|
||||
<div class="w-[4.5rem] h-[4.5rem] flex items-center justify-center text-black dark:text-white shrink-0">
|
||||
{{ $logo }}
|
||||
</div>
|
||||
<div class="flex flex-col pl-2 ">
|
||||
<div class="dark:text-white text-md">
|
||||
{{ $title }}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@
|
|||
<x-status.stopped :status="$resource->status" />
|
||||
@endif
|
||||
@if (!str($resource->status)->contains('exited') && $showRefreshButton)
|
||||
<button wire:loading.remove title="Refresh Status" wire:click='checkStatus'
|
||||
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
|
||||
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button wire:loading title="Refreshing Status" wire:click='checkStatus'
|
||||
<button wire:loading.delay.shortest wire:target="manualCheckStatus" title="Refreshing Status" wire:click='manualCheckStatus'
|
||||
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
|
||||
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@
|
|||
<x-status.stopped :status="$complexStatus" />
|
||||
@endif
|
||||
@if (!str($complexStatus)->contains('exited') && $showRefreshButton)
|
||||
<button wire:loading.remove title="Refresh Status" wire:click='checkStatus'
|
||||
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
|
||||
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button wire:loading title="Refreshing Status" wire:click='checkStatus'
|
||||
<button wire:loading.delay.shortest wire:target="manualCheckStatus" title="Refreshing Status" wire:click='manualCheckStatus'
|
||||
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
|
||||
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html data-theme="dark" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
|
||||
<script>
|
||||
// Immediate theme application - runs before any rendering
|
||||
(function() {
|
||||
const t = localStorage.theme || 'dark';
|
||||
const d = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList[d ? 'add' : 'remove']('dark');
|
||||
})();
|
||||
</script>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="theme-color" content="#101010" id="theme-color-meta" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="Description" content="Coolify: An open-source & self-hostable Heroku / Netlify / Vercel alternative" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
|
@ -41,6 +49,12 @@
|
|||
@endenv
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
@vite(['resources/js/app.js', 'resources/css/app.css'])
|
||||
<script>
|
||||
// Update theme-color meta tag (non-critical, can run async)
|
||||
const t = localStorage.theme || 'dark';
|
||||
const isDark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.getElementById('theme-color-meta')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
|
||||
</script>
|
||||
<style>
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
|
|
@ -108,7 +122,7 @@
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Existing link sanitization
|
||||
if (node.nodeName === 'A' && node.hasAttribute('href')) {
|
||||
const href = node.getAttribute('href') || '';
|
||||
|
|
@ -123,20 +137,11 @@
|
|||
return DOMPurify.sanitize(html, config);
|
||||
};
|
||||
|
||||
// Initialize theme if not set
|
||||
if (!('theme' in localStorage)) {
|
||||
localStorage.theme = 'dark';
|
||||
document.documentElement.classList.add('dark')
|
||||
} else if (localStorage.theme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else if (localStorage.theme === 'light') {
|
||||
document.documentElement.classList.remove('dark')
|
||||
} else {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
let theme = localStorage.theme
|
||||
let cpuColor = '#1e90ff'
|
||||
let ramColor = '#00ced1'
|
||||
|
|
|
|||
|
|
@ -13,9 +13,55 @@
|
|||
<div x-data="searchResources()">
|
||||
@if ($current_step === 'type')
|
||||
<div x-init="window.addEventListener('scroll', () => isSticky = window.pageYOffset > 100)" class="sticky z-10 top-10 py-2">
|
||||
<input autocomplete="off" x-ref="searchInput" class="input-sticky"
|
||||
:class="{ 'input-sticky-active': isSticky }" x-model="search" placeholder="Type / to search..."
|
||||
@keydown.window.slash.prevent="$refs.searchInput.focus()">
|
||||
<div class="flex gap-2 items-start">
|
||||
<input autocomplete="off" x-ref="searchInput" class="input-sticky flex-1"
|
||||
:class="{ 'input-sticky-active': isSticky }" x-model="search" placeholder="Type / to search..."
|
||||
@keydown.window.slash.prevent="$refs.searchInput.focus()">
|
||||
<!-- Category Filter Dropdown -->
|
||||
<div class="relative" x-data="{ openCategoryDropdown: false, categorySearch: '' }" @click.outside="openCategoryDropdown = false">
|
||||
<!-- Loading/Disabled State -->
|
||||
<div x-show="loading || categories.length === 0"
|
||||
class="flex items-center justify-between gap-2 py-1.5 px-3 w-64 text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-neutral-100 dark:bg-coolgray-200 cursor-not-allowed whitespace-nowrap opacity-50">
|
||||
<span class="text-sm text-neutral-400 dark:text-neutral-600">Filter by category</span>
|
||||
<svg class="w-4 h-4 text-neutral-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Active State -->
|
||||
<div x-show="!loading && categories.length > 0"
|
||||
@click="openCategoryDropdown = !openCategoryDropdown; $nextTick(() => { if (openCategoryDropdown) $refs.categorySearchInput.focus() })"
|
||||
class="flex items-center justify-between gap-2 py-1.5 px-3 w-64 text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-pointer hover:ring-coolgray-400 transition-all whitespace-nowrap">
|
||||
<span class="text-sm truncate" x-text="selectedCategory === '' ? 'Filter by category' : selectedCategory" :class="selectedCategory === '' ? 'text-neutral-400 dark:text-neutral-600' : 'capitalize text-black dark:text-white'"></span>
|
||||
<svg class="w-4 h-4 transition-transform text-neutral-400 shrink-0" :class="{ 'rotate-180': openCategoryDropdown }" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Dropdown Menu -->
|
||||
<div x-show="openCategoryDropdown" x-transition
|
||||
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg overflow-hidden">
|
||||
<div class="sticky top-0 p-2 bg-white dark:bg-coolgray-100 border-b border-neutral-300 dark:border-coolgray-400">
|
||||
<input type="text" x-ref="categorySearchInput" x-model="categorySearch" placeholder="Search categories..."
|
||||
class="w-full px-2 py-1 text-sm rounded border border-neutral-300 dark:border-coolgray-400 bg-white dark:bg-coolgray-200 focus:outline-none focus:ring-2 focus:ring-coolgray-400"
|
||||
@click.stop>
|
||||
</div>
|
||||
<div class="max-h-60 overflow-auto scrollbar">
|
||||
<div @click="selectedCategory = ''; categorySearch = ''; openCategoryDropdown = false"
|
||||
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200"
|
||||
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selectedCategory === '' }">
|
||||
<span class="text-sm">All Categories</span>
|
||||
</div>
|
||||
<template x-for="category in categories.filter(cat => categorySearch === '' || cat.toLowerCase().includes(categorySearch.toLowerCase()))" :key="category">
|
||||
<div @click="selectedCategory = category; categorySearch = ''; openCategoryDropdown = false"
|
||||
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 capitalize"
|
||||
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selectedCategory === category }">
|
||||
<span class="text-sm" x-text="category"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="loading">Loading...</div>
|
||||
<div x-show="!loading" class="flex flex-col gap-4 py-4">
|
||||
|
|
@ -28,13 +74,13 @@ class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-1">
|
|||
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
|
||||
<x-resource-view>
|
||||
<x-slot:title><span x-text="application.name"></span></x-slot>
|
||||
<x-slot:description>
|
||||
<span x-html="window.sanitizeHTML(application.description)"></span>
|
||||
</x-slot>
|
||||
<x-slot:logo>
|
||||
<img class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:bg-white/10 bg-black/10"
|
||||
:src="application.logo">
|
||||
</x-slot:logo>
|
||||
<x-slot:description>
|
||||
<span x-html="window.sanitizeHTML(application.description)"></span>
|
||||
</x-slot>
|
||||
<x-slot:logo>
|
||||
<img class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
|
||||
:src="application.logo">
|
||||
</x-slot:logo>
|
||||
</x-resource-view>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -47,10 +93,10 @@ class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-3">
|
|||
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
|
||||
<x-resource-view>
|
||||
<x-slot:title><span x-text="application.name"></span></x-slot>
|
||||
<x-slot:description><span x-text="application.description"></span></x-slot>
|
||||
<x-slot:logo> <img
|
||||
class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:bg-white/10 bg-black/10 "
|
||||
:src="application.logo"></x-slot>
|
||||
<x-slot:description><span x-text="application.description"></span></x-slot>
|
||||
<x-slot:logo> <img
|
||||
class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
|
||||
:src="application.logo"></x-slot>
|
||||
</x-resource-view>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -63,12 +109,12 @@ class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-2">
|
|||
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
|
||||
<x-resource-view>
|
||||
<x-slot:title><span x-text="database.name"></span></x-slot>
|
||||
<x-slot:description><span x-text="database.description"></span></x-slot>
|
||||
<x-slot:logo>
|
||||
<span x-show="database.logo">
|
||||
<span x-html="database.logo"></span>
|
||||
</span>
|
||||
</x-slot>
|
||||
<x-slot:description><span x-text="database.description"></span></x-slot>
|
||||
<x-slot:logo>
|
||||
<span x-show="database.logo">
|
||||
<span x-html="database.logo"></span>
|
||||
</span>
|
||||
</x-slot>
|
||||
</x-resource-view>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -95,33 +141,33 @@ class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-2">
|
|||
<template x-if="service.name">
|
||||
<span x-text="service.name"></span>
|
||||
</template>
|
||||
</x-slot>
|
||||
<x-slot:description>
|
||||
<template x-if="service.slogan">
|
||||
<span x-text="service.slogan"></span>
|
||||
</template>
|
||||
</x-slot>
|
||||
<x-slot:logo>
|
||||
<template x-if="service.logo">
|
||||
<img class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:bg-white/10 bg-black/10"
|
||||
:src='service.logo'
|
||||
x-on:error.window="$event.target.src = service.logo_github_url"
|
||||
onerror="this.onerror=null; this.src=this.getAttribute('data-fallback');"
|
||||
x-on:error="$event.target.src = '/coolify-logo.svg'"
|
||||
:data-fallback='service.logo_github_url' />
|
||||
</template>
|
||||
</x-slot:logo>
|
||||
<x-slot:documentation>
|
||||
<template x-if="service.documentation">
|
||||
<div class="flex items-center px-2" title="Read the documentation.">
|
||||
<a class="p-2 rounded-sm hover:bg-gray-100 dark:hover:bg-coolgray-200 hover:no-underline dark:group-hover:text-white text-neutral-600"
|
||||
onclick="event.stopPropagation()" :href="service.documentation"
|
||||
target="_blank">
|
||||
Docs
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</x-slot:documentation>
|
||||
</x-slot>
|
||||
<x-slot:description>
|
||||
<template x-if="service.slogan">
|
||||
<span x-text="service.slogan"></span>
|
||||
</template>
|
||||
</x-slot>
|
||||
<x-slot:logo>
|
||||
<template x-if="service.logo">
|
||||
<img class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
|
||||
:src='service.logo'
|
||||
x-on:error.window="$event.target.src = service.logo_github_url"
|
||||
onerror="this.onerror=null; this.src=this.getAttribute('data-fallback');"
|
||||
x-on:error="$event.target.src = '/coolify-logo.svg'"
|
||||
:data-fallback='service.logo_github_url' />
|
||||
</template>
|
||||
</x-slot:logo>
|
||||
<x-slot:documentation>
|
||||
<template x-if="service.documentation">
|
||||
<div class="flex items-center px-2" title="Read the documentation.">
|
||||
<a class="p-2 rounded-sm hover:bg-gray-100 dark:hover:bg-coolgray-200 hover:no-underline dark:group-hover:text-white text-neutral-600"
|
||||
onclick="event.stopPropagation()" :href="service.documentation"
|
||||
target="_blank">
|
||||
Docs
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</x-slot:documentation>
|
||||
</x-resource-view>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -140,6 +186,8 @@ function sortFn(a, b) {
|
|||
function searchResources() {
|
||||
return {
|
||||
search: '',
|
||||
selectedCategory: '',
|
||||
categories: [],
|
||||
loading: false,
|
||||
isSticky: false,
|
||||
selecting: false,
|
||||
|
|
@ -156,11 +204,13 @@ function searchResources() {
|
|||
this.loading = true;
|
||||
const {
|
||||
services,
|
||||
categories,
|
||||
gitBasedApplications,
|
||||
dockerBasedApplications,
|
||||
databases
|
||||
} = await this.$wire.loadServices();
|
||||
this.services = services;
|
||||
this.categories = categories || [];
|
||||
this.gitBasedApplications = gitBasedApplications;
|
||||
this.dockerBasedApplications = dockerBasedApplications;
|
||||
this.databases = databases;
|
||||
|
|
@ -171,15 +221,30 @@ function searchResources() {
|
|||
},
|
||||
filterAndSort(items, isSort = true) {
|
||||
const searchLower = this.search.trim().toLowerCase();
|
||||
let filtered = Object.values(items);
|
||||
|
||||
if (searchLower === '') {
|
||||
return isSort ? Object.values(items).sort(sortFn) : Object.values(items);
|
||||
// Filter by category if selected
|
||||
if (this.selectedCategory !== '') {
|
||||
const selectedCategoryLower = this.selectedCategory.toLowerCase();
|
||||
filtered = filtered.filter(item => {
|
||||
if (!item.category) return false;
|
||||
// Handle comma-separated categories
|
||||
const categories = item.category.includes(',')
|
||||
? item.category.split(',').map(c => c.trim().toLowerCase())
|
||||
: [item.category.toLowerCase()];
|
||||
return categories.includes(selectedCategoryLower);
|
||||
});
|
||||
}
|
||||
const filtered = Object.values(items).filter(item => {
|
||||
return (item.name?.toLowerCase().includes(searchLower) ||
|
||||
item.description?.toLowerCase().includes(searchLower) ||
|
||||
item.slogan?.toLowerCase().includes(searchLower))
|
||||
})
|
||||
|
||||
// Filter by search term
|
||||
if (searchLower !== '') {
|
||||
filtered = filtered.filter(item => {
|
||||
return (item.name?.toLowerCase().includes(searchLower) ||
|
||||
item.description?.toLowerCase().includes(searchLower) ||
|
||||
item.slogan?.toLowerCase().includes(searchLower))
|
||||
});
|
||||
}
|
||||
|
||||
return isSort ? filtered.sort(sortFn) : filtered;
|
||||
},
|
||||
get filteredGitBasedApplications() {
|
||||
|
|
@ -236,14 +301,14 @@ function searchResources() {
|
|||
{{ $server->name }}
|
||||
</div>
|
||||
<div class="box-description">
|
||||
{{ $server->description }}</div>
|
||||
{{ $server->description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div>
|
||||
|
||||
<div>No validated & reachable servers found. <a class="underline dark:text-white"
|
||||
href="/servers">
|
||||
<div>No validated & reachable servers found. <a class="underline dark:text-white" href="/servers">
|
||||
Go to servers page
|
||||
</a></div>
|
||||
</div>
|
||||
|
|
@ -303,8 +368,7 @@ function searchResources() {
|
|||
|
||||
<div class="flex items-center px-2" title="Read the documentation.">
|
||||
<a class="p-2 hover:underline dark:group-hover:text-white dark:text-white text-neutral-6000"
|
||||
onclick="event.stopPropagation()" href="https://hub.docker.com/_/postgres/"
|
||||
target="_blank">
|
||||
onclick="event.stopPropagation()" href="https://hub.docker.com/_/postgres/" target="_blank">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -322,8 +386,7 @@ function searchResources() {
|
|||
<div class="flex-1"></div>
|
||||
<div class="flex items-center px-2" title="Read the documentation.">
|
||||
<a class="p-2 hover:underline dark:group-hover:text-white dark:text-white text-neutral-600"
|
||||
onclick="event.stopPropagation()" href="https://github.com/supabase/postgres"
|
||||
target="_blank">
|
||||
onclick="event.stopPropagation()" href="https://github.com/supabase/postgres" target="_blank">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -361,8 +424,7 @@ function searchResources() {
|
|||
|
||||
<div class="flex items-center px-2" title="Read the documentation.">
|
||||
<a class="p-2 hover:underline dark:group-hover:text-white dark:text-white text-neutral-600"
|
||||
onclick="event.stopPropagation()" href="https://github.com/pgvector/pgvector"
|
||||
target="_blank">
|
||||
onclick="event.stopPropagation()" href="https://github.com/pgvector/pgvector" target="_blank">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -377,4 +439,4 @@ function searchResources() {
|
|||
<x-forms.button type="submit">Add Database</x-forms.button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,23 +1,30 @@
|
|||
<style>
|
||||
.compose-editor-container .coolify-monaco-editor>div>div>div {
|
||||
height: 512px !important;
|
||||
min-height: 512px !important;
|
||||
}
|
||||
</style>
|
||||
<div x-data="{ raw: true, showNormalTextarea: false }">
|
||||
<div class="pb-4">Volume names are updated upon save. The service UUID will be added as a prefix to all volumes, to
|
||||
prevent
|
||||
name collision. <br>To see the actual volume names, check the Deployable Compose file, or go to Storage
|
||||
menu.</div>
|
||||
|
||||
<div x-cloak x-show="raw" class="font-mono">
|
||||
<div x-cloak x-show="showNormalTextarea">
|
||||
<x-forms.textarea rows="20" id="dockerComposeRaw">
|
||||
<div class="compose-editor-container">
|
||||
<div x-cloak x-show="raw" class="font-mono">
|
||||
<div x-cloak x-show="showNormalTextarea">
|
||||
<x-forms.textarea rows="25" id="dockerComposeRaw">
|
||||
</x-forms.textarea>
|
||||
</div>
|
||||
<div x-cloak x-show="!showNormalTextarea">
|
||||
<x-forms.textarea allowTab useMonacoEditor monacoEditorLanguage="yaml" id="dockerComposeRaw">
|
||||
</x-forms.textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div x-cloak x-show="raw === false" class="font-mono">
|
||||
<x-forms.textarea rows="25" readonly id="dockerCompose">
|
||||
</x-forms.textarea>
|
||||
</div>
|
||||
<div x-cloak x-show="!showNormalTextarea">
|
||||
<x-forms.textarea allowTab useMonacoEditor monacoEditorLanguage="yaml" rows="20"
|
||||
id="dockerComposeRaw">
|
||||
</x-forms.textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div x-cloak x-show="raw === false" class="font-mono">
|
||||
<x-forms.textarea rows="20" readonly id="dockerCompose">
|
||||
</x-forms.textarea>
|
||||
</div>
|
||||
<div class="pt-2 flex gap-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
|
|
@ -46,4 +53,4 @@
|
|||
Save
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,21 +49,30 @@
|
|||
localStorage.setItem('theme', userSettings);
|
||||
|
||||
const themeMetaTag = document.querySelector('meta[name=theme-color]');
|
||||
let isDark = false;
|
||||
|
||||
if (userSettings === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
themeMetaTag.setAttribute('content', this.darkColorContent);
|
||||
this.theme = 'dark';
|
||||
isDark = true;
|
||||
} else if (userSettings === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
themeMetaTag.setAttribute('content', this.whiteColorContent);
|
||||
this.theme = 'light';
|
||||
} else if (darkModePreference) {
|
||||
isDark = false;
|
||||
} else if (userSettings === 'system') {
|
||||
this.theme = 'system';
|
||||
document.documentElement.classList.add('dark');
|
||||
} else if (!darkModePreference) {
|
||||
this.theme = 'system';
|
||||
document.documentElement.classList.remove('dark');
|
||||
if (darkModePreference) {
|
||||
document.documentElement.classList.add('dark');
|
||||
isDark = true;
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
isDark = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update theme-color meta tag
|
||||
if (themeMetaTag) {
|
||||
themeMetaTag.setAttribute('content', isDark ? '#101010' : '#ffffff');
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# documentation: https://github.com/siyuan-note/siyuan
|
||||
# slogan: A privacy-first, self-hosted, fully open source personal knowledge management software, written in typescript and golang.
|
||||
# tags: note-taking,markdown,pkm
|
||||
# category: documentation
|
||||
# logo: svgs/siyuan.svg
|
||||
# port: 6806
|
||||
|
||||
|
|
|
|||
|
|
@ -3791,7 +3791,7 @@
|
|||
"markdown",
|
||||
"pkm"
|
||||
],
|
||||
"category": null,
|
||||
"category": "documentation",
|
||||
"logo": "svgs/siyuan.svg",
|
||||
"minversion": "0.0.0",
|
||||
"port": "6806"
|
||||
|
|
|
|||
|
|
@ -3791,7 +3791,7 @@
|
|||
"markdown",
|
||||
"pkm"
|
||||
],
|
||||
"category": null,
|
||||
"category": "documentation",
|
||||
"logo": "svgs/siyuan.svg",
|
||||
"minversion": "0.0.0",
|
||||
"port": "6806"
|
||||
|
|
|
|||
100
tests/Unit/DockerComposeRawContentRemovalTest.php
Normal file
100
tests/Unit/DockerComposeRawContentRemovalTest.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify that docker_compose_raw only has content: removed from volumes,
|
||||
* while docker_compose contains all Coolify additions (labels, environment variables, networks).
|
||||
*
|
||||
* These tests verify the fix for the issue where docker_compose_raw was being set to the
|
||||
* fully processed compose (with Coolify labels, networks, etc.) instead of keeping it clean
|
||||
* with only content: fields removed.
|
||||
*/
|
||||
it('ensures applicationParser stores original compose before processing', function () {
|
||||
// Read the applicationParser function from parsers.php
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that originalCompose is stored at the start of the function
|
||||
expect($parsersFile)
|
||||
->toContain('$compose = data_get($resource, \'docker_compose_raw\');')
|
||||
->toContain('// Store original compose for later use to update docker_compose_raw with content removed')
|
||||
->toContain('$originalCompose = $compose;');
|
||||
});
|
||||
|
||||
it('ensures serviceParser stores original compose before processing', function () {
|
||||
// Read the serviceParser function from parsers.php
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that originalCompose is stored at the start of the function
|
||||
expect($parsersFile)
|
||||
->toContain('function serviceParser(Service $resource): Collection')
|
||||
->toContain('$compose = data_get($resource, \'docker_compose_raw\');')
|
||||
->toContain('// Store original compose for later use to update docker_compose_raw with content removed')
|
||||
->toContain('$originalCompose = $compose;');
|
||||
});
|
||||
|
||||
it('ensures applicationParser updates docker_compose_raw from original compose, not cleaned compose', function () {
|
||||
// Read the applicationParser function from parsers.php
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that docker_compose_raw is set from originalCompose, not cleanedCompose
|
||||
expect($parsersFile)
|
||||
->toContain('$originalYaml = Yaml::parse($originalCompose);')
|
||||
->toContain('$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);')
|
||||
->not->toContain('$resource->docker_compose_raw = $cleanedCompose;');
|
||||
});
|
||||
|
||||
it('ensures serviceParser updates docker_compose_raw from original compose, not cleaned compose', function () {
|
||||
// Read the serviceParser function from parsers.php
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Find the serviceParser function content
|
||||
$serviceParserStart = strpos($parsersFile, 'function serviceParser(Service $resource): Collection');
|
||||
$serviceParserContent = substr($parsersFile, $serviceParserStart);
|
||||
|
||||
// Check that docker_compose_raw is set from originalCompose within serviceParser
|
||||
expect($serviceParserContent)
|
||||
->toContain('$originalYaml = Yaml::parse($originalCompose);')
|
||||
->toContain('$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);')
|
||||
->not->toContain('$resource->docker_compose_raw = $cleanedCompose;');
|
||||
});
|
||||
|
||||
it('ensures applicationParser removes content, isDirectory, and is_directory from volumes', function () {
|
||||
// Read the applicationParser function from parsers.php
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that content removal logic exists
|
||||
expect($parsersFile)
|
||||
->toContain('// Remove content, isDirectory, and is_directory from all volume definitions')
|
||||
->toContain("unset(\$volume['content']);")
|
||||
->toContain("unset(\$volume['isDirectory']);")
|
||||
->toContain("unset(\$volume['is_directory']);");
|
||||
});
|
||||
|
||||
it('ensures serviceParser removes content, isDirectory, and is_directory from volumes', function () {
|
||||
// Read the serviceParser function from parsers.php
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Find the serviceParser function content
|
||||
$serviceParserStart = strpos($parsersFile, 'function serviceParser(Service $resource): Collection');
|
||||
$serviceParserContent = substr($parsersFile, $serviceParserStart);
|
||||
|
||||
// Check that content removal logic exists within serviceParser
|
||||
expect($serviceParserContent)
|
||||
->toContain('// Remove content, isDirectory, and is_directory from all volume definitions')
|
||||
->toContain("unset(\$volume['content']);")
|
||||
->toContain("unset(\$volume['isDirectory']);")
|
||||
->toContain("unset(\$volume['is_directory']);");
|
||||
});
|
||||
|
||||
it('ensures docker_compose_raw update is wrapped in try-catch for error handling', function () {
|
||||
// Read the parsers file
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that docker_compose_raw update has error handling
|
||||
expect($parsersFile)
|
||||
->toContain('// Update docker_compose_raw to remove content: from volumes only')
|
||||
->toContain('// This keeps the original user input clean while preventing content reapplication')
|
||||
->toContain('try {')
|
||||
->toContain('$originalYaml = Yaml::parse($originalCompose);')
|
||||
->toContain('} catch (\Exception $e) {')
|
||||
->toContain("ray('Failed to update docker_compose_raw");
|
||||
});
|
||||
90
tests/Unit/DockerComposeRawSeparationTest.php
Normal file
90
tests/Unit/DockerComposeRawSeparationTest.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* Integration test to verify docker_compose_raw remains clean after parsing
|
||||
*/
|
||||
it('verifies docker_compose_raw does not contain Coolify labels after parsing', function () {
|
||||
// This test requires database, so skip if not available
|
||||
if (! DB::connection()->getDatabaseName()) {
|
||||
$this->markTestSkipped('Database not available');
|
||||
}
|
||||
|
||||
// Create a simple compose file with volumes containing content
|
||||
$originalCompose = <<<'YAML'
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./config
|
||||
target: /etc/nginx/conf.d
|
||||
content: |
|
||||
server {
|
||||
listen 80;
|
||||
}
|
||||
labels:
|
||||
- "my.custom.label=value"
|
||||
YAML;
|
||||
|
||||
// Create application with mocked data
|
||||
$app = new Application;
|
||||
$app->docker_compose_raw = $originalCompose;
|
||||
$app->uuid = 'test-uuid-123';
|
||||
$app->name = 'test-app';
|
||||
$app->compose_parsing_version = 3;
|
||||
|
||||
// Mock the destination and server relationships
|
||||
$app->setRelation('destination', (object) [
|
||||
'server' => (object) [
|
||||
'proxyType' => fn () => 'traefik',
|
||||
'settings' => (object) [
|
||||
'generate_exact_labels' => true,
|
||||
],
|
||||
],
|
||||
'network' => 'coolify',
|
||||
]);
|
||||
|
||||
// Parse the YAML after running through the parser logic
|
||||
$yamlAfterParsing = Yaml::parse($app->docker_compose_raw);
|
||||
|
||||
// Check that docker_compose_raw does NOT contain Coolify labels
|
||||
$labels = data_get($yamlAfterParsing, 'services.web.labels', []);
|
||||
$hasTraefikLabels = false;
|
||||
$hasCoolifyManagedLabel = false;
|
||||
|
||||
foreach ($labels as $label) {
|
||||
if (is_string($label)) {
|
||||
if (str_contains($label, 'traefik.')) {
|
||||
$hasTraefikLabels = true;
|
||||
}
|
||||
if (str_contains($label, 'coolify.managed')) {
|
||||
$hasCoolifyManagedLabel = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// docker_compose_raw should NOT have Coolify additions
|
||||
expect($hasTraefikLabels)->toBeFalse('docker_compose_raw should not contain Traefik labels');
|
||||
expect($hasCoolifyManagedLabel)->toBeFalse('docker_compose_raw should not contain coolify.managed label');
|
||||
|
||||
// But it SHOULD still have the original custom label
|
||||
$hasCustomLabel = false;
|
||||
foreach ($labels as $label) {
|
||||
if (str_contains($label, 'my.custom.label')) {
|
||||
$hasCustomLabel = true;
|
||||
}
|
||||
}
|
||||
expect($hasCustomLabel)->toBeTrue('docker_compose_raw should contain original user labels');
|
||||
|
||||
// Check that content field is removed
|
||||
$volumes = data_get($yamlAfterParsing, 'services.web.volumes', []);
|
||||
foreach ($volumes as $volume) {
|
||||
if (is_array($volume)) {
|
||||
expect($volume)->not->toHaveKey('content', 'content field should be removed from volumes');
|
||||
}
|
||||
}
|
||||
});
|
||||
60
tests/Unit/ScheduledJobManagerLockTest.php
Normal file
60
tests/Unit/ScheduledJobManagerLockTest.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
|
||||
it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks', function () {
|
||||
$job = new ScheduledJobManager;
|
||||
$middleware = $job->middleware();
|
||||
|
||||
// Assert middleware exists
|
||||
expect($middleware)->toBeArray()
|
||||
->and($middleware)->toHaveCount(1);
|
||||
|
||||
$overlappingMiddleware = $middleware[0];
|
||||
|
||||
// Assert it's a WithoutOverlapping instance
|
||||
expect($overlappingMiddleware)->toBeInstanceOf(WithoutOverlapping::class);
|
||||
|
||||
// Use reflection to check private properties
|
||||
$reflection = new ReflectionClass($overlappingMiddleware);
|
||||
|
||||
// Check expireAfter is set (should be 60 seconds - matches job frequency)
|
||||
$expiresAfterProperty = $reflection->getProperty('expiresAfter');
|
||||
$expiresAfterProperty->setAccessible(true);
|
||||
$expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware);
|
||||
|
||||
expect($expiresAfter)->toBe(60)
|
||||
->and($expiresAfter)->toBeGreaterThan(0, 'expireAfter must be set to prevent stale locks');
|
||||
|
||||
// Check releaseAfter is NOT set (we use dontRelease)
|
||||
$releaseAfterProperty = $reflection->getProperty('releaseAfter');
|
||||
$releaseAfterProperty->setAccessible(true);
|
||||
$releaseAfter = $releaseAfterProperty->getValue($overlappingMiddleware);
|
||||
|
||||
expect($releaseAfter)->toBeNull('releaseAfter should be null when using dontRelease()');
|
||||
|
||||
// Check the lock key
|
||||
$keyProperty = $reflection->getProperty('key');
|
||||
$keyProperty->setAccessible(true);
|
||||
$key = $keyProperty->getValue($overlappingMiddleware);
|
||||
|
||||
expect($key)->toBe('scheduled-job-manager');
|
||||
});
|
||||
|
||||
it('prevents stale locks by ensuring expireAfter is always set', function () {
|
||||
$job = new ScheduledJobManager;
|
||||
$middleware = $job->middleware();
|
||||
|
||||
$overlappingMiddleware = $middleware[0];
|
||||
$reflection = new ReflectionClass($overlappingMiddleware);
|
||||
|
||||
$expiresAfterProperty = $reflection->getProperty('expiresAfter');
|
||||
$expiresAfterProperty->setAccessible(true);
|
||||
$expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware);
|
||||
|
||||
// Critical check: expireAfter MUST be set to prevent GitHub issue #4539
|
||||
expect($expiresAfter)->not->toBeNull(
|
||||
'expireAfter() is required to prevent stale locks (see GitHub #4539)'
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue