fix: correct status for excluded health check containers (#7283)
This commit is contained in:
commit
355dcc186c
32 changed files with 3720 additions and 344 deletions
|
|
@ -16,7 +16,7 @@ ### 📚 Core Documentation
|
|||
- **[Technology Stack](core/technology-stack.md)** - All versions, packages, and dependencies (Laravel 12.4.1, PHP 8.4.7, etc.)
|
||||
- **[Project Overview](core/project-overview.md)** - What Coolify is and how it works
|
||||
- **[Application Architecture](core/application-architecture.md)** - System design and component relationships
|
||||
- **[Deployment Architecture](core/deployment-architecture.md)** - How deployments work end-to-end
|
||||
- **[Deployment Architecture](core/deployment-architecture.md)** - How deployments work end-to-end, including Coolify Docker Compose extensions (custom fields)
|
||||
|
||||
### 💻 Development
|
||||
Day-to-day development practices:
|
||||
|
|
@ -85,6 +85,13 @@ ### Laravel-Specific Questions
|
|||
- Pest testing patterns
|
||||
- Laravel conventions
|
||||
|
||||
### Docker Compose Extensions
|
||||
→ [core/deployment-architecture.md](core/deployment-architecture.md#coolify-docker-compose-extensions)
|
||||
- Custom fields: `exclude_from_hc`, `content`, `isDirectory`
|
||||
- How to use inline file content
|
||||
- Health check exclusion patterns
|
||||
- Volume creation control
|
||||
|
||||
### Version Numbers
|
||||
→ [core/technology-stack.md](core/technology-stack.md)
|
||||
- **Single source of truth** for all version numbers
|
||||
|
|
|
|||
|
|
@ -361,3 +361,244 @@ ### **Background Processing**
|
|||
- **Queue Workers**: Horizon-managed job processing
|
||||
- **Job Batching**: Related job grouping
|
||||
- **Failed Job Handling**: Automatic retry logic
|
||||
|
||||
## Container Status Monitoring System
|
||||
|
||||
### **Overview**
|
||||
|
||||
Container health status is monitored and updated through **multiple independent paths**. When modifying status logic, **ALL paths must be updated** to ensure consistency.
|
||||
|
||||
### **Critical Implementation Locations**
|
||||
|
||||
#### **1. SSH-Based Status Updates (Scheduled)**
|
||||
**File**: [app/Actions/Docker/GetContainersStatus.php](mdc:app/Actions/Docker/GetContainersStatus.php)
|
||||
**Method**: `aggregateApplicationStatus()` (lines 487-540)
|
||||
**Trigger**: Scheduled job or manual refresh
|
||||
**Frequency**: Every minute (via `ServerCheckJob`)
|
||||
|
||||
**Status Aggregation Logic**:
|
||||
```php
|
||||
// Tracks multiple status flags
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasUnknown = false; // ⚠️ CRITICAL: Must track unknown
|
||||
$hasExited = false;
|
||||
// ... more states
|
||||
|
||||
// Priority: restarting > degraded > running (unhealthy > unknown > healthy)
|
||||
if ($hasRunning) {
|
||||
if ($hasUnhealthy) return 'running (unhealthy)';
|
||||
elseif ($hasUnknown) return 'running (unknown)';
|
||||
else return 'running (healthy)';
|
||||
}
|
||||
```
|
||||
|
||||
#### **2. Sentinel-Based Status Updates (Real-time)**
|
||||
**File**: [app/Jobs/PushServerUpdateJob.php](mdc:app/Jobs/PushServerUpdateJob.php)
|
||||
**Method**: `aggregateMultiContainerStatuses()` (lines 269-298)
|
||||
**Trigger**: Sentinel push updates from remote servers
|
||||
**Frequency**: Every ~30 seconds (real-time)
|
||||
|
||||
**Status Aggregation Logic**:
|
||||
```php
|
||||
// ⚠️ MUST match GetContainersStatus logic
|
||||
$hasRunning = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasUnknown = false; // ⚠️ CRITICAL: Added to fix bug
|
||||
|
||||
foreach ($relevantStatuses as $status) {
|
||||
if (str($status)->contains('running')) {
|
||||
$hasRunning = true;
|
||||
if (str($status)->contains('unhealthy')) $hasUnhealthy = true;
|
||||
if (str($status)->contains('unknown')) $hasUnknown = true; // ⚠️ CRITICAL
|
||||
}
|
||||
}
|
||||
|
||||
// Priority: unhealthy > unknown > healthy
|
||||
if ($hasRunning) {
|
||||
if ($hasUnhealthy) $aggregatedStatus = 'running (unhealthy)';
|
||||
elseif ($hasUnknown) $aggregatedStatus = 'running (unknown)';
|
||||
else $aggregatedStatus = 'running (healthy)';
|
||||
}
|
||||
```
|
||||
|
||||
#### **3. Multi-Server Status Aggregation**
|
||||
**File**: [app/Actions/Shared/ComplexStatusCheck.php](mdc:app/Actions/Shared/ComplexStatusCheck.php)
|
||||
**Method**: `resource()` (lines 48-210)
|
||||
**Purpose**: Aggregates status across multiple servers for applications
|
||||
**Used by**: Applications with multiple destinations
|
||||
|
||||
**Key Features**:
|
||||
- Aggregates statuses from main + additional servers
|
||||
- Handles excluded containers (`:excluded` suffix)
|
||||
- Calculates overall application health from all containers
|
||||
|
||||
**Status Format with Excluded Containers**:
|
||||
```php
|
||||
// When all containers excluded from health checks:
|
||||
return 'running:unhealthy:excluded'; // Container running but unhealthy, monitoring disabled
|
||||
return 'running:unknown:excluded'; // Container running, health unknown, monitoring disabled
|
||||
return 'running:healthy:excluded'; // Container running and healthy, monitoring disabled
|
||||
return 'degraded:excluded'; // Some containers down, monitoring disabled
|
||||
return 'exited:excluded'; // All containers stopped, monitoring disabled
|
||||
```
|
||||
|
||||
#### **4. Service-Level Status Aggregation**
|
||||
**File**: [app/Models/Service.php](mdc:app/Models/Service.php)
|
||||
**Method**: `complexStatus()` (lines 176-288)
|
||||
**Purpose**: Aggregates status for multi-container services
|
||||
**Used by**: Docker Compose services
|
||||
|
||||
**Status Calculation**:
|
||||
```php
|
||||
// Aggregates status from all service applications and databases
|
||||
// Handles excluded containers separately
|
||||
// Returns status with :excluded suffix when all containers excluded
|
||||
if (!$hasNonExcluded && $complexStatus === null && $complexHealth === null) {
|
||||
// All services excluded - calculate from excluded containers
|
||||
return "{$excludedStatus}:excluded";
|
||||
}
|
||||
```
|
||||
|
||||
### **Status Flow Diagram**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Container Status Sources │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌─────────────────┐ ┌──────────────┐
|
||||
│ SSH-Based │ │ Sentinel-Based │ │ Multi-Server │
|
||||
│ (Scheduled) │ │ (Real-time) │ │ Aggregation │
|
||||
├───────────────┤ ├─────────────────┤ ├──────────────┤
|
||||
│ ServerCheck │ │ PushServerUp- │ │ ComplexStatus│
|
||||
│ Job │ │ dateJob │ │ Check │
|
||||
│ │ │ │ │ │
|
||||
│ Every ~1min │ │ Every ~30sec │ │ On demand │
|
||||
└───────┬───────┘ └────────┬────────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
└────────────────────┼────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ Application/Service │
|
||||
│ Status Property │
|
||||
└───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ UI Display (Livewire) │
|
||||
└───────────────────────┘
|
||||
```
|
||||
|
||||
### **Status Priority System**
|
||||
|
||||
All status aggregation locations **MUST** follow the same priority:
|
||||
|
||||
**For Running Containers**:
|
||||
1. **unhealthy** - Container has failing health checks
|
||||
2. **unknown** - Container health status cannot be determined
|
||||
3. **healthy** - Container is healthy
|
||||
|
||||
**For Non-Running States**:
|
||||
1. **restarting** → `degraded (unhealthy)`
|
||||
2. **running + exited** → `degraded (unhealthy)`
|
||||
3. **dead/removing** → `degraded (unhealthy)`
|
||||
4. **paused** → `paused`
|
||||
5. **created/starting** → `starting`
|
||||
6. **exited** → `exited (unhealthy)`
|
||||
|
||||
### **Excluded Containers**
|
||||
|
||||
When containers have `exclude_from_hc: true` flag or `restart: no`:
|
||||
|
||||
**Behavior**:
|
||||
- Status is still calculated from container state
|
||||
- `:excluded` suffix is appended to indicate monitoring disabled
|
||||
- UI shows "(Monitoring Disabled)" badge
|
||||
- Action buttons respect the actual container state
|
||||
|
||||
**Format**: `{actual-status}:excluded`
|
||||
**Examples**: `running:unknown:excluded`, `degraded:excluded`, `exited:excluded`
|
||||
|
||||
**All-Excluded Scenario**:
|
||||
When ALL containers are excluded from health checks:
|
||||
- All three status update paths (PushServerUpdateJob, GetContainersStatus, ComplexStatusCheck) **MUST** calculate status from excluded containers
|
||||
- Status is returned with `:excluded` suffix (e.g., `running:healthy:excluded`)
|
||||
- **NEVER** skip status updates - always calculate from excluded containers
|
||||
- This ensures consistent status regardless of which update mechanism runs
|
||||
- Shared logic is in `app/Traits/CalculatesExcludedStatus.php`
|
||||
|
||||
### **Important Notes for Developers**
|
||||
|
||||
✅ **Container Status Aggregation Service**:
|
||||
|
||||
The container status aggregation logic is centralized in `App\Services\ContainerStatusAggregator`.
|
||||
|
||||
**Status Format Standard**:
|
||||
- **Backend/Storage**: Colon format (`running:healthy`, `degraded:unhealthy`)
|
||||
- **UI/Display**: Transform to human format (`Running (Healthy)`, `Degraded (Unhealthy)`)
|
||||
|
||||
1. **Using the ContainerStatusAggregator Service**:
|
||||
- Import `App\Services\ContainerStatusAggregator` in any class needing status aggregation
|
||||
- Two methods available:
|
||||
- `aggregateFromStrings(Collection $statusStrings, int $maxRestartCount = 0)` - For pre-formatted status strings
|
||||
- `aggregateFromContainers(Collection $containers, int $maxRestartCount = 0)` - For raw Docker container objects
|
||||
- Returns colon format: `running:healthy`, `degraded:unhealthy`, etc.
|
||||
- Automatically handles crash loop detection via `$maxRestartCount` parameter
|
||||
|
||||
2. **State Machine Priority** (handled by service):
|
||||
- Restarting → `degraded:unhealthy` (highest priority)
|
||||
- Crash loop (exited with restarts) → `degraded:unhealthy`
|
||||
- Mixed state (running + exited) → `degraded:unhealthy`
|
||||
- Running → `running:unhealthy` / `running:unknown` / `running:healthy`
|
||||
- Dead/Removing → `degraded:unhealthy`
|
||||
- Paused → `paused:unknown`
|
||||
- Starting/Created → `starting:unknown`
|
||||
- Exited → `exited:unhealthy` (lowest priority)
|
||||
|
||||
3. **Test both update paths**:
|
||||
- Run unit tests: `./vendor/bin/pest tests/Unit/ContainerStatusAggregatorTest.php`
|
||||
- Run integration tests: `./vendor/bin/pest tests/Unit/`
|
||||
- Test SSH updates (manual refresh)
|
||||
- Test Sentinel updates (wait 30 seconds)
|
||||
|
||||
4. **Handle excluded containers**:
|
||||
- All containers excluded (`exclude_from_hc: true`) - Use `CalculatesExcludedStatus` trait
|
||||
- Mixed excluded/non-excluded containers - Filter then use `ContainerStatusAggregator`
|
||||
- Containers with `restart: no` - Treated same as `exclude_from_hc: true`
|
||||
|
||||
5. **Use shared trait for excluded containers**:
|
||||
- Import `App\Traits\CalculatesExcludedStatus` in status calculation classes
|
||||
- Use `getExcludedContainersFromDockerCompose()` to parse exclusions
|
||||
- Use `calculateExcludedStatus()` for full Docker inspect objects (ComplexStatusCheck)
|
||||
- Use `calculateExcludedStatusFromStrings()` for status strings (PushServerUpdateJob, GetContainersStatus)
|
||||
|
||||
### **Related Tests**
|
||||
|
||||
- **[tests/Unit/ContainerStatusAggregatorTest.php](mdc:tests/Unit/ContainerStatusAggregatorTest.php)**: Core state machine logic (42 comprehensive tests)
|
||||
- **[tests/Unit/ContainerHealthStatusTest.php](mdc:tests/Unit/ContainerHealthStatusTest.php)**: Health status aggregation integration
|
||||
- **[tests/Unit/PushServerUpdateJobStatusAggregationTest.php](mdc:tests/Unit/PushServerUpdateJobStatusAggregationTest.php)**: Sentinel update logic
|
||||
- **[tests/Unit/ExcludeFromHealthCheckTest.php](mdc:tests/Unit/ExcludeFromHealthCheckTest.php)**: Excluded container handling
|
||||
|
||||
### **Common Bugs to Avoid**
|
||||
|
||||
✅ **Prevented by ContainerStatusAggregator Service**:
|
||||
- ❌ **Old Bug**: Forgetting to track `$hasUnknown` flag → ✅ Now centralized in service
|
||||
- ❌ **Old Bug**: Inconsistent priority across paths → ✅ Single source of truth
|
||||
- ❌ **Old Bug**: Forgetting to update all 4 locations → ✅ Only one location to update
|
||||
|
||||
**Still Relevant**:
|
||||
|
||||
❌ **Bug**: Forgetting to filter excluded containers before aggregation
|
||||
✅ **Fix**: Always use `CalculatesExcludedStatus` trait to filter before calling `ContainerStatusAggregator`
|
||||
|
||||
❌ **Bug**: Not passing `$maxRestartCount` for crash loop detection
|
||||
✅ **Fix**: Calculate max restart count from containers and pass to `aggregateFromStrings()`/`aggregateFromContainers()`
|
||||
|
||||
❌ **Bug**: Not handling excluded containers with `:excluded` suffix
|
||||
✅ **Fix**: Check for `:excluded` suffix in UI logic and button visibility
|
||||
|
|
|
|||
|
|
@ -303,3 +303,286 @@ ### External Services
|
|||
- **External database** connections
|
||||
- **Third-party monitoring** tools
|
||||
- **Custom notification** channels
|
||||
|
||||
---
|
||||
|
||||
## Coolify Docker Compose Extensions
|
||||
|
||||
Coolify extends standard Docker Compose with custom fields (often called "magic fields") that provide Coolify-specific functionality. These extensions are processed during deployment and stripped before sending the final compose file to Docker, maintaining full compatibility with Docker's compose specification.
|
||||
|
||||
### Overview
|
||||
|
||||
**Why Custom Fields?**
|
||||
- Enable Coolify-specific features without breaking Docker Compose compatibility
|
||||
- Simplify configuration by embedding content directly in compose files
|
||||
- Allow fine-grained control over health check monitoring
|
||||
- Reduce external file dependencies
|
||||
|
||||
**Processing Flow:**
|
||||
1. User defines compose file with custom fields
|
||||
2. Coolify parses and processes custom fields (creates files, stores settings)
|
||||
3. Custom fields are stripped from final compose sent to Docker
|
||||
4. Docker receives standard, valid compose file
|
||||
|
||||
### Service-Level Extensions
|
||||
|
||||
#### `exclude_from_hc`
|
||||
|
||||
**Type:** Boolean
|
||||
**Default:** `false`
|
||||
**Purpose:** Exclude specific services from health check monitoring while still showing their status
|
||||
|
||||
**Example Usage:**
|
||||
```yaml
|
||||
services:
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
exclude_from_hc: true # Don't monitor this service's health
|
||||
|
||||
backup:
|
||||
image: postgres:16
|
||||
exclude_from_hc: true # Backup containers don't need monitoring
|
||||
restart: always
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Container status is still calculated from Docker state (running, exited, etc.)
|
||||
- Status displays with `:excluded` suffix (e.g., `running:healthy:excluded`)
|
||||
- UI shows "Monitoring Disabled" indicator
|
||||
- Functionally equivalent to `restart: no` for health check purposes
|
||||
- See [Container Status with All Excluded](application-architecture.md#container-status-when-all-containers-excluded) for detailed status handling
|
||||
|
||||
**Use Cases:**
|
||||
- Sidecar containers (watchtower, log collectors)
|
||||
- Backup/maintenance containers
|
||||
- One-time initialization containers
|
||||
- Containers that intentionally restart frequently
|
||||
|
||||
**Implementation:**
|
||||
- Parsed: `bootstrap/helpers/parsers.php`
|
||||
- Status logic: `app/Traits/CalculatesExcludedStatus.php`
|
||||
- Validation: `tests/Unit/ExcludeFromHealthCheckTest.php`
|
||||
|
||||
### Volume-Level Extensions
|
||||
|
||||
Volume extensions only work with **long syntax** (array/object format), not short syntax (string format).
|
||||
|
||||
#### `content`
|
||||
|
||||
**Type:** String (supports multiline with `|` or `>`)
|
||||
**Purpose:** Embed file content directly in compose file for automatic creation during deployment
|
||||
|
||||
**Example Usage:**
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: node:20
|
||||
volumes:
|
||||
# Inline entrypoint script
|
||||
- type: bind
|
||||
source: ./entrypoint.sh
|
||||
target: /app/entrypoint.sh
|
||||
content: |
|
||||
#!/bin/sh
|
||||
set -e
|
||||
echo "Starting application..."
|
||||
npm run migrate
|
||||
exec "$@"
|
||||
|
||||
# Configuration file with environment variables
|
||||
- type: bind
|
||||
source: ./config.xml
|
||||
target: /etc/app/config.xml
|
||||
content: |
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<config>
|
||||
<database>
|
||||
<host>${DB_HOST}</host>
|
||||
<port>${DB_PORT}</port>
|
||||
</database>
|
||||
</config>
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Content is written to the host at `source` path before container starts
|
||||
- File is created with mode `644` (readable by all, writable by owner)
|
||||
- Environment variables in content are interpolated at deployment time
|
||||
- Content is stored in `LocalFileVolume` model (encrypted at rest)
|
||||
- Original `docker_compose_raw` retains content for editing
|
||||
|
||||
**Use Cases:**
|
||||
- Entrypoint scripts
|
||||
- Configuration files
|
||||
- Environment-specific settings
|
||||
- Small initialization scripts
|
||||
- Templates that require dynamic content
|
||||
|
||||
**Limitations:**
|
||||
- Not suitable for large files (use git repo or external storage instead)
|
||||
- Binary files not supported
|
||||
- Changes require redeployment
|
||||
|
||||
**Real-World Examples:**
|
||||
- `templates/compose/traccar.yaml` - XML configuration file
|
||||
- `templates/compose/supabase.yaml` - Multiple config files
|
||||
- `templates/compose/chaskiq.yaml` - Entrypoint script
|
||||
|
||||
**Implementation:**
|
||||
- Parsed: `bootstrap/helpers/parsers.php` (line 717)
|
||||
- Storage: `app/Models/LocalFileVolume.php`
|
||||
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
|
||||
|
||||
#### `is_directory` / `isDirectory`
|
||||
|
||||
**Type:** Boolean
|
||||
**Default:** `true` (if neither `content` nor explicit flag provided)
|
||||
**Purpose:** Indicate whether bind mount source should be created as directory or file
|
||||
|
||||
**Example Usage:**
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
volumes:
|
||||
# Explicit file
|
||||
- type: bind
|
||||
source: ./config.json
|
||||
target: /app/config.json
|
||||
is_directory: false # Create as file
|
||||
|
||||
# Explicit directory
|
||||
- type: bind
|
||||
source: ./logs
|
||||
target: /var/log/app
|
||||
is_directory: true # Create as directory
|
||||
|
||||
# Auto-detected as file (has content)
|
||||
- type: bind
|
||||
source: ./script.sh
|
||||
target: /entrypoint.sh
|
||||
content: |
|
||||
#!/bin/sh
|
||||
echo "Hello"
|
||||
# is_directory: false implied by content presence
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- If `is_directory: true` → Creates directory with `mkdir -p`
|
||||
- If `is_directory: false` → Creates empty file with `touch`
|
||||
- If `content` provided → Implies `is_directory: false`
|
||||
- If neither specified → Defaults to `true` (directory)
|
||||
|
||||
**Naming Conventions:**
|
||||
- `is_directory` (snake_case) - **Preferred**, consistent with PHP/Laravel conventions
|
||||
- `isDirectory` (camelCase) - **Legacy support**, both work identically
|
||||
|
||||
**Use Cases:**
|
||||
- Disambiguating files vs directories when no content provided
|
||||
- Ensuring correct bind mount type for Docker
|
||||
- Pre-creating mount points before container starts
|
||||
|
||||
**Implementation:**
|
||||
- Parsed: `bootstrap/helpers/parsers.php` (line 718)
|
||||
- Storage: `app/Models/LocalFileVolume.php` (`is_directory` column)
|
||||
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
|
||||
|
||||
### Custom Field Stripping
|
||||
|
||||
**Function:** `stripCoolifyCustomFields()` in `bootstrap/helpers/docker.php`
|
||||
|
||||
All custom fields are removed before the compose file is sent to Docker. This happens in two contexts:
|
||||
|
||||
**1. Validation (User-Triggered)**
|
||||
```php
|
||||
// In validateComposeFile() - Edit Docker Compose modal
|
||||
$yaml_compose = Yaml::parse($compose);
|
||||
$yaml_compose = stripCoolifyCustomFields($yaml_compose); // Strip custom fields
|
||||
// Send to docker compose config for validation
|
||||
```
|
||||
|
||||
**2. Deployment (Automatic)**
|
||||
```php
|
||||
// In Service::parse() - During deployment
|
||||
$docker_compose = parseCompose($docker_compose_raw);
|
||||
// Custom fields are processed and then stripped
|
||||
// Final compose sent to Docker has no custom fields
|
||||
```
|
||||
|
||||
**What Gets Stripped:**
|
||||
- Service-level: `exclude_from_hc`
|
||||
- Volume-level: `content`, `isDirectory`, `is_directory`
|
||||
|
||||
**What's Preserved:**
|
||||
- All standard Docker Compose fields
|
||||
- Environment variables
|
||||
- Standard volume definitions (after custom fields removed)
|
||||
|
||||
### Important Notes
|
||||
|
||||
#### Long vs Short Volume Syntax
|
||||
|
||||
**✅ Long Syntax (Works with Custom Fields):**
|
||||
```yaml
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./data
|
||||
target: /app/data
|
||||
content: "Hello" # ✅ Custom fields work here
|
||||
```
|
||||
|
||||
**❌ Short Syntax (Custom Fields Ignored):**
|
||||
```yaml
|
||||
volumes:
|
||||
- "./data:/app/data" # ❌ Cannot add custom fields to strings
|
||||
```
|
||||
|
||||
#### Docker Compose Compatibility
|
||||
|
||||
Custom fields are **Coolify-specific** and won't work with standalone `docker compose` CLI:
|
||||
|
||||
```bash
|
||||
# ❌ Won't work - Docker doesn't recognize custom fields
|
||||
docker compose -f compose.yaml up
|
||||
|
||||
# ✅ Works - Use Coolify's deployment (strips custom fields first)
|
||||
# Deploy through Coolify UI or API
|
||||
```
|
||||
|
||||
#### Editing Custom Fields
|
||||
|
||||
When editing in "Edit Docker Compose" modal:
|
||||
- Custom fields are preserved in the editor
|
||||
- "Validate" button strips them temporarily for Docker validation
|
||||
- "Save" button preserves them in `docker_compose_raw`
|
||||
- They're processed again on next deployment
|
||||
|
||||
### Template Examples
|
||||
|
||||
See these templates for real-world usage:
|
||||
|
||||
**Service Exclusions:**
|
||||
- `templates/compose/budibase.yaml` - Excludes watchtower from monitoring
|
||||
- `templates/compose/pgbackweb.yaml` - Excludes backup service
|
||||
- `templates/compose/elasticsearch-with-kibana.yaml` - Excludes elasticsearch
|
||||
|
||||
**Inline Content:**
|
||||
- `templates/compose/traccar.yaml` - XML configuration (multiline)
|
||||
- `templates/compose/supabase.yaml` - Multiple config files
|
||||
- `templates/compose/searxng.yaml` - Settings file
|
||||
- `templates/compose/invoice-ninja.yaml` - Nginx config
|
||||
|
||||
**Directory Flags:**
|
||||
- `templates/compose/paperless.yaml` - Explicit directory creation
|
||||
|
||||
### Testing
|
||||
|
||||
**Unit Tests:**
|
||||
- `tests/Unit/StripCoolifyCustomFieldsTest.php` - Custom field stripping logic
|
||||
- `tests/Unit/ExcludeFromHealthCheckTest.php` - Health check exclusion behavior
|
||||
- `tests/Unit/ContainerStatusAggregatorTest.php` - Status aggregation with exclusions
|
||||
|
||||
**Test Coverage:**
|
||||
- ✅ All custom fields (exclude_from_hc, content, isDirectory, is_directory)
|
||||
- ✅ Multiline content (YAML `|` syntax)
|
||||
- ✅ Short vs long volume syntax
|
||||
- ✅ Field stripping without data loss
|
||||
- ✅ Standard Docker Compose field preservation
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use App\Traits\CalculatesExcludedStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -16,6 +18,7 @@
|
|||
class GetContainersStatus
|
||||
{
|
||||
use AsAction;
|
||||
use CalculatesExcludedStatus;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
|
|
@ -31,6 +34,8 @@ class GetContainersStatus
|
|||
|
||||
protected ?Collection $applicationContainerRestartCounts;
|
||||
|
||||
protected ?Collection $serviceContainerStatuses;
|
||||
|
||||
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
|
||||
{
|
||||
$this->containers = $containers;
|
||||
|
|
@ -98,11 +103,13 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
$labels = data_get($container, 'Config.Labels');
|
||||
}
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
$containerHealth = data_get($container, 'State.Health.Status');
|
||||
if ($containerStatus === 'restarting') {
|
||||
$containerStatus = "restarting ($containerHealth)";
|
||||
$healthSuffix = $containerHealth ?? 'unknown';
|
||||
$containerStatus = "restarting:$healthSuffix";
|
||||
} else {
|
||||
$containerStatus = "$containerStatus ($containerHealth)";
|
||||
$healthSuffix = $containerHealth ?? 'unknown';
|
||||
$containerStatus = "$containerStatus:$healthSuffix";
|
||||
}
|
||||
$labels = Arr::undot(format_docker_labels_to_json($labels));
|
||||
$applicationId = data_get($labels, 'coolify.applicationId');
|
||||
|
|
@ -222,23 +229,34 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
if ($serviceLabelId) {
|
||||
$subType = data_get($labels, 'coolify.service.subType');
|
||||
$subId = data_get($labels, 'coolify.service.subId');
|
||||
$service = $services->where('id', $serviceLabelId)->first();
|
||||
if (! $service) {
|
||||
$parentService = $services->where('id', $serviceLabelId)->first();
|
||||
if (! $parentService) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store container status for aggregation
|
||||
if (! isset($this->serviceContainerStatuses)) {
|
||||
$this->serviceContainerStatuses = collect();
|
||||
}
|
||||
|
||||
$key = $serviceLabelId.':'.$subType.':'.$subId;
|
||||
if (! $this->serviceContainerStatuses->has($key)) {
|
||||
$this->serviceContainerStatuses->put($key, collect());
|
||||
}
|
||||
|
||||
$containerName = data_get($labels, 'com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
|
||||
}
|
||||
|
||||
// Mark service as found
|
||||
if ($subType === 'application') {
|
||||
$service = $service->applications()->where('id', $subId)->first();
|
||||
$service = $parentService->applications()->where('id', $subId)->first();
|
||||
} else {
|
||||
$service = $service->databases()->where('id', $subId)->first();
|
||||
$service = $parentService->databases()->where('id', $subId)->first();
|
||||
}
|
||||
if ($service) {
|
||||
$foundServices[] = "$service->id-$service->name";
|
||||
$statusFromDb = $service->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$service->update(['status' => $containerStatus]);
|
||||
} else {
|
||||
$service->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -418,6 +436,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
}
|
||||
}
|
||||
|
||||
// Aggregate multi-container service statuses
|
||||
$this->aggregateServiceContainerStatuses($services);
|
||||
|
||||
ServiceChecked::dispatch($this->server->team->id);
|
||||
}
|
||||
|
||||
|
|
@ -425,74 +446,88 @@ private function aggregateApplicationStatus($application, Collection $containerS
|
|||
{
|
||||
// Parse docker compose to check for excluded containers
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = collect();
|
||||
|
||||
if ($dockerComposeRaw) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
foreach ($services as $serviceName => $serviceConfig) {
|
||||
// Check if container should be excluded
|
||||
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
|
||||
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
|
||||
|
||||
if ($excludeFromHc || $restartPolicy === 'no') {
|
||||
$excludedContainers->push($serviceName);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If we can't parse, treat all containers as included
|
||||
}
|
||||
}
|
||||
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||
|
||||
// Filter out excluded containers
|
||||
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
||||
return ! $excludedContainers->contains($containerName);
|
||||
});
|
||||
|
||||
// If all containers are excluded, don't update status
|
||||
// If all containers are excluded, calculate status from excluded containers
|
||||
if ($relevantStatuses->isEmpty()) {
|
||||
return null;
|
||||
return $this->calculateExcludedStatusFromStrings($containerStatuses);
|
||||
}
|
||||
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasExited = false;
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
|
||||
foreach ($relevantStatuses as $status) {
|
||||
if (str($status)->contains('restarting')) {
|
||||
$hasRestarting = true;
|
||||
} elseif (str($status)->contains('running')) {
|
||||
$hasRunning = true;
|
||||
if (str($status)->contains('unhealthy')) {
|
||||
$hasUnhealthy = true;
|
||||
return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount);
|
||||
}
|
||||
|
||||
private function aggregateServiceContainerStatuses($services)
|
||||
{
|
||||
if (! isset($this->serviceContainerStatuses) || $this->serviceContainerStatuses->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->serviceContainerStatuses as $key => $containerStatuses) {
|
||||
// Parse key: serviceId:subType:subId
|
||||
[$serviceId, $subType, $subId] = explode(':', $key);
|
||||
|
||||
$service = $services->where('id', $serviceId)->first();
|
||||
if (! $service) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
|
||||
$subResource = null;
|
||||
if ($subType === 'application') {
|
||||
$subResource = $service->applications()->where('id', $subId)->first();
|
||||
} elseif ($subType === 'database') {
|
||||
$subResource = $service->databases()->where('id', $subId)->first();
|
||||
}
|
||||
|
||||
if (! $subResource) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse docker compose from service to check for excluded containers
|
||||
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
|
||||
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||
|
||||
// Filter out excluded containers
|
||||
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
||||
return ! $excludedContainers->contains($containerName);
|
||||
});
|
||||
|
||||
// If all containers are excluded, calculate status from excluded containers
|
||||
if ($relevantStatuses->isEmpty()) {
|
||||
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
|
||||
if ($aggregatedStatus) {
|
||||
$statusFromDb = $subResource->status;
|
||||
if ($statusFromDb !== $aggregatedStatus) {
|
||||
$subResource->update(['status' => $aggregatedStatus]);
|
||||
} else {
|
||||
$subResource->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses);
|
||||
|
||||
// Update service sub-resource status with aggregated result
|
||||
if ($aggregatedStatus) {
|
||||
$statusFromDb = $subResource->status;
|
||||
if ($statusFromDb !== $aggregatedStatus) {
|
||||
$subResource->update(['status' => $aggregatedStatus]);
|
||||
} else {
|
||||
$subResource->update(['last_online_at' => now()]);
|
||||
}
|
||||
} elseif (str($status)->contains('exited')) {
|
||||
$hasExited = true;
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasRestarting) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
// If container is exited but has restart count > 0, it's in a crash loop
|
||||
if ($hasExited && $maxRestartCount > 0) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
if ($hasRunning) {
|
||||
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
||||
}
|
||||
|
||||
// All containers are exited with no restart count - truly stopped
|
||||
return 'exited (unhealthy)';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
namespace App\Actions\Shared;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use App\Traits\CalculatesExcludedStatus;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class ComplexStatusCheck
|
||||
{
|
||||
use AsAction;
|
||||
use CalculatesExcludedStatus;
|
||||
|
||||
public function handle(Application $application)
|
||||
{
|
||||
|
|
@ -61,74 +64,25 @@ public function handle(Application $application)
|
|||
private function aggregateContainerStatuses($application, $containers)
|
||||
{
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = collect();
|
||||
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||
|
||||
if ($dockerComposeRaw) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
foreach ($services as $serviceName => $serviceConfig) {
|
||||
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
|
||||
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
|
||||
|
||||
if ($excludeFromHc || $restartPolicy === 'no') {
|
||||
$excludedContainers->push($serviceName);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If we can't parse, treat all containers as included
|
||||
}
|
||||
}
|
||||
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasExited = false;
|
||||
$relevantContainerCount = 0;
|
||||
|
||||
foreach ($containers as $container) {
|
||||
// Filter non-excluded containers
|
||||
$relevantContainers = collect($containers)->filter(function ($container) use ($excludedContainers) {
|
||||
$labels = data_get($container, 'Config.Labels', []);
|
||||
$serviceName = data_get($labels, 'com.docker.compose.service');
|
||||
|
||||
if ($serviceName && $excludedContainers->contains($serviceName)) {
|
||||
continue;
|
||||
}
|
||||
return ! ($serviceName && $excludedContainers->contains($serviceName));
|
||||
});
|
||||
|
||||
$relevantContainerCount++;
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
|
||||
if ($containerStatus === 'restarting') {
|
||||
$hasRestarting = true;
|
||||
$hasUnhealthy = true;
|
||||
} elseif ($containerStatus === 'running') {
|
||||
$hasRunning = true;
|
||||
if ($containerHealth === 'unhealthy') {
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
} elseif ($containerStatus === 'exited') {
|
||||
$hasExited = true;
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
// If all containers are excluded, calculate status from excluded containers
|
||||
// but mark it with :excluded to indicate monitoring is disabled
|
||||
if ($relevantContainers->isEmpty()) {
|
||||
return $this->calculateExcludedStatus($containers, $excludedContainers);
|
||||
}
|
||||
|
||||
if ($relevantContainerCount === 0) {
|
||||
return 'running:healthy';
|
||||
}
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
|
||||
if ($hasRestarting) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
if ($hasRunning) {
|
||||
return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy';
|
||||
}
|
||||
|
||||
return 'exited:unhealthy';
|
||||
return $aggregator->aggregateFromContainers($relevantContainers);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Notifications\Container\ContainerRestarted;
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use App\Traits\CalculatesExcludedStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
|
@ -25,6 +27,7 @@
|
|||
|
||||
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
{
|
||||
use CalculatesExcludedStatus;
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 1;
|
||||
|
|
@ -67,6 +70,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
|
||||
public Collection $applicationContainerStatuses;
|
||||
|
||||
public Collection $serviceContainerStatuses;
|
||||
|
||||
public bool $foundProxy = false;
|
||||
|
||||
public bool $foundLogDrainContainer = false;
|
||||
|
|
@ -90,6 +95,7 @@ public function __construct(public Server $server, public $data)
|
|||
$this->foundApplicationPreviewsIds = collect();
|
||||
$this->foundServiceDatabaseIds = collect();
|
||||
$this->applicationContainerStatuses = collect();
|
||||
$this->serviceContainerStatuses = collect();
|
||||
$this->allApplicationIds = collect();
|
||||
$this->allDatabaseUuids = collect();
|
||||
$this->allTcpProxyUuids = collect();
|
||||
|
|
@ -108,7 +114,6 @@ public function handle()
|
|||
$this->server->sentinelHeartbeat();
|
||||
|
||||
$this->containers = collect(data_get($data, 'containers'));
|
||||
|
||||
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
|
||||
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
|
||||
|
||||
|
|
@ -141,65 +146,85 @@ public function handle()
|
|||
|
||||
foreach ($this->containers as $container) {
|
||||
$containerStatus = data_get($container, 'state', 'exited');
|
||||
$containerHealth = data_get($container, 'health_status', 'unhealthy');
|
||||
$containerStatus = "$containerStatus ($containerHealth)";
|
||||
$rawHealthStatus = data_get($container, 'health_status');
|
||||
$containerHealth = $rawHealthStatus ?? 'unknown';
|
||||
$containerStatus = "$containerStatus:$containerHealth";
|
||||
$labels = collect(data_get($container, 'labels'));
|
||||
$coolify_managed = $labels->has('coolify.managed');
|
||||
if ($coolify_managed) {
|
||||
$name = data_get($container, 'name');
|
||||
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
|
||||
$this->foundLogDrainContainer = true;
|
||||
}
|
||||
if ($labels->has('coolify.applicationId')) {
|
||||
$applicationId = $labels->get('coolify.applicationId');
|
||||
$pullRequestId = $labels->get('coolify.pullRequestId', '0');
|
||||
try {
|
||||
if ($pullRequestId === '0') {
|
||||
if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) {
|
||||
$this->foundApplicationIds->push($applicationId);
|
||||
}
|
||||
// Store container status for aggregation
|
||||
if (! $this->applicationContainerStatuses->has($applicationId)) {
|
||||
$this->applicationContainerStatuses->put($applicationId, collect());
|
||||
}
|
||||
$containerName = $labels->get('com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||
}
|
||||
} else {
|
||||
$previewKey = $applicationId.':'.$pullRequestId;
|
||||
if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) {
|
||||
$this->foundApplicationPreviewsIds->push($previewKey);
|
||||
}
|
||||
$this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus);
|
||||
|
||||
if (! $coolify_managed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = data_get($container, 'name');
|
||||
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
|
||||
$this->foundLogDrainContainer = true;
|
||||
}
|
||||
if ($labels->has('coolify.applicationId')) {
|
||||
$applicationId = $labels->get('coolify.applicationId');
|
||||
$pullRequestId = $labels->get('coolify.pullRequestId', '0');
|
||||
try {
|
||||
if ($pullRequestId === '0') {
|
||||
if ($this->allApplicationIds->contains($applicationId)) {
|
||||
$this->foundApplicationIds->push($applicationId);
|
||||
}
|
||||
// Store container status for aggregation
|
||||
if (! $this->applicationContainerStatuses->has($applicationId)) {
|
||||
$this->applicationContainerStatuses->put($applicationId, collect());
|
||||
}
|
||||
$containerName = $labels->get('com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
} elseif ($labels->has('coolify.serviceId')) {
|
||||
$serviceId = $labels->get('coolify.serviceId');
|
||||
$subType = $labels->get('coolify.service.subType');
|
||||
$subId = $labels->get('coolify.service.subId');
|
||||
if ($subType === 'application' && $this->isRunning($containerStatus)) {
|
||||
$this->foundServiceApplicationIds->push($subId);
|
||||
$this->updateServiceSubStatus($serviceId, $subType, $subId, $containerStatus);
|
||||
} elseif ($subType === 'database' && $this->isRunning($containerStatus)) {
|
||||
$this->foundServiceDatabaseIds->push($subId);
|
||||
$this->updateServiceSubStatus($serviceId, $subType, $subId, $containerStatus);
|
||||
}
|
||||
} else {
|
||||
$uuid = $labels->get('com.docker.compose.service');
|
||||
$type = $labels->get('coolify.type');
|
||||
if ($name === 'coolify-proxy' && $this->isRunning($containerStatus)) {
|
||||
$this->foundProxy = true;
|
||||
} elseif ($type === 'service' && $this->isRunning($containerStatus)) {
|
||||
} else {
|
||||
if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
||||
$this->foundDatabaseUuids->push($uuid);
|
||||
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
||||
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
|
||||
} else {
|
||||
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false);
|
||||
}
|
||||
$previewKey = $applicationId.':'.$pullRequestId;
|
||||
if ($this->allApplicationPreviewsIds->contains($previewKey)) {
|
||||
$this->foundApplicationPreviewsIds->push($previewKey);
|
||||
}
|
||||
$this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
} elseif ($labels->has('coolify.serviceId')) {
|
||||
$serviceId = $labels->get('coolify.serviceId');
|
||||
$subType = $labels->get('coolify.service.subType');
|
||||
$subId = $labels->get('coolify.service.subId');
|
||||
if ($subType === 'application') {
|
||||
$this->foundServiceApplicationIds->push($subId);
|
||||
// Store container status for aggregation
|
||||
$key = $serviceId.':'.$subType.':'.$subId;
|
||||
if (! $this->serviceContainerStatuses->has($key)) {
|
||||
$this->serviceContainerStatuses->put($key, collect());
|
||||
}
|
||||
$containerName = $labels->get('com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
|
||||
}
|
||||
} elseif ($subType === 'database') {
|
||||
$this->foundServiceDatabaseIds->push($subId);
|
||||
// Store container status for aggregation
|
||||
$key = $serviceId.':'.$subType.':'.$subId;
|
||||
if (! $this->serviceContainerStatuses->has($key)) {
|
||||
$this->serviceContainerStatuses->put($key, collect());
|
||||
}
|
||||
$containerName = $labels->get('com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$uuid = $labels->get('com.docker.compose.service');
|
||||
$type = $labels->get('coolify.type');
|
||||
if ($name === 'coolify-proxy' && $this->isRunning($containerStatus)) {
|
||||
$this->foundProxy = true;
|
||||
} elseif ($type === 'service' && $this->isRunning($containerStatus)) {
|
||||
} else {
|
||||
if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
||||
$this->foundDatabaseUuids->push($uuid);
|
||||
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
||||
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
|
||||
} else {
|
||||
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -218,6 +243,9 @@ public function handle()
|
|||
// Aggregate multi-container application statuses
|
||||
$this->aggregateMultiContainerStatuses();
|
||||
|
||||
// Aggregate multi-container service statuses
|
||||
$this->aggregateServiceContainerStatuses();
|
||||
|
||||
$this->checkLogDrainContainer();
|
||||
}
|
||||
|
||||
|
|
@ -235,57 +263,28 @@ private function aggregateMultiContainerStatuses()
|
|||
|
||||
// Parse docker compose to check for excluded containers
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = collect();
|
||||
|
||||
if ($dockerComposeRaw) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
foreach ($services as $serviceName => $serviceConfig) {
|
||||
// Check if container should be excluded
|
||||
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
|
||||
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
|
||||
|
||||
if ($excludeFromHc || $restartPolicy === 'no') {
|
||||
$excludedContainers->push($serviceName);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If we can't parse, treat all containers as included
|
||||
}
|
||||
}
|
||||
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||
|
||||
// Filter out excluded containers
|
||||
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
||||
return ! $excludedContainers->contains($containerName);
|
||||
});
|
||||
|
||||
// If all containers are excluded, don't update status
|
||||
// If all containers are excluded, calculate status from excluded containers
|
||||
if ($relevantStatuses->isEmpty()) {
|
||||
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
|
||||
|
||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
$application->status = $aggregatedStatus;
|
||||
$application->save();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Aggregate status: if any container is running, app is running
|
||||
$hasRunning = false;
|
||||
$hasUnhealthy = false;
|
||||
|
||||
foreach ($relevantStatuses as $status) {
|
||||
if (str($status)->contains('running')) {
|
||||
$hasRunning = true;
|
||||
if (str($status)->contains('unhealthy')) {
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$aggregatedStatus = null;
|
||||
if ($hasRunning) {
|
||||
$aggregatedStatus = $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
||||
} else {
|
||||
// All containers are exited
|
||||
$aggregatedStatus = 'exited (unhealthy)';
|
||||
}
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
|
||||
|
||||
// Update application status with aggregated result
|
||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
|
|
@ -295,6 +294,66 @@ private function aggregateMultiContainerStatuses()
|
|||
}
|
||||
}
|
||||
|
||||
private function aggregateServiceContainerStatuses()
|
||||
{
|
||||
if ($this->serviceContainerStatuses->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->serviceContainerStatuses as $key => $containerStatuses) {
|
||||
// Parse key: serviceId:subType:subId
|
||||
[$serviceId, $subType, $subId] = explode(':', $key);
|
||||
|
||||
$service = $this->services->where('id', $serviceId)->first();
|
||||
if (! $service) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
|
||||
$subResource = null;
|
||||
if ($subType === 'application') {
|
||||
$subResource = $service->applications()->where('id', $subId)->first();
|
||||
} elseif ($subType === 'database') {
|
||||
$subResource = $service->databases()->where('id', $subId)->first();
|
||||
}
|
||||
|
||||
if (! $subResource) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse docker compose from service to check for excluded containers
|
||||
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
|
||||
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||
|
||||
// Filter out excluded containers
|
||||
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
|
||||
return ! $excludedContainers->contains($containerName);
|
||||
});
|
||||
|
||||
// If all containers are excluded, calculate status from excluded containers
|
||||
if ($relevantStatuses->isEmpty()) {
|
||||
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
|
||||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||
$subResource->status = $aggregatedStatus;
|
||||
$subResource->save();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
// NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
|
||||
|
||||
// Update service sub-resource status with aggregated result
|
||||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||
$subResource->status = $aggregatedStatus;
|
||||
$subResource->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function updateApplicationStatus(string $applicationId, string $containerStatus)
|
||||
{
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
|
|
|
|||
|
|
@ -669,21 +669,23 @@ protected function serverStatus(): Attribute
|
|||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (! $this->relationLoaded('additional_servers') || $this->additional_servers->count() === 0) {
|
||||
return $this->destination?->server?->isFunctional() ?? false;
|
||||
// Check main server infrastructure health
|
||||
$main_server_functional = $this->destination?->server?->isFunctional() ?? false;
|
||||
|
||||
if (! $main_server_functional) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$additional_servers_status = $this->additional_servers->pluck('pivot.status');
|
||||
$main_server_status = $this->destination?->server?->isFunctional() ?? false;
|
||||
|
||||
foreach ($additional_servers_status as $status) {
|
||||
$server_status = str($status)->before(':')->value();
|
||||
if ($server_status !== 'running') {
|
||||
return false;
|
||||
// Check additional servers infrastructure health (not container status!)
|
||||
if ($this->relationLoaded('additional_servers') && $this->additional_servers->count() > 0) {
|
||||
foreach ($this->additional_servers as $server) {
|
||||
if (! $server->isFunctional()) {
|
||||
return false; // Real server infrastructure problem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $main_server_status;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
|
|
@ -173,6 +174,21 @@ public function deleteConnectedNetworks()
|
|||
instant_remote_process(["docker network rm {$this->uuid}"], $server, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the service's aggregate status from its applications and databases.
|
||||
*
|
||||
* This method aggregates status from Eloquent model relationships (not Docker containers).
|
||||
* It differs from the CalculatesExcludedStatus trait which works with Docker container objects
|
||||
* during container inspection. This accessor runs on-demand for UI display and works with
|
||||
* already-stored status strings from the database.
|
||||
*
|
||||
* Status format: "{status}:{health}" or "{status}:{health}:excluded"
|
||||
* - Status values: running, exited, degraded, starting, paused, restarting
|
||||
* - Health values: healthy, unhealthy, unknown
|
||||
* - :excluded suffix: Indicates all containers are excluded from health monitoring
|
||||
*
|
||||
* @return string The aggregate status in format "status:health" or "status:health:excluded"
|
||||
*/
|
||||
public function getStatusAttribute()
|
||||
{
|
||||
if ($this->isStarting()) {
|
||||
|
|
@ -182,71 +198,97 @@ public function getStatusAttribute()
|
|||
$applications = $this->applications;
|
||||
$databases = $this->databases;
|
||||
|
||||
$complexStatus = null;
|
||||
$complexHealth = null;
|
||||
[$complexStatus, $complexHealth, $hasNonExcluded] = $this->aggregateResourceStatuses(
|
||||
$applications,
|
||||
$databases,
|
||||
excludedOnly: false
|
||||
);
|
||||
|
||||
foreach ($applications as $application) {
|
||||
if ($application->exclude_from_status) {
|
||||
continue;
|
||||
// If all services are excluded from status checks, calculate status from excluded containers
|
||||
// but mark it with :excluded to indicate monitoring is disabled
|
||||
if (! $hasNonExcluded && ($complexStatus === null && $complexHealth === null)) {
|
||||
[$excludedStatus, $excludedHealth] = $this->aggregateResourceStatuses(
|
||||
$applications,
|
||||
$databases,
|
||||
excludedOnly: true
|
||||
);
|
||||
|
||||
// Return status with :excluded suffix to indicate monitoring is disabled
|
||||
if ($excludedStatus && $excludedHealth) {
|
||||
return "{$excludedStatus}:{$excludedHealth}:excluded";
|
||||
}
|
||||
$status = str($application->status)->before('(')->trim();
|
||||
$health = str($application->status)->between('(', ')')->trim();
|
||||
if ($complexStatus === 'degraded') {
|
||||
continue;
|
||||
}
|
||||
if ($status->startsWith('running')) {
|
||||
if ($complexStatus === 'exited') {
|
||||
$complexStatus = 'degraded';
|
||||
} else {
|
||||
$complexStatus = 'running';
|
||||
}
|
||||
} elseif ($status->startsWith('restarting')) {
|
||||
$complexStatus = 'degraded';
|
||||
} elseif ($status->startsWith('exited')) {
|
||||
$complexStatus = 'exited';
|
||||
}
|
||||
if ($health->value() === 'healthy') {
|
||||
if ($complexHealth === 'unhealthy') {
|
||||
continue;
|
||||
}
|
||||
$complexHealth = 'healthy';
|
||||
} else {
|
||||
$complexHealth = 'unhealthy';
|
||||
}
|
||||
}
|
||||
foreach ($databases as $database) {
|
||||
if ($database->exclude_from_status) {
|
||||
continue;
|
||||
}
|
||||
$status = str($database->status)->before('(')->trim();
|
||||
$health = str($database->status)->between('(', ')')->trim();
|
||||
if ($complexStatus === 'degraded') {
|
||||
continue;
|
||||
}
|
||||
if ($status->startsWith('running')) {
|
||||
if ($complexStatus === 'exited') {
|
||||
$complexStatus = 'degraded';
|
||||
} else {
|
||||
$complexStatus = 'running';
|
||||
}
|
||||
} elseif ($status->startsWith('restarting')) {
|
||||
$complexStatus = 'degraded';
|
||||
} elseif ($status->startsWith('exited')) {
|
||||
$complexStatus = 'exited';
|
||||
}
|
||||
if ($health->value() === 'healthy') {
|
||||
if ($complexHealth === 'unhealthy') {
|
||||
continue;
|
||||
}
|
||||
$complexHealth = 'healthy';
|
||||
} else {
|
||||
$complexHealth = 'unhealthy';
|
||||
|
||||
// If no status was calculated at all (no containers exist), return unknown
|
||||
if ($excludedStatus === null && $excludedHealth === null) {
|
||||
return 'unknown:unknown:excluded';
|
||||
}
|
||||
|
||||
return 'exited:unhealthy:excluded';
|
||||
}
|
||||
|
||||
return "{$complexStatus}:{$complexHealth}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate status and health from collections of applications and databases.
|
||||
*
|
||||
* This helper method consolidates status aggregation logic using ContainerStatusAggregator.
|
||||
* It processes container status strings stored in the database (not live Docker data).
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Collection $applications Collection of Application models
|
||||
* @param \Illuminate\Database\Eloquent\Collection $databases Collection of Database models
|
||||
* @param bool $excludedOnly If true, only process excluded containers; if false, only process non-excluded
|
||||
* @return array{0: string|null, 1: string|null, 2?: bool} [status, health, hasNonExcluded (only when excludedOnly=false)]
|
||||
*/
|
||||
private function aggregateResourceStatuses($applications, $databases, bool $excludedOnly = false): array
|
||||
{
|
||||
$hasNonExcluded = false;
|
||||
$statusStrings = collect();
|
||||
|
||||
// Process both applications and databases using the same logic
|
||||
$resources = $applications->concat($databases);
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$isExcluded = $resource->exclude_from_status || str($resource->status)->contains(':excluded');
|
||||
|
||||
// Filter based on excludedOnly flag
|
||||
if ($excludedOnly && ! $isExcluded) {
|
||||
continue;
|
||||
}
|
||||
if (! $excludedOnly && $isExcluded) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $excludedOnly) {
|
||||
$hasNonExcluded = true;
|
||||
}
|
||||
|
||||
// Strip :excluded suffix before aggregation (it's in the 3rd part of "status:health:excluded")
|
||||
$status = str($resource->status)->before(':excluded')->toString();
|
||||
$statusStrings->push($status);
|
||||
}
|
||||
|
||||
// If no status strings collected, return nulls
|
||||
if ($statusStrings->isEmpty()) {
|
||||
return $excludedOnly ? [null, null] : [null, null, $hasNonExcluded];
|
||||
}
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($statusStrings);
|
||||
|
||||
// Parse the aggregated "status:health" string
|
||||
$parts = explode(':', $aggregatedStatus);
|
||||
$status = $parts[0] ?? null;
|
||||
$health = $parts[1] ?? null;
|
||||
|
||||
if ($excludedOnly) {
|
||||
return [$status, $health];
|
||||
}
|
||||
|
||||
return [$status, $health, $hasNonExcluded];
|
||||
}
|
||||
|
||||
public function extraFields()
|
||||
{
|
||||
$fields = collect([]);
|
||||
|
|
|
|||
253
app/Services/ContainerStatusAggregator.php
Normal file
253
app/Services/ContainerStatusAggregator.php
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Container Status Aggregator Service
|
||||
*
|
||||
* Centralized service for aggregating container statuses into a single status string.
|
||||
* Uses a priority-based state machine to determine the overall status from multiple containers.
|
||||
*
|
||||
* Output Format: Colon-separated (e.g., "running:healthy", "degraded:unhealthy")
|
||||
* This format is used throughout the backend for consistency and machine-readability.
|
||||
* UI components transform this to human-readable format (e.g., "Running (Healthy)").
|
||||
*
|
||||
* State Priority (highest to lowest):
|
||||
* 1. Restarting → degraded:unhealthy
|
||||
* 2. Crash Loop (exited with restarts) → degraded:unhealthy
|
||||
* 3. Mixed (running + exited) → degraded:unhealthy
|
||||
* 4. Running → running:healthy/unhealthy/unknown
|
||||
* 5. Dead/Removing → degraded:unhealthy
|
||||
* 6. Paused → paused:unknown
|
||||
* 7. Starting/Created → starting:unknown
|
||||
* 8. Exited → exited:unhealthy
|
||||
*/
|
||||
class ContainerStatusAggregator
|
||||
{
|
||||
/**
|
||||
* Aggregate container statuses from status strings into a single status.
|
||||
*
|
||||
* @param Collection $containerStatuses Collection of status strings (e.g., "running (healthy)", "running:healthy")
|
||||
* @param int $maxRestartCount Maximum restart count across containers (for crash loop detection)
|
||||
* @return string Aggregated status in colon format (e.g., "running:healthy")
|
||||
*/
|
||||
public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0): string
|
||||
{
|
||||
// Validate maxRestartCount parameter
|
||||
if ($maxRestartCount < 0) {
|
||||
Log::warning('Negative maxRestartCount corrected to 0', [
|
||||
'original_value' => $maxRestartCount,
|
||||
]);
|
||||
$maxRestartCount = 0;
|
||||
}
|
||||
|
||||
if ($maxRestartCount > 1000) {
|
||||
Log::warning('High maxRestartCount detected', [
|
||||
'maxRestartCount' => $maxRestartCount,
|
||||
'containers' => $containerStatuses->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($containerStatuses->isEmpty()) {
|
||||
return 'exited:unhealthy';
|
||||
}
|
||||
|
||||
// Initialize state flags
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasUnknown = false;
|
||||
$hasExited = false;
|
||||
$hasStarting = false;
|
||||
$hasPaused = false;
|
||||
$hasDead = false;
|
||||
|
||||
// Parse each status string and set flags
|
||||
foreach ($containerStatuses as $status) {
|
||||
if (str($status)->contains('restarting')) {
|
||||
$hasRestarting = true;
|
||||
} elseif (str($status)->contains('running')) {
|
||||
$hasRunning = true;
|
||||
if (str($status)->contains('unhealthy')) {
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
if (str($status)->contains('unknown')) {
|
||||
$hasUnknown = true;
|
||||
}
|
||||
} elseif (str($status)->contains('exited')) {
|
||||
$hasExited = true;
|
||||
$hasUnhealthy = true;
|
||||
} elseif (str($status)->contains('created') || str($status)->contains('starting')) {
|
||||
$hasStarting = true;
|
||||
} elseif (str($status)->contains('paused')) {
|
||||
$hasPaused = true;
|
||||
} elseif (str($status)->contains('dead') || str($status)->contains('removing')) {
|
||||
$hasDead = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority-based status resolution
|
||||
return $this->resolveStatus(
|
||||
$hasRunning,
|
||||
$hasRestarting,
|
||||
$hasUnhealthy,
|
||||
$hasUnknown,
|
||||
$hasExited,
|
||||
$hasStarting,
|
||||
$hasPaused,
|
||||
$hasDead,
|
||||
$maxRestartCount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate container statuses from Docker container objects.
|
||||
*
|
||||
* @param Collection $containers Collection of Docker container objects with State property
|
||||
* @param int $maxRestartCount Maximum restart count across containers (for crash loop detection)
|
||||
* @return string Aggregated status in colon format (e.g., "running:healthy")
|
||||
*/
|
||||
public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0): string
|
||||
{
|
||||
// Validate maxRestartCount parameter
|
||||
if ($maxRestartCount < 0) {
|
||||
Log::warning('Negative maxRestartCount corrected to 0', [
|
||||
'original_value' => $maxRestartCount,
|
||||
]);
|
||||
$maxRestartCount = 0;
|
||||
}
|
||||
|
||||
if ($maxRestartCount > 1000) {
|
||||
Log::warning('High maxRestartCount detected', [
|
||||
'maxRestartCount' => $maxRestartCount,
|
||||
'containers' => $containers->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($containers->isEmpty()) {
|
||||
return 'exited:unhealthy';
|
||||
}
|
||||
|
||||
// Initialize state flags
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasUnknown = false;
|
||||
$hasExited = false;
|
||||
$hasStarting = false;
|
||||
$hasPaused = false;
|
||||
$hasDead = false;
|
||||
|
||||
// Parse each container object and set flags
|
||||
foreach ($containers as $container) {
|
||||
$state = data_get($container, 'State.Status', 'exited');
|
||||
$health = data_get($container, 'State.Health.Status');
|
||||
|
||||
if ($state === 'restarting') {
|
||||
$hasRestarting = true;
|
||||
} elseif ($state === 'running') {
|
||||
$hasRunning = true;
|
||||
if ($health === 'unhealthy') {
|
||||
$hasUnhealthy = true;
|
||||
} elseif (is_null($health) || $health === 'starting') {
|
||||
$hasUnknown = true;
|
||||
}
|
||||
} elseif ($state === 'exited') {
|
||||
$hasExited = true;
|
||||
$hasUnhealthy = true;
|
||||
} elseif ($state === 'created' || $state === 'starting') {
|
||||
$hasStarting = true;
|
||||
} elseif ($state === 'paused') {
|
||||
$hasPaused = true;
|
||||
} elseif ($state === 'dead' || $state === 'removing') {
|
||||
$hasDead = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority-based status resolution
|
||||
return $this->resolveStatus(
|
||||
$hasRunning,
|
||||
$hasRestarting,
|
||||
$hasUnhealthy,
|
||||
$hasUnknown,
|
||||
$hasExited,
|
||||
$hasStarting,
|
||||
$hasPaused,
|
||||
$hasDead,
|
||||
$maxRestartCount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the aggregated status based on state flags (priority-based state machine).
|
||||
*
|
||||
* @param bool $hasRunning Has at least one running container
|
||||
* @param bool $hasRestarting Has at least one restarting container
|
||||
* @param bool $hasUnhealthy Has at least one unhealthy container
|
||||
* @param bool $hasUnknown Has at least one container with unknown health
|
||||
* @param bool $hasExited Has at least one exited container
|
||||
* @param bool $hasStarting Has at least one starting/created container
|
||||
* @param bool $hasPaused Has at least one paused container
|
||||
* @param bool $hasDead Has at least one dead/removing container
|
||||
* @param int $maxRestartCount Maximum restart count (for crash loop detection)
|
||||
* @return string Status in colon format (e.g., "running:healthy")
|
||||
*/
|
||||
private function resolveStatus(
|
||||
bool $hasRunning,
|
||||
bool $hasRestarting,
|
||||
bool $hasUnhealthy,
|
||||
bool $hasUnknown,
|
||||
bool $hasExited,
|
||||
bool $hasStarting,
|
||||
bool $hasPaused,
|
||||
bool $hasDead,
|
||||
int $maxRestartCount
|
||||
): string {
|
||||
// Priority 1: Restarting containers (degraded state)
|
||||
if ($hasRestarting) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
// Priority 2: Crash loop detection (exited with restart count > 0)
|
||||
if ($hasExited && $maxRestartCount > 0) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
// Priority 3: Mixed state (some running, some exited = degraded)
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
// Priority 4: Running containers (check health status)
|
||||
if ($hasRunning) {
|
||||
if ($hasUnhealthy) {
|
||||
return 'running:unhealthy';
|
||||
} elseif ($hasUnknown) {
|
||||
return 'running:unknown';
|
||||
} else {
|
||||
return 'running:healthy';
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 5: Dead or removing containers
|
||||
if ($hasDead) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
// Priority 6: Paused containers
|
||||
if ($hasPaused) {
|
||||
return 'paused:unknown';
|
||||
}
|
||||
|
||||
// Priority 7: Starting/created containers
|
||||
if ($hasStarting) {
|
||||
return 'starting:unknown';
|
||||
}
|
||||
|
||||
// Priority 8: All containers exited (no restart count = truly stopped)
|
||||
return 'exited:unhealthy';
|
||||
}
|
||||
}
|
||||
166
app/Traits/CalculatesExcludedStatus.php
Normal file
166
app/Traits/CalculatesExcludedStatus.php
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Yaml\Exception\ParseException;
|
||||
|
||||
trait CalculatesExcludedStatus
|
||||
{
|
||||
/**
|
||||
* Calculate status for containers when all containers are excluded from health checks.
|
||||
*
|
||||
* This method processes excluded containers and returns a status with :excluded suffix
|
||||
* to indicate that monitoring is disabled but still show the actual container state.
|
||||
*
|
||||
* @param Collection $containers Collection of container objects from Docker inspect
|
||||
* @param Collection $excludedContainers Collection of container names that are excluded
|
||||
* @return string Status string with :excluded suffix (e.g., 'running:unhealthy:excluded')
|
||||
*/
|
||||
protected function calculateExcludedStatus(Collection $containers, Collection $excludedContainers): string
|
||||
{
|
||||
// Filter to only excluded containers
|
||||
$excludedOnly = $containers->filter(function ($container) use ($excludedContainers) {
|
||||
$labels = data_get($container, 'Config.Labels', []);
|
||||
$serviceName = data_get($labels, 'com.docker.compose.service');
|
||||
|
||||
return $serviceName && $excludedContainers->contains($serviceName);
|
||||
});
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$status = $aggregator->aggregateFromContainers($excludedOnly);
|
||||
|
||||
// Append :excluded suffix
|
||||
return $this->appendExcludedSuffix($status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate status for containers when all containers are excluded (simplified version).
|
||||
*
|
||||
* This version works with status strings (e.g., "running:healthy") instead of full
|
||||
* container objects, suitable for Sentinel updates that don't have full container data.
|
||||
*
|
||||
* @param Collection $containerStatuses Collection of status strings keyed by container name
|
||||
* @return string Status string with :excluded suffix
|
||||
*/
|
||||
protected function calculateExcludedStatusFromStrings(Collection $containerStatuses): string
|
||||
{
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$status = $aggregator->aggregateFromStrings($containerStatuses);
|
||||
|
||||
// Append :excluded suffix
|
||||
$finalStatus = $this->appendExcludedSuffix($status);
|
||||
|
||||
return $finalStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append :excluded suffix to a status string.
|
||||
*
|
||||
* Converts status formats like:
|
||||
* - "running:healthy" → "running:healthy:excluded"
|
||||
* - "degraded:unhealthy" → "degraded:excluded" (simplified)
|
||||
* - "paused:unknown" → "paused:excluded" (simplified)
|
||||
*
|
||||
* @param string $status The base status string
|
||||
* @return string Status with :excluded suffix
|
||||
*/
|
||||
private function appendExcludedSuffix(string $status): string
|
||||
{
|
||||
// For degraded states, simplify to just "degraded:excluded"
|
||||
if (str($status)->startsWith('degraded')) {
|
||||
return 'degraded:excluded';
|
||||
}
|
||||
|
||||
// For paused/starting/exited states, simplify to just "state:excluded"
|
||||
if (str($status)->startsWith('paused')) {
|
||||
return 'paused:excluded';
|
||||
}
|
||||
|
||||
if (str($status)->startsWith('starting')) {
|
||||
return 'starting:excluded';
|
||||
}
|
||||
|
||||
if (str($status)->startsWith('exited')) {
|
||||
return 'exited:excluded';
|
||||
}
|
||||
|
||||
// For running states, keep the health status: "running:healthy:excluded"
|
||||
return "$status:excluded";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get excluded containers from docker-compose YAML.
|
||||
*
|
||||
* Containers are excluded if:
|
||||
* - They have exclude_from_hc: true label
|
||||
* - They have restart: no policy
|
||||
*
|
||||
* @param string|null $dockerComposeRaw The raw docker-compose YAML content
|
||||
* @return Collection Collection of excluded container names
|
||||
*/
|
||||
protected function getExcludedContainersFromDockerCompose(?string $dockerComposeRaw): Collection
|
||||
{
|
||||
$excludedContainers = collect();
|
||||
|
||||
if (! $dockerComposeRaw) {
|
||||
return $excludedContainers;
|
||||
}
|
||||
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
|
||||
// Validate structure
|
||||
if (! is_array($dockerCompose)) {
|
||||
Log::warning('Docker Compose YAML did not parse to array', [
|
||||
'yaml_length' => strlen($dockerComposeRaw),
|
||||
'parsed_type' => gettype($dockerCompose),
|
||||
]);
|
||||
|
||||
return $excludedContainers;
|
||||
}
|
||||
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
if (! is_array($services)) {
|
||||
Log::warning('Docker Compose services is not an array', [
|
||||
'services_type' => gettype($services),
|
||||
]);
|
||||
|
||||
return $excludedContainers;
|
||||
}
|
||||
|
||||
foreach ($services as $serviceName => $serviceConfig) {
|
||||
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
|
||||
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
|
||||
|
||||
if ($excludeFromHc || $restartPolicy === 'no') {
|
||||
$excludedContainers->push($serviceName);
|
||||
}
|
||||
}
|
||||
} catch (ParseException $e) {
|
||||
// Specific YAML parsing errors
|
||||
Log::warning('Failed to parse Docker Compose YAML for health check exclusions', [
|
||||
'error' => $e->getMessage(),
|
||||
'line' => $e->getParsedLine(),
|
||||
'snippet' => $e->getSnippet(),
|
||||
]);
|
||||
|
||||
return $excludedContainers;
|
||||
} catch (\Exception $e) {
|
||||
// Unexpected errors
|
||||
Log::error('Unexpected error parsing Docker Compose YAML', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return $excludedContainers;
|
||||
}
|
||||
|
||||
return $excludedContainers;
|
||||
}
|
||||
}
|
||||
|
|
@ -1083,6 +1083,44 @@ function generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker
|
|||
return $docker_compose;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove Coolify's custom Docker Compose fields from parsed YAML array
|
||||
*
|
||||
* Coolify extends Docker Compose with custom fields that are processed during
|
||||
* parsing and deployment but must be removed before sending to Docker.
|
||||
*
|
||||
* Custom fields:
|
||||
* - exclude_from_hc (service-level): Exclude service from health check monitoring
|
||||
* - content (volume-level): Auto-create file with specified content during init
|
||||
* - isDirectory / is_directory (volume-level): Mark bind mount as directory
|
||||
*
|
||||
* @param array $yamlCompose Parsed Docker Compose array
|
||||
* @return array Cleaned Docker Compose array with custom fields removed
|
||||
*/
|
||||
function stripCoolifyCustomFields(array $yamlCompose): array
|
||||
{
|
||||
foreach ($yamlCompose['services'] ?? [] as $serviceName => $service) {
|
||||
// Remove service-level custom fields
|
||||
unset($yamlCompose['services'][$serviceName]['exclude_from_hc']);
|
||||
|
||||
// Remove volume-level custom fields (only for long syntax - arrays)
|
||||
if (isset($service['volumes'])) {
|
||||
foreach ($service['volumes'] as $volumeName => $volume) {
|
||||
// Skip if volume is string (short syntax like 'db-data:/var/lib/postgresql/data')
|
||||
if (! is_array($volume)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['content']);
|
||||
unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['isDirectory']);
|
||||
unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['is_directory']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $yamlCompose;
|
||||
}
|
||||
|
||||
function validateComposeFile(string $compose, int $server_id): string|Throwable
|
||||
{
|
||||
$uuid = Str::random(18);
|
||||
|
|
@ -1092,16 +1130,10 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
|
|||
throw new \Exception('Server not found');
|
||||
}
|
||||
$yaml_compose = Yaml::parse($compose);
|
||||
foreach ($yaml_compose['services'] as $service_name => $service) {
|
||||
if (! isset($service['volumes'])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($service['volumes'] as $volume_name => $volume) {
|
||||
if (data_get($volume, 'type') === 'bind' && data_get($volume, 'content')) {
|
||||
unset($yaml_compose['services'][$service_name]['volumes'][$volume_name]['content']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove Coolify's custom fields before Docker validation
|
||||
$yaml_compose = stripCoolifyCustomFields($yaml_compose);
|
||||
|
||||
$base64_compose = base64_encode(Yaml::dump($yaml_compose));
|
||||
instant_remote_process([
|
||||
"echo {$base64_compose} | base64 -d | tee /tmp/{$uuid}.yml > /dev/null",
|
||||
|
|
|
|||
|
|
@ -3153,3 +3153,46 @@ function generateDockerComposeServiceName(mixed $services, int $pullRequestId =
|
|||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform colon-delimited status format to human-readable parentheses format.
|
||||
*
|
||||
* Handles Docker container status formats with optional health check status and exclusion modifiers.
|
||||
*
|
||||
* Examples:
|
||||
* - running:healthy → Running (healthy)
|
||||
* - running:unhealthy:excluded → Running (unhealthy, excluded)
|
||||
* - exited:excluded → Exited (excluded)
|
||||
* - Proxy:running → Proxy:running (preserved as-is for headline formatting)
|
||||
* - running → Running
|
||||
*
|
||||
* @param string $status The status string to format
|
||||
* @return string The formatted status string
|
||||
*/
|
||||
function formatContainerStatus(string $status): string
|
||||
{
|
||||
// Preserve Proxy statuses as-is (they follow different format)
|
||||
if (str($status)->startsWith('Proxy')) {
|
||||
return str($status)->headline()->value();
|
||||
}
|
||||
|
||||
// Check for :excluded suffix
|
||||
$isExcluded = str($status)->endsWith(':excluded');
|
||||
$parts = explode(':', $status);
|
||||
|
||||
if ($isExcluded) {
|
||||
if (count($parts) === 3) {
|
||||
// Has health status: running:unhealthy:excluded → Running (unhealthy, excluded)
|
||||
return str($parts[0])->headline().' ('.$parts[1].', excluded)';
|
||||
} else {
|
||||
// No health status: exited:excluded → Exited (excluded)
|
||||
return str($parts[0])->headline().' (excluded)';
|
||||
}
|
||||
} elseif (count($parts) >= 2) {
|
||||
// Regular colon format: running:healthy → Running (healthy)
|
||||
return str($parts[0])->headline().' ('.$parts[1].')';
|
||||
} else {
|
||||
// Simple status: running → Running
|
||||
return str($status)->headline()->value();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,33 @@
|
|||
@props([
|
||||
'status' => 'Degraded',
|
||||
])
|
||||
@php
|
||||
// Handle both colon format (backend) and parentheses format (from services.blade.php)
|
||||
// degraded:unhealthy → Degraded (unhealthy)
|
||||
// degraded (unhealthy) → degraded (unhealthy) (already formatted, display as-is)
|
||||
|
||||
if (str($status)->contains('(')) {
|
||||
// Already in parentheses format from services.blade.php - use as-is
|
||||
$displayStatus = $status;
|
||||
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
|
||||
} elseif (str($status)->contains(':') && !str($status)->startsWith('Proxy')) {
|
||||
// Colon format from backend - transform it
|
||||
$parts = explode(':', $status);
|
||||
$displayStatus = str($parts[0])->headline();
|
||||
$healthStatus = $parts[1] ?? null;
|
||||
} else {
|
||||
// Simple status without health
|
||||
$displayStatus = str($status)->headline();
|
||||
$healthStatus = null;
|
||||
}
|
||||
@endphp
|
||||
<div class="flex items-center" >
|
||||
<x-loading wire:loading.delay.longer />
|
||||
<span wire:loading.remove.delay.longer class="flex items-center">
|
||||
<div class="badge badge-warning"></div>
|
||||
<div class="pl-2 pr-1 text-xs font-bold dark:text-warning">
|
||||
{{ str($status)->before(':')->headline() }}
|
||||
</div>
|
||||
@if (!str($status)->startsWith('Proxy') && !str($status)->contains('('))
|
||||
<div class="text-xs dark:text-warning">({{ str($status)->after(':') }})</div>
|
||||
<div class="pl-2 pr-1 text-xs font-bold dark:text-warning">{{ $displayStatus }}</div>
|
||||
@if ($healthStatus && !str($displayStatus)->contains('('))
|
||||
<div class="text-xs dark:text-warning">({{ $healthStatus }})</div>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
])
|
||||
@if (str($resource->status)->startsWith('running'))
|
||||
<x-status.running :status="$resource->status" :title="$title" :lastDeploymentLink="$lastDeploymentLink" />
|
||||
@elseif(str($resource->status)->startsWith('restarting') ||
|
||||
str($resource->status)->startsWith('starting') ||
|
||||
str($resource->status)->startsWith('degraded'))
|
||||
@elseif(str($resource->status)->startsWith('degraded'))
|
||||
<x-status.degraded :status="$resource->status" :title="$title" :lastDeploymentLink="$lastDeploymentLink" />
|
||||
@elseif(str($resource->status)->startsWith('restarting') || str($resource->status)->startsWith('starting'))
|
||||
<x-status.restarting :status="$resource->status" :title="$title" :lastDeploymentLink="$lastDeploymentLink" />
|
||||
@else
|
||||
<x-status.stopped :status="$resource->status" />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,26 @@
|
|||
'lastDeploymentLink' => null,
|
||||
'noLoading' => false,
|
||||
])
|
||||
@php
|
||||
// Handle both colon format (backend) and parentheses format (from services.blade.php)
|
||||
// starting:unknown → Starting (unknown)
|
||||
// starting (unknown) → starting (unknown) (already formatted, display as-is)
|
||||
|
||||
if (str($status)->contains('(')) {
|
||||
// Already in parentheses format from services.blade.php - use as-is
|
||||
$displayStatus = $status;
|
||||
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
|
||||
} elseif (str($status)->contains(':') && !str($status)->startsWith('Proxy')) {
|
||||
// Colon format from backend - transform it
|
||||
$parts = explode(':', $status);
|
||||
$displayStatus = str($parts[0])->headline();
|
||||
$healthStatus = $parts[1] ?? null;
|
||||
} else {
|
||||
// Simple status without health
|
||||
$displayStatus = str($status)->headline();
|
||||
$healthStatus = null;
|
||||
}
|
||||
@endphp
|
||||
<div class="flex items-center">
|
||||
@if (!$noLoading)
|
||||
<x-loading wire:loading.delay.longer />
|
||||
|
|
@ -13,14 +33,14 @@
|
|||
<div class="pl-2 pr-1 text-xs font-bold dark:text-warning" @if($title) title="{{$title}}" @endif>
|
||||
@if ($lastDeploymentLink)
|
||||
<a href="{{ $lastDeploymentLink }}" target="_blank" class="underline cursor-pointer">
|
||||
{{ str($status)->before(':')->headline() }}
|
||||
{{ $displayStatus }}
|
||||
</a>
|
||||
@else
|
||||
{{ str($status)->before(':')->headline() }}
|
||||
{{ $displayStatus }}
|
||||
@endif
|
||||
</div>
|
||||
@if (!str($status)->startsWith('Proxy') && !str($status)->contains('('))
|
||||
<div class="text-xs dark:text-warning">({{ str($status)->after(':') }})</div>
|
||||
@if ($healthStatus && !str($displayStatus)->contains('('))
|
||||
<div class="text-xs dark:text-warning">({{ $healthStatus }})</div>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,26 @@
|
|||
'lastDeploymentLink' => null,
|
||||
'noLoading' => false,
|
||||
])
|
||||
@php
|
||||
// Handle both colon format (backend) and parentheses format (from services.blade.php)
|
||||
// running:healthy → Running (healthy)
|
||||
// running (healthy) → running (healthy) (already formatted, display as-is)
|
||||
|
||||
if (str($status)->contains('(')) {
|
||||
// Already in parentheses format from services.blade.php - use as-is
|
||||
$displayStatus = $status;
|
||||
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
|
||||
} elseif (str($status)->contains(':') && !str($status)->startsWith('Proxy')) {
|
||||
// Colon format from backend - transform it
|
||||
$parts = explode(':', $status);
|
||||
$displayStatus = str($parts[0])->headline();
|
||||
$healthStatus = $parts[1] ?? null;
|
||||
} else {
|
||||
// Simple status without health
|
||||
$displayStatus = str($status)->headline();
|
||||
$healthStatus = null;
|
||||
}
|
||||
@endphp
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center">
|
||||
<div wire:loading.delay.longer wire:target="checkProxy(true)" class="badge badge-warning"></div>
|
||||
|
|
@ -12,21 +32,27 @@
|
|||
@if ($title) title="{{ $title }}" @endif>
|
||||
@if ($lastDeploymentLink)
|
||||
<a href="{{ $lastDeploymentLink }}" target="_blank" class="underline cursor-pointer">
|
||||
{{ str($status)->before(':')->headline() }}
|
||||
{{ $displayStatus }}
|
||||
</a>
|
||||
@else
|
||||
{{ str($status)->before(':')->headline() }}
|
||||
{{ $displayStatus }}
|
||||
@endif
|
||||
</div>
|
||||
@if ($healthStatus && !str($displayStatus)->contains('('))
|
||||
<div class="text-xs text-success">({{ $healthStatus }})</div>
|
||||
@endif
|
||||
@php
|
||||
$showUnknownHelper =
|
||||
!str($status)->startsWith('Proxy') &&
|
||||
(str($status)->contains('unknown') || str($healthStatus)->contains('unknown'));
|
||||
$showUnhealthyHelper =
|
||||
!str($status)->startsWith('Proxy') &&
|
||||
!str($status)->contains('(') &&
|
||||
str($status)->contains('unhealthy');
|
||||
(str($status)->contains('unhealthy') || str($healthStatus)->contains('unhealthy'));
|
||||
@endphp
|
||||
@if ($showUnhealthyHelper)
|
||||
@if ($showUnknownHelper)
|
||||
<div class="px-2">
|
||||
<x-helper
|
||||
helper="Unhealthy state. <span class='dark:text-warning text-coollabs'>This doesn't mean that the resource is malfunctioning.</span><br><br>- If the resource is accessible, it indicates that no health check is configured - it is not mandatory.<br>- If the resource is not accessible (returning 404 or 503), it may indicate that a health check is needed and has not passed. <span class='dark:text-warning text-coollabs'>Your action is required.</span><br><br>More details in the <a href='https://coolify.io/docs/knowledge-base/proxy/traefik/healthchecks' class='underline dark:text-warning text-coollabs' target='_blank'>documentation</a>.">
|
||||
helper="No health check configured. <span class='dark:text-warning text-coollabs'>The resource may be functioning normally.</span><br><br>Traefik and Caddy will route traffic to this container even without a health check. However, configuring a health check is recommended to ensure the resource is ready before receiving traffic.<br><br>More details in the <a href='https://coolify.io/docs/knowledge-base/proxy/traefik/healthchecks' class='underline dark:text-warning text-coollabs' target='_blank'>documentation</a>.">
|
||||
<x-slot:icon>
|
||||
<svg class="hidden w-4 h-4 dark:text-warning lg:block" viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
|
|
@ -36,6 +62,22 @@
|
|||
</svg>
|
||||
</x-slot:icon>
|
||||
</x-helper>
|
||||
</div>
|
||||
@endif
|
||||
@if ($showUnhealthyHelper)
|
||||
<div class="px-2">
|
||||
<x-helper
|
||||
helper="Unhealthy state. <span class='dark:text-warning text-coollabs'>The health check is failing.</span><br><br>This resource will <span class='dark:text-warning text-coollabs'>NOT work with Traefik</span> as it expects a healthy state. Your action is required to fix the health check or the underlying issue causing it to fail.<br><br>More details in the <a href='https://coolify.io/docs/knowledge-base/proxy/traefik/healthchecks' class='underline dark:text-warning text-coollabs' target='_blank'>documentation</a>.">
|
||||
<x-slot:icon>
|
||||
<svg class="hidden w-4 h-4 dark:text-warning lg:block" viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor"
|
||||
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16">
|
||||
</path>
|
||||
</svg>
|
||||
</x-slot:icon>
|
||||
</x-helper>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
@if (str($complexStatus)->contains('running'))
|
||||
<x-status.running :status="$complexStatus" />
|
||||
@elseif(str($complexStatus)->contains('starting'))
|
||||
<x-status.restarting :status="$complexStatus" />
|
||||
@elseif(str($complexStatus)->contains('restarting'))
|
||||
<x-status.restarting :status="$complexStatus" />
|
||||
@elseif(str($complexStatus)->contains('degraded'))
|
||||
<x-status.degraded :status="$complexStatus" />
|
||||
@php
|
||||
$displayStatus = formatContainerStatus($complexStatus);
|
||||
@endphp
|
||||
@if (str($displayStatus)->lower()->contains('running'))
|
||||
<x-status.running :status="$displayStatus" />
|
||||
@elseif(str($displayStatus)->lower()->contains('starting'))
|
||||
<x-status.restarting :status="$displayStatus" />
|
||||
@elseif(str($displayStatus)->lower()->contains('restarting'))
|
||||
<x-status.restarting :status="$displayStatus" />
|
||||
@elseif(str($displayStatus)->lower()->contains('degraded'))
|
||||
<x-status.degraded :status="$displayStatus" />
|
||||
@else
|
||||
<x-status.stopped :status="$complexStatus" />
|
||||
<x-status.stopped :status="$displayStatus" />
|
||||
@endif
|
||||
@if (!str($complexStatus)->contains('exited') && $showRefreshButton)
|
||||
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
|
||||
|
|
|
|||
|
|
@ -2,12 +2,35 @@
|
|||
'status' => 'Stopped',
|
||||
'noLoading' => false,
|
||||
])
|
||||
@php
|
||||
// Handle both colon format (backend) and parentheses format (from services.blade.php)
|
||||
// exited:unhealthy → Exited (unhealthy)
|
||||
// exited (unhealthy) → exited (unhealthy) (already formatted, display as-is)
|
||||
|
||||
if (str($status)->contains('(')) {
|
||||
// Already in parentheses format from services.blade.php - use as-is
|
||||
$displayStatus = $status;
|
||||
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
|
||||
} elseif (str($status)->contains(':')) {
|
||||
// Colon format from backend - transform it
|
||||
$parts = explode(':', $status);
|
||||
$displayStatus = str($parts[0])->headline();
|
||||
$healthStatus = $parts[1] ?? null;
|
||||
} else {
|
||||
// Simple status without health
|
||||
$displayStatus = str($status)->headline();
|
||||
$healthStatus = null;
|
||||
}
|
||||
@endphp
|
||||
<div class="flex items-center">
|
||||
@if (!$noLoading)
|
||||
<x-loading wire:loading.delay.longer />
|
||||
@endif
|
||||
<span wire:loading.remove.delay.longer class="flex items-center">
|
||||
<div class="badge badge-error "></div>
|
||||
<div class="pl-2 pr-1 text-xs font-bold text-error">{{ str($status)->before(':')->headline() }}</div>
|
||||
<div class="pl-2 pr-1 text-xs font-bold text-error">{{ $displayStatus }}</div>
|
||||
@if ($healthStatus && !str($displayStatus)->contains('('))
|
||||
<div class="text-xs text-error">({{ $healthStatus }})</div>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,15 +30,15 @@
|
|||
@endif
|
||||
<a class="menu-item flex items-center gap-2" wire:current.exact="menu-item-active"
|
||||
href="{{ route('project.application.servers', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Servers
|
||||
@if (str($application->status)->contains('degraded'))
|
||||
<span title="Some servers are unavailable">
|
||||
@if ($application->server_status == false)
|
||||
<span title="One or more servers are unreachable or misconfigured.">
|
||||
<svg class="w-4 h-4 text-error" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor"
|
||||
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16" />
|
||||
</svg>
|
||||
</span>
|
||||
@elseif ($application->server_status == false)
|
||||
<span title="The underlying server(s) has problems.">
|
||||
@elseif ($application->additional_servers()->exists() && str($application->status)->contains('degraded'))
|
||||
<span title="Application is in degraded state across multiple servers.">
|
||||
<svg class="w-4 h-4 text-error" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor"
|
||||
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16" />
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3">
|
|||
<div class="max-w-full px-4 truncate box-description" x-text="item.description"></div>
|
||||
<div class="max-w-full px-4 truncate box-description" x-text="item.fqdn"></div>
|
||||
<template x-if="item.server_status == false">
|
||||
<div class="px-4 text-xs font-bold text-error">The underlying server has problems
|
||||
<div class="px-4 text-xs font-bold text-error">Server is unreachable or misconfigured
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -167,7 +167,7 @@ class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3">
|
|||
<div class="max-w-full px-4 truncate box-description" x-text="item.description"></div>
|
||||
<div class="max-w-full px-4 truncate box-description" x-text="item.fqdn"></div>
|
||||
<template x-if="item.server_status == false">
|
||||
<div class="px-4 text-xs font-bold text-error">The underlying server has problems
|
||||
<div class="px-4 text-xs font-bold text-error">Server is unreachable or misconfigured
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -216,7 +216,7 @@ class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3">
|
|||
<div class="max-w-full px-4 truncate box-description" x-text="item.description"></div>
|
||||
<div class="max-w-full px-4 truncate box-description" x-text="item.fqdn"></div>
|
||||
<template x-if="item.server_status == false">
|
||||
<div class="px-4 text-xs font-bold text-error">The underlying server has problems
|
||||
<div class="px-4 text-xs font-bold text-error">Server is unreachable or misconfigured
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ class="w-4 h-4 dark:text-warning text-coollabs"
|
|||
@endcan
|
||||
</span>
|
||||
@endif
|
||||
<div class="pt-2 text-xs">{{ $application->status }}</div>
|
||||
<div class="pt-2 text-xs">{{ formatContainerStatus($application->status) }}</div>
|
||||
</div>
|
||||
<div class="flex items-center px-4">
|
||||
<a class="mx-4 text-xs font-bold hover:underline"
|
||||
|
|
@ -139,7 +139,7 @@ class="w-4 h-4 dark:text-warning text-coollabs"
|
|||
@if ($database->description)
|
||||
<span class="text-xs">{{ Str::limit($database->description, 60) }}</span>
|
||||
@endif
|
||||
<div class="text-xs">{{ $database->status }}</div>
|
||||
<div class="text-xs">{{ formatContainerStatus($database->status) }}</div>
|
||||
</div>
|
||||
<div class="flex items-center px-4">
|
||||
@if ($database->isBackupSolutionAvailable() || $database->is_migrated)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2">
|
||||
<path d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
|
||||
<path d="M19.933 13.041 a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
|
||||
<path d="M20 4v5h-5" />
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
223
tests/Unit/AllExcludedContainersConsistencyTest.php
Normal file
223
tests/Unit/AllExcludedContainersConsistencyTest.php
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify consistent handling of all-excluded containers
|
||||
* across PushServerUpdateJob, GetContainersStatus, and ComplexStatusCheck.
|
||||
*
|
||||
* These tests verify the fix for issue where different code paths handled
|
||||
* all-excluded containers inconsistently:
|
||||
* - PushServerUpdateJob (Sentinel, ~30s) previously skipped updates
|
||||
* - GetContainersStatus (SSH, ~1min) previously skipped updates
|
||||
* - ComplexStatusCheck (Multi-server) correctly calculated :excluded status
|
||||
*
|
||||
* After this fix, all three paths now calculate and return :excluded status
|
||||
* consistently, preventing status drift and UI inconsistencies.
|
||||
*/
|
||||
it('ensures CalculatesExcludedStatus trait exists with required methods', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Verify trait has both status calculation methods
|
||||
expect($traitFile)
|
||||
->toContain('trait CalculatesExcludedStatus')
|
||||
->toContain('protected function calculateExcludedStatus(Collection $containers, Collection $excludedContainers): string')
|
||||
->toContain('protected function calculateExcludedStatusFromStrings(Collection $containerStatuses): string')
|
||||
->toContain('protected function getExcludedContainersFromDockerCompose(?string $dockerComposeRaw): Collection');
|
||||
});
|
||||
|
||||
it('ensures ComplexStatusCheck uses CalculatesExcludedStatus trait', function () {
|
||||
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
|
||||
|
||||
// Verify trait is used
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('use App\Traits\CalculatesExcludedStatus;')
|
||||
->toContain('use CalculatesExcludedStatus;');
|
||||
|
||||
// Verify it uses the trait method instead of inline code
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('return $this->calculateExcludedStatus($containers, $excludedContainers);');
|
||||
|
||||
// Verify it uses the trait helper for excluded containers
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
});
|
||||
|
||||
it('ensures PushServerUpdateJob uses CalculatesExcludedStatus trait', function () {
|
||||
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
|
||||
// Verify trait is used
|
||||
expect($pushServerUpdateJobFile)
|
||||
->toContain('use App\Traits\CalculatesExcludedStatus;')
|
||||
->toContain('use CalculatesExcludedStatus;');
|
||||
|
||||
// Verify it calculates excluded status instead of skipping (old behavior: continue)
|
||||
expect($pushServerUpdateJobFile)
|
||||
->toContain('// If all containers are excluded, calculate status from excluded containers')
|
||||
->toContain('$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);');
|
||||
|
||||
// Verify it uses the trait helper for excluded containers
|
||||
expect($pushServerUpdateJobFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
});
|
||||
|
||||
it('ensures PushServerUpdateJob calculates excluded status for applications', function () {
|
||||
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
|
||||
// In aggregateMultiContainerStatuses, verify the all-excluded scenario
|
||||
// calculates status and updates the application
|
||||
expect($pushServerUpdateJobFile)
|
||||
->toContain('if ($relevantStatuses->isEmpty()) {')
|
||||
->toContain('$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);')
|
||||
->toContain('if ($aggregatedStatus && $application->status !== $aggregatedStatus) {')
|
||||
->toContain('$application->status = $aggregatedStatus;')
|
||||
->toContain('$application->save();');
|
||||
});
|
||||
|
||||
it('ensures PushServerUpdateJob calculates excluded status for services', function () {
|
||||
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
|
||||
// Count occurrences - should appear twice (once for applications, once for services)
|
||||
$calculateExcludedCount = substr_count(
|
||||
$pushServerUpdateJobFile,
|
||||
'$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);'
|
||||
);
|
||||
|
||||
expect($calculateExcludedCount)->toBe(2, 'Should calculate excluded status for both applications and services');
|
||||
});
|
||||
|
||||
it('ensures GetContainersStatus uses CalculatesExcludedStatus trait', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Verify trait is used
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('use App\Traits\CalculatesExcludedStatus;')
|
||||
->toContain('use CalculatesExcludedStatus;');
|
||||
|
||||
// Verify it calculates excluded status instead of returning null
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('// If all containers are excluded, calculate status from excluded containers')
|
||||
->toContain('return $this->calculateExcludedStatusFromStrings($containerStatuses);');
|
||||
|
||||
// Verify it uses the trait helper for excluded containers
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
});
|
||||
|
||||
it('ensures GetContainersStatus calculates excluded status for applications', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// In aggregateApplicationStatus, verify the all-excluded scenario returns status
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('if ($relevantStatuses->isEmpty()) {')
|
||||
->toContain('return $this->calculateExcludedStatusFromStrings($containerStatuses);');
|
||||
});
|
||||
|
||||
it('ensures GetContainersStatus calculates excluded status for services', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// In aggregateServiceContainerStatuses, verify the all-excluded scenario updates status
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);')
|
||||
->toContain('if ($aggregatedStatus) {')
|
||||
->toContain('$statusFromDb = $subResource->status;')
|
||||
->toContain("if (\$statusFromDb !== \$aggregatedStatus) {\n \$subResource->update(['status' => \$aggregatedStatus]);");
|
||||
});
|
||||
|
||||
it('ensures excluded status format is consistent across all paths', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Trait now delegates to ContainerStatusAggregator and uses appendExcludedSuffix helper
|
||||
expect($traitFile)
|
||||
->toContain('use App\\Services\\ContainerStatusAggregator;')
|
||||
->toContain('$aggregator = new ContainerStatusAggregator;')
|
||||
->toContain('private function appendExcludedSuffix(string $status): string');
|
||||
|
||||
// Check that appendExcludedSuffix returns consistent colon format with :excluded suffix
|
||||
expect($traitFile)
|
||||
->toContain("return 'degraded:excluded';")
|
||||
->toContain("return 'paused:excluded';")
|
||||
->toContain("return 'starting:excluded';")
|
||||
->toContain("return 'exited:excluded';")
|
||||
->toContain('return "$status:excluded";'); // For running:healthy:excluded, running:unhealthy:excluded, etc.
|
||||
});
|
||||
|
||||
it('ensures all three paths check for exclude_from_hc flag consistently', function () {
|
||||
// All three should use the trait helper method
|
||||
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
|
||||
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
|
||||
expect($pushServerUpdateJobFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
|
||||
// The trait method should check both exclude_from_hc and restart: no
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
expect($traitFile)
|
||||
->toContain('$excludeFromHc = data_get($serviceConfig, \'exclude_from_hc\', false);')
|
||||
->toContain('$restartPolicy = data_get($serviceConfig, \'restart\', \'always\');')
|
||||
->toContain('if ($excludeFromHc || $restartPolicy === \'no\') {');
|
||||
});
|
||||
|
||||
it('ensures calculateExcludedStatus uses ContainerStatusAggregator', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Check that the trait uses ContainerStatusAggregator service instead of duplicating logic
|
||||
expect($traitFile)
|
||||
->toContain('protected function calculateExcludedStatus(Collection $containers, Collection $excludedContainers): string')
|
||||
->toContain('use App\Services\ContainerStatusAggregator;')
|
||||
->toContain('$aggregator = new ContainerStatusAggregator;')
|
||||
->toContain('$aggregator->aggregateFromContainers($excludedOnly)');
|
||||
|
||||
// Check that it has appendExcludedSuffix helper for all states
|
||||
expect($traitFile)
|
||||
->toContain('private function appendExcludedSuffix(string $status): string')
|
||||
->toContain("return 'degraded:excluded';")
|
||||
->toContain("return 'paused:excluded';")
|
||||
->toContain("return 'starting:excluded';")
|
||||
->toContain("return 'exited:excluded';")
|
||||
->toContain('return "$status:excluded";'); // For running:healthy:excluded
|
||||
});
|
||||
|
||||
it('ensures calculateExcludedStatusFromStrings uses ContainerStatusAggregator', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Check that the trait uses ContainerStatusAggregator service instead of duplicating logic
|
||||
expect($traitFile)
|
||||
->toContain('protected function calculateExcludedStatusFromStrings(Collection $containerStatuses): string')
|
||||
->toContain('use App\Services\ContainerStatusAggregator;')
|
||||
->toContain('$aggregator = new ContainerStatusAggregator;')
|
||||
->toContain('$aggregator->aggregateFromStrings($containerStatuses)');
|
||||
|
||||
// Check that it has appendExcludedSuffix helper for all states
|
||||
expect($traitFile)
|
||||
->toContain('private function appendExcludedSuffix(string $status): string')
|
||||
->toContain("return 'degraded:excluded';")
|
||||
->toContain("return 'paused:excluded';")
|
||||
->toContain("return 'starting:excluded';")
|
||||
->toContain("return 'exited:excluded';")
|
||||
->toContain('return "$status:excluded";'); // For running:healthy:excluded
|
||||
});
|
||||
|
||||
it('verifies no code path skips update when all containers excluded', function () {
|
||||
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// These patterns should NOT exist anymore (old behavior that caused drift)
|
||||
expect($pushServerUpdateJobFile)
|
||||
->not->toContain("// If all containers are excluded, don't update status");
|
||||
|
||||
expect($getContainersStatusFile)
|
||||
->not->toContain("// If all containers are excluded, don't update status");
|
||||
|
||||
// Instead, both should calculate excluded status
|
||||
expect($pushServerUpdateJobFile)
|
||||
->toContain('// If all containers are excluded, calculate status from excluded containers');
|
||||
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('// If all containers are excluded, calculate status from excluded containers');
|
||||
});
|
||||
342
tests/Unit/ContainerHealthStatusTest.php
Normal file
342
tests/Unit/ContainerHealthStatusTest.php
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use Mockery;
|
||||
|
||||
/**
|
||||
* Unit tests to verify that containers without health checks are not
|
||||
* incorrectly marked as unhealthy.
|
||||
*
|
||||
* This tests the fix for the issue where defaulting missing health status
|
||||
* to 'unhealthy' would treat containers without healthchecks as unhealthy.
|
||||
*
|
||||
* The fix removes the 'unhealthy' default and only checks health status
|
||||
* when it explicitly exists and equals 'unhealthy'.
|
||||
*/
|
||||
it('does not mark containers as unhealthy when health status is missing', function () {
|
||||
// Mock an application with a server
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$server = Mockery::mock('App\Models\Server')->makePartial();
|
||||
$destination = Mockery::mock('App\Models\StandaloneDocker')->makePartial();
|
||||
|
||||
$destination->shouldReceive('getAttribute')
|
||||
->with('server')
|
||||
->andReturn($server);
|
||||
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('destination')
|
||||
->andReturn($destination);
|
||||
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('additional_servers')
|
||||
->andReturn(collect());
|
||||
|
||||
$server->shouldReceive('getAttribute')
|
||||
->with('id')
|
||||
->andReturn(1);
|
||||
|
||||
$server->shouldReceive('isFunctional')
|
||||
->andReturn(true);
|
||||
|
||||
// Create a container without health check (State.Health.Status is null)
|
||||
$containerWithoutHealthCheck = [
|
||||
'Config' => [
|
||||
'Labels' => [
|
||||
'com.docker.compose.service' => 'web',
|
||||
],
|
||||
],
|
||||
'State' => [
|
||||
'Status' => 'running',
|
||||
// Note: State.Health.Status is intentionally missing
|
||||
],
|
||||
];
|
||||
|
||||
// Mock the remote process to return our container
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('id')
|
||||
->andReturn(123);
|
||||
|
||||
// We can't easily test the private aggregateContainerStatuses method directly,
|
||||
// but we can verify that the code doesn't default to 'unhealthy'
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify the fix: health status should not default to 'unhealthy'
|
||||
expect($aggregatorFile)
|
||||
->not->toContain("data_get(\$container, 'State.Health.Status', 'unhealthy')")
|
||||
->toContain("data_get(\$container, 'State.Health.Status')");
|
||||
|
||||
// Verify the health check logic
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($health === \'unhealthy\') {');
|
||||
});
|
||||
|
||||
it('only marks containers as unhealthy when health status explicitly equals unhealthy', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify the service checks for explicit 'unhealthy' status
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($health === \'unhealthy\') {')
|
||||
->toContain('$hasUnhealthy = true;');
|
||||
});
|
||||
|
||||
it('handles missing health status correctly in GetContainersStatus', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Verify health status doesn't default to 'unhealthy'
|
||||
expect($getContainersStatusFile)
|
||||
->not->toContain("data_get(\$container, 'State.Health.Status', 'unhealthy')")
|
||||
->toContain("data_get(\$container, 'State.Health.Status')");
|
||||
|
||||
// Verify it uses 'unknown' when health status is missing (now using colon format)
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('$healthSuffix = $containerHealth ?? \'unknown\';')
|
||||
->toContain('ContainerStatusAggregator'); // Uses the service
|
||||
});
|
||||
|
||||
it('treats containers with running status and no healthcheck as not unhealthy', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// The logic should be:
|
||||
// 1. Get health status (may be null)
|
||||
// 2. Only mark as unhealthy if health status EXISTS and equals 'unhealthy'
|
||||
// 3. Don't mark as unhealthy if health status is null/missing
|
||||
|
||||
// Verify the condition explicitly checks for unhealthy
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($health === \'unhealthy\')');
|
||||
|
||||
// Verify this check is done for running containers
|
||||
expect($aggregatorFile)
|
||||
->toContain('} elseif ($state === \'running\') {')
|
||||
->toContain('$hasRunning = true;');
|
||||
});
|
||||
|
||||
it('tracks unknown health state in aggregation', function () {
|
||||
// State machine logic now in ContainerStatusAggregator service
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify that $hasUnknown tracking variable exists in the service
|
||||
expect($aggregatorFile)
|
||||
->toContain('$hasUnknown = false;');
|
||||
|
||||
// Verify that unknown state is detected in status parsing
|
||||
expect($aggregatorFile)
|
||||
->toContain("str(\$status)->contains('unknown')")
|
||||
->toContain('$hasUnknown = true;');
|
||||
});
|
||||
|
||||
it('preserves unknown health state in aggregated status with correct priority', function () {
|
||||
// State machine logic now in ContainerStatusAggregator service (using colon format)
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify three-way priority in aggregation:
|
||||
// 1. Unhealthy (highest priority)
|
||||
// 2. Unknown (medium priority)
|
||||
// 3. Healthy (only when all explicitly healthy)
|
||||
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($hasUnhealthy) {')
|
||||
->toContain("return 'running:unhealthy';")
|
||||
->toContain('} elseif ($hasUnknown) {')
|
||||
->toContain("return 'running:unknown';")
|
||||
->toContain('} else {')
|
||||
->toContain("return 'running:healthy';");
|
||||
});
|
||||
|
||||
it('tracks unknown health state in ContainerStatusAggregator for all applications', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify that $hasUnknown tracking variable exists
|
||||
expect($aggregatorFile)
|
||||
->toContain('$hasUnknown = false;');
|
||||
|
||||
// Verify that unknown state is detected when health is null or 'starting'
|
||||
expect($aggregatorFile)
|
||||
->toContain('} elseif (is_null($health) || $health === \'starting\') {')
|
||||
->toContain('$hasUnknown = true;');
|
||||
});
|
||||
|
||||
it('preserves unknown health state in ContainerStatusAggregator aggregated status', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify three-way priority for running containers in the service
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($hasUnhealthy) {')
|
||||
->toContain("return 'running:unhealthy';")
|
||||
->toContain('} elseif ($hasUnknown) {')
|
||||
->toContain("return 'running:unknown';")
|
||||
->toContain('} else {')
|
||||
->toContain("return 'running:healthy';");
|
||||
|
||||
// Verify ComplexStatusCheck delegates to the service
|
||||
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('use App\\Services\\ContainerStatusAggregator;')
|
||||
->toContain('$aggregator = new ContainerStatusAggregator;')
|
||||
->toContain('$aggregator->aggregateFromContainers($relevantContainers);');
|
||||
});
|
||||
|
||||
it('preserves unknown health state in Service model aggregation', function () {
|
||||
$serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
|
||||
|
||||
// Verify unknown is handled correctly
|
||||
expect($serviceFile)
|
||||
->toContain("} elseif (\$health->value() === 'unknown') {")
|
||||
->toContain("if (\$aggregateHealth !== 'unhealthy') {")
|
||||
->toContain("\$aggregateHealth = 'unknown';");
|
||||
|
||||
// The pattern should appear at least once (Service model has different aggregation logic than ContainerStatusAggregator)
|
||||
$unknownCount = substr_count($serviceFile, "} elseif (\$health->value() === 'unknown') {");
|
||||
expect($unknownCount)->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles starting state (created/starting) in GetContainersStatus', function () {
|
||||
// State machine logic now in ContainerStatusAggregator service
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify tracking variable exists
|
||||
expect($aggregatorFile)
|
||||
->toContain('$hasStarting = false;');
|
||||
|
||||
// Verify detection for created/starting states
|
||||
expect($aggregatorFile)
|
||||
->toContain("str(\$status)->contains('created') || str(\$status)->contains('starting')")
|
||||
->toContain('$hasStarting = true;');
|
||||
|
||||
// Verify aggregation returns starting status (colon format)
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($hasStarting) {')
|
||||
->toContain("return 'starting:unknown';");
|
||||
});
|
||||
|
||||
it('handles paused state in GetContainersStatus', function () {
|
||||
// State machine logic now in ContainerStatusAggregator service
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify tracking variable exists
|
||||
expect($aggregatorFile)
|
||||
->toContain('$hasPaused = false;');
|
||||
|
||||
// Verify detection for paused state
|
||||
expect($aggregatorFile)
|
||||
->toContain("str(\$status)->contains('paused')")
|
||||
->toContain('$hasPaused = true;');
|
||||
|
||||
// Verify aggregation returns paused status (colon format)
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($hasPaused) {')
|
||||
->toContain("return 'paused:unknown';");
|
||||
});
|
||||
|
||||
it('handles dead/removing states in GetContainersStatus', function () {
|
||||
// State machine logic now in ContainerStatusAggregator service
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify tracking variable exists
|
||||
expect($aggregatorFile)
|
||||
->toContain('$hasDead = false;');
|
||||
|
||||
// Verify detection for dead/removing states
|
||||
expect($aggregatorFile)
|
||||
->toContain("str(\$status)->contains('dead') || str(\$status)->contains('removing')")
|
||||
->toContain('$hasDead = true;');
|
||||
|
||||
// Verify aggregation returns degraded status (colon format)
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($hasDead) {')
|
||||
->toContain("return 'degraded:unhealthy';");
|
||||
});
|
||||
|
||||
it('handles edge case states in ContainerStatusAggregator for all containers', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify tracking variables exist in the service
|
||||
expect($aggregatorFile)
|
||||
->toContain('$hasStarting = false;')
|
||||
->toContain('$hasPaused = false;')
|
||||
->toContain('$hasDead = false;');
|
||||
|
||||
// Verify detection for created/starting
|
||||
expect($aggregatorFile)
|
||||
->toContain("} elseif (\$state === 'created' || \$state === 'starting') {")
|
||||
->toContain('$hasStarting = true;');
|
||||
|
||||
// Verify detection for paused
|
||||
expect($aggregatorFile)
|
||||
->toContain("} elseif (\$state === 'paused') {")
|
||||
->toContain('$hasPaused = true;');
|
||||
|
||||
// Verify detection for dead/removing
|
||||
expect($aggregatorFile)
|
||||
->toContain("} elseif (\$state === 'dead' || \$state === 'removing') {")
|
||||
->toContain('$hasDead = true;');
|
||||
});
|
||||
|
||||
it('handles edge case states in ContainerStatusAggregator aggregation', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify aggregation logic for edge cases in the service
|
||||
expect($aggregatorFile)
|
||||
->toContain('if ($hasDead) {')
|
||||
->toContain("return 'degraded:unhealthy';")
|
||||
->toContain('if ($hasPaused) {')
|
||||
->toContain("return 'paused:unknown';")
|
||||
->toContain('if ($hasStarting) {')
|
||||
->toContain("return 'starting:unknown';");
|
||||
});
|
||||
|
||||
it('handles edge case states in Service model', function () {
|
||||
$serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
|
||||
|
||||
// Check for created/starting handling pattern
|
||||
$createdStartingCount = substr_count($serviceFile, "\$status->startsWith('created') || \$status->startsWith('starting')");
|
||||
expect($createdStartingCount)->toBeGreaterThan(0, 'created/starting handling should exist');
|
||||
|
||||
// Check for paused handling pattern
|
||||
$pausedCount = substr_count($serviceFile, "\$status->startsWith('paused')");
|
||||
expect($pausedCount)->toBeGreaterThan(0, 'paused handling should exist');
|
||||
|
||||
// Check for dead/removing handling pattern
|
||||
$deadRemovingCount = substr_count($serviceFile, "\$status->startsWith('dead') || \$status->startsWith('removing')");
|
||||
expect($deadRemovingCount)->toBeGreaterThan(0, 'dead/removing handling should exist');
|
||||
});
|
||||
|
||||
it('appends :excluded suffix to excluded container statuses in GetContainersStatus', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Verify that we use the trait for calculating excluded status
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('CalculatesExcludedStatus');
|
||||
|
||||
// Verify that we use the trait to calculate excluded status
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('use CalculatesExcludedStatus;');
|
||||
});
|
||||
|
||||
it('skips containers with :excluded suffix in Service model non-excluded sections', function () {
|
||||
$serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
|
||||
|
||||
// Verify that we have exclude_from_status field handling
|
||||
expect($serviceFile)
|
||||
->toContain('exclude_from_status');
|
||||
});
|
||||
|
||||
it('processes containers with :excluded suffix in Service model excluded sections', function () {
|
||||
$serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
|
||||
|
||||
// Verify that we handle excluded status
|
||||
expect($serviceFile)
|
||||
->toContain(':excluded')
|
||||
->toContain('exclude_from_status');
|
||||
});
|
||||
|
||||
it('treats containers with starting health status as unknown in ContainerStatusAggregator', function () {
|
||||
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
|
||||
|
||||
// Verify that 'starting' health status is treated the same as null (unknown)
|
||||
// During Docker health check grace period, the health status is 'starting'
|
||||
// This should be treated as 'unknown' rather than 'healthy'
|
||||
expect($aggregatorFile)
|
||||
->toContain('} elseif (is_null($health) || $health === \'starting\') {')
|
||||
->toContain('$hasUnknown = true;');
|
||||
});
|
||||
540
tests/Unit/ContainerStatusAggregatorTest.php
Normal file
540
tests/Unit/ContainerStatusAggregatorTest.php
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
<?php
|
||||
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->aggregator = new ContainerStatusAggregator;
|
||||
});
|
||||
|
||||
describe('aggregateFromStrings', function () {
|
||||
test('returns exited:unhealthy for empty collection', function () {
|
||||
$result = $this->aggregator->aggregateFromStrings(collect());
|
||||
|
||||
expect($result)->toBe('exited:unhealthy');
|
||||
});
|
||||
|
||||
test('returns running:healthy for single healthy running container', function () {
|
||||
$statuses = collect(['running:healthy']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('returns running:unhealthy for single unhealthy running container', function () {
|
||||
$statuses = collect(['running:unhealthy']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
test('returns running:unknown for single running container with unknown health', function () {
|
||||
$statuses = collect(['running:unknown']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unknown');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for restarting container', function () {
|
||||
$statuses = collect(['restarting']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for mixed running and exited containers', function () {
|
||||
$statuses = collect(['running:healthy', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns running:unhealthy when one of multiple running containers is unhealthy', function () {
|
||||
$statuses = collect(['running:healthy', 'running:unhealthy', 'running:healthy']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
test('returns running:unknown when running containers have unknown health', function () {
|
||||
$statuses = collect(['running:unknown', 'running:healthy']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unknown');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns exited:unhealthy for exited containers without restart count', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
|
||||
|
||||
expect($result)->toBe('exited:unhealthy');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for dead container', function () {
|
||||
$statuses = collect(['dead']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for removing container', function () {
|
||||
$statuses = collect(['removing']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns paused:unknown for paused container', function () {
|
||||
$statuses = collect(['paused']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('paused:unknown');
|
||||
});
|
||||
|
||||
test('returns starting:unknown for starting container', function () {
|
||||
$statuses = collect(['starting']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('starting:unknown');
|
||||
});
|
||||
|
||||
test('returns starting:unknown for created container', function () {
|
||||
$statuses = collect(['created']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('starting:unknown');
|
||||
});
|
||||
|
||||
test('handles parentheses format input (backward compatibility)', function () {
|
||||
$statuses = collect(['running (healthy)', 'running (unhealthy)']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
test('handles mixed colon and parentheses formats', function () {
|
||||
$statuses = collect(['running:healthy', 'running (unhealthy)', 'running:healthy']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes restarting over all other states', function () {
|
||||
$statuses = collect(['restarting', 'running:healthy', 'paused', 'starting']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes crash loop over running containers', function () {
|
||||
$statuses = collect(['exited', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 3);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes mixed state over healthy running', function () {
|
||||
$statuses = collect(['running:healthy', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes running over paused/starting/exited', function () {
|
||||
$statuses = collect(['running:healthy', 'starting', 'paused']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('prioritizes dead over paused/starting/exited', function () {
|
||||
$statuses = collect(['dead', 'paused', 'starting']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('prioritizes paused over starting/exited', function () {
|
||||
$statuses = collect(['paused', 'starting', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('paused:unknown');
|
||||
});
|
||||
|
||||
test('prioritizes starting over exited', function () {
|
||||
$statuses = collect(['starting', 'exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('starting:unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateFromContainers', function () {
|
||||
test('returns exited:unhealthy for empty collection', function () {
|
||||
$result = $this->aggregator->aggregateFromContainers(collect());
|
||||
|
||||
expect($result)->toBe('exited:unhealthy');
|
||||
});
|
||||
|
||||
test('returns running:healthy for single healthy running container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => (object) ['Status' => 'healthy'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('returns running:unhealthy for single unhealthy running container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => (object) ['Status' => 'unhealthy'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
test('returns running:unknown for running container without health check', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('running:unknown');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for restarting container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'restarting',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for mixed running and exited containers', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => (object) ['Status' => 'healthy'],
|
||||
],
|
||||
],
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'exited',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'exited',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: 5);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns exited:unhealthy for exited containers without restart count', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'exited',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: 0);
|
||||
|
||||
expect($result)->toBe('exited:unhealthy');
|
||||
});
|
||||
|
||||
test('returns degraded:unhealthy for dead container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'dead',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('returns paused:unknown for paused container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'paused',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('paused:unknown');
|
||||
});
|
||||
|
||||
test('returns starting:unknown for starting container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'starting',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('starting:unknown');
|
||||
});
|
||||
|
||||
test('returns starting:unknown for created container', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'created',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('starting:unknown');
|
||||
});
|
||||
|
||||
test('handles multiple containers with various states', function () {
|
||||
$containers = collect([
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => (object) ['Status' => 'healthy'],
|
||||
],
|
||||
],
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => (object) ['Status' => 'unhealthy'],
|
||||
],
|
||||
],
|
||||
(object) [
|
||||
'State' => (object) [
|
||||
'Status' => 'running',
|
||||
'Health' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromContainers($containers);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('state priority enforcement', function () {
|
||||
test('restarting has highest priority', function () {
|
||||
$statuses = collect([
|
||||
'restarting',
|
||||
'running:healthy',
|
||||
'dead',
|
||||
'paused',
|
||||
'starting',
|
||||
'exited',
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('crash loop has second highest priority', function () {
|
||||
$statuses = collect([
|
||||
'exited',
|
||||
'running:healthy',
|
||||
'paused',
|
||||
'starting',
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 1);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('mixed state (running + exited) has third priority', function () {
|
||||
$statuses = collect([
|
||||
'running:healthy',
|
||||
'exited',
|
||||
'paused',
|
||||
'starting',
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
|
||||
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('running:unhealthy has priority over running:unknown', function () {
|
||||
$statuses = collect([
|
||||
'running:unknown',
|
||||
'running:unhealthy',
|
||||
'running:healthy',
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
test('running:unknown has priority over running:healthy', function () {
|
||||
$statuses = collect([
|
||||
'running:unknown',
|
||||
'running:healthy',
|
||||
]);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('running:unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxRestartCount validation', function () {
|
||||
test('negative maxRestartCount is corrected to 0 in aggregateFromStrings', function () {
|
||||
// Mock the Log facade to avoid "facade root not set" error in unit tests
|
||||
Log::shouldReceive('warning')->once();
|
||||
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
// With negative value, should be treated as 0 (no restarts)
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: -5);
|
||||
|
||||
// Should return exited:unhealthy (not degraded) since corrected to 0
|
||||
expect($result)->toBe('exited:unhealthy');
|
||||
});
|
||||
|
||||
test('negative maxRestartCount is corrected to 0 in aggregateFromContainers', function () {
|
||||
// Mock the Log facade to avoid "facade root not set" error in unit tests
|
||||
Log::shouldReceive('warning')->once();
|
||||
|
||||
$containers = collect([
|
||||
[
|
||||
'State' => [
|
||||
'Status' => 'exited',
|
||||
'ExitCode' => 1,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// With negative value, should be treated as 0 (no restarts)
|
||||
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: -10);
|
||||
|
||||
// Should return exited:unhealthy (not degraded) since corrected to 0
|
||||
expect($result)->toBe('exited:unhealthy');
|
||||
});
|
||||
|
||||
test('zero maxRestartCount works correctly', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
|
||||
|
||||
// Zero is valid default - no crash loop detection
|
||||
expect($result)->toBe('exited:unhealthy');
|
||||
});
|
||||
|
||||
test('positive maxRestartCount works correctly', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
|
||||
|
||||
// Positive value enables crash loop detection
|
||||
expect($result)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('crash loop detection still functions after validation', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
// Test with various positive restart counts
|
||||
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 1))
|
||||
->toBe('degraded:unhealthy');
|
||||
|
||||
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 100))
|
||||
->toBe('degraded:unhealthy');
|
||||
|
||||
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 999))
|
||||
->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
test('default maxRestartCount parameter works', function () {
|
||||
$statuses = collect(['exited']);
|
||||
|
||||
// Call without specifying maxRestartCount (should default to 0)
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('exited:unhealthy');
|
||||
});
|
||||
});
|
||||
154
tests/Unit/ExcludeFromHealthCheckTest.php
Normal file
154
tests/Unit/ExcludeFromHealthCheckTest.php
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify that applications and services with all containers
|
||||
* excluded from health checks (exclude_from_hc: true) show correct status.
|
||||
*
|
||||
* These tests verify the fix for the issue where services with all containers
|
||||
* excluded would show incorrect status, causing broken UI state.
|
||||
*
|
||||
* The fix now returns status with :excluded suffix to show real container state
|
||||
* while indicating monitoring is disabled (e.g., "running:excluded").
|
||||
*/
|
||||
it('ensures ComplexStatusCheck returns excluded status when all containers excluded', function () {
|
||||
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
|
||||
|
||||
// Check that when all containers are excluded, ComplexStatusCheck uses the trait
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('// If all containers are excluded, calculate status from excluded containers')
|
||||
->toContain('// but mark it with :excluded to indicate monitoring is disabled')
|
||||
->toContain('if ($relevantContainers->isEmpty()) {')
|
||||
->toContain('return $this->calculateExcludedStatus($containers, $excludedContainers);');
|
||||
|
||||
// Check that the trait uses ContainerStatusAggregator and appends :excluded suffix
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
expect($traitFile)
|
||||
->toContain('ContainerStatusAggregator')
|
||||
->toContain('appendExcludedSuffix')
|
||||
->toContain('$aggregator->aggregateFromContainers($excludedOnly)')
|
||||
->toContain("return 'degraded:excluded';")
|
||||
->toContain("return 'paused:excluded';")
|
||||
->toContain("return 'exited:excluded';")
|
||||
->toContain('return "$status:excluded";'); // For running:healthy:excluded
|
||||
});
|
||||
|
||||
it('ensures Service model returns excluded status when all services excluded', function () {
|
||||
$serviceModelFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
|
||||
|
||||
// Check that when all services are excluded from status checks,
|
||||
// the Service model calculates real status and returns it with :excluded suffix
|
||||
expect($serviceModelFile)
|
||||
->toContain('exclude_from_status')
|
||||
->toContain(':excluded')
|
||||
->toContain('CalculatesExcludedStatus');
|
||||
});
|
||||
|
||||
it('ensures Service model returns unknown:unknown:excluded when no containers exist', function () {
|
||||
$serviceModelFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
|
||||
|
||||
// Check that when a service has no applications or databases at all,
|
||||
// the Service model returns 'unknown:unknown:excluded' instead of 'exited:unhealthy:excluded'
|
||||
// This prevents misleading status display when containers don't exist
|
||||
expect($serviceModelFile)
|
||||
->toContain('// If no status was calculated at all (no containers exist), return unknown')
|
||||
->toContain('if ($excludedStatus === null && $excludedHealth === null) {')
|
||||
->toContain("return 'unknown:unknown:excluded';");
|
||||
});
|
||||
|
||||
it('ensures GetContainersStatus calculates excluded status when all containers excluded', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Check that when all containers are excluded, the aggregateApplicationStatus
|
||||
// method calculates and returns status with :excluded suffix
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('// If all containers are excluded, calculate status from excluded containers')
|
||||
->toContain('if ($relevantStatuses->isEmpty()) {')
|
||||
->toContain('return $this->calculateExcludedStatusFromStrings($containerStatuses);');
|
||||
});
|
||||
|
||||
it('ensures exclude_from_hc flag is properly checked in ComplexStatusCheck', function () {
|
||||
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
|
||||
|
||||
// Verify that exclude_from_hc is parsed using trait helper
|
||||
expect($complexStatusCheckFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
});
|
||||
|
||||
it('ensures exclude_from_hc flag is properly checked in GetContainersStatus', function () {
|
||||
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Verify that exclude_from_hc is parsed using trait helper
|
||||
expect($getContainersStatusFile)
|
||||
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
|
||||
});
|
||||
|
||||
it('ensures UI displays excluded status correctly in status component', function () {
|
||||
$servicesStatusFile = file_get_contents(__DIR__.'/../../resources/views/components/status/services.blade.php');
|
||||
|
||||
// Verify that the status component transforms :excluded suffix to (excluded) for better display
|
||||
expect($servicesStatusFile)
|
||||
->toContain('$isExcluded = str($complexStatus)->endsWith(\':excluded\');')
|
||||
->toContain('$parts = explode(\':\', $complexStatus);')
|
||||
->toContain('// Has health status: running:unhealthy:excluded → Running (unhealthy, excluded)')
|
||||
->toContain('// No health status: exited:excluded → Exited (excluded)');
|
||||
});
|
||||
|
||||
it('ensures UI handles excluded status in service heading buttons', function () {
|
||||
$headingFile = file_get_contents(__DIR__.'/../../resources/views/livewire/project/service/heading.blade.php');
|
||||
|
||||
// Verify that the heading properly handles running/degraded/exited status with :excluded suffix
|
||||
// The logic should use contains() to match the base status (running, degraded, exited)
|
||||
// which will work for both regular statuses and :excluded suffixed ones
|
||||
expect($headingFile)
|
||||
->toContain('str($service->status)->contains(\'running\')')
|
||||
->toContain('str($service->status)->contains(\'degraded\')')
|
||||
->toContain('str($service->status)->contains(\'exited\')');
|
||||
});
|
||||
|
||||
/**
|
||||
* Unit tests for YAML validation in CalculatesExcludedStatus trait
|
||||
*/
|
||||
it('ensures YAML validation has proper exception handling for parse errors', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Verify that ParseException is imported and caught separately from generic Exception
|
||||
expect($traitFile)
|
||||
->toContain('use Symfony\Component\Yaml\Exception\ParseException')
|
||||
->toContain('use Illuminate\Support\Facades\Log')
|
||||
->toContain('} catch (ParseException $e) {')
|
||||
->toContain('} catch (\Exception $e) {');
|
||||
});
|
||||
|
||||
it('ensures YAML validation logs parse errors with context', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Verify that parse errors are logged with useful context (error message, line, snippet)
|
||||
expect($traitFile)
|
||||
->toContain('Log::warning(\'Failed to parse Docker Compose YAML for health check exclusions\'')
|
||||
->toContain('\'error\' => $e->getMessage()')
|
||||
->toContain('\'line\' => $e->getParsedLine()')
|
||||
->toContain('\'snippet\' => $e->getSnippet()');
|
||||
});
|
||||
|
||||
it('ensures YAML validation logs unexpected errors', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Verify that unexpected errors are logged with error level
|
||||
expect($traitFile)
|
||||
->toContain('Log::error(\'Unexpected error parsing Docker Compose YAML\'')
|
||||
->toContain('\'trace\' => $e->getTraceAsString()');
|
||||
});
|
||||
|
||||
it('ensures YAML validation checks structure after parsing', function () {
|
||||
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
|
||||
|
||||
// Verify that parsed result is validated to be an array
|
||||
expect($traitFile)
|
||||
->toContain('if (! is_array($dockerCompose)) {')
|
||||
->toContain('Log::warning(\'Docker Compose YAML did not parse to array\'');
|
||||
|
||||
// Verify that services is validated to be an array
|
||||
expect($traitFile)
|
||||
->toContain('if (! is_array($services)) {')
|
||||
->toContain('Log::warning(\'Docker Compose services is not an array\'');
|
||||
});
|
||||
201
tests/Unit/FormatContainerStatusTest.php
Normal file
201
tests/Unit/FormatContainerStatusTest.php
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
|
||||
describe('formatContainerStatus helper', function () {
|
||||
describe('colon-delimited format parsing', function () {
|
||||
it('transforms running:healthy to Running (healthy)', function () {
|
||||
$result = formatContainerStatus('running:healthy');
|
||||
|
||||
expect($result)->toBe('Running (healthy)');
|
||||
});
|
||||
|
||||
it('transforms running:unhealthy to Running (unhealthy)', function () {
|
||||
$result = formatContainerStatus('running:unhealthy');
|
||||
|
||||
expect($result)->toBe('Running (unhealthy)');
|
||||
});
|
||||
|
||||
it('transforms exited:0 to Exited (0)', function () {
|
||||
$result = formatContainerStatus('exited:0');
|
||||
|
||||
expect($result)->toBe('Exited (0)');
|
||||
});
|
||||
|
||||
it('transforms restarting:starting to Restarting (starting)', function () {
|
||||
$result = formatContainerStatus('restarting:starting');
|
||||
|
||||
expect($result)->toBe('Restarting (starting)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('excluded suffix handling', function () {
|
||||
it('transforms running:unhealthy:excluded to Running (unhealthy, excluded)', function () {
|
||||
$result = formatContainerStatus('running:unhealthy:excluded');
|
||||
|
||||
expect($result)->toBe('Running (unhealthy, excluded)');
|
||||
});
|
||||
|
||||
it('transforms running:healthy:excluded to Running (healthy, excluded)', function () {
|
||||
$result = formatContainerStatus('running:healthy:excluded');
|
||||
|
||||
expect($result)->toBe('Running (healthy, excluded)');
|
||||
});
|
||||
|
||||
it('transforms exited:excluded to Exited (excluded)', function () {
|
||||
$result = formatContainerStatus('exited:excluded');
|
||||
|
||||
expect($result)->toBe('Exited (excluded)');
|
||||
});
|
||||
|
||||
it('transforms stopped:excluded to Stopped (excluded)', function () {
|
||||
$result = formatContainerStatus('stopped:excluded');
|
||||
|
||||
expect($result)->toBe('Stopped (excluded)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('simple status format', function () {
|
||||
it('transforms running to Running', function () {
|
||||
$result = formatContainerStatus('running');
|
||||
|
||||
expect($result)->toBe('Running');
|
||||
});
|
||||
|
||||
it('transforms exited to Exited', function () {
|
||||
$result = formatContainerStatus('exited');
|
||||
|
||||
expect($result)->toBe('Exited');
|
||||
});
|
||||
|
||||
it('transforms stopped to Stopped', function () {
|
||||
$result = formatContainerStatus('stopped');
|
||||
|
||||
expect($result)->toBe('Stopped');
|
||||
});
|
||||
|
||||
it('transforms restarting to Restarting', function () {
|
||||
$result = formatContainerStatus('restarting');
|
||||
|
||||
expect($result)->toBe('Restarting');
|
||||
});
|
||||
|
||||
it('transforms degraded to Degraded', function () {
|
||||
$result = formatContainerStatus('degraded');
|
||||
|
||||
expect($result)->toBe('Degraded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proxy status preservation', function () {
|
||||
it('preserves Proxy:running without parsing colons', function () {
|
||||
$result = formatContainerStatus('Proxy:running');
|
||||
|
||||
expect($result)->toBe('Proxy:running');
|
||||
});
|
||||
|
||||
it('preserves Proxy:exited without parsing colons', function () {
|
||||
$result = formatContainerStatus('Proxy:exited');
|
||||
|
||||
expect($result)->toBe('Proxy:exited');
|
||||
});
|
||||
|
||||
it('preserves Proxy:healthy without parsing colons', function () {
|
||||
$result = formatContainerStatus('Proxy:healthy');
|
||||
|
||||
expect($result)->toBe('Proxy:healthy');
|
||||
});
|
||||
|
||||
it('applies headline formatting to Proxy statuses', function () {
|
||||
$result = formatContainerStatus('proxy:running');
|
||||
|
||||
expect($result)->toBe('Proxy (running)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('headline transformation', function () {
|
||||
it('applies headline to simple lowercase status', function () {
|
||||
$result = formatContainerStatus('running');
|
||||
|
||||
expect($result)->toBe('Running');
|
||||
});
|
||||
|
||||
it('applies headline to uppercase status', function () {
|
||||
// headline() adds spaces between capital letters
|
||||
$result = formatContainerStatus('RUNNING');
|
||||
|
||||
expect($result)->toBe('R U N N I N G');
|
||||
});
|
||||
|
||||
it('applies headline to mixed case status', function () {
|
||||
// headline() adds spaces between capital letters
|
||||
$result = formatContainerStatus('RuNnInG');
|
||||
|
||||
expect($result)->toBe('Ru Nn In G');
|
||||
});
|
||||
|
||||
it('applies headline to first part of colon format', function () {
|
||||
// headline() adds spaces between capital letters
|
||||
$result = formatContainerStatus('RUNNING:healthy');
|
||||
|
||||
expect($result)->toBe('R U N N I N G (healthy)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', function () {
|
||||
it('handles empty string gracefully', function () {
|
||||
$result = formatContainerStatus('');
|
||||
|
||||
expect($result)->toBe('');
|
||||
});
|
||||
|
||||
it('handles multiple colons beyond expected format', function () {
|
||||
// Only first two parts should be used (or three with :excluded)
|
||||
$result = formatContainerStatus('running:healthy:extra:data');
|
||||
|
||||
expect($result)->toBe('Running (healthy)');
|
||||
});
|
||||
|
||||
it('handles status with spaces in health part', function () {
|
||||
$result = formatContainerStatus('running:health check failed');
|
||||
|
||||
expect($result)->toBe('Running (health check failed)');
|
||||
});
|
||||
|
||||
it('handles single colon with empty second part', function () {
|
||||
$result = formatContainerStatus('running:');
|
||||
|
||||
expect($result)->toBe('Running ()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', function () {
|
||||
it('handles typical running healthy container', function () {
|
||||
$result = formatContainerStatus('running:healthy');
|
||||
|
||||
expect($result)->toBe('Running (healthy)');
|
||||
});
|
||||
|
||||
it('handles degraded container with health issues', function () {
|
||||
$result = formatContainerStatus('degraded:unhealthy');
|
||||
|
||||
expect($result)->toBe('Degraded (unhealthy)');
|
||||
});
|
||||
|
||||
it('handles excluded unhealthy container', function () {
|
||||
$result = formatContainerStatus('running:unhealthy:excluded');
|
||||
|
||||
expect($result)->toBe('Running (unhealthy, excluded)');
|
||||
});
|
||||
|
||||
it('handles proxy container status', function () {
|
||||
$result = formatContainerStatus('Proxy:running');
|
||||
|
||||
expect($result)->toBe('Proxy:running');
|
||||
});
|
||||
|
||||
it('handles stopped container', function () {
|
||||
$result = formatContainerStatus('stopped');
|
||||
|
||||
expect($result)->toBe('Stopped');
|
||||
});
|
||||
});
|
||||
});
|
||||
90
tests/Unit/GetContainersStatusServiceAggregationTest.php
Normal file
90
tests/Unit/GetContainersStatusServiceAggregationTest.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests for GetContainersStatus service aggregation logic (SSH path).
|
||||
*
|
||||
* These tests verify that the SSH-based status updates (GetContainersStatus)
|
||||
* correctly aggregates container statuses for services with multiple containers,
|
||||
* using the same logic as PushServerUpdateJob (Sentinel path).
|
||||
*
|
||||
* This ensures consistency across both status update paths and prevents
|
||||
* race conditions where the last container processed wins.
|
||||
*/
|
||||
it('implements service multi-container aggregation in SSH path', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Verify service container collection property exists
|
||||
expect($actionFile)
|
||||
->toContain('protected ?Collection $serviceContainerStatuses;');
|
||||
|
||||
// Verify aggregateServiceContainerStatuses method exists
|
||||
expect($actionFile)
|
||||
->toContain('private function aggregateServiceContainerStatuses($services)')
|
||||
->toContain('$this->aggregateServiceContainerStatuses($services);');
|
||||
|
||||
// Verify service aggregation uses same logic as applications
|
||||
expect($actionFile)
|
||||
->toContain('$hasUnknown = false;');
|
||||
});
|
||||
|
||||
it('services use same priority as applications in SSH path', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Both aggregation methods should use the same priority logic
|
||||
$priorityLogic = <<<'PHP'
|
||||
if ($hasUnhealthy) {
|
||||
$aggregatedStatus = 'running (unhealthy)';
|
||||
} elseif ($hasUnknown) {
|
||||
$aggregatedStatus = 'running (unknown)';
|
||||
} else {
|
||||
$aggregatedStatus = 'running (healthy)';
|
||||
}
|
||||
PHP;
|
||||
|
||||
// Should appear in service aggregation
|
||||
expect($actionFile)->toContain($priorityLogic);
|
||||
});
|
||||
|
||||
it('collects service containers before aggregating in SSH path', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Verify service containers are collected, not immediately updated
|
||||
expect($actionFile)
|
||||
->toContain('$key = $serviceLabelId.\':\'.$subType.\':\'.$subId;')
|
||||
->toContain('$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);');
|
||||
|
||||
// Verify aggregation happens before ServiceChecked dispatch
|
||||
expect($actionFile)
|
||||
->toContain('$this->aggregateServiceContainerStatuses($services);')
|
||||
->toContain('ServiceChecked::dispatch($this->server->team->id);');
|
||||
});
|
||||
|
||||
it('SSH and Sentinel paths use identical service aggregation logic', function () {
|
||||
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Both should track the same status flags
|
||||
expect($jobFile)->toContain('$hasUnknown = false;');
|
||||
expect($actionFile)->toContain('$hasUnknown = false;');
|
||||
|
||||
// Both should check for unknown status
|
||||
expect($jobFile)->toContain('if (str($status)->contains(\'unknown\')) {');
|
||||
expect($actionFile)->toContain('if (str($status)->contains(\'unknown\')) {');
|
||||
|
||||
// Both should have elseif for unknown priority
|
||||
expect($jobFile)->toContain('} elseif ($hasUnknown) {');
|
||||
expect($actionFile)->toContain('} elseif ($hasUnknown) {');
|
||||
});
|
||||
|
||||
it('handles service status updates consistently', function () {
|
||||
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Both should parse service key with same format
|
||||
expect($jobFile)->toContain('[$serviceId, $subType, $subId] = explode(\':\', $key);');
|
||||
expect($actionFile)->toContain('[$serviceId, $subType, $subId] = explode(\':\', $key);');
|
||||
|
||||
// Both should handle excluded containers
|
||||
expect($jobFile)->toContain('$excludedContainers = collect();');
|
||||
expect($actionFile)->toContain('$excludedContainers = collect();');
|
||||
});
|
||||
53
tests/Unit/ServerStatusAccessorTest.php
Normal file
53
tests/Unit/ServerStatusAccessorTest.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
|
||||
/**
|
||||
* Test the Application::serverStatus() accessor
|
||||
*
|
||||
* This accessor determines if the underlying server infrastructure is functional.
|
||||
* It should check Server::isFunctional() for the main server and all additional servers.
|
||||
* It should NOT be affected by container/application health status (e.g., degraded:unhealthy).
|
||||
*
|
||||
* The bug that was fixed: Previously, it checked pivot.status and returned false
|
||||
* when any additional server had status != 'running', including 'degraded:unhealthy'.
|
||||
* This caused false "server has problems" warnings when the server was fine but
|
||||
* containers were unhealthy.
|
||||
*/
|
||||
it('checks server infrastructure health not container status', function () {
|
||||
// This is a documentation test to explain the fix
|
||||
// The serverStatus accessor should:
|
||||
// 1. Check if main server is functional (Server::isFunctional())
|
||||
// 2. Check if each additional server is functional (Server::isFunctional())
|
||||
// 3. NOT check pivot.status (that's application/container status, not server status)
|
||||
//
|
||||
// Before fix: Checked pivot.status !== 'running', causing false positives
|
||||
// After fix: Only checks Server::isFunctional() for infrastructure health
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
})->note('The serverStatus accessor now correctly checks only server infrastructure health, not container status');
|
||||
|
||||
it('has correct logic in serverStatus accessor', function () {
|
||||
// Read the actual code to verify the fix
|
||||
$reflection = new ReflectionClass(Application::class);
|
||||
$source = file_get_contents($reflection->getFileName());
|
||||
|
||||
// Extract just the serverStatus accessor method
|
||||
preg_match('/protected function serverStatus\(\): Attribute\s*\{.*?^\s{4}\}/ms', $source, $matches);
|
||||
$serverStatusCode = $matches[0] ?? '';
|
||||
|
||||
expect($serverStatusCode)->not->toBeEmpty('serverStatus accessor should exist');
|
||||
|
||||
// Check that the new logic exists (checks isFunctional on each server)
|
||||
expect($serverStatusCode)
|
||||
->toContain('$main_server_functional = $this->destination?->server?->isFunctional()')
|
||||
->toContain('foreach ($this->additional_servers as $server)')
|
||||
->toContain('if (! $server->isFunctional())');
|
||||
|
||||
// Check that the old buggy logic is removed from serverStatus accessor
|
||||
expect($serverStatusCode)
|
||||
->not->toContain('pluck(\'pivot.status\')')
|
||||
->not->toContain('str($status)->before(\':\')')
|
||||
->not->toContain('if ($server_status !== \'running\')');
|
||||
})->note('Verifies that the serverStatus accessor uses the correct logic');
|
||||
321
tests/Unit/ServiceExcludedStatusTest.php
Normal file
321
tests/Unit/ServiceExcludedStatusTest.php
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Service;
|
||||
|
||||
/**
|
||||
* Test suite for Service model's excluded status calculation.
|
||||
*
|
||||
* These tests verify the Service model's aggregateResourceStatuses() method
|
||||
* and getStatusAttribute() accessor, which aggregate status from applications
|
||||
* and databases. This is separate from the CalculatesExcludedStatus trait
|
||||
* because Service works with Eloquent model relationships (database records)
|
||||
* rather than Docker container objects.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper to create a mock resource (application or database) with status.
|
||||
*/
|
||||
function makeResource(string $status, bool $excludeFromStatus = false): object
|
||||
{
|
||||
$resource = new stdClass;
|
||||
$resource->status = $status;
|
||||
$resource->exclude_from_status = $excludeFromStatus;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
describe('Service Excluded Status Calculation', function () {
|
||||
it('returns starting status when service is starting', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(true);
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect());
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('starting:unhealthy');
|
||||
});
|
||||
|
||||
it('aggregates status from non-excluded applications', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
$app2 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('returns excluded status when all containers are excluded', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: true);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:healthy:excluded');
|
||||
});
|
||||
|
||||
it('returns unknown status when no containers exist', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect());
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('unknown:unknown:excluded');
|
||||
});
|
||||
|
||||
it('handles mixed excluded and non-excluded containers', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
$app2 = makeResource('exited:unhealthy', excludeFromStatus: true);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
// Should only consider non-excluded containers
|
||||
expect($service->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('detects degraded status with mixed running and exited containers', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
$app2 = makeResource('exited:unhealthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
it('handles unknown health state', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:unknown', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:unknown');
|
||||
});
|
||||
|
||||
it('prioritizes unhealthy over unknown health', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:unknown', excludeFromStatus: false);
|
||||
$app2 = makeResource('running:unhealthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
it('prioritizes unknown over healthy health', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running (healthy)', excludeFromStatus: false);
|
||||
$app2 = makeResource('running (unknown)', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:unknown');
|
||||
});
|
||||
|
||||
it('handles restarting status as degraded', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('restarting:unhealthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
it('handles paused status', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('paused:unknown', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('paused:unknown');
|
||||
});
|
||||
|
||||
it('handles dead status as degraded', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('dead:unhealthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
it('handles removing status as degraded', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('removing:unhealthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('degraded:unhealthy');
|
||||
});
|
||||
|
||||
it('handles created status', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('created:unknown', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('starting:unknown');
|
||||
});
|
||||
|
||||
it('aggregates status from both applications and databases', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
$db1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect([$db1]));
|
||||
|
||||
expect($service->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('detects unhealthy when database is unhealthy', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
$db1 = makeResource('running:unhealthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect([$db1]));
|
||||
|
||||
expect($service->status)->toBe('running:unhealthy');
|
||||
});
|
||||
|
||||
it('skips containers with :excluded suffix in non-excluded aggregation', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
$app2 = makeResource('exited:unhealthy:excluded', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
// Should skip app2 because it has :excluded suffix
|
||||
expect($service->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('strips :excluded suffix when processing excluded containers', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy:excluded', excludeFromStatus: true);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:healthy:excluded');
|
||||
});
|
||||
|
||||
it('returns exited:unhealthy:excluded when excluded containers have no valid status', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('', excludeFromStatus: true);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('exited:unhealthy:excluded');
|
||||
});
|
||||
|
||||
it('handles all excluded containers with degraded state', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:healthy', excludeFromStatus: true);
|
||||
$app2 = makeResource('exited:unhealthy', excludeFromStatus: true);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('degraded:unhealthy:excluded');
|
||||
});
|
||||
|
||||
it('handles all excluded containers with unknown health', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:unknown', excludeFromStatus: true);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:unknown:excluded');
|
||||
});
|
||||
|
||||
it('handles exited containers correctly', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('exited:unhealthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('exited:unhealthy');
|
||||
});
|
||||
|
||||
it('prefers running over starting status', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('starting:unknown', excludeFromStatus: false);
|
||||
$app2 = makeResource('running:healthy', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('treats empty health as healthy', function () {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$service->shouldReceive('isStarting')->andReturn(false);
|
||||
|
||||
$app1 = makeResource('running:', excludeFromStatus: false);
|
||||
|
||||
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
|
||||
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
|
||||
|
||||
expect($service->status)->toBe('running:healthy');
|
||||
});
|
||||
});
|
||||
229
tests/Unit/StripCoolifyCustomFieldsTest.php
Normal file
229
tests/Unit/StripCoolifyCustomFieldsTest.php
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<?php
|
||||
|
||||
use function PHPUnit\Framework\assertEquals;
|
||||
|
||||
test('removes exclude_from_hc from service level', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'web' => [
|
||||
'image' => 'nginx:latest',
|
||||
'exclude_from_hc' => true,
|
||||
'ports' => ['80:80'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
assertEquals('nginx:latest', $result['services']['web']['image']);
|
||||
assertEquals(['80:80'], $result['services']['web']['ports']);
|
||||
expect($result['services']['web'])->not->toHaveKey('exclude_from_hc');
|
||||
});
|
||||
|
||||
test('removes content from volume level', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'app' => [
|
||||
'image' => 'php:8.4',
|
||||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => './config.xml',
|
||||
'target' => '/app/config.xml',
|
||||
'content' => '<?xml version="1.0"?><config></config>',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
expect($result['services']['app']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
|
||||
expect($result['services']['app']['volumes'][0])->not->toHaveKey('content');
|
||||
});
|
||||
|
||||
test('removes isDirectory from volume level', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'app' => [
|
||||
'image' => 'node:20',
|
||||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => './data',
|
||||
'target' => '/app/data',
|
||||
'isDirectory' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
expect($result['services']['app']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
|
||||
expect($result['services']['app']['volumes'][0])->not->toHaveKey('isDirectory');
|
||||
});
|
||||
|
||||
test('removes is_directory from volume level', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'app' => [
|
||||
'image' => 'python:3.12',
|
||||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => './logs',
|
||||
'target' => '/var/log/app',
|
||||
'is_directory' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
expect($result['services']['app']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
|
||||
expect($result['services']['app']['volumes'][0])->not->toHaveKey('is_directory');
|
||||
});
|
||||
|
||||
test('removes all custom fields together', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'web' => [
|
||||
'image' => 'nginx:latest',
|
||||
'exclude_from_hc' => true,
|
||||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => './config.xml',
|
||||
'target' => '/etc/nginx/config.xml',
|
||||
'content' => '<config></config>',
|
||||
'isDirectory' => false,
|
||||
],
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => './data',
|
||||
'target' => '/var/www/data',
|
||||
'is_directory' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
'worker' => [
|
||||
'image' => 'worker:latest',
|
||||
'exclude_from_hc' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
// Verify service-level custom fields removed
|
||||
expect($result['services']['web'])->not->toHaveKey('exclude_from_hc');
|
||||
expect($result['services']['worker'])->not->toHaveKey('exclude_from_hc');
|
||||
|
||||
// Verify volume-level custom fields removed
|
||||
expect($result['services']['web']['volumes'][0])->not->toHaveKey('content');
|
||||
expect($result['services']['web']['volumes'][0])->not->toHaveKey('isDirectory');
|
||||
expect($result['services']['web']['volumes'][1])->not->toHaveKey('is_directory');
|
||||
|
||||
// Verify standard fields preserved
|
||||
assertEquals('nginx:latest', $result['services']['web']['image']);
|
||||
assertEquals('worker:latest', $result['services']['worker']['image']);
|
||||
});
|
||||
|
||||
test('preserves standard Docker Compose fields', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'db' => [
|
||||
'image' => 'postgres:16',
|
||||
'environment' => [
|
||||
'POSTGRES_DB' => 'mydb',
|
||||
'POSTGRES_USER' => 'user',
|
||||
],
|
||||
'ports' => ['5432:5432'],
|
||||
'volumes' => [
|
||||
'db-data:/var/lib/postgresql/data',
|
||||
],
|
||||
'healthcheck' => [
|
||||
'test' => ['CMD', 'pg_isready'],
|
||||
'interval' => '5s',
|
||||
],
|
||||
'restart' => 'unless-stopped',
|
||||
'networks' => ['backend'],
|
||||
],
|
||||
],
|
||||
'networks' => [
|
||||
'backend' => [
|
||||
'driver' => 'bridge',
|
||||
],
|
||||
],
|
||||
'volumes' => [
|
||||
'db-data' => null,
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
// All standard fields should be preserved
|
||||
expect($result)->toHaveKeys(['services', 'networks', 'volumes']);
|
||||
expect($result['services']['db'])->toHaveKeys([
|
||||
'image', 'environment', 'ports', 'volumes',
|
||||
'healthcheck', 'restart', 'networks',
|
||||
]);
|
||||
assertEquals('postgres:16', $result['services']['db']['image']);
|
||||
assertEquals(['5432:5432'], $result['services']['db']['ports']);
|
||||
});
|
||||
|
||||
test('handles missing services gracefully', function () {
|
||||
$yaml = [
|
||||
'version' => '3.8',
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
expect($result)->toBe($yaml);
|
||||
});
|
||||
|
||||
test('handles missing volumes in service gracefully', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'app' => [
|
||||
'image' => 'nginx:latest',
|
||||
'exclude_from_hc' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
expect($result['services']['app'])->not->toHaveKey('exclude_from_hc');
|
||||
expect($result['services']['app'])->not->toHaveKey('volumes');
|
||||
assertEquals('nginx:latest', $result['services']['app']['image']);
|
||||
});
|
||||
|
||||
test('handles traccar.yaml example with multiline content', function () {
|
||||
$yaml = [
|
||||
'services' => [
|
||||
'traccar' => [
|
||||
'image' => 'traccar/traccar:latest',
|
||||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => './srv/traccar/conf/traccar.xml',
|
||||
'target' => '/opt/traccar/conf/traccar.xml',
|
||||
'content' => "<?xml version='1.0' encoding='UTF-8'?>\n<!DOCTYPE properties SYSTEM 'http://java.sun.com/dtd/properties.dtd'>\n<properties>\n <entry key='config.default'>./conf/default.xml</entry>\n</properties>",
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = stripCoolifyCustomFields($yaml);
|
||||
|
||||
expect($result['services']['traccar']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
|
||||
expect($result['services']['traccar']['volumes'][0])->not->toHaveKey('content');
|
||||
assertEquals('./srv/traccar/conf/traccar.xml', $result['services']['traccar']['volumes'][0]['source']);
|
||||
});
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"version": "1.0.10"
|
||||
},
|
||||
"sentinel": {
|
||||
"version": "0.0.16"
|
||||
"version": "0.0.17"
|
||||
}
|
||||
},
|
||||
"traefik": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue