mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
+1
-1
@@ -19,7 +19,7 @@
|
||||
- SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f <name> || true` before `docker run --name <name>`).
|
||||
- Do not put `pkill -f "playwright run-server"` in the same shell command as other work; the pattern matches that shell and kills it. Run it as its own command.
|
||||
- Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open.
|
||||
- Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors.
|
||||
- Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. `click()` also hung on a button far down the long `/analytics` page; click it with `$page->script("document.querySelector('...').click()")` there.
|
||||
- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error.
|
||||
- Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only.
|
||||
- The helper image runs `docker compose` for deployments and for the Coolify upgrade itself. Before you bump its Docker CLI or Compose, test them against the oldest supported Docker Engine (for example a `docker:<version>-dind` container); newer clients can reject data from older daemons.
|
||||
|
||||
@@ -3818,7 +3818,7 @@ class ApplicationsController extends Controller
|
||||
// Must run after fqdn is filled: flags are kept only for domains the app still has.
|
||||
$application->setNoindexDomains($request->input('noindex_domains') ?? []);
|
||||
}
|
||||
if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains || $requestHasHttpBasicAuth) && $server->isProxyShouldRun()) {
|
||||
if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains || $requestHasHttpBasicAuth || $application->isDirty('ports_exposes')) && $server->isProxyShouldRun()) {
|
||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
}
|
||||
$application->withoutAuditLogging(fn () => $application->save());
|
||||
|
||||
@@ -5871,6 +5871,17 @@ function deviceLabel(?string $device): string
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a raw User-Agent comes from container tooling (Docker CLI and Engine,
|
||||
* BuildKit, containerd, Podman, Skopeo) instead of a person or a bot. The
|
||||
* analytics views hide these rows behind a toggle: image pulls and builds are
|
||||
* normal, but the raw strings look like an attack or a leak of internal data.
|
||||
*/
|
||||
function isContainerToolingUserAgent(?string $userAgent): bool
|
||||
{
|
||||
return preg_match('#^(docker|docker-client|buildkit|containerd|podman|skopeo|cri-o)/#i', trim((string) $userAgent)) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare aggregated `city` breakdown rows (`CC|subdivision|City`, from
|
||||
* TrafficAnalyticsAggregator::breakdowns(), not raw Sentinel rows) for display,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// its sort values as JSON in `data-row`. Rows are moved in the DOM, so keyboard
|
||||
// and screen-reader order match the visual order. A Livewire morph puts the rows
|
||||
// back in server order (they are keyed) and can add or remove rows, so the rows
|
||||
// are sorted again after it.
|
||||
// are sorted again after it. Rows marked `data-hidable` (container tooling user
|
||||
// agents) are left out of the sort and the pages until `showHidden` is on.
|
||||
function compareValues(a, b) {
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
return a - b;
|
||||
@@ -23,6 +24,8 @@ function trafficTable({ per = 10, key = 'requests', dir = 'desc' } = {}) {
|
||||
key,
|
||||
dir,
|
||||
tick: 0,
|
||||
showHidden: false,
|
||||
hiddenCount: 0,
|
||||
|
||||
init() {
|
||||
this.resort();
|
||||
@@ -65,9 +68,19 @@ function trafficTable({ per = 10, key = 'requests', dir = 'desc' } = {}) {
|
||||
this.resort();
|
||||
},
|
||||
|
||||
toggleHidden() {
|
||||
this.showHidden = !this.showHidden;
|
||||
this.page = 0;
|
||||
this.resort();
|
||||
},
|
||||
|
||||
resort() {
|
||||
const sign = this.dir === 'asc' ? 1 : -1;
|
||||
const rows = [...this.$root.querySelectorAll('[data-row]')].map((el, index) => {
|
||||
const all = [...this.$root.querySelectorAll('[data-row]')];
|
||||
const hidable = all.filter((el) => 'hidable' in el.dataset);
|
||||
hidable.forEach((el) => ranks.delete(el));
|
||||
this.hiddenCount = hidable.length;
|
||||
const rows = all.filter((el) => this.showHidden || !('hidable' in el.dataset)).map((el, index) => {
|
||||
let values = {};
|
||||
try {
|
||||
values = JSON.parse(el.dataset.row) || {};
|
||||
@@ -97,7 +110,10 @@ function trafficTable({ per = 10, key = 'requests', dir = 'desc' } = {}) {
|
||||
|
||||
shown(el) {
|
||||
this.tick;
|
||||
const rank = ranks.get(el) ?? 0;
|
||||
const rank = ranks.get(el);
|
||||
if (rank === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return rank >= this.page * this.per && rank < (this.page + 1) * this.per;
|
||||
},
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
One breakdown dimension rendered as a sortable, paginated settings-section. Handles the
|
||||
per-dimension presentation: referrers get a DuckDuckGo favicon + an openable
|
||||
external link (rel="noopener noreferrer nofollow", new tab), devices are mapped
|
||||
to friendly capitalized labels, and Sentinel's `__other__` overflow bucket reads
|
||||
to friendly capitalized labels, container tooling user agents (Docker, BuildKit,
|
||||
…) are hidden behind a toggle, and Sentinel's `__other__` overflow bucket reads
|
||||
as "Other". Expects `$dimension`, `$label`, `$rows` in scope; optional `$helper`.
|
||||
|
||||
@param string $dimension Sentinel dimension key (referer, device, agent, …)
|
||||
@@ -32,6 +33,7 @@
|
||||
@php
|
||||
$value = (string) ($row['value'] ?? '');
|
||||
$isOther = $value === '__other__';
|
||||
$isHidable = $dimension === 'useragent' && isContainerToolingUserAgent($value);
|
||||
$host = ! $isOther && $dimension === 'referer' ? refererHost($value) : null;
|
||||
$display = $isOther
|
||||
? 'Other'
|
||||
@@ -46,7 +48,7 @@
|
||||
$width = min(100, round(($requests / $maxRequests) * 100, 1));
|
||||
@endphp
|
||||
<div wire:key="analytics-{{ $dimension }}-{{ md5($value) }}"
|
||||
data-row="{{ json_encode(['name' => $display, 'requests' => $requests, 'bytesOut' => $bytesOut]) }}" @if ($isOther) data-pinned @endif
|
||||
data-row="{{ json_encode(['name' => $display, 'requests' => $requests, 'bytesOut' => $bytesOut]) }}" @if ($isOther) data-pinned @endif @if ($isHidable) data-hidable @endif
|
||||
x-show="shown($el)"
|
||||
class="flex min-h-11 items-center gap-3 border-t border-neutral-200 px-4 py-2 transition-colors hover:bg-neutral-50 dark:border-white/[0.07] dark:hover:bg-white/[0.03]">
|
||||
@if ($dimension === 'referer' && $host)
|
||||
@@ -66,6 +68,12 @@
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div x-show="hiddenCount > 0" x-cloak
|
||||
class="flex items-center border-t border-neutral-200 px-4 py-2 dark:border-white/[0.07]">
|
||||
<button type="button" @click="toggleHidden()"
|
||||
class="text-[11px] font-medium text-neutral-500 transition-colors hover:text-black dark:text-fg-faint dark:hover:text-fg"
|
||||
x-text="showHidden ? 'Hide container tooling' : `Show ${hiddenCount} container tooling ${hiddenCount === 1 ? 'agent' : 'agents'} (Docker, BuildKit, …)`"></button>
|
||||
</div>
|
||||
@include('livewire.traffic._pager')
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'ports-exposes-api-test-'.Str::random(6),
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['*'],
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$environment = Environment::factory()->create([
|
||||
'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id,
|
||||
]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'fqdn' => 'https://app.example.com',
|
||||
'ports_exposes' => '80',
|
||||
]);
|
||||
$this->application->settings->update(['is_container_label_readonly_enabled' => true]);
|
||||
$this->application->update([
|
||||
'custom_labels' => base64_encode(implode("\n", generateLabelsApplication($this->application->fresh()))),
|
||||
]);
|
||||
});
|
||||
|
||||
function patchPortsExposes(string $bearerToken, string $uuid, array $payload)
|
||||
{
|
||||
return test()->withHeaders(['Authorization' => 'Bearer '.$bearerToken])
|
||||
->patchJson("/api/v1/applications/{$uuid}", $payload);
|
||||
}
|
||||
|
||||
test('regenerates the load balancer port label when ports_exposes changes', function () {
|
||||
expect((string) $this->application->fresh()->parseContainerLabels())
|
||||
->toContain('loadbalancer.server.port=80');
|
||||
|
||||
patchPortsExposes($this->bearerToken, $this->application->uuid, ['ports_exposes' => '9000'])
|
||||
->assertOk();
|
||||
|
||||
$labels = (string) $this->application->fresh()->parseContainerLabels();
|
||||
|
||||
expect($labels)->toContain('loadbalancer.server.port=9000')
|
||||
->not->toContain('loadbalancer.server.port=80');
|
||||
});
|
||||
|
||||
test('keeps custom labels when readonly labels are disabled', function () {
|
||||
$this->application->settings->update(['is_container_label_readonly_enabled' => false]);
|
||||
$this->application->update(['custom_labels' => base64_encode('my.custom=label')]);
|
||||
|
||||
patchPortsExposes($this->bearerToken, $this->application->uuid, ['ports_exposes' => '9000'])
|
||||
->assertOk();
|
||||
|
||||
expect((string) $this->application->fresh()->parseContainerLabels())->toBe('my.custom=label');
|
||||
});
|
||||
@@ -73,3 +73,25 @@ it('distinguishes a missing response status from numeric values in other dimensi
|
||||
'overflow status' => ['status', '__other__', 'Other'],
|
||||
'zero in another dimension' => ['path', '0', '0'],
|
||||
]);
|
||||
|
||||
it('detects container tooling user agents', function (?string $userAgent, bool $expected) {
|
||||
expect(isContainerToolingUserAgent($userAgent))->toBe($expected);
|
||||
})->with([
|
||||
'buildkit' => ['buildkit/v0.33', true],
|
||||
'docker with compose upstream' => ['docker/29.6.2 go/go1.26.5 git-commit/3d80467 kernel/6.1.0-51-amd64 os/linux arch/amd64 UpstreamClient(compose/v5.3.1)', true],
|
||||
'docker-client' => ['docker-client/1.0', true],
|
||||
'containerd' => ['containerd/2.3.3+unknown', true],
|
||||
'podman' => ['podman/5.4.0', true],
|
||||
'skopeo' => ['skopeo/1.18.0', true],
|
||||
'cri-o' => ['cri-o/1.32.1', true],
|
||||
'mixed case' => ['Docker/29.6.2', true],
|
||||
'leading whitespace' => [' buildkit/v0.33', true],
|
||||
'docker without slash' => ['docker', false],
|
||||
'docker as a later token' => ['curl/8.5.0 docker/29.6.2', false],
|
||||
'docker prefix of another name' => ['dockerd/29.6.2', false],
|
||||
'firefox' => ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:156.0) Gecko/20100101 Firefox/156.0', false],
|
||||
'curl' => ['curl/8.5.0', false],
|
||||
'overflow bucket' => ['__other__', false],
|
||||
'empty' => ['', false],
|
||||
'null' => [null, false],
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class FakeBrowserUserAgentTrafficClient extends SentinelTrafficClient
|
||||
{
|
||||
protected function raw(string $url): string
|
||||
{
|
||||
$responses = [
|
||||
'/traffic/apps' => [],
|
||||
'/traffic/overview' => [
|
||||
'requests' => 900, 'bytes_in' => 5000, 'bytes_out' => 12000,
|
||||
'status' => ['s2xx' => 900, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0],
|
||||
'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
|
||||
'unique_visitors' => 320,
|
||||
],
|
||||
'/breakdown/useragent' => [
|
||||
['value' => 'Mozilla/5.0 Firefox/156.0', 'requests' => 654, 'bytes_out' => 1000],
|
||||
['value' => 'buildkit/v0.33', 'requests' => 138, 'bytes_out' => 0],
|
||||
['value' => 'docker/29.6.2 go/go1.26.5 os/linux', 'requests' => 63, 'bytes_out' => 500],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($responses as $needle => $response) {
|
||||
if (str_contains($url, $needle)) {
|
||||
return json_encode($response);
|
||||
}
|
||||
}
|
||||
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
|
||||
$this->stack = seedBrowserResourceStack();
|
||||
|
||||
$server = $this->stack['server'];
|
||||
$server->settings->is_traffic_analytics_enabled = true;
|
||||
$server->settings->save();
|
||||
|
||||
app()->bind(SentinelTrafficClient::class, fn () => new FakeBrowserUserAgentTrafficClient($server));
|
||||
});
|
||||
|
||||
/**
|
||||
* The names of the visible user agent rows.
|
||||
*/
|
||||
function visibleUserAgentNames(mixed $page): array
|
||||
{
|
||||
return $page->script(<<<'JS'
|
||||
[...document.querySelectorAll('#analytics-useragent-section [data-row]')]
|
||||
.filter((row) => getComputedStyle(row).display !== 'none')
|
||||
.map((row) => JSON.parse(row.dataset.row).name)
|
||||
JS);
|
||||
}
|
||||
|
||||
it('hides container tooling user agents until the toggle is selected', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$page = visit('/analytics');
|
||||
$toggle = '#analytics-useragent-section button[x-text]';
|
||||
|
||||
$page->assertSeeIn($toggle, 'Show 2 container tooling agents');
|
||||
expect(visibleUserAgentNames($page))->toBe(['Mozilla/5.0 Firefox/156.0']);
|
||||
|
||||
$page->script("document.querySelector('{$toggle}').click()");
|
||||
$page->assertSeeIn($toggle, 'Hide container tooling')
|
||||
->wait(0.5);
|
||||
expect(visibleUserAgentNames($page))->toBe([
|
||||
'Mozilla/5.0 Firefox/156.0',
|
||||
'buildkit/v0.33',
|
||||
'docker/29.6.2 go/go1.26.5 os/linux',
|
||||
]);
|
||||
|
||||
$page->script("document.querySelector('{$toggle}').click()");
|
||||
$page->assertSeeIn($toggle, 'Show 2 container tooling agents')
|
||||
->wait(0.5)
|
||||
->assertNoJavaScriptErrors()
|
||||
->screenshot(filename: 'analytics-useragent-tooling');
|
||||
expect(visibleUserAgentNames($page))->toBe(['Mozilla/5.0 Firefox/156.0']);
|
||||
});
|
||||
Reference in New Issue
Block a user