test(parsers): add golden tests for the compose parsers

ServiceParserReferenceTest parses reference fixtures for serviceParser and
applicationParser (one service per feature, with setting variations and error
cases) and every template, and compares the output with expected files.
scripts/parser-snapshot-diff shows how a code change alters the output of all
cases (save / baseline). Template expected files are gitignored.
This commit is contained in:
Andras Bacsai committed 2026-10-18 11:29:39 +02:00
1 parent fc4562ede7
commit 6d4551faec
21 files changed
+24063

No files matched your search

+3
View File
@@ -54,3 +54,6 @@ ref
# Multi-instance local Coolify env files (scripts/dev-instances)
.dev-instances/
# Service parser template snapshots: generated locally by scripts/parser-snapshot-diff, not committed
/tests/Fixtures/parser/expected/templates/
+16
View File
@@ -66,6 +66,22 @@ npm run dev # vite dev server
npm run build # production build
```
## Testing the Service Parser
`tests/Feature/ServiceParserReferenceTest.php` is a golden test for both compose parsers (`serviceParser` with `updateCompose`, and `applicationParser`) and the proxy label and magic-variable helpers. It parses `tests/Fixtures/parser/service-reference.yaml` and `application-reference.yaml` (one service per parser feature, with setting variations and error fixtures) and every template in `templates/compose`, then compares the output with `tests/Fixtures/parser/expected/`. Only the reference expected files are committed. Template expected files are gitignored, so CI checks only that each template parses.
```bash
./scripts/parser-snapshot-diff save # before a parser change: save the output of all cases (~90 s)
./scripts/parser-snapshot-diff baseline # after the change: one line per changed case, full diff in storage/framework/testing/parser-snapshot-diff.diff
php artisan test --compact tests/Feature/ServiceParserReferenceTest.php # before you commit
UPDATE_PARSER_SNAPSHOTS=1 php artisan test --compact tests/Feature/ServiceParserReferenceTest.php # accept an intended change, then review git diff tests/Fixtures/parser/
```
- A refactor must give "No differences". For an intended change, check every changed line in the diff file.
- When you add a parser feature, add a small service that uses it to `service-reference.yaml` or `application-reference.yaml` and regenerate the expected files.
- `./scripts/parser-snapshot-diff <git-ref>` compares with a commit instead of a saved baseline.
- It covers only the latest compose parsing version. Older parsing versions are not covered.
## Browser Tests (Pest Browser Plugin)
Uses `pestphp/pest-plugin-browser` (Playwright). Browser tests go in `tests/v4/Browser/`.
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Show how a code change alters the service parser output for every template and the reference fixture.
#
# Usage:
# ./scripts/parser-snapshot-diff save # 1. before you change code: save the current output
# ./scripts/parser-snapshot-diff baseline # 2. after the change: compare with the saved output
# ./scripts/parser-snapshot-diff [git-ref] # or compare with a git ref (default: main)
# ./scripts/parser-snapshot-diff baseline -- --filter='"template appwrite"' # extra Pest arguments
#
# Each run parses everything with tests/Feature/ServiceParserReferenceTest.php (about 90 seconds).
# A git ref is exported with git archive to a temporary directory and parsed with the current test,
# fixtures and templates, so the diff shows only the effect of the code change.
# The terminal shows one line per changed case; the full diff is written to storage/framework/testing/.
set -euo pipefail
repo="$(git -C "$(dirname "$0")/.." rev-parse --show-toplevel)"
mode="${1:-main}"
shift || true
[[ "${1:-}" == "--" ]] && shift
pest_args=("$@")
testing="$repo/storage/framework/testing" # gitignored, same file system as the repository
saved="$testing/parser-snapshots-baseline"
report="$testing/parser-snapshot-diff.diff"
work="$(mktemp -d "$testing/parser-snapshot-diff.XXXXXX")"
trap 'rm -rf "$work"' EXIT
# Run the golden test in <dir> with UPDATE_PARSER_SNAPSHOTS=1 and copy its expected files to <target>.
parse_into() {
local dir="$1" target="$2" label="$3" expected="$1/tests/Fixtures/parser/expected" backup="$work/expected-backup" status=0
echo "==> Parsing on $label (about 90 seconds)"
# The run rewrites the expected files: put the previous ones back afterwards, so the repository is unchanged.
rm -rf "$backup"
cp -r "$expected" "$backup"
rm -rf "$expected/templates"
(cd "$dir" && UPDATE_PARSER_SNAPSHOTS=1 php -d memory_limit=2G artisan test --compact \
tests/Feature/ServiceParserReferenceTest.php "${pest_args[@]}" >"$work/run.log" 2>&1) || status=$?
rm -rf "$target"
cp -r "$expected" "$target"
rm -rf "$expected"
mv "$backup" "$expected"
if [[ $status -ne 0 ]]; then
echo "Test run on $label failed:" >&2
tail -30 "$work/run.log" >&2
exit 1
fi
}
case "$mode" in
save)
parse_into "$repo" "$saved" "working tree"
echo "Saved baseline: $saved"
echo "Change the code, then run: ./scripts/parser-snapshot-diff baseline"
exit 0
;;
baseline)
[[ -d "$saved" ]] || { echo "No saved baseline. Run ./scripts/parser-snapshot-diff save first." >&2; exit 1; }
base_label="saved baseline"
cp -r "$saved" "$work/base-expected"
;;
*)
base_label="$mode"
base="$work/base"
mkdir -p "$base"
echo "==> Exporting $mode ($(git -C "$repo" rev-parse --short "$mode"))"
git -C "$repo" archive "$mode" | tar -x -C "$base"
# Hard links: fast and no extra disk space. A symlink would make Composer load the classes of $repo.
cp -al "$repo/vendor" "$base/vendor"
[[ -f "$repo/.env" ]] && cp "$repo/.env" "$base/.env"
rm -rf "$base/templates/compose" "$base/tests/Fixtures/parser"
cp -r "$repo/templates/compose" "$base/templates/compose"
mkdir -p "$base/tests/Fixtures/parser"
cp "$repo"/tests/Fixtures/parser/*.yaml "$base/tests/Fixtures/parser/"
cp "$repo/tests/Feature/ServiceParserReferenceTest.php" "$base/tests/Feature/"
parse_into "$base" "$work/base-expected" "$mode"
;;
esac
parse_into "$repo" "$work/current-expected" "working tree"
echo
if diff -ru "$work/base-expected" "$work/current-expected" | sed -E "s#$work/(base|current)-expected/##g" >"$report"; then
echo "No differences from $base_label: the parser output of every case is the same."
exit 0
fi
# Line 2 is the first step (1_deploy): an error there means the case did not parse at all on that side.
first_step_error() { sed -n 2p "$1" | grep '^ error:' | cut -c10-130 || true; }
summary() {
local file="$1" base_file="$work/base-expected/$1" current_file="$work/current-expected/$1" base_error current_error
[[ -f "$base_file" ]] || { echo " new case $file"; return; }
[[ -f "$current_file" ]] || { echo " removed case $file"; return; }
base_error="$(first_step_error "$base_file")"
current_error="$(first_step_error "$current_file")"
if [[ -n "$base_error" && -z "$current_error" ]]; then
echo " base error $file: $base_error"
elif [[ -n "$current_error" && -z "$base_error" ]]; then
echo " CURRENT ERROR $file: $current_error"
else
echo " changed $file (+$(diff "$base_file" "$current_file" | grep -c '^>' || true) -$(diff "$base_file" "$current_file" | grep -c '^<' || true))"
fi
}
changed_cases=()
while IFS= read -r file; do
cmp -s "$work/base-expected/$file" "$work/current-expected/$file" || changed_cases+=("$file")
done < <( (cd "$work/base-expected" && find . -name '*.yaml'; cd "$work/current-expected" && find . -name '*.yaml') | sed 's|^\./||' | sort -u)
lines="$(for file in "${changed_cases[@]}"; do summary "$file"; done)"
echo "${#changed_cases[@]} case(s) differ from $base_label:"
echo "$lines"
echo
echo "Full diff: $report"
if grep -q '^ base error' <<<"$lines"; then
echo "Note: 'base error' cases did not parse on $base_label, so there is no base output to compare with."
fi
exit 1
@@ -0,0 +1,428 @@
<?php
/**
* Golden test for the compose parsers (serviceParser + updateCompose, and applicationParser), latest compose
* parsing version only.
*
* Every case parses a compose file in three steps, the same way the UI does:
* 1. deploy (create + start parse the compose twice),
* 2. custom domains: each generated host becomes <name>.example.com, paths are kept (shared hosts stay shared),
* 3. a domain saved on each application that had none (for example a worker that copies a declaration).
* The result (domains, environment variable rows, storages, generated compose with proxy labels, or the error)
* is compared with tests/Fixtures/parser/expected/<case>.yaml.
*
* Cases: tests/Fixtures/parser/service-reference.yaml (one service per parser feature) with server and
* application setting variations, an error-path fixture, and every template in templates/compose (also the
* `# ignore: true` ones: existing services can still use them).
*
* Applications: tests/Fixtures/parser/application-reference.yaml (Docker Compose build pack) in three steps:
* deploy, custom domains on the General page, and pull request preview deployments.
*
* Only the reference expected files are committed. Template expected files are gitignored: when one is missing,
* the case only checks that the template parses without an error, and it writes the file.
*
* Before/after check of a parser change for every template:
* ./scripts/parser-snapshot-diff save (before the change)
* ./scripts/parser-snapshot-diff baseline (after the change; or: ./scripts/parser-snapshot-diff <git-ref>)
* When a change of the reference output is intended, regenerate the files and review the diff:
* UPDATE_PARSER_SNAPSHOTS=1 php artisan test --compact tests/Feature/ServiceParserReferenceTest.php
*/
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
beforeEach(function () {
// Every volume dispatches a job that reads files over SSH.
Bus::fake();
Queue::fake();
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0]);
});
it('parses the compose exactly as recorded', function (string $composePath, ?string $serviceType, array $options, string $expectedName) {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id, 'name' => 'reference-server', 'ip' => '10.0.0.1']);
$server->settings->update($options['server'] ?? []);
if (isset($options['proxy'])) {
$server->proxy->set('type', $options['proxy']);
$server->save();
}
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id, 'name' => 'Reference Project']);
$environment = Environment::where('project_id', $project->id)->where('name', 'production')->first()
?? Environment::factory()->create(['project_id' => $project->id, 'name' => 'production']);
SharedEnvironmentVariable::create([
'key' => 'SHARED_KEY', 'value' => 'shared-value', 'type' => 'environment',
'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id,
]);
$service = Service::factory()->create([
'name' => 'reference',
'service_type' => $serviceType,
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'docker_compose_raw' => file_get_contents(base_path($composePath)),
]);
$service->forceFill(['uuid' => 'referenceservice00000001'])->save();
// A user converted these compose services to the other type before the first parse.
foreach ($options['migrated'] ?? [] as $name => $type) {
($type === 'database' ? ServiceDatabase::class : ServiceApplication::class)::create(['name' => $name, 'service_id' => $service->id, 'is_migrated' => true]);
}
$snapshot = [];
$step = function (string $label, Closure $before) use ($service, &$snapshot): bool {
try {
$before();
$snapshot[$label] = serviceParserReferenceSnapshot($service->fresh(), $service->fresh()->parse());
return true;
} catch (Throwable $e) {
$snapshot[$label] = ['error' => class_basename($e).': '.$e->getMessage()];
return false;
}
};
$saveDomain = function (ServiceApplication $application, string $fqdn): void {
$application->fqdn = $fqdn;
$application->save();
updateCompose($application->fresh());
};
// Step 1: create + deploy (Create.php parses once, StartService parses again), then application settings.
$deployed = $step('1_deploy', function () use ($service, $options) {
$service->fresh()->parse();
foreach ($options['applications'] ?? [] as $name => $settings) {
$application = $service->applications()->where('name', $name)->firstOrFail();
$noindex = data_get($settings, 'noindex');
unset($settings['noindex']);
$application->fill($settings);
if ($noindex) {
$application->setNoindexDomains(explode(',', (string) $application->fqdn));
}
$application->save();
}
});
if ($deployed) {
// Step 2: custom domains saved like the Domains page does (save fqdn, updateCompose, parse).
$step('2_custom_domains', function () use ($service, $saveDomain) {
foreach ($service->fresh()->applications()->whereNotNull('fqdn')->where('fqdn', '!=', '')->orderBy('name')->get() as $application) {
$saveDomain($application, serviceParserReferenceCustomDomains($application->fqdn));
}
});
// Step 3: a domain saved on each application without one; copies must not move the shared variables.
$step('3_domain_on_applications_without_one', function () use ($service, $saveDomain) {
foreach ($service->fresh()->applications()->orderBy('name')->get()->filter(fn ($application) => blank($application->fqdn)) as $application) {
$saveDomain($application, "https://{$application->name}-extra.example.com");
}
});
}
$actual = Yaml::dump(serviceParserReferenceNormalize($service->fresh(), $snapshot), 12, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
$expectedPath = base_path("tests/Fixtures/parser/expected/{$expectedName}.yaml");
if (getenv('UPDATE_PARSER_SNAPSHOTS') || ! file_exists($expectedPath)) {
@mkdir(dirname($expectedPath), 0755, true);
file_put_contents($expectedPath, $actual);
}
expect($actual)->toBe(file_get_contents($expectedPath));
})->with(fn () => serviceParserReferenceCases());
it('parses the application compose exactly as recorded', function (array $options, string $expectedName) {
// Production container names of compose applications end with the deployment time.
$this->travelTo(CarbonImmutable::parse('2026-01-02 03:04:05'));
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id, 'name' => 'reference-server', 'ip' => '10.0.0.1']);
$server->settings->update($options['server'] ?? []);
if (isset($options['proxy'])) {
$server->proxy->set('type', $options['proxy']);
$server->save();
}
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id, 'name' => 'Reference Project']);
$environment = Environment::where('project_id', $project->id)->where('name', 'production')->first()
?? Environment::factory()->create(['project_id' => $project->id, 'name' => 'production']);
SharedEnvironmentVariable::create([
'key' => 'SHARED_KEY', 'value' => 'shared-value', 'type' => 'environment',
'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id,
]);
$application = Application::factory()->create([
'name' => 'reference',
'build_pack' => 'dockercompose',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'docker_compose_raw' => file_get_contents(base_path('tests/Fixtures/parser/application-reference.yaml')),
'preview_url_template' => '{{pr_id}}.{{domain}}',
'git_branch' => 'main',
]);
$application->forceFill(['uuid' => 'referenceapplication0001'])->save();
$application->settings->update($options['settings'] ?? []);
$commit = 'abcdef0123456789abcdef0123456789abcdef01';
$snapshot = [];
$step = function (string $label, Closure $parse) use ($application, &$snapshot): void {
try {
$compose = $parse();
$snapshot[$label] = serviceParserReferenceApplicationSnapshot($application->fresh(), $compose);
} catch (Throwable $e) {
$snapshot[$label] = ['error' => class_basename($e).': '.$e->getMessage()];
}
};
// Step 1: first deploy (the parser fills docker_compose_domains from SERVICE_URL_/SERVICE_FQDN_ declarations).
$step('1_deploy', fn () => $application->fresh()->parse(commit: $commit));
// Step 2: custom domains saved on the General page: one per service, shared paths kept.
$step('2_custom_domains', function () use ($application, $commit) {
$application = $application->fresh();
$domains = collect(json_decode($application->docker_compose_domains ?: '[]', true))
->map(fn (array $entry) => ['domain' => filled($entry['domain'] ?? null) ? serviceParserReferenceCustomDomains($entry['domain']) : null])
->put('api.v2', ['domain' => 'https://api-v2.example.com'])
->put('api', ['domain' => 'https://www.api.example.com', 'redirect' => 'non-www'])
->all();
$application->docker_compose_domains = json_encode($domains);
$application->save();
return $application->fresh()->parse(commit: $commit);
});
// Step 3: pull request deployment (preview domains from preview_url_template, preview volume names).
$step('3_preview', function () use ($application, $commit) {
$preview = ApplicationPreview::create([
'uuid' => 'referencepreview00000001',
'application_id' => $application->id,
'pull_request_id' => 7,
'pull_request_html_url' => 'https://github.com/example/repository/pull/7',
]);
// The pull request webhook and ApplicationDeploymentJob generate the preview domains before the parse.
$preview->generate_preview_fqdn_compose();
return $application->fresh()->parse(pull_request_id: 7, preview_id: $preview->id, commit: $commit);
});
// Step 4: preview without generated preview domains: the parser generates them from the application domains.
$step('4_preview_without_generated_domains', function () use ($application, $commit) {
$preview = ApplicationPreview::create([
'uuid' => 'referencepreview00000002',
'application_id' => $application->id,
'pull_request_id' => 8,
'pull_request_html_url' => 'https://github.com/example/repository/pull/8',
]);
return $application->fresh()->parse(pull_request_id: 8, preview_id: $preview->id, commit: $commit);
});
$actual = Yaml::dump(serviceParserReferenceApplicationNormalize($application->fresh(), $snapshot), 12, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
$expectedPath = base_path("tests/Fixtures/parser/expected/{$expectedName}.yaml");
if (getenv('UPDATE_PARSER_SNAPSHOTS') || ! file_exists($expectedPath)) {
file_put_contents($expectedPath, $actual);
}
expect($actual)->toBe(file_get_contents($expectedPath));
})->with([
'application' => [[], 'application'],
'application with exact traefik labels' => [['proxy' => 'TRAEFIK', 'server' => ['generate_exact_labels' => true]], 'application-with-exact-traefik-labels'],
'application with exact caddy labels' => [['proxy' => 'CADDY', 'server' => ['generate_exact_labels' => true]], 'application-with-exact-caddy-labels'],
'application with settings' => [[
'server' => ['is_logdrain_custom_enabled' => true],
'settings' => ['is_gzip_enabled' => false, 'is_stripprefix_enabled' => false, 'is_container_label_escape_enabled' => false, 'is_log_drain_enabled' => true, 'connect_to_docker_network' => true],
], 'application-with-settings'],
]);
/**
* @return array<string, mixed>
*/
function serviceParserReferenceApplicationSnapshot(Application $application, Collection $compose): array
{
return [
'docker_compose_domains' => json_decode($application->docker_compose_domains ?: '[]', true),
'previews' => $application->previews()->orderBy('pull_request_id')->get()
->mapWithKeys(fn (ApplicationPreview $preview) => [$preview->pull_request_id => array_filter([
'fqdn' => $preview->fqdn,
'docker_compose_domains' => json_decode($preview->docker_compose_domains ?: '[]', true),
])])->all(),
'volumes' => $application->persistentStorages()->orderBy('name')->get()
->map(fn ($volume) => "{$volume->name}:{$volume->mount_path}")->all(),
'files' => $application->fileStorages()->orderBy('mount_path')->get()
->map(fn ($file) => array_filter([
'fs_path' => $file->fs_path,
'mount_path' => $file->mount_path,
'is_directory' => (bool) $file->is_directory,
'content' => $file->content,
], fn ($value) => $value !== null))->all(),
'environment_variables' => $application->environment_variables()->orderBy('key')->get()->mapWithKeys(fn ($variable) => [
$variable->key => $variable->is_required ? ['value' => $variable->value, 'required' => true] : $variable->value,
])->all(),
'compose' => $compose->toArray(),
];
}
/**
* @param array<string, mixed> $snapshot
* @return array<string, mixed>
*/
function serviceParserReferenceApplicationNormalize(Application $application, array $snapshot): array
{
$replacements = [config('constants.coolify.version') => '<coolify-version>'];
foreach ($application->environment_variables()->get()->concat($application->environment_variables_preview()->get()) as $variable) {
if (filled($variable->value) && preg_match('/^SERVICE_(PASSWORD|BASE64|REALBASE64|HEX|USER|LOWERCASEUSER)/', $variable->key)) {
$replacements[$variable->value] = "<generated:{$variable->key}:".strlen($variable->value).'>';
}
}
uksort($replacements, fn ($a, $b) => strlen((string) $b) <=> strlen((string) $a));
array_walk_recursive($snapshot, function (&$value) use ($replacements) {
if (is_string($value)) {
$value = strtr($value, $replacements);
}
});
return $snapshot;
}
/**
* @return array<string, array{0: string, 1: ?string, 2: array<string, mixed>, 3: string}>
*/
function serviceParserReferenceCases(): array
{
$reference = 'tests/Fixtures/parser/service-reference.yaml';
$cases = [
'reference' => [$reference, null, []],
'reference with exact traefik labels' => [$reference, null, ['proxy' => 'TRAEFIK', 'server' => ['generate_exact_labels' => true]]],
'reference with exact caddy labels' => [$reference, null, ['proxy' => 'CADDY', 'server' => ['generate_exact_labels' => true, 'is_traffic_analytics_enabled' => true]]],
'reference with application settings' => [$reference, null, [
'server' => ['is_logdrain_custom_enabled' => true],
'applications' => [
'web' => ['is_gzip_enabled' => false, 'is_force_https_enabled' => false, 'redirect' => 'www', 'noindex' => true, 'is_log_drain_enabled' => true],
'api' => ['is_stripprefix_enabled' => false],
'www' => ['redirect' => 'non-www'],
],
]],
'reference with migrated services' => [$reference, null, ['migrated' => ['db' => 'application', 'sidecar' => 'database']]],
'unsafe volume path' => ['tests/Fixtures/parser/service-unsafe.yaml', null, []],
'unsafe service name' => ['tests/Fixtures/parser/service-unsafe-name.yaml', null, []],
'invalid yaml' => ['tests/Fixtures/parser/service-invalid-yaml.yaml', null, []],
];
foreach (glob(dirname(__DIR__, 2).'/templates/compose/*.y*ml') as $template) {
$name = pathinfo($template, PATHINFO_FILENAME);
$cases["template {$name}"] = ['templates/compose/'.basename($template), $name, []];
}
return collect($cases)->mapWithKeys(fn (array $case, string $name) => [
$name => [...$case, str_starts_with($name, 'template ') ? 'templates/'.Str::after($name, 'template ') : Str::slug($name)],
])->all();
}
/**
* Replace each generated host with <name>.example.com and keep the port and path, so services that share a host still do.
*/
function serviceParserReferenceCustomDomains(string $fqdns): string
{
return collect(explode(',', $fqdns))->map(function (string $fqdn) {
$host = parse_url(trim($fqdn), PHP_URL_HOST);
$name = Str::before(Str::before($host, '-referenceservice00000001'), '-referenceapplication0001');
$name = Str::before($name, '.');
$port = parse_url(trim($fqdn), PHP_URL_PORT);
return 'https://'.$name.'.example.com'.($port ? ":{$port}" : '').(parse_url(trim($fqdn), PHP_URL_PATH) ?? '');
})->implode(',');
}
/**
* @return array<string, mixed>
*/
function serviceParserReferenceSnapshot(Service $service, Collection $compose): array
{
$storages = fn ($resource) => array_filter([
'volumes' => $resource->persistentStorages()->orderBy('name')->get()
->map(fn ($volume) => "{$volume->name}:{$volume->mount_path}")->all(),
'files' => $resource->fileStorages()->orderBy('mount_path')->get()
->map(fn ($file) => array_filter([
'fs_path' => $file->fs_path,
'mount_path' => $file->mount_path,
'is_directory' => (bool) $file->is_directory,
'content' => $file->content,
], fn ($value) => $value !== null))->all(),
]);
return [
'applications' => $service->applications()->orderBy('name')->get()->mapWithKeys(fn ($application) => [
$application->name => array_filter([
'fqdn' => $application->fqdn,
'required_port' => $application->getRequiredPort(),
'exclude_from_status' => (bool) $application->exclude_from_status,
'ports' => $application->ports,
...$storages($application),
], fn ($value) => $value !== null && $value !== []),
])->all(),
'databases' => $service->databases()->orderBy('name')->get()->mapWithKeys(fn ($database) => [
$database->name => array_filter([
'exclude_from_status' => (bool) $database->exclude_from_status,
...$storages($database),
], fn ($value) => $value !== null && $value !== []),
])->all(),
'environment_variables' => $service->environment_variables()->orderBy('key')->get()->mapWithKeys(fn ($variable) => [
$variable->key => $variable->is_required ? ['value' => $variable->value, 'required' => true] : $variable->value,
])->all(),
'compose' => $compose->toArray(),
];
}
/**
* Replace values that change on every run: generated secrets, uuids of generated rows, Coolify version.
*
* @param array<string, mixed> $snapshot
* @return array<string, mixed>
*/
function serviceParserReferenceNormalize(Service $service, array $snapshot): array
{
$replacements = [config('constants.coolify.version') => '<coolify-version>'];
foreach ($service->applications()->get()->concat($service->databases()->get()) as $resource) {
$replacements[$resource->uuid] = "<uuid:{$resource->name}>";
}
foreach ($service->environment_variables()->get() as $variable) {
if (filled($variable->value) && preg_match('/^SERVICE_(PASSWORD|BASE64|REALBASE64|HEX|USER|LOWERCASEUSER|SUPABASEANON|SUPABASESERVICE)/', $variable->key)) {
$replacements[$variable->value] = "<generated:{$variable->key}:".strlen($variable->value).'>';
}
}
uksort($replacements, fn ($a, $b) => strlen((string) $b) <=> strlen((string) $a));
array_walk_recursive($snapshot, function (&$value) use ($replacements) {
if (is_string($value)) {
$value = strtr($value, $replacements);
}
});
return $snapshot;
}
@@ -0,0 +1,142 @@
# Reference compose for the application parser (Docker Compose build pack, applicationParser).
# Test-only: tests/Feature/ServiceParserReferenceTest.php parses it and compares the result
# with tests/Fixtures/parser/expected/application-*.yaml. Each service covers one group of parser features.
services:
# Domain from a magic variable with a port, all environment syntaxes, magic secrets, labels, volumes.
web:
build:
context: .
dockerfile: Dockerfile
args:
- BUILD_VERSION=${BUILD_VERSION:-1.0.0}
environment:
- SERVICE_URL_WEB_3000
- LITERAL=plain-value
- BARE_PASSTHROUGH
- WITH_DEFAULT=${WITH_DEFAULT:-fallback}
- REQUIRED=${REQUIRED:?REQUIRED must be set}
- NO_BRACES=$NO_BRACES_VALUE
- NESTED=${NESTED_OUTER:-${NESTED_INNER:-inner-default}}
- NESTED_NO_DEFAULT=${NESTED_NO_DEFAULT_OUTER:-${NESTED_NO_DEFAULT_INNER}}
- DASH_DEFAULT=${DASH_DEFAULT_VAR-dash-default}
- NO_BRACES_DEFAULT=$NO_BRACES_DEFAULT_VAR:-no-braces-default
- NO_BRACES_MAGIC=$SERVICE_PASSWORD_NOBRACES
- SELF_REF=${SELF_REF}
- SHARED={{environment.SHARED_KEY}}
- PASSWORD=${SERVICE_PASSWORD_WEB}
- BASE64_64=${SERVICE_BASE64_64_WEB}
- HEX_32=${SERVICE_HEX_32_WEB}
- USER=${SERVICE_USER_WEB}
- API_URL=${SERVICE_URL_API}
labels:
com.example.team: platform
com.example.dollar: price-$5
volumes:
- web-data:/app/data
- ./config:/app/config:ro
- ./selinux:/app/selinux:z
- /var/run/docker.sock:/var/run/docker.sock:ro
- type: bind
source: ./web-init.sh
target: /docker-entrypoint.d/init.sh
read_only: true
content: |
#!/bin/sh
echo "application reference fixture"
depends_on:
db:
condition: service_healthy
# Path value with FQDN declaration, image only, ports, map-style environment.
api:
image: node:22-alpine
environment:
SERVICE_FQDN_API: /api
MAP_LITERAL: map-value
MAP_NULL:
ports:
- "3001:3001"
- target: 3002
published: 3002
protocol: tcp
expose:
- 3001
volumes:
- shared-external:/shared
- nfs-data:/mnt/nfs
# Hyphenated and dotted service names.
my-app:
image: httpd:2.4-alpine
environment:
- SERVICE_URL_MY_APP_80
api.v2:
image: traefik/whoami
# SERVICE_FQDN_ declaration with a port.
admin:
image: nginx:alpine
environment:
- SERVICE_FQDN_ADMIN_8080
networks:
internal:
ipv4_address: 172.30.0.10
# Ghost image with a path domain: extra redirect labels.
blog:
image: ghost:5-alpine
environment:
- SERVICE_URL_BLOG_2368=/blog
# Database image: no domain, no proxy labels.
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=${SERVICE_USER_DB}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_DB}
- POSTGRES_DB=app
volumes:
- db-data:/var/lib/postgresql/data
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
# restart "no", network_mode and a worker without a domain.
sidecar:
image: busybox
restart: "no"
network_mode: "service:web"
command: ["true"]
worker:
image: node:22-alpine
command: ["node", "worker.js"]
environment:
- APP_URL=${SERVICE_URL_WEB}
- APP_URL_WITH_PORT=${SERVICE_URL_WEB_3000}
- API_HOST=${SERVICE_FQDN_API}
depends_on:
- db
volumes:
web-data:
db-data:
driver_opts:
type: none
o: bind
device: /srv/db
shared-external:
external: true
nfs-data:
driver_opts:
type: nfs
o: addr=10.0.0.2,rw
device: ":/exports/data"
networks:
internal:
default:
aliased:
aliases:
- alias-name
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,15 @@
1_deploy:
applications: { }
databases: { }
environment_variables: { }
compose: { }
2_custom_domains:
applications: { }
databases: { }
environment_variables: { }
compose: { }
3_domain_on_applications_without_one:
applications: { }
databases: { }
environment_variables: { }
compose: { }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,2 @@
1_deploy:
error: "Exception: Invalid Docker Compose service name: Invalid service name: contains forbidden character ';' (command separator). Shell metacharacters are not allowed for security reasons. Service names must not contain shell metacharacters."
@@ -0,0 +1,2 @@
1_deploy:
error: "Exception: Invalid Docker volume definition: Invalid volume source: contains forbidden character ';' (command separator). Shell metacharacters are not allowed for security reasons. Please use safe path names without shell metacharacters."
@@ -0,0 +1,5 @@
# Reference compose for the service parser: invalid YAML gives an empty result.
services:
web:
image: nginx:alpine
environment: [unclosed
@@ -0,0 +1,181 @@
# Reference compose for the service parser (serviceParser + updateCompose).
# Test-only: tests/Feature/ServiceParserReferenceTest.php parses it and compares the result
# with service-reference.expected.yaml. Each service covers one group of parser features.
services:
# Domain with a port, magic secrets, all environment syntaxes, labels, volumes.
web:
image: nginx:alpine
environment:
- SERVICE_URL_WEB_8080
- LITERAL=plain-value
- BARE_PASSTHROUGH
- SELF_REF=${SELF_REF}
- WITH_DEFAULT=${WITH_DEFAULT:-fallback}
- REQUIRED=${REQUIRED:?REQUIRED must be set}
- NO_BRACES=$NO_BRACES_VALUE
- NESTED=${NESTED_OUTER:-${NESTED_INNER:-inner-default}}
- NESTED_NO_DEFAULT=${NESTED_NO_DEFAULT_OUTER:-${NESTED_NO_DEFAULT_INNER}}
- DASH_DEFAULT=${DASH_DEFAULT_VAR-dash-default}
- NO_BRACES_DEFAULT=$NO_BRACES_DEFAULT_VAR:-no-braces-default
- SHARED={{environment.SHARED_KEY}}
- SHARED_MISSING={{environment.NOT_DEFINED}}
- INTERPOLATED=http://${INTERPOLATED_HOST:-localhost}/path
- PASSWORD=${SERVICE_PASSWORD_WEB}
- PASSWORD_64=${SERVICE_PASSWORD_64_WEB}
- PASSWORD_SYMBOLS=${SERVICE_PASSWORDWITHSYMBOLS_WEB}
- BASE64=${SERVICE_BASE64_WEB}
- BASE64_64=${SERVICE_BASE64_64_WEB}
- REALBASE64_64=${SERVICE_REALBASE64_64_WEB}
- PASSWORD_SYMBOLS_64=${SERVICE_PASSWORDWITHSYMBOLS_64_WEB}
- REALBASE64_128=${SERVICE_REALBASE64_128_WEB}
- HEX_32=${SERVICE_HEX_32_WEB}
- HEX_128=${SERVICE_HEX_128_WEB}
- SUPABASE_ANON_WITHOUT_JWT=${SERVICE_SUPABASEANON_KEY}
- USER=${SERVICE_USER_WEB}
- LOWERCASE_USER=${SERVICE_LOWERCASEUSER_WEB}
- UNKNOWN_MAGIC=${SERVICE_FOO_WEB}
- API_URL=${SERVICE_URL_API}
labels:
com.example.team: platform
com.example.dollar: price-$5
traefik.http.middlewares.reference-headers.headers.customresponseheaders.X-Reference: "1"
volumes:
- web-data:/usr/share/nginx/html
- ./config:/etc/nginx/conf.d:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- type: bind
source: ./web-init.sh
target: /docker-entrypoint.d/init.sh
read_only: true
content: |
#!/bin/sh
echo "reference fixture"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080"]
interval: 10s
depends_on:
db:
condition: service_healthy
env_file: custom.env
# Path value with FQDN declaration, map-style environment, build args, ports.
api:
image: node:22-alpine
build:
context: .
args:
- BUILD_VERSION=${BUILD_VERSION:-1.0.0}
environment:
SERVICE_FQDN_API: /api
MAP_LITERAL: map-value
MAP_NULL:
GHOST_URL: ${SERVICE_URL_GHOST}
DB_URL: postgres://${SERVICE_USER_DB}:${SERVICE_PASSWORD_DB}@db:5432/app
ports:
- "3000:3000"
- target: 3001
published: 3001
protocol: tcp
volumes:
- nfs-data:/mnt/nfs
- type: bind
source: ./api-data
target: /data
- type: bind
source: ./optional
target: /optional
bind:
create_host_path: false
expose:
- 3000
# Hyphenated service name: SERVICE_URL_MY_APP maps to compose service "my-app".
my-app:
image: httpd:2.4-alpine
environment:
- SERVICE_URL_MY_APP_80
# Host www.example.com after the custom domain step (redirect to non-www in the settings case).
www:
image: nginx:alpine
environment:
- SERVICE_URL_WWW
# Ghost image with a path domain: extra redirect labels for the path.
blog:
image: ghost:5-alpine
environment:
- SERVICE_URL_BLOG_2368=/blog
# Shared host with path routing (Appwrite style): same variable, different paths.
gateway:
image: traefik/whoami
environment:
- SERVICE_URL_GATEWAY=/
gateway-ws:
image: traefik/whoami
environment:
- SERVICE_URL_GATEWAY=/ws
# Exact copy of web's declaration (old Twenty template, #11924): no domain, no variable writes.
worker:
image: nginx:alpine
command: ["sleep", "infinity"]
environment:
- SERVICE_URL_WEB_8080
- APP_URL=${SERVICE_URL_WEB}
# SERVICE_FQDN_ twin of web's SERVICE_URL_ declaration: also a copy.
scheduler:
image: nginx:alpine
command: ["sleep", "infinity"]
environment:
- SERVICE_FQDN_WEB_8080
exclude_from_hc: true
# Database image: ServiceDatabase row, no domain, no proxy labels.
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=${SERVICE_USER_DB}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_DB}
- POSTGRES_DB=app
- SERVICE_URL_DB_5432
volumes:
- db-data:/var/lib/postgresql/data
- shared-external:/backups
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
# restart "no": excluded from status checks; shares web's network namespace.
sidecar:
image: busybox
restart: "no"
network_mode: "service:web"
command: ["true"]
volumes:
web-data:
db-data:
driver_opts:
type: none
o: bind
device: /srv/db
shared-external:
external: true
nfs-data:
driver_opts:
type: nfs
o: addr=10.0.0.2,rw
device: ":/exports/data"
networks:
internal:
default:
# Networks with aliases are not added to every service.
aliased:
aliases:
- alias-name
@@ -0,0 +1,4 @@
# Reference compose for the service parser: a service name with shell metacharacters is rejected.
services:
"web;rm -rf /":
image: nginx:alpine
@@ -0,0 +1,6 @@
# Reference compose for the service parser error path: a bind source with shell metacharacters must be rejected.
services:
web:
image: nginx:alpine
volumes:
- "./data;touch /tmp/pwned:/data"