feat: add validation for YAML parsing, integer parameters, and Docker Compose custom fields

This commit adds comprehensive validation improvements and DRY principles for handling Coolify's custom Docker Compose extensions.

## Changes

### 1. Created Reusable stripCoolifyCustomFields() Function
- Added shared helper in bootstrap/helpers/docker.php
- Removes all Coolify custom fields (exclude_from_hc, content, isDirectory, is_directory)
- Handles both long syntax (arrays) and short syntax (strings) for volumes
- Well-documented with comprehensive docblock
- Follows DRY principle for consistent field stripping

### 2. Fixed Docker Compose Modal Validation
- Updated validateComposeFile() to use stripCoolifyCustomFields()
- Now removes ALL custom fields before Docker validation (previously only removed content)
- Fixes validation errors when using templates with custom fields (e.g., traccar.yaml)
- Users can now validate compose files with Coolify extensions in UI

### 3. Enhanced YAML Validation in CalculatesExcludedStatus
- Added proper exception handling with ParseException vs generic Exception
- Added structure validation (checks if parsed result and services are arrays)
- Comprehensive logging with context (error message, line number, snippet)
- Maintains safe fallback behavior (returns empty collection on error)

### 4. Added Integer Validation to ContainerStatusAggregator
- Validates maxRestartCount parameter in both aggregateFromStrings() and aggregateFromContainers()
- Corrects negative values to 0 with warning log
- Logs warnings for suspiciously high values (> 1000)
- Prevents logic errors in crash loop detection

### 5. Comprehensive Unit Tests
- tests/Unit/StripCoolifyCustomFieldsTest.php (NEW) - 9 tests, 43 assertions
- tests/Unit/ContainerStatusAggregatorTest.php - Added 6 tests for integer validation
- tests/Unit/ExcludeFromHealthCheckTest.php - Added 4 tests for YAML validation
- All tests passing with proper Log facade mocking

### 6. Documentation
- Added comprehensive Docker Compose extensions documentation to .ai/core/deployment-architecture.md
- Documents all custom fields: exclude_from_hc, content, isDirectory/is_directory
- Includes examples, use cases, implementation details, and test references
- Updated .ai/README.md with navigation links to new documentation

## Benefits
- Better UX: Users can validate compose files with custom fields
- Better Debugging: Comprehensive logging for errors
- Better Code Quality: DRY principle with reusable validation
- Better Reliability: Prevents logic errors from invalid parameters
- Better Maintainability: Easy to add new custom fields in future

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai 2025-11-20 18:34:49 +01:00
parent ae6eef3cdb
commit 7ceb124e9b
8 changed files with 755 additions and 12 deletions

View file

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

View file

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

View file

@ -3,6 +3,7 @@
namespace App\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* Container Status Aggregator Service
@ -35,6 +36,21 @@ class ContainerStatusAggregator
*/
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';
}
@ -96,6 +112,21 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest
*/
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';
}

View file

@ -4,6 +4,8 @@
use App\Services\ContainerStatusAggregator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Yaml\Exception\ParseException;
trait CalculatesExcludedStatus
{
@ -111,8 +113,27 @@ protected function getExcludedContainersFromDockerCompose(?string $dockerCompose
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');
@ -121,8 +142,23 @@ protected function getExcludedContainersFromDockerCompose(?string $dockerCompose
$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) {
// If we can't parse, treat all containers as included
// Unexpected errors
Log::error('Unexpected error parsing Docker Compose YAML', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return $excludedContainers;
}
return $excludedContainers;

View file

@ -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",

View file

@ -1,6 +1,7 @@
<?php
use App\Services\ContainerStatusAggregator;
use Illuminate\Support\Facades\Log;
beforeEach(function () {
$this->aggregator = new ContainerStatusAggregator;
@ -461,3 +462,79 @@
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');
});
});

View file

@ -104,3 +104,51 @@
->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\'');
});

View 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']);
});