mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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:
1 parent
fc4562ede7
commit
6d4551faec
21 files changed
+24063
No files matched your search
@@ -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/
|
||||
@@ -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/`.
|
||||
|
||||
Executable
+118
@@ -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"
|
||||
Reference in new issue
Block a user