mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 01:54:37 +08:00
Environments without resources now appear as placeholder cards on the
project, team projects and server canvases. They sit in the zone that
holds most of their project's cards, or in a new "Empty environments"
zone when there is none. A new EmptyCard component renders them.
- Project canvas: admins can move groups and cards, and the moves are
saved on their environment canvas. Environments are ordered by id.
- Server canvas: admins can move single cards as well as groups.
- Shared variable canvas: built-in COOLIFY_SERVER_UUID/NAME variables
and their links are hidden on server scope.
- Main nav: resource menus register through registerMainNavContext()
with depth levels (#main-nav-context-{depth}). Back goes one level
lower, and Back at depth 0 returns to the main menu.
- The deploy canvas state, styles and controls are simplified, and the
related sidebar, navbar and dashboard views are updated.
Tests are added or updated for the canvases and the main nav resource
menus.
452 lines
22 KiB
PHP
452 lines
22 KiB
PHP
<?php
|
|
|
|
use App\Models\Application;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\LocalPersistentVolume;
|
|
use App\Models\Project;
|
|
use App\Models\ResourceLink;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\ServiceDatabase;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config([
|
|
'cache.default' => 'array',
|
|
'session.driver' => 'array',
|
|
'queue.default' => 'sync',
|
|
'app.maintenance.driver' => 'file',
|
|
]);
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
|
|
Server::flushIdentityMap();
|
|
|
|
$this->team = Team::factory()->create(['show_boarding' => false]);
|
|
$this->admin = User::factory()->create();
|
|
$this->team->members()->attach($this->admin->id, ['role' => 'admin']);
|
|
|
|
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'main-server', 'ip' => '203.0.113.10']);
|
|
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
|
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$this->environment = Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'canvas-env']);
|
|
});
|
|
|
|
function environmentCanvasActingAs(User $user, Team $team): mixed
|
|
{
|
|
return test()->actingAs($user)->withSession(['currentTeam' => $team])
|
|
->withHeader('X-Canvas-Team', (string) $team->id);
|
|
}
|
|
|
|
function environmentCanvasUrl(Project $project, Environment $environment, string $suffix = ''): string
|
|
{
|
|
return "/canvas/api/projects/{$project->uuid}/environments/{$environment->uuid}/canvas{$suffix}";
|
|
}
|
|
|
|
function environmentCanvasApplication(Environment $environment, StandaloneDocker $destination, array $attributes = []): Application
|
|
{
|
|
return Application::factory()->create([
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
function environmentCanvasPostgres(Environment $environment, StandaloneDocker $destination): StandalonePostgresql
|
|
{
|
|
return create_standalone_postgresql($environment->id, $destination, [
|
|
'name' => 'main-db',
|
|
'postgres_password' => 'canvas-secret-password',
|
|
], 'postgres:17-alpine');
|
|
}
|
|
|
|
describe('GET canvas', function () {
|
|
test('returns zones, nodes, links and positions of the environment', function () {
|
|
$extraServer = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'extra-server']);
|
|
$extraDestination = StandaloneDocker::query()->where('server_id', $extraServer->id)->firstOrFail();
|
|
|
|
$application = environmentCanvasApplication($this->environment, $this->destination, [
|
|
'name' => 'web',
|
|
'git_branch' => 'main',
|
|
'fqdn' => 'https://web.example.com, https://www.example.com,',
|
|
'status' => 'running:healthy',
|
|
]);
|
|
$application->additional_servers()->attach($extraServer->id, ['standalone_docker_id' => $extraDestination->id, 'status' => 'running:healthy']);
|
|
LocalPersistentVolume::create(['name' => 'web-data', 'mount_path' => '/data', 'resource_type' => $application->getMorphClass(), 'resource_id' => $application->id]);
|
|
|
|
$service = Service::factory()->create([
|
|
'name' => 'stack',
|
|
'environment_id' => $this->environment->id,
|
|
'server_id' => $this->server->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
]);
|
|
ServiceApplication::forceCreate(['name' => 'ghost', 'service_id' => $service->id, 'fqdn' => 'https://ghost.example.com', 'status' => 'running:healthy']);
|
|
ServiceDatabase::forceCreate(['name' => 'mysql', 'service_id' => $service->id, 'status' => 'running:healthy']);
|
|
|
|
$database = environmentCanvasPostgres($this->environment, $this->destination);
|
|
|
|
$link = ResourceLink::create([
|
|
'environment_id' => $this->environment->id,
|
|
'source_type' => $application->getMorphClass(),
|
|
'source_id' => $application->id,
|
|
'target_type' => $database->getMorphClass(),
|
|
'target_id' => $database->id,
|
|
'label' => 'DATABASE_URL',
|
|
]);
|
|
$this->environment->forceFill(['canvas_positions' => [$application->uuid => ['x' => 10, 'y' => -20], 'gone-resource' => ['x' => 1, 'y' => 1]]])->save();
|
|
|
|
environmentCanvasActingAs($this->admin, $this->team);
|
|
$response = $this
|
|
->getJson(environmentCanvasUrl($this->project, $this->environment))
|
|
->assertOk();
|
|
|
|
$response->assertJsonPath('project', ['uuid' => $this->project->uuid, 'name' => $this->project->name, 'link' => route('project.show', ['project_uuid' => $this->project->uuid])])
|
|
->assertJsonPath('environment', ['uuid' => $this->environment->uuid, 'name' => 'canvas-env', 'link' => route('project.resource.index', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid])])
|
|
->assertJsonPath('can_edit', true)
|
|
->assertJsonPath('links', [['id' => $link->uuid, 'source' => $application->uuid, 'target' => $database->uuid, 'label' => 'DATABASE_URL']])
|
|
->assertJsonPath('positions', [$application->uuid => ['x' => 10, 'y' => -20]]);
|
|
|
|
expect(collect($response->json('zones'))->keyBy('id')->all())->toEqual([
|
|
$this->server->uuid => ['id' => $this->server->uuid, 'name' => 'main-server', 'ip' => '203.0.113.10', 'is_reachable' => false, 'is_usable' => false, 'link' => route('server.show', ['server_uuid' => $this->server->uuid])],
|
|
$extraServer->uuid => ['id' => $extraServer->uuid, 'name' => 'extra-server', 'ip' => $extraServer->ip, 'is_reachable' => false, 'is_usable' => false, 'link' => route('server.show', ['server_uuid' => $extraServer->uuid])],
|
|
]);
|
|
|
|
$nodes = collect($response->json('nodes'))->keyBy('id');
|
|
expect($nodes)->toHaveCount(3)
|
|
->and($nodes[$application->uuid])->toEqual([
|
|
'id' => $application->uuid,
|
|
'type' => 'application',
|
|
'name' => 'web',
|
|
'status' => 'running:healthy',
|
|
'zone' => $this->server->uuid,
|
|
'extra_zones' => [$extraServer->uuid],
|
|
'urls' => ['https://web.example.com', 'https://www.example.com'],
|
|
'kind' => 'nixpacks',
|
|
'subtitle' => 'Nixpacks · main',
|
|
'children' => [],
|
|
'link' => $application->link(),
|
|
'logo' => ['src' => resource_logo_url('public-repo'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => 'https://web.example.com/favicon.ico'],
|
|
'saved' => ['x' => 10, 'y' => -20],
|
|
])
|
|
->and($nodes[$service->uuid])->toEqual([
|
|
'id' => $service->uuid,
|
|
'type' => 'service',
|
|
'name' => 'stack',
|
|
'status' => 'running:healthy',
|
|
'zone' => $this->server->uuid,
|
|
'extra_zones' => [],
|
|
'urls' => ['https://ghost.example.com'],
|
|
'kind' => null,
|
|
'subtitle' => 'Service · 2 containers',
|
|
'children' => [
|
|
['name' => 'ghost', 'type' => 'application', 'status' => 'running:healthy'],
|
|
['name' => 'mysql', 'type' => 'database', 'status' => 'running:healthy'],
|
|
],
|
|
'link' => $service->link(),
|
|
'logo' => null,
|
|
'saved' => null,
|
|
])
|
|
->and($nodes[$database->uuid])->toMatchArray([
|
|
'type' => 'database',
|
|
'name' => 'main-db',
|
|
'zone' => $this->server->uuid,
|
|
'urls' => [],
|
|
'kind' => 'postgresql',
|
|
'subtitle' => 'PostgreSQL · postgres:17-alpine',
|
|
'logo' => ['src' => resource_logo_url('postgres'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => null],
|
|
'saved' => null,
|
|
])
|
|
->and($response->getContent())->not->toContain('canvas-secret-password');
|
|
});
|
|
|
|
test('drops links whose resource was deleted', function () {
|
|
$application = environmentCanvasApplication($this->environment, $this->destination);
|
|
$database = environmentCanvasPostgres($this->environment, $this->destination);
|
|
ResourceLink::create([
|
|
'environment_id' => $this->environment->id,
|
|
'source_type' => $application->getMorphClass(),
|
|
'source_id' => $application->id,
|
|
'target_type' => $database->getMorphClass(),
|
|
'target_id' => $database->id,
|
|
]);
|
|
$database->forceDelete();
|
|
|
|
environmentCanvasActingAs($this->admin, $this->team)
|
|
->getJson(environmentCanvasUrl($this->project, $this->environment))
|
|
->assertOk()
|
|
->assertJsonPath('links', []);
|
|
});
|
|
|
|
test('hides server ips and editing from a member', function () {
|
|
$member = User::factory()->create();
|
|
$this->team->members()->attach($member->id, ['role' => 'member']);
|
|
environmentCanvasApplication($this->environment, $this->destination);
|
|
|
|
environmentCanvasActingAs($member, $this->team)
|
|
->getJson(environmentCanvasUrl($this->project, $this->environment))
|
|
->assertOk()
|
|
->assertJsonPath('zones.0.ip', null)
|
|
->assertJsonPath('can_edit', false);
|
|
});
|
|
|
|
test('finds the environment by name', function () {
|
|
environmentCanvasActingAs($this->admin, $this->team)
|
|
->getJson("/canvas/api/projects/{$this->project->uuid}/environments/canvas-env/canvas")
|
|
->assertOk()
|
|
->assertJsonPath('environment.uuid', $this->environment->uuid);
|
|
});
|
|
|
|
test('does not run more queries for more applications', function () {
|
|
environmentCanvasActingAs($this->admin, $this->team);
|
|
$countQueries = function (): int {
|
|
DB::flushQueryLog();
|
|
DB::enableQueryLog();
|
|
$this
|
|
->getJson(environmentCanvasUrl($this->project, $this->environment))
|
|
->assertOk();
|
|
DB::disableQueryLog();
|
|
|
|
return collect(DB::getQueryLog())->reject(fn (array $query) => str_contains($query['query'], 'audit_events'))->count();
|
|
};
|
|
environmentCanvasApplication($this->environment, $this->destination, ['fqdn' => 'https://a.example.com']);
|
|
$countQueries();
|
|
$queriesForOne = $countQueries();
|
|
|
|
foreach (range(1, 4) as $index) {
|
|
environmentCanvasApplication($this->environment, $this->destination, ['fqdn' => "https://a{$index}.example.com"]);
|
|
}
|
|
$queriesForFive = $countQueries();
|
|
|
|
expect($queriesForFive)->toBe($queriesForOne);
|
|
});
|
|
});
|
|
|
|
describe('PATCH canvas positions', function () {
|
|
test('merges positions, ignores unknown resources and prunes removed ones', function () {
|
|
$first = environmentCanvasApplication($this->environment, $this->destination);
|
|
$second = environmentCanvasApplication($this->environment, $this->destination);
|
|
$this->environment->forceFill(['canvas_positions' => [
|
|
$first->uuid => ['x' => 1, 'y' => 2],
|
|
'removed-resource' => ['x' => 5, 'y' => 5],
|
|
]])->save();
|
|
$otherEnvironment = Environment::factory()->create(['project_id' => $this->project->id]);
|
|
$foreign = environmentCanvasApplication($otherEnvironment, $this->destination);
|
|
|
|
environmentCanvasActingAs($this->admin, $this->team)
|
|
->patchJson(environmentCanvasUrl($this->project, $this->environment, '/positions'), ['positions' => [
|
|
$second->uuid => ['x' => -300, 'y' => 400],
|
|
$foreign->uuid => ['x' => 9, 'y' => 9],
|
|
'unknown' => ['x' => 'not-a-number'],
|
|
]])
|
|
->assertOk()
|
|
->assertExactJson(['positions' => [
|
|
$first->uuid => ['x' => 1, 'y' => 2],
|
|
$second->uuid => ['x' => -300, 'y' => 400],
|
|
]]);
|
|
|
|
expect($this->environment->refresh()->canvas_positions)->toBe([
|
|
$first->uuid => ['x' => 1, 'y' => 2],
|
|
$second->uuid => ['x' => -300, 'y' => 400],
|
|
]);
|
|
});
|
|
|
|
test('rejects coordinates out of range', function () {
|
|
$application = environmentCanvasApplication($this->environment, $this->destination);
|
|
|
|
environmentCanvasActingAs($this->admin, $this->team)
|
|
->patchJson(environmentCanvasUrl($this->project, $this->environment, '/positions'), ['positions' => [
|
|
$application->uuid => ['x' => 100001, 'y' => 0],
|
|
]])
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors(["positions.{$application->uuid}.x"]);
|
|
});
|
|
});
|
|
|
|
describe('canvas links', function () {
|
|
test('creates, relabels and deletes a link', function () {
|
|
$application = environmentCanvasApplication($this->environment, $this->destination);
|
|
$database = environmentCanvasPostgres($this->environment, $this->destination);
|
|
environmentCanvasActingAs($this->admin, $this->team);
|
|
|
|
$response = $this
|
|
->postJson(environmentCanvasUrl($this->project, $this->environment, '/links'), [
|
|
'source' => $application->uuid,
|
|
'target' => $database->uuid,
|
|
'label' => 'uses',
|
|
])
|
|
->assertCreated()
|
|
->assertJson(['source' => $application->uuid, 'target' => $database->uuid, 'label' => 'uses']);
|
|
$linkUuid = $response->json('id');
|
|
expect(ResourceLink::query()->whereUuid($linkUuid)->firstOrFail()->created_by)->toBe($this->admin->id);
|
|
|
|
$this
|
|
->patchJson(environmentCanvasUrl($this->project, $this->environment, "/links/{$linkUuid}"), ['label' => 'reads'])
|
|
->assertOk()
|
|
->assertExactJson(['id' => $linkUuid, 'source' => $application->uuid, 'target' => $database->uuid, 'label' => 'reads']);
|
|
|
|
$this
|
|
->deleteJson(environmentCanvasUrl($this->project, $this->environment, "/links/{$linkUuid}"))
|
|
->assertOk()
|
|
->assertJson(['message' => 'Link deleted.']);
|
|
|
|
expect(ResourceLink::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('rejects a link to itself', function () {
|
|
$application = environmentCanvasApplication($this->environment, $this->destination);
|
|
|
|
environmentCanvasActingAs($this->admin, $this->team)
|
|
->postJson(environmentCanvasUrl($this->project, $this->environment, '/links'), [
|
|
'source' => $application->uuid,
|
|
'target' => $application->uuid,
|
|
])
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors(['target']);
|
|
});
|
|
|
|
test('rejects a resource of another environment', function () {
|
|
$application = environmentCanvasApplication($this->environment, $this->destination);
|
|
$otherEnvironment = Environment::factory()->create(['project_id' => $this->project->id]);
|
|
$foreign = environmentCanvasApplication($otherEnvironment, $this->destination);
|
|
|
|
environmentCanvasActingAs($this->admin, $this->team)
|
|
->postJson(environmentCanvasUrl($this->project, $this->environment, '/links'), [
|
|
'source' => $application->uuid,
|
|
'target' => $foreign->uuid,
|
|
])
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors(['target']);
|
|
|
|
expect(ResourceLink::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('rejects a duplicate link but allows the opposite direction', function () {
|
|
$application = environmentCanvasApplication($this->environment, $this->destination);
|
|
$database = environmentCanvasPostgres($this->environment, $this->destination);
|
|
environmentCanvasActingAs($this->admin, $this->team);
|
|
$url = environmentCanvasUrl($this->project, $this->environment, '/links');
|
|
|
|
$this->postJson($url, ['source' => $application->uuid, 'target' => $database->uuid])->assertCreated();
|
|
$this->postJson($url, ['source' => $application->uuid, 'target' => $database->uuid])
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors(['target']);
|
|
$this->postJson($url, ['source' => $database->uuid, 'target' => $application->uuid])->assertCreated();
|
|
});
|
|
|
|
test('returns 404 for a link of another environment', function () {
|
|
$otherEnvironment = Environment::factory()->create(['project_id' => $this->project->id]);
|
|
$first = environmentCanvasApplication($otherEnvironment, $this->destination);
|
|
$second = environmentCanvasApplication($otherEnvironment, $this->destination);
|
|
$link = ResourceLink::create([
|
|
'environment_id' => $otherEnvironment->id,
|
|
'source_type' => $first->getMorphClass(),
|
|
'source_id' => $first->id,
|
|
'target_type' => $second->getMorphClass(),
|
|
'target_id' => $second->id,
|
|
]);
|
|
environmentCanvasActingAs($this->admin, $this->team);
|
|
|
|
$this
|
|
->patchJson(environmentCanvasUrl($this->project, $this->environment, "/links/{$link->uuid}"), ['label' => 'x'])
|
|
->assertNotFound();
|
|
$this
|
|
->deleteJson(environmentCanvasUrl($this->project, $this->environment, "/links/{$link->uuid}"))
|
|
->assertNotFound();
|
|
|
|
expect($link->fresh())->not->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('canvas authorization', function () {
|
|
test('a member can read the canvas but cannot change it', function () {
|
|
$member = User::factory()->create();
|
|
$this->team->members()->attach($member->id, ['role' => 'member']);
|
|
$application = environmentCanvasApplication($this->environment, $this->destination);
|
|
$database = environmentCanvasPostgres($this->environment, $this->destination);
|
|
environmentCanvasActingAs($member, $this->team);
|
|
|
|
$this->getJson(environmentCanvasUrl($this->project, $this->environment))->assertOk();
|
|
$this
|
|
->patchJson(environmentCanvasUrl($this->project, $this->environment, '/positions'), ['positions' => [$application->uuid => ['x' => 1, 'y' => 1]]])
|
|
->assertForbidden();
|
|
$this
|
|
->postJson(environmentCanvasUrl($this->project, $this->environment, '/links'), ['source' => $application->uuid, 'target' => $database->uuid])
|
|
->assertForbidden();
|
|
|
|
expect($this->environment->refresh()->canvas_positions)->toBeNull()
|
|
->and(ResourceLink::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('returns 404 for a project of another team', function () {
|
|
$otherTeam = Team::factory()->create();
|
|
$otherUser = User::factory()->create();
|
|
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
|
|
$application = environmentCanvasApplication($this->environment, $this->destination);
|
|
environmentCanvasActingAs($otherUser, $otherTeam);
|
|
|
|
$this->getJson(environmentCanvasUrl($this->project, $this->environment))->assertNotFound();
|
|
$this
|
|
->patchJson(environmentCanvasUrl($this->project, $this->environment, '/positions'), ['positions' => [$application->uuid => ['x' => 1, 'y' => 1]]])
|
|
->assertNotFound();
|
|
});
|
|
test('is not part of the public API', function () {
|
|
$plainTextToken = 'environment-canvas-test-token';
|
|
$token = $this->admin->tokens()->create([
|
|
'name' => 'environment-canvas-test',
|
|
'token' => hash('sha256', $plainTextToken),
|
|
'abilities' => ['root'],
|
|
'team_id' => $this->team->id,
|
|
]);
|
|
|
|
$this->withHeaders(['Authorization' => "Bearer {$token->getKey()}|{$plainTextToken}"])
|
|
->getJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}/canvas")
|
|
->assertNotFound();
|
|
});
|
|
});
|
|
|
|
describe('canvas page', function () {
|
|
test('add resource opens the deploy canvas for this environment', function () {
|
|
$deployCanvasUrl = route('deploy-canvas', ['project' => $this->project->uuid, 'environment' => $this->environment->uuid]);
|
|
|
|
$this->actingAs($this->admin)->withSession(['currentTeam' => $this->team])
|
|
->withoutVite()
|
|
->get(route('project.environment.canvas', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid]))
|
|
->assertOk()
|
|
->assertSee('data-new="'.e($deployCanvasUrl).'"', false)
|
|
->assertSee('href="'.e($deployCanvasUrl).'"', false)
|
|
->assertDontSee(route('project.resource.create', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid]), false);
|
|
});
|
|
|
|
test('a team member can open the canvas page', function () {
|
|
$member = User::factory()->create();
|
|
$this->team->members()->attach($member->id, ['role' => 'member']);
|
|
|
|
$this->actingAs($member)->withSession(['currentTeam' => $this->team])
|
|
->withoutVite()
|
|
->get(route('project.environment.canvas', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid]))
|
|
->assertOk()
|
|
->assertSee('data-environment-canvas', false)
|
|
->assertSee('data-environment="'.$this->environment->uuid.'"', false);
|
|
});
|
|
|
|
test('another team gets a 404', function () {
|
|
$otherTeam = Team::factory()->create(['show_boarding' => false]);
|
|
$otherUser = User::factory()->create();
|
|
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
|
|
|
|
$this->actingAs($otherUser)->withSession(['currentTeam' => $otherTeam])
|
|
->withoutVite()
|
|
->get(route('project.environment.canvas', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid]))
|
|
->assertNotFound();
|
|
});
|
|
});
|