mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix: enforce resource team isolation and preserve ClickHouse data
Scope resource permissions and related records to the owning team across team switches, cloning, deployments, and resource operations. Merge ClickHouse volume mounts, preserve anonymous data volumes on stop, and require adopting current data before starting or restarting. Fix file storage deletion checks and add regression coverage.
This commit is contained in:
@@ -7,10 +7,12 @@
|
||||
- Use the same regression test before and after the production change so the result shows the behavior difference.
|
||||
- Several dev instances can run from one checkout (see `./scripts/dev urls`). After you add a migration, run it on each running instance of that checkout, or those instances fail on the new code.
|
||||
- The checkout is shared with other sessions. When the full suite fails, rerun each failing file alone and compare with a clean `git archive HEAD` copy before you connect a failure to your change.
|
||||
- Run seeders and other artisan commands that write SSH keys in a dev container as `www-data` (`docker exec -u www-data`). Root-owned files in `storage/app/ssh` make every SSH call from the web process fail.
|
||||
- `sshd` keeps the login shell of an open multiplexed SSH connection. Call `SshMultiplexingHelper::removeMuxFile()` before a live test of SSH or login-shell behavior.
|
||||
- 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>`).
|
||||
- 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('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`.
|
||||
- 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.
|
||||
- Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`.
|
||||
|
||||
## Verify the complete user flow
|
||||
|
||||
@@ -150,6 +150,12 @@ Because the "server" and the test share one PHP process, they share the phpunit
|
||||
- Multi-tenancy via Teams — team auto-initializes notification settings on creation
|
||||
- Authorize every server-side read and mutation where access can vary by user, role, team, or resource. Use policies, gates, or `$this->authorize(...)`; never rely on hidden Blade/Livewire controls such as `@can` for security.
|
||||
- Scope queries to the current team before returning records. Treat route and model identifiers as untrusted, and prevent users from reading or changing resources owned by another team.
|
||||
- Use the session team (`currentTeam()`, `Model::ownedByCurrentTeam()`) only to find or open records, for "my team" lists, to create new top-level records, and on team pages. A user can switch teams in another tab while a page stays open, and every user owns a personal team.
|
||||
- Once a component, action, or controller works on a loaded resource, use that resource's team for everything after it:
|
||||
- Roles and redaction: use the policy (`$this->authorize('update', $resource)`) or `auth()->user()->isAdminOfTeam($resource->team()->id)`. Never use `isMember()`, `isAdmin()`, or `role()` for resource permissions; they read the role in the session team.
|
||||
- Related records the user can pick (S3 storages, keys, servers, sources, tags, tokens): query with `->where('team_id', $resource->team()->id)` and validate submitted IDs against that team.
|
||||
- New rows that belong to the resource: set `team_id` from the resource's team, not `currentTeam()->id`.
|
||||
- Test this with a user who is a member of the resource's team and the owner of another team, with the session switched to the other team after mount.
|
||||
- Apply authorization consistently across Livewire actions, API and web controllers, actions, downloads, exports, search, event listeners, and any other path that exposes or changes protected data.
|
||||
- Default to denying access when a policy or ownership relationship is missing or ambiguous. Members must not gain access to administrative, credential, security, billing, or instance-wide data merely because they belong to the team.
|
||||
- Add authorization regression tests for protected changes. Cover permitted access, member restrictions where applicable, and cross-team access; verify unauthorized reads and writes return `403` or otherwise reveal no protected data.
|
||||
|
||||
@@ -84,13 +84,23 @@ class StartClickhouse
|
||||
if (count($this->database->ports_mappings_array) > 0) {
|
||||
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
||||
}
|
||||
|
||||
$docker_compose['services'][$container_name]['volumes'] ??= [];
|
||||
|
||||
if (count($persistent_storages) > 0) {
|
||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_storages;
|
||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||
$docker_compose['services'][$container_name]['volumes'],
|
||||
$persistent_storages
|
||||
);
|
||||
}
|
||||
|
||||
if (count($persistent_file_volumes) > 0) {
|
||||
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) {
|
||||
return "$item->fs_path:$item->mount_path";
|
||||
})->toArray();
|
||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||
$docker_compose['services'][$container_name]['volumes'],
|
||||
$persistent_file_volumes->map(function ($item) {
|
||||
return "$item->fs_path:$item->mount_path";
|
||||
})->toArray()
|
||||
);
|
||||
}
|
||||
if (count($volume_names) > 0) {
|
||||
$docker_compose['volumes'] = $volume_names;
|
||||
|
||||
@@ -267,6 +267,10 @@ class StartDatabase
|
||||
*/
|
||||
public static function prerequisiteError(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $database): ?string
|
||||
{
|
||||
if ($database instanceof StandaloneClickhouse && ($volumeName = $database->anonymousDataVolume()) !== null) {
|
||||
return DatabaseStartException::clickhouseDataInAnonymousVolume($volumeName)->getMessage();
|
||||
}
|
||||
|
||||
if (! $database->enable_ssl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class StopDatabase
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $database, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainer = true): string
|
||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $database, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainer = true, bool $keepAnonymousDataVolume = true): string
|
||||
{
|
||||
try {
|
||||
$server = $database->destination->server;
|
||||
@@ -28,6 +28,11 @@ class StopDatabase
|
||||
return 'Server is not functional';
|
||||
}
|
||||
|
||||
// A removed container loses the link to the unnamed volume that holds the current data.
|
||||
if ($removeContainer && $keepAnonymousDataVolume && $database instanceof StandaloneClickhouse && $database->anonymousDataVolume() !== null) {
|
||||
$removeContainer = false;
|
||||
}
|
||||
|
||||
$this->stopContainer($database, $database->uuid, 30, $removeContainer);
|
||||
|
||||
// Reset restart tracking when database is manually stopped
|
||||
|
||||
@@ -15,6 +15,11 @@ class DatabaseStartException extends Exception
|
||||
return new static('No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||
}
|
||||
|
||||
public static function clickhouseDataInAnonymousVolume(string $volumeName): static
|
||||
{
|
||||
return new static("This ClickHouse database keeps its current data in the unnamed Docker volume {$volumeName}, not in its data volume. A start or restart would use the data volume, and the current data would seem lost. On the database's General page, click \"Keep current data\" first.");
|
||||
}
|
||||
|
||||
public static function startCommandsDidNotRun(): static
|
||||
{
|
||||
return new static('Database start ended without running the start commands.');
|
||||
|
||||
@@ -4802,7 +4802,7 @@ class DatabasesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($storage->isSharedWithAnotherResource()) {
|
||||
if ($storage instanceof LocalPersistentVolume && $storage->isSharedWithAnotherResource()) {
|
||||
return response()->json([
|
||||
'message' => 'This volume is mounted by an application. Unlink it on the SQLite database page first.',
|
||||
], 422);
|
||||
|
||||
@@ -459,8 +459,8 @@ class DeployController extends Controller
|
||||
// $message->push("Tag {$tag} not found.");
|
||||
continue;
|
||||
}
|
||||
$applications = $found_tag->applications()->get();
|
||||
$services = $found_tag->services()->get();
|
||||
$applications = $found_tag->applications()->whereRelation('environment.project', 'team_id', $team_id)->get();
|
||||
$services = $found_tag->services()->whereRelation('environment.project', 'team_id', $team_id)->get();
|
||||
if ($applications->count() === 0 && $services->count() === 0) {
|
||||
$message->push("No resources found for tag {$tag}.");
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
||||
case 'standalone-dragonfly':
|
||||
case 'standalone-clickhouse':
|
||||
case 'standalone-sqlite':
|
||||
StopDatabase::run($this->resource, dockerCleanup: $this->dockerCleanup);
|
||||
StopDatabase::run($this->resource, dockerCleanup: $this->dockerCleanup, keepAnonymousDataVolume: false);
|
||||
break;
|
||||
case 'service':
|
||||
StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup);
|
||||
|
||||
@@ -72,7 +72,7 @@ class Docker extends Component
|
||||
{
|
||||
try {
|
||||
$isSwarm = $this->selectedServer->isSwarm();
|
||||
$this->authorize('create', $isSwarm ? SwarmDocker::class : StandaloneDocker::class);
|
||||
$this->authorize('update', $this->selectedServer);
|
||||
$this->validate();
|
||||
if ($isSwarm) {
|
||||
$found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first();
|
||||
|
||||
@@ -69,19 +69,22 @@ class DeploymentNavbar extends Component
|
||||
|
||||
public function copyLogsToClipboard(): string
|
||||
{
|
||||
$this->authorize('view', $this->application);
|
||||
|
||||
$logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
if (! $logs) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$isMember = auth()->user()->isMember();
|
||||
$teamId = $this->application->team()?->id;
|
||||
$hideDebugLines = is_null($teamId) || ! auth()->user()->isAdminOfTeam($teamId);
|
||||
|
||||
$markdown = "# Deployment Logs\n\n";
|
||||
$markdown .= "```\n";
|
||||
|
||||
foreach ($logs as $log) {
|
||||
if ($isMember && ! empty($log['hidden'])) {
|
||||
if ($hideDebugLines && ! empty($log['hidden'])) {
|
||||
continue;
|
||||
}
|
||||
if (isset($log['output'])) {
|
||||
@@ -101,8 +104,9 @@ class DeploymentNavbar extends Component
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
$teamId = $this->application->team()->id;
|
||||
$server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id;
|
||||
$server = Server::ownedByCurrentTeam()->find($server_id);
|
||||
$server = Server::where('team_id', $teamId)->find($server_id);
|
||||
|
||||
// First, mark the deployment as cancelled to prevent further processing
|
||||
$this->application_deployment_queue->update([
|
||||
@@ -110,7 +114,7 @@ class DeploymentNavbar extends Component
|
||||
]);
|
||||
try {
|
||||
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user.', 'stderr');
|
||||
CleanupCancelledDeployment::run($this->application_deployment_queue, currentTeam()->id);
|
||||
CleanupCancelledDeployment::run($this->application_deployment_queue, $teamId);
|
||||
} catch (\Throwable $e) {
|
||||
// Still mark as cancelled even if cleanup fails
|
||||
return handleError($e, $this);
|
||||
|
||||
@@ -923,7 +923,7 @@ class General extends Component
|
||||
$this->application->custom_labels = base64_encode($this->customLabels);
|
||||
$this->application->save();
|
||||
$this->application->refresh();
|
||||
$dnsCleanup->queueReleaseOfRemovedHostnames($this->application, $previousDnsHostnames, currentTeam()->id);
|
||||
$dnsCleanup->queueReleaseOfRemovedHostnames($this->application, $previousDnsHostnames, $this->application->team()->id);
|
||||
$this->syncData();
|
||||
if ($oldPortsExposes !== $this->portsExposes) {
|
||||
$this->dispatch('applicationNetworkingUpdated')->to(InternalAccess::class);
|
||||
|
||||
@@ -503,7 +503,7 @@ class PreviewDomains extends Component
|
||||
'changed_fields' => $changedFields,
|
||||
]);
|
||||
}
|
||||
$dnsCleanup->queueReleaseOfRemovedHostnames($this->preview, $previousDnsHostnames, currentTeam()->id);
|
||||
$dnsCleanup->queueReleaseOfRemovedHostnames($this->preview, $previousDnsHostnames, $this->preview->application->team()->id);
|
||||
$this->persistDnsStatuses();
|
||||
$this->refreshDomains();
|
||||
$this->dispatch('update_links');
|
||||
|
||||
@@ -89,14 +89,14 @@ class Source extends Component
|
||||
|
||||
private function getPrivateKeys()
|
||||
{
|
||||
$this->privateKeys = PrivateKey::whereTeamId(currentTeam()->id)->get()->reject(function ($key) {
|
||||
$this->privateKeys = PrivateKey::where('team_id', $this->application->team()->id)->get()->reject(function ($key) {
|
||||
return $key->id == $this->privateKeyId;
|
||||
});
|
||||
}
|
||||
|
||||
private function getSources()
|
||||
{
|
||||
$this->sources = currentTeam()->sources()->filter(function ($source) {
|
||||
$this->sources = $this->application->team()->sources()->filter(function ($source) {
|
||||
if ($source->id === $this->application->source_id
|
||||
&& $source->getMorphClass() === $this->application->source_type) {
|
||||
return false;
|
||||
@@ -116,7 +116,7 @@ class Source extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId);
|
||||
$key = PrivateKey::where('team_id', $this->application->team()->id)->findOrFail($privateKeyId);
|
||||
$this->privateKeyId = $key->id;
|
||||
$this->syncData(true);
|
||||
$this->getPrivateKeys();
|
||||
@@ -151,7 +151,10 @@ class Source extends Component
|
||||
$this->authorize('update', $this->application);
|
||||
$allowedSourceTypes = [GithubApp::class, GitlabApp::class];
|
||||
abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404);
|
||||
$source = $sourceType::ownedByCurrentTeam()->findOrFail($sourceId);
|
||||
$teamId = $this->application->team()->id;
|
||||
$source = $sourceType::query()
|
||||
->where(fn ($query) => $query->where('team_id', $teamId)->orWhere('is_system_wide', true))
|
||||
->findOrFail($sourceId);
|
||||
$this->application->update([
|
||||
'source_id' => $source->id,
|
||||
'source_type' => $sourceType,
|
||||
|
||||
+315
-288
@@ -10,8 +10,11 @@ use App\Jobs\VolumeCloneJob;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class CloneMe extends Component
|
||||
@@ -59,8 +62,8 @@ class CloneMe extends Component
|
||||
$this->project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
|
||||
$this->environment = $this->project->environments->where('uuid', $this->environment_uuid)->first();
|
||||
$this->project_id = $this->project->id;
|
||||
$this->servers = currentTeam()
|
||||
->servers()
|
||||
$this->servers = Server::query()
|
||||
->where('team_id', $this->project->team_id)
|
||||
->get()
|
||||
->reject(fn ($server) => $server->isBuildServer());
|
||||
$this->newName = str($this->project->name.'-clone-'.new_public_id())->slug();
|
||||
@@ -93,12 +96,16 @@ class CloneMe extends Component
|
||||
public function clone(string $type)
|
||||
{
|
||||
try {
|
||||
$this->authorize('create', Project::class);
|
||||
$this->authorize('update', $this->project);
|
||||
$this->validate([
|
||||
'selectedDestination' => 'required',
|
||||
'newName' => ValidationPatterns::nameRules(),
|
||||
]);
|
||||
$selectedDestination = find_resource_destination_for_current_team($this->selectedDestination);
|
||||
$teamId = $this->project->team_id;
|
||||
if (! $this->environment || (int) $this->environment->project_id !== (int) $this->project->id) {
|
||||
throw new \Exception('Environment not found.');
|
||||
}
|
||||
$selectedDestination = $this->findProjectTeamDestination($this->selectedDestination);
|
||||
if (! $selectedDestination) {
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
@@ -110,94 +117,247 @@ class CloneMe extends Component
|
||||
'new_name' => $this->newName,
|
||||
'destination_uuid' => $selectedDestination->uuid,
|
||||
]);
|
||||
if ($type === 'project') {
|
||||
$foundProject = Project::where('name', $this->newName)->first();
|
||||
if ($foundProject) {
|
||||
throw new \Exception('Project with the same name already exists.');
|
||||
}
|
||||
$project = Project::create([
|
||||
'name' => $this->newName,
|
||||
'team_id' => currentTeam()->id,
|
||||
'description' => $this->project->description.' (clone)',
|
||||
]);
|
||||
if ($this->environment->name !== 'production') {
|
||||
$project->environments()->create([
|
||||
'name' => $this->environment->name,
|
||||
'uuid' => new_public_id(),
|
||||
]);
|
||||
}
|
||||
$environment = $project->environments->where('name', $this->environment->name)->first();
|
||||
} else {
|
||||
$foundEnv = $this->project->environments()->where('name', $this->newName)->first();
|
||||
if ($foundEnv) {
|
||||
throw new \Exception('Environment with the same name already exists.');
|
||||
}
|
||||
$project = $this->project;
|
||||
$environment = $this->project->environments()->create([
|
||||
'name' => $this->newName,
|
||||
[$project, $environment] = DB::transaction(fn () => $this->cloneInto($type, $teamId, $selectedDestination));
|
||||
} catch (\Exception $e) {
|
||||
handleError($e, $this);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return redirect()->route('project.resource.index', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Project, 1: Environment}
|
||||
*/
|
||||
private function cloneInto(string $type, int $teamId, StandaloneDocker|SwarmDocker $selectedDestination): array
|
||||
{
|
||||
if ($type === 'project') {
|
||||
$foundProject = Project::query()->where('team_id', $teamId)->where('name', $this->newName)->first();
|
||||
if ($foundProject) {
|
||||
throw new \Exception('Project with the same name already exists.');
|
||||
}
|
||||
$project = Project::create([
|
||||
'name' => $this->newName,
|
||||
'team_id' => $teamId,
|
||||
'description' => $this->project->description.' (clone)',
|
||||
]);
|
||||
if ($this->environment->name !== 'production') {
|
||||
$project->environments()->create([
|
||||
'name' => $this->environment->name,
|
||||
'uuid' => new_public_id(),
|
||||
]);
|
||||
}
|
||||
$applications = $this->environment->applications;
|
||||
$databases = $this->environment->databases();
|
||||
$services = $this->environment->services;
|
||||
foreach ($applications as $application) {
|
||||
clone_application($application, $selectedDestination, [
|
||||
'environment_id' => $environment->id,
|
||||
], $this->cloneVolumeData);
|
||||
$environment = $project->environments->where('name', $this->environment->name)->first();
|
||||
} else {
|
||||
$foundEnv = $this->project->environments()->where('name', $this->newName)->first();
|
||||
if ($foundEnv) {
|
||||
throw new \Exception('Environment with the same name already exists.');
|
||||
}
|
||||
$project = $this->project;
|
||||
$environment = $this->project->environments()->create([
|
||||
'name' => $this->newName,
|
||||
'uuid' => new_public_id(),
|
||||
]);
|
||||
}
|
||||
$applications = $this->environment->applications;
|
||||
$databases = $this->environment->databases();
|
||||
$services = $this->environment->services;
|
||||
foreach ($applications as $application) {
|
||||
clone_application($application, $selectedDestination, [
|
||||
'environment_id' => $environment->id,
|
||||
], $this->cloneVolumeData);
|
||||
}
|
||||
|
||||
foreach ($databases as $database) {
|
||||
$uuid = new_public_id();
|
||||
$newDatabase = $database->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => $uuid,
|
||||
'status' => 'exited',
|
||||
'started_at' => null,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $selectedDestination->id,
|
||||
'destination_type' => $selectedDestination->getMorphClass(),
|
||||
]);
|
||||
$newDatabase->save();
|
||||
|
||||
$tags = $database->tags;
|
||||
foreach ($tags as $tag) {
|
||||
$newDatabase->tags()->attach($tag->id);
|
||||
}
|
||||
|
||||
foreach ($databases as $database) {
|
||||
$newDatabase->persistentStorages()->delete();
|
||||
$persistentVolumes = $database->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
$originalName = $volume->name;
|
||||
$newName = '';
|
||||
|
||||
if (str_starts_with($originalName, 'postgres-data-')) {
|
||||
$newName = 'postgres-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'mysql-data-')) {
|
||||
$newName = 'mysql-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'redis-data-')) {
|
||||
$newName = 'redis-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'clickhouse-data-')) {
|
||||
$newName = 'clickhouse-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'mariadb-data-')) {
|
||||
$newName = 'mariadb-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'mongodb-data-')) {
|
||||
$newName = 'mongodb-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'keydb-data-')) {
|
||||
$newName = 'keydb-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'dragonfly-data-')) {
|
||||
$newName = 'dragonfly-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'sqlite-data-')) {
|
||||
$newName = 'sqlite-data-'.$newDatabase->uuid;
|
||||
} else {
|
||||
if (str_starts_with($volume->name, $database->uuid)) {
|
||||
$newName = str($volume->name)->replace($database->uuid, $newDatabase->uuid);
|
||||
} else {
|
||||
$newName = $newDatabase->uuid.'-'.$volume->name;
|
||||
}
|
||||
}
|
||||
|
||||
$newPersistentVolume = $volume->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $newDatabase->id,
|
||||
]);
|
||||
$newPersistentVolume->save();
|
||||
|
||||
if ($this->cloneVolumeData) {
|
||||
try {
|
||||
StopDatabase::dispatch($database);
|
||||
$sourceVolume = $volume->name;
|
||||
$targetVolume = $newPersistentVolume->name;
|
||||
$sourceServer = $database->destination->server;
|
||||
$targetServer = $newDatabase->destination->server;
|
||||
|
||||
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
|
||||
|
||||
StartDatabase::dispatch($database);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$fileStorages = $database->fileStorages()->get();
|
||||
foreach ($fileStorages as $storage) {
|
||||
$newStorage = $storage->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resource_id' => $newDatabase->id,
|
||||
]);
|
||||
$newStorage->save();
|
||||
}
|
||||
|
||||
$scheduledBackups = $database->scheduledBackups()->get();
|
||||
foreach ($scheduledBackups as $backup) {
|
||||
$uuid = new_public_id();
|
||||
$newDatabase = $database->replicate([
|
||||
$newBackup = $backup->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => $uuid,
|
||||
'status' => 'exited',
|
||||
'started_at' => null,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $selectedDestination->id,
|
||||
'destination_type' => $selectedDestination->getMorphClass(),
|
||||
'database_id' => $newDatabase->id,
|
||||
'database_type' => $newDatabase->getMorphClass(),
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
$newDatabase->save();
|
||||
$newBackup->save();
|
||||
}
|
||||
|
||||
$tags = $database->tags;
|
||||
foreach ($tags as $tag) {
|
||||
$newDatabase->tags()->attach($tag->id);
|
||||
}
|
||||
$environmentVaribles = $database->environment_variables()->get();
|
||||
foreach ($environmentVaribles as $environmentVarible) {
|
||||
$payload = [];
|
||||
$payload['resourceable_id'] = $newDatabase->id;
|
||||
$payload['resourceable_type'] = $newDatabase->getMorphClass();
|
||||
$newEnvironmentVariable = $environmentVarible->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill($payload);
|
||||
$newEnvironmentVariable->save();
|
||||
}
|
||||
|
||||
$newDatabase->persistentStorages()->delete();
|
||||
$persistentVolumes = $database->persistentStorages()->get();
|
||||
$database->cloneSecretManagerLinkTo($newDatabase);
|
||||
}
|
||||
|
||||
foreach ($services as $service) {
|
||||
$uuid = new_public_id();
|
||||
$newService = $service->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => $uuid,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $selectedDestination->id,
|
||||
'destination_type' => $selectedDestination->getMorphClass(),
|
||||
'server_id' => $selectedDestination->server_id,
|
||||
]);
|
||||
$newService->save();
|
||||
|
||||
$tags = $service->tags;
|
||||
foreach ($tags as $tag) {
|
||||
$newService->tags()->attach($tag->id);
|
||||
}
|
||||
|
||||
$scheduledTasks = $service->scheduled_tasks()->get();
|
||||
foreach ($scheduledTasks as $task) {
|
||||
$newTask = $task->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => new_public_id(),
|
||||
'service_id' => $newService->id,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
$newTask->save();
|
||||
}
|
||||
|
||||
$environmentVariables = $service->environment_variables()->get();
|
||||
foreach ($environmentVariables as $environmentVariable) {
|
||||
$newEnvironmentVariable = $environmentVariable->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resourceable_id' => $newService->id,
|
||||
'resourceable_type' => $newService->getMorphClass(),
|
||||
]);
|
||||
$newEnvironmentVariable->save();
|
||||
}
|
||||
|
||||
$service->cloneSecretManagerLinkTo($newService);
|
||||
|
||||
foreach ($newService->applications() as $application) {
|
||||
$application->fill([
|
||||
'status' => 'exited',
|
||||
])->save();
|
||||
|
||||
$persistentVolumes = $application->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
$originalName = $volume->name;
|
||||
$newName = '';
|
||||
|
||||
if (str_starts_with($originalName, 'postgres-data-')) {
|
||||
$newName = 'postgres-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'mysql-data-')) {
|
||||
$newName = 'mysql-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'redis-data-')) {
|
||||
$newName = 'redis-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'clickhouse-data-')) {
|
||||
$newName = 'clickhouse-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'mariadb-data-')) {
|
||||
$newName = 'mariadb-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'mongodb-data-')) {
|
||||
$newName = 'mongodb-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'keydb-data-')) {
|
||||
$newName = 'keydb-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'dragonfly-data-')) {
|
||||
$newName = 'dragonfly-data-'.$newDatabase->uuid;
|
||||
} elseif (str_starts_with($originalName, 'sqlite-data-')) {
|
||||
$newName = 'sqlite-data-'.$newDatabase->uuid;
|
||||
if (str_starts_with($volume->name, $application->uuid)) {
|
||||
$newName = str($volume->name)->replace($application->uuid, $application->uuid);
|
||||
} else {
|
||||
if (str_starts_with($volume->name, $database->uuid)) {
|
||||
$newName = str($volume->name)->replace($database->uuid, $newDatabase->uuid);
|
||||
} else {
|
||||
$newName = $newDatabase->uuid.'-'.$volume->name;
|
||||
}
|
||||
$newName = $application->uuid.'-'.$volume->name;
|
||||
}
|
||||
|
||||
$newPersistentVolume = $volume->replicate([
|
||||
@@ -207,21 +367,76 @@ class CloneMe extends Component
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $newDatabase->id,
|
||||
'resource_id' => $application->id,
|
||||
]);
|
||||
$newPersistentVolume->save();
|
||||
|
||||
if ($this->cloneVolumeData) {
|
||||
try {
|
||||
StopDatabase::dispatch($database);
|
||||
StopService::dispatch($application);
|
||||
$sourceVolume = $volume->name;
|
||||
$targetVolume = $newPersistentVolume->name;
|
||||
$sourceServer = $database->destination->server;
|
||||
$targetServer = $newDatabase->destination->server;
|
||||
$sourceServer = $application->service->destination->server;
|
||||
$targetServer = $newService->destination->server;
|
||||
|
||||
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
|
||||
|
||||
StartDatabase::dispatch($database);
|
||||
StartService::dispatch($application);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$fileStorages = $application->fileStorages()->get();
|
||||
foreach ($fileStorages as $storage) {
|
||||
$newStorage = $storage->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resource_id' => $application->id,
|
||||
]);
|
||||
$newStorage->save();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($newService->databases() as $database) {
|
||||
$database->fill([
|
||||
'status' => 'exited',
|
||||
])->save();
|
||||
|
||||
$persistentVolumes = $database->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
$newName = '';
|
||||
if (str_starts_with($volume->name, $database->uuid)) {
|
||||
$newName = str($volume->name)->replace($database->uuid, $database->uuid);
|
||||
} else {
|
||||
$newName = $database->uuid.'-'.$volume->name;
|
||||
}
|
||||
|
||||
$newPersistentVolume = $volume->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $database->id,
|
||||
]);
|
||||
$newPersistentVolume->save();
|
||||
|
||||
if ($this->cloneVolumeData) {
|
||||
try {
|
||||
StopService::dispatch($database->service);
|
||||
$sourceVolume = $volume->name;
|
||||
$targetVolume = $newPersistentVolume->name;
|
||||
$sourceServer = $database->service->destination->server;
|
||||
$targetServer = $newService->destination->server;
|
||||
|
||||
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
|
||||
|
||||
StartService::dispatch($database->service);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
}
|
||||
@@ -235,7 +450,7 @@ class CloneMe extends Component
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resource_id' => $newDatabase->id,
|
||||
'resource_id' => $database->id,
|
||||
]);
|
||||
$newStorage->save();
|
||||
}
|
||||
@@ -249,218 +464,30 @@ class CloneMe extends Component
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => $uuid,
|
||||
'database_id' => $newDatabase->id,
|
||||
'database_type' => $newDatabase->getMorphClass(),
|
||||
'team_id' => currentTeam()->id,
|
||||
'database_id' => $database->id,
|
||||
'database_type' => $database->getMorphClass(),
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
$newBackup->save();
|
||||
}
|
||||
|
||||
$environmentVaribles = $database->environment_variables()->get();
|
||||
foreach ($environmentVaribles as $environmentVarible) {
|
||||
$payload = [];
|
||||
$payload['resourceable_id'] = $newDatabase->id;
|
||||
$payload['resourceable_type'] = $newDatabase->getMorphClass();
|
||||
$newEnvironmentVariable = $environmentVarible->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill($payload);
|
||||
$newEnvironmentVariable->save();
|
||||
}
|
||||
|
||||
$database->cloneSecretManagerLinkTo($newDatabase);
|
||||
}
|
||||
|
||||
foreach ($services as $service) {
|
||||
$uuid = new_public_id();
|
||||
$newService = $service->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => $uuid,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $selectedDestination->id,
|
||||
'destination_type' => $selectedDestination->getMorphClass(),
|
||||
'server_id' => $selectedDestination->server_id,
|
||||
]);
|
||||
$newService->save();
|
||||
|
||||
$tags = $service->tags;
|
||||
foreach ($tags as $tag) {
|
||||
$newService->tags()->attach($tag->id);
|
||||
}
|
||||
|
||||
$scheduledTasks = $service->scheduled_tasks()->get();
|
||||
foreach ($scheduledTasks as $task) {
|
||||
$newTask = $task->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => new_public_id(),
|
||||
'service_id' => $newService->id,
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
$newTask->save();
|
||||
}
|
||||
|
||||
$environmentVariables = $service->environment_variables()->get();
|
||||
foreach ($environmentVariables as $environmentVariable) {
|
||||
$newEnvironmentVariable = $environmentVariable->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resourceable_id' => $newService->id,
|
||||
'resourceable_type' => $newService->getMorphClass(),
|
||||
]);
|
||||
$newEnvironmentVariable->save();
|
||||
}
|
||||
|
||||
$service->cloneSecretManagerLinkTo($newService);
|
||||
|
||||
foreach ($newService->applications() as $application) {
|
||||
$application->fill([
|
||||
'status' => 'exited',
|
||||
])->save();
|
||||
|
||||
$persistentVolumes = $application->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
$newName = '';
|
||||
if (str_starts_with($volume->name, $application->uuid)) {
|
||||
$newName = str($volume->name)->replace($application->uuid, $application->uuid);
|
||||
} else {
|
||||
$newName = $application->uuid.'-'.$volume->name;
|
||||
}
|
||||
|
||||
$newPersistentVolume = $volume->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $application->id,
|
||||
]);
|
||||
$newPersistentVolume->save();
|
||||
|
||||
if ($this->cloneVolumeData) {
|
||||
try {
|
||||
StopService::dispatch($application);
|
||||
$sourceVolume = $volume->name;
|
||||
$targetVolume = $newPersistentVolume->name;
|
||||
$sourceServer = $application->service->destination->server;
|
||||
$targetServer = $newService->destination->server;
|
||||
|
||||
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
|
||||
|
||||
StartService::dispatch($application);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$fileStorages = $application->fileStorages()->get();
|
||||
foreach ($fileStorages as $storage) {
|
||||
$newStorage = $storage->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resource_id' => $application->id,
|
||||
]);
|
||||
$newStorage->save();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($newService->databases() as $database) {
|
||||
$database->fill([
|
||||
'status' => 'exited',
|
||||
])->save();
|
||||
|
||||
$persistentVolumes = $database->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
$newName = '';
|
||||
if (str_starts_with($volume->name, $database->uuid)) {
|
||||
$newName = str($volume->name)->replace($database->uuid, $database->uuid);
|
||||
} else {
|
||||
$newName = $database->uuid.'-'.$volume->name;
|
||||
}
|
||||
|
||||
$newPersistentVolume = $volume->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $database->id,
|
||||
]);
|
||||
$newPersistentVolume->save();
|
||||
|
||||
if ($this->cloneVolumeData) {
|
||||
try {
|
||||
StopService::dispatch($database->service);
|
||||
$sourceVolume = $volume->name;
|
||||
$targetVolume = $newPersistentVolume->name;
|
||||
$sourceServer = $database->service->destination->server;
|
||||
$targetServer = $newService->destination->server;
|
||||
|
||||
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
|
||||
|
||||
StartService::dispatch($database->service);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$fileStorages = $database->fileStorages()->get();
|
||||
foreach ($fileStorages as $storage) {
|
||||
$newStorage = $storage->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'resource_id' => $database->id,
|
||||
]);
|
||||
$newStorage->save();
|
||||
}
|
||||
|
||||
$scheduledBackups = $database->scheduledBackups()->get();
|
||||
foreach ($scheduledBackups as $backup) {
|
||||
$uuid = new_public_id();
|
||||
$newBackup = $backup->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => $uuid,
|
||||
'database_id' => $database->id,
|
||||
'database_type' => $database->getMorphClass(),
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
$newBackup->save();
|
||||
}
|
||||
}
|
||||
|
||||
$newService->parse();
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
handleError($e, $this);
|
||||
|
||||
return;
|
||||
} finally {
|
||||
if (! isset($e)) {
|
||||
return redirect()->route('project.resource.index', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
]);
|
||||
}
|
||||
$newService->parse();
|
||||
}
|
||||
|
||||
return [$project, $environment];
|
||||
}
|
||||
|
||||
private function findProjectTeamDestination(?string $uuid): StandaloneDocker|SwarmDocker|null
|
||||
{
|
||||
if (blank($uuid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$teamId = $this->project->team_id;
|
||||
$destination = StandaloneDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first()
|
||||
?? SwarmDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first();
|
||||
|
||||
return $destination?->server?->canHostResources() ? $destination : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Project\Database\Backup;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -43,7 +44,10 @@ class Execution extends Component
|
||||
$this->database = $database;
|
||||
$this->backup = $backup;
|
||||
$this->executions = $executions;
|
||||
$this->s3s = currentTeam()->s3s;
|
||||
$this->s3s = S3Storage::query()
|
||||
->where('team_id', $database->team()->id)
|
||||
->where('is_usable', true)
|
||||
->get();
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->section = match (request()->route()?->getName()) {
|
||||
'project.database.backup.s3' => 's3',
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Jobs\DatabaseBackupJob;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
@@ -400,22 +401,16 @@ class BackupEdit extends Component
|
||||
|
||||
private function availableS3StorageIds(): Collection
|
||||
{
|
||||
$storages = collect($this->availableS3Storages);
|
||||
$storageIds = $storages->pluck('id')->filter()->all();
|
||||
$database = $this->backup->database;
|
||||
// The instance database (id 0) has no project and belongs to the root team.
|
||||
$teamId = $database instanceof StandalonePostgresql && $database->id === 0 ? 0 : $database?->team()?->id;
|
||||
|
||||
if (empty($storageIds)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$teamIds = $storages->pluck('team_id')->reject(fn ($teamId) => $teamId === null)->unique()->values()->all();
|
||||
|
||||
if (empty($teamIds)) {
|
||||
if ($teamId === null) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return S3Storage::query()
|
||||
->whereKey($storageIds)
|
||||
->whereIn('team_id', $teamIds)
|
||||
->where('team_id', $teamId)
|
||||
->where('is_usable', true)
|
||||
->pluck('id');
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Support\ValidationPatterns;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class General extends Component
|
||||
@@ -45,6 +46,10 @@ class General extends Component
|
||||
|
||||
public bool $isPasswordHiddenForMember = false;
|
||||
|
||||
/** Unnamed Docker volume that holds the current data; shown as a warning. */
|
||||
#[Locked]
|
||||
public ?string $anonymousDataVolume = null;
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
return $this->teamChannelListeners([
|
||||
@@ -63,6 +68,7 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
$this->anonymousDataVolume = $this->database->anonymousDataVolume();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -197,6 +203,27 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function keepCurrentDataVolume(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
$volumeName = $this->database->anonymousDataVolume();
|
||||
if ($volumeName === null) {
|
||||
$this->anonymousDataVolume = null;
|
||||
$this->dispatch('info', 'This database already uses its data volume.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->database->keepAnonymousDataVolume($volumeName);
|
||||
$this->anonymousDataVolume = null;
|
||||
$this->dispatch('success', 'The current data volume is now the data volume of this database. You can restart it safely.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function databaseProxyStopped(): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
|
||||
@@ -122,12 +122,14 @@ class BackupExecutions extends Component
|
||||
->whereIn('id', $rows->where('type', 'storage')->pluck('id'))
|
||||
->get()->keyBy('id');
|
||||
|
||||
return $rows->map(function (object $row) use ($databaseExecutions, $volumeExecutions): array {
|
||||
$serviceTeamId = $this->service->team()?->id;
|
||||
|
||||
return $rows->map(function (object $row) use ($databaseExecutions, $volumeExecutions, $serviceTeamId): array {
|
||||
$isDatabase = $row->type === 'database';
|
||||
$execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id);
|
||||
$schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup;
|
||||
$storage = $isDatabase ? ($schedule->save_s3 ? $schedule->s3 : null) : $execution->s3;
|
||||
if ($storage?->team_id !== currentTeam()->id) {
|
||||
if ($storage?->team_id !== $serviceTeamId) {
|
||||
$storage = null;
|
||||
}
|
||||
$storageLabel = $storage ? $storage->name.' (bucket: '.$storage->bucket.')' : 'Unavailable';
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
@@ -77,7 +78,10 @@ class DatabaseBackups extends Component
|
||||
$this->backup = $this->serviceDatabase->scheduledBackups()
|
||||
->where('uuid', request()->route('backup_uuid'))
|
||||
->firstOrFail();
|
||||
$this->s3s = currentTeam()->s3s;
|
||||
$this->s3s = S3Storage::query()
|
||||
->where('team_id', $this->service->team()->id)
|
||||
->where('is_usable', true)
|
||||
->get();
|
||||
$this->backupParameters = [...$this->parameters, 'backup_uuid' => $this->backup->uuid];
|
||||
$this->section = match (request()->route()?->getName()) {
|
||||
'project.service.database.backup.s3' => 's3',
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service;
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Actions\Service\DeleteService;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
@@ -128,7 +129,7 @@ class Index extends Component
|
||||
$this->serviceApplication = null;
|
||||
$this->resourceType = 'database';
|
||||
$this->initializeDatabaseProperties();
|
||||
$this->s3s = currentTeam()->s3s;
|
||||
$this->s3s = $this->serviceTeamS3Storages();
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -146,7 +147,7 @@ class Index extends Component
|
||||
$this->serviceApplication = $serviceApplication;
|
||||
$this->resourceType = 'application';
|
||||
$this->initializeApplicationProperties();
|
||||
$this->s3s = currentTeam()->s3s;
|
||||
$this->s3s = $this->serviceTeamS3Storages();
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -189,12 +190,20 @@ class Index extends Component
|
||||
$this->serviceDatabase->getFilesFromServer();
|
||||
$this->initializeDatabaseProperties();
|
||||
}
|
||||
$this->s3s = currentTeam()->s3s;
|
||||
$this->s3s = $this->serviceTeamS3Storages();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function serviceTeamS3Storages(): Collection
|
||||
{
|
||||
return S3Storage::query()
|
||||
->where('team_id', $this->service->team()->id)
|
||||
->where('is_usable', true)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function initializeDatabaseProperties(): void
|
||||
{
|
||||
$this->server = $this->serviceDatabase->service->destination->server;
|
||||
@@ -661,7 +670,7 @@ class Index extends Component
|
||||
$this->serviceApplication->save();
|
||||
$this->auditServiceSubResourceUpdate($this->serviceApplication, $changedFields);
|
||||
$this->serviceApplication->refresh();
|
||||
app(ManagedDnsRecordCleanup::class)->queueReleaseOfRemovedHostnames($this->serviceApplication, $previousDnsHostnames, currentTeam()->id);
|
||||
app(ManagedDnsRecordCleanup::class)->queueReleaseOfRemovedHostnames($this->serviceApplication, $previousDnsHostnames, $this->service->team()->id);
|
||||
$this->syncApplicationData(false);
|
||||
updateCompose($this->serviceApplication);
|
||||
if (str($this->serviceApplication->fqdn)->contains(',')) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Service\VolumeBackup;
|
||||
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Jobs\VolumeBackupJob;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Service;
|
||||
@@ -65,7 +66,10 @@ class Index extends Component
|
||||
{
|
||||
$this->authorize('update', $this->service);
|
||||
$this->loadSelectedSchedule($backupUuid);
|
||||
$this->s3s = currentTeam()->s3s;
|
||||
$this->s3s = S3Storage::query()
|
||||
->where('team_id', $this->service->team()?->id)
|
||||
->where('is_usable', true)
|
||||
->get();
|
||||
$this->scheduleModalOpen = true;
|
||||
}
|
||||
|
||||
@@ -132,6 +136,7 @@ class Index extends Component
|
||||
'backups' => $backups,
|
||||
'databaseBackups' => $databaseBackups,
|
||||
'databaseTargets' => $databaseTargets,
|
||||
'serviceTeamId' => $this->service->team()?->id,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,8 @@ class ConfigurationChecker extends Component
|
||||
$array = $diff->toArray();
|
||||
|
||||
// Fail closed: only owners/admins may see unlocked env values.
|
||||
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
|
||||
$teamId = $this->resource->team()?->id;
|
||||
$redactEnvironment = is_null($teamId) || ! (bool) auth()->user()?->isAdminOfTeam($teamId);
|
||||
$array['changes'] = $this->redactHiddenChanges($array['changes'] ?? [], $redactEnvironment);
|
||||
$this->configurationDiff = $array;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
@@ -45,9 +46,16 @@ class Destination extends Component
|
||||
$all_networks = $all_networks->push($this->resource->destination);
|
||||
$all_networks = $all_networks->merge($this->resource->additional_networks);
|
||||
|
||||
$this->networks = Server::isUsable()->get()->map(function ($server) {
|
||||
return $server->standaloneDockers;
|
||||
})->flatten();
|
||||
$this->networks = $this->resourceServers()
|
||||
->whereRelation('settings', 'is_reachable', true)
|
||||
->whereRelation('settings', 'is_usable', true)
|
||||
->whereRelation('settings', 'is_swarm_worker', false)
|
||||
->whereRelation('settings', 'force_disabled', false)
|
||||
->get()
|
||||
->filter(fn (Server $server) => $server->canHostResources())
|
||||
->map(function ($server) {
|
||||
return $server->standaloneDockers;
|
||||
})->flatten();
|
||||
$this->networks = $this->networks->reject(function ($network) use ($all_networks) {
|
||||
return $all_networks->pluck('id')->contains($network->id);
|
||||
});
|
||||
@@ -61,11 +69,19 @@ class Destination extends Component
|
||||
}
|
||||
}
|
||||
|
||||
private function resourceServers(): Builder
|
||||
{
|
||||
return Server::query()
|
||||
->where('team_id', $this->resource->team()->id)
|
||||
->with('settings', 'standaloneDockers')
|
||||
->orderBy('name');
|
||||
}
|
||||
|
||||
public function stop($serverId)
|
||||
{
|
||||
try {
|
||||
$this->authorize('deploy', $this->resource);
|
||||
$server = Server::ownedByCurrentTeam()->findOrFail($serverId);
|
||||
$server = $this->resourceServers()->findOrFail($serverId);
|
||||
StopApplicationOneServer::run($this->resource, $server);
|
||||
auditLog('ui.application.destination_stopped', [
|
||||
'team_id' => $this->resource->team()?->id,
|
||||
@@ -90,7 +106,7 @@ class Destination extends Component
|
||||
return;
|
||||
}
|
||||
$deployment_uuid = new_public_id();
|
||||
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
|
||||
$server = $this->resourceServers()->findOrFail($server_id);
|
||||
$destination = $server->standaloneDockers->where('id', $network_id)->firstOrFail();
|
||||
$result = queue_application_deployment(
|
||||
deployment_uuid: $deployment_uuid,
|
||||
@@ -125,8 +141,8 @@ class Destination extends Component
|
||||
public function promote(int $network_id, int $server_id)
|
||||
{
|
||||
try {
|
||||
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
|
||||
$network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
|
||||
$server = $this->resourceServers()->findOrFail($server_id);
|
||||
$network = StandaloneDocker::query()->where('server_id', $server->id)->findOrFail($network_id);
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->resource->getConnection()->transaction(function () use ($network, $server) {
|
||||
$mainDestination = $this->resource->destination;
|
||||
@@ -156,8 +172,8 @@ class Destination extends Component
|
||||
public function addServer(int $network_id, int $server_id)
|
||||
{
|
||||
try {
|
||||
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
|
||||
$network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
|
||||
$server = $this->resourceServers()->findOrFail($server_id);
|
||||
$network = StandaloneDocker::query()->where('server_id', $server->id)->findOrFail($network_id);
|
||||
$this->authorize('update', $this->resource);
|
||||
$reason = $this->resource instanceof Application
|
||||
? $this->resource->additionalServersUnavailableReason($server)
|
||||
@@ -190,7 +206,7 @@ class Destination extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
|
||||
$server = $this->resourceServers()->findOrFail($server_id);
|
||||
StopApplicationOneServer::run($this->resource, $server);
|
||||
$this->resource->additional_networks()
|
||||
->wherePivot('server_id', $server_id)
|
||||
|
||||
@@ -92,7 +92,7 @@ class Add extends Component
|
||||
#[Computed]
|
||||
public function availableSharedVariables(): array
|
||||
{
|
||||
$team = currentTeam();
|
||||
$team = $this->resource ? $this->resource->team() : currentTeam();
|
||||
$result = [
|
||||
'team' => [],
|
||||
'project' => [],
|
||||
|
||||
@@ -219,12 +219,17 @@ class All extends Component
|
||||
return $this->environmentVariableRowCount > 0;
|
||||
}
|
||||
|
||||
private function canViewEnvironmentValues(): bool
|
||||
{
|
||||
return auth()->user()?->can('manageEnvironment', $this->resource) ?? false;
|
||||
}
|
||||
|
||||
private function nullLockedValues($envs)
|
||||
{
|
||||
$isMember = auth()->user()?->isMember();
|
||||
$hideValues = ! $this->canViewEnvironmentValues();
|
||||
|
||||
$envs->each(function ($env) use ($isMember) {
|
||||
if ($env->is_shown_once || $isMember) {
|
||||
$envs->each(function ($env) use ($hideValues) {
|
||||
if ($env->is_shown_once || $hideValues) {
|
||||
$env->value = null;
|
||||
$env->real_value = null;
|
||||
}
|
||||
@@ -828,12 +833,12 @@ class All extends Component
|
||||
|
||||
private function formatEnvironmentVariables($variables)
|
||||
{
|
||||
$isMember = auth()->user()?->isMember();
|
||||
$hideValues = ! $this->canViewEnvironmentValues();
|
||||
|
||||
return $variables
|
||||
->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key))
|
||||
->map(function ($item) use ($isMember) {
|
||||
if ($isMember) {
|
||||
->map(function ($item) use ($hideValues) {
|
||||
if ($hideValues) {
|
||||
return "$item->key=(Hidden, only admins can view)";
|
||||
}
|
||||
if ($item->is_shown_once) {
|
||||
@@ -849,6 +854,7 @@ class All extends Component
|
||||
|
||||
public function switch()
|
||||
{
|
||||
$this->authorize('view', $this->resource);
|
||||
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
|
||||
if ($this->view === 'dev') {
|
||||
$this->ensureEnvironmentVariablesLoaded();
|
||||
|
||||
@@ -173,7 +173,7 @@ class Show extends Component
|
||||
|
||||
public function copyValue(): ?string
|
||||
{
|
||||
if ($this->env->is_shown_once || (auth()->user()?->cannot('update', $this->env) ?? true)) {
|
||||
if ($this->env->is_shown_once || $this->valuesHiddenForUser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ class Show extends Component
|
||||
$this->is_required = (bool) ($this->env->is_required ?? false);
|
||||
// Use the stored column, not the value-based accessor (that decrypts).
|
||||
$this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false);
|
||||
$this->isValueHidden = auth()->user()?->isMember() ?? true;
|
||||
$this->isValueHidden = $this->valuesHiddenForUser();
|
||||
|
||||
if ($this->valuesLoaded) {
|
||||
$this->hydrateValueFields();
|
||||
@@ -260,12 +260,17 @@ class Show extends Component
|
||||
$this->is_really_required = $this->is_required && blank($this->value);
|
||||
}
|
||||
|
||||
if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
|
||||
if ($this->env->is_shown_once || $this->valuesHiddenForUser()) {
|
||||
$this->value = null;
|
||||
$this->real_value = null;
|
||||
}
|
||||
|
||||
$this->isValueHidden = auth()->user()?->isMember() ?? true;
|
||||
$this->isValueHidden = $this->valuesHiddenForUser();
|
||||
}
|
||||
|
||||
private function valuesHiddenForUser(): bool
|
||||
{
|
||||
return auth()->user()?->cannot('update', $this->env) ?? true;
|
||||
}
|
||||
|
||||
public function checkEnvs()
|
||||
@@ -345,7 +350,7 @@ class Show extends Component
|
||||
// Shared across all Show row components in the same request (edit modals).
|
||||
static $requestCache = [];
|
||||
|
||||
$team = currentTeam();
|
||||
$team = $this->isSharedVariable ? $this->env->team : $this->env->resourceable?->team();
|
||||
$cacheKey = implode('|', [
|
||||
$team?->id ?? 'none',
|
||||
data_get($this->parameters, 'project_uuid', ''),
|
||||
|
||||
@@ -11,7 +11,9 @@ use App\Jobs\VolumeCloneJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
@@ -20,9 +22,11 @@ use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\StandaloneSqlite;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
use RuntimeException;
|
||||
|
||||
class ResourceOperations extends Component
|
||||
{
|
||||
@@ -49,8 +53,9 @@ class ResourceOperations extends Component
|
||||
$parameters = get_route_parameters();
|
||||
$this->projectUuid = data_get($parameters, 'project_uuid');
|
||||
$this->environmentUuid = data_get($parameters, 'environment_uuid');
|
||||
$this->projects = Project::ownedByCurrentTeamCached();
|
||||
$servers = currentTeam()->servers()->get();
|
||||
$teamId = $this->resourceTeamId();
|
||||
$this->projects = Project::query()->where('team_id', $teamId)->orderByRaw('LOWER(name)')->get();
|
||||
$servers = Server::query()->where('team_id', $teamId)->get();
|
||||
$this->servers = $servers->reject(fn ($server) => $server->isBuildServer());
|
||||
$this->buildServers = $servers->filter(fn ($server) => $server->isBuildServer());
|
||||
}
|
||||
@@ -72,13 +77,13 @@ class ResourceOperations extends Component
|
||||
|
||||
$new_environment = $this->resource->environment;
|
||||
if ($environment_id !== null) {
|
||||
$new_environment = Environment::ownedByCurrentTeam()->find($environment_id);
|
||||
$new_environment = $this->findResourceTeamEnvironment($environment_id);
|
||||
if (! $new_environment) {
|
||||
return $this->addError('environment_id', 'Environment not found.');
|
||||
}
|
||||
}
|
||||
|
||||
$new_destination = find_resource_destination_for_current_team($destination_uuid);
|
||||
$new_destination = $this->findResourceTeamDestination($destination_uuid);
|
||||
if (! $new_destination) {
|
||||
return $this->addError('destination_id', 'Destination not found.');
|
||||
}
|
||||
@@ -224,7 +229,7 @@ class ResourceOperations extends Component
|
||||
'uuid' => $uuid,
|
||||
'database_id' => $new_resource->id,
|
||||
'database_type' => $new_resource->getMorphClass(),
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->resourceTeamId(),
|
||||
]);
|
||||
$newBackup->save();
|
||||
}
|
||||
@@ -283,7 +288,7 @@ class ResourceOperations extends Component
|
||||
])->fill([
|
||||
'uuid' => new_public_id(),
|
||||
'service_id' => $new_resource->id,
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->resourceTeamId(),
|
||||
]);
|
||||
$newTask->save();
|
||||
}
|
||||
@@ -408,9 +413,12 @@ class ResourceOperations extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id);
|
||||
$new_environment = $this->findResourceTeamEnvironment($environment_id);
|
||||
if (! $new_environment) {
|
||||
return $this->addError('environment_id', 'Environment not found.');
|
||||
}
|
||||
$this->resource->fill([
|
||||
'environment_id' => $environment_id,
|
||||
'environment_id' => $new_environment->id,
|
||||
])->save();
|
||||
if ($this->resource->type() === 'application') {
|
||||
$route = route('project.application.configuration', [
|
||||
@@ -447,7 +455,7 @@ class ResourceOperations extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$new_destination = find_resource_destination_for_current_team($destination_uuid);
|
||||
$new_destination = $this->findResourceTeamDestination($destination_uuid);
|
||||
if (! $new_destination) {
|
||||
return $this->addError('destination_id', 'Destination not found.');
|
||||
}
|
||||
@@ -500,6 +508,32 @@ class ResourceOperations extends Component
|
||||
}
|
||||
}
|
||||
|
||||
private function resourceTeamId(): int
|
||||
{
|
||||
return $this->resource->team()?->id ?? throw new RuntimeException('The resource has no team.');
|
||||
}
|
||||
|
||||
/** Cloning and moving stay inside the resource team; the session team can differ after a team switch. */
|
||||
private function findResourceTeamEnvironment(int|string|null $environmentId): ?Environment
|
||||
{
|
||||
return Environment::query()
|
||||
->whereRelation('project', 'team_id', $this->resourceTeamId())
|
||||
->find($environmentId);
|
||||
}
|
||||
|
||||
private function findResourceTeamDestination(?string $uuid): StandaloneDocker|SwarmDocker|null
|
||||
{
|
||||
if (blank($uuid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$teamId = $this->resourceTeamId();
|
||||
$destination = StandaloneDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first()
|
||||
?? SwarmDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first();
|
||||
|
||||
return $destination?->server?->canHostResources() ? $destination : null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.resource-operations');
|
||||
|
||||
@@ -111,7 +111,7 @@ class Add extends Component
|
||||
$task->frequency = $this->frequency;
|
||||
$task->container = $this->container;
|
||||
$task->timeout = $this->timeout;
|
||||
$task->team_id = currentTeam()->id;
|
||||
$task->team_id = $this->resource->team()->id;
|
||||
|
||||
switch ($this->type) {
|
||||
case 'application':
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
@@ -28,7 +29,7 @@ class Tags extends Component
|
||||
|
||||
public function loadTags()
|
||||
{
|
||||
$this->tags = Tag::ownedByCurrentTeam()->get();
|
||||
$this->tags = $this->resourceTags()->get();
|
||||
$this->filteredTags = $this->tags->filter(function ($tag) {
|
||||
return ! $this->resource->tags->contains($tag);
|
||||
});
|
||||
@@ -52,13 +53,11 @@ class Tags extends Component
|
||||
|
||||
continue;
|
||||
}
|
||||
$found = Tag::ownedByCurrentTeam()->where(['name' => $tag])->exists();
|
||||
if (! $found) {
|
||||
$found = Tag::create([
|
||||
$found = $this->resourceTags()->where('name', $tag)->first()
|
||||
?? Tag::create([
|
||||
'name' => $tag,
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->resource->team()->id,
|
||||
]);
|
||||
}
|
||||
$this->resource->tags()->attach($found->id);
|
||||
}
|
||||
$this->refresh();
|
||||
@@ -71,7 +70,7 @@ class Tags extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$tag = Tag::ownedByCurrentTeam()->findOrFail($id);
|
||||
$tag = $this->resourceTags()->findOrFail($id);
|
||||
if ($this->resource->tags()->whereKey($tag->id)->exists()) {
|
||||
$this->dispatch('error', 'Duplicate tags.', 'Tag <span class=\'dark:text-warning\'>'.e($tag->name).'</span> already added.');
|
||||
|
||||
@@ -90,7 +89,7 @@ class Tags extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->resource->tags()->detach($id);
|
||||
$found_more_tags = Tag::ownedByCurrentTeam()->find($id);
|
||||
$found_more_tags = $this->resourceTags()->find($id);
|
||||
$found_more_tags?->deleteIfOrphaned();
|
||||
$this->refresh();
|
||||
$this->dispatch('success', 'Tag deleted.');
|
||||
@@ -99,6 +98,11 @@ class Tags extends Component
|
||||
}
|
||||
}
|
||||
|
||||
private function resourceTags(): Builder
|
||||
{
|
||||
return Tag::query()->where('team_id', $this->resource->team()?->id)->orderBy('name');
|
||||
}
|
||||
|
||||
public function refresh()
|
||||
{
|
||||
$this->resource->refresh(); // Remove this when legacy_model_binding is false
|
||||
|
||||
@@ -63,7 +63,7 @@ class Show extends Component
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('create', Environment::class);
|
||||
$this->authorize('update', $this->project);
|
||||
$this->validate();
|
||||
$environment = Environment::create([
|
||||
'name' => $this->name,
|
||||
|
||||
@@ -44,7 +44,7 @@ class Show extends Component
|
||||
$this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner';
|
||||
$this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner';
|
||||
|
||||
$this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
$this->cloudProviderTokens = CloudProviderToken::where('team_id', $this->server->team_id)
|
||||
->where('provider', $this->provider)
|
||||
->get();
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class Show extends Component
|
||||
|
||||
public function setCloudProviderToken($tokenId)
|
||||
{
|
||||
$ownedToken = CloudProviderToken::ownedByCurrentTeam()->find($tokenId);
|
||||
$ownedToken = CloudProviderToken::where('team_id', $this->server->team_id)->find($tokenId);
|
||||
if (is_null($ownedToken)) {
|
||||
$this->dispatch('error', 'You are not allowed to use this token.');
|
||||
|
||||
@@ -77,7 +77,7 @@ class Show extends Component
|
||||
$this->server->save();
|
||||
|
||||
auditLog('ui.server.cloud_token_assigned', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->server->team_id,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'server_name' => $this->server->name,
|
||||
'cloud_token_uuid' => $ownedToken->uuid,
|
||||
@@ -171,7 +171,7 @@ class Show extends Component
|
||||
}
|
||||
|
||||
auditLog('ui.server.cloud_token_validated', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->server->team_id,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'server_name' => $this->server->name,
|
||||
'cloud_token_uuid' => $token->uuid,
|
||||
|
||||
@@ -29,8 +29,8 @@ class Destinations extends Component
|
||||
|
||||
public function add($name)
|
||||
{
|
||||
$this->authorize('update', $this->server);
|
||||
if ($this->server->isSwarm()) {
|
||||
$this->authorize('create', SwarmDocker::class);
|
||||
$found = $this->server->swarmDockers()->where('network', $name)->first();
|
||||
if ($found) {
|
||||
$this->dispatch('error', 'Network already added to this server.');
|
||||
@@ -45,7 +45,6 @@ class Destinations extends Component
|
||||
$this->auditDestinationCreated($destination, 'swarm');
|
||||
}
|
||||
} else {
|
||||
$this->authorize('create', StandaloneDocker::class);
|
||||
$found = $this->server->standaloneDockers()->where('network', $name)->first();
|
||||
if ($found) {
|
||||
$this->dispatch('error', 'Network already added to this server.');
|
||||
|
||||
@@ -22,15 +22,20 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->privateKeys = PrivateKey::ownedByCurrentTeam()->get()->where('is_git_related', false);
|
||||
$this->loadPrivateKeys();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function loadPrivateKeys(): void
|
||||
{
|
||||
$this->privateKeys = PrivateKey::where('team_id', $this->server->team_id)->get()->where('is_git_related', false);
|
||||
}
|
||||
|
||||
public function setPrivateKey($privateKeyId)
|
||||
{
|
||||
$ownedPrivateKey = PrivateKey::ownedByCurrentTeam()->find($privateKeyId);
|
||||
$ownedPrivateKey = PrivateKey::where('team_id', $this->server->team_id)->find($privateKeyId);
|
||||
if (is_null($ownedPrivateKey)) {
|
||||
$this->dispatch('error', 'You are not allowed to use this private key.');
|
||||
|
||||
@@ -59,6 +64,7 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('create', PrivateKey::class);
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
if (! in_array($type, ['ed25519', 'rsa'], true)) {
|
||||
$this->dispatch('error', 'Invalid private key type.');
|
||||
@@ -71,10 +77,10 @@ class Show extends Component
|
||||
'name' => $keyData['name'],
|
||||
'description' => $keyData['description'],
|
||||
'private_key' => $keyData['private_key'],
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->server->team_id,
|
||||
]);
|
||||
|
||||
$this->privateKeys = PrivateKey::ownedByCurrentTeam()->get()->where('is_git_related', false);
|
||||
$this->loadPrivateKeys();
|
||||
$this->dispatch('copyPublicKeyToClipboard', publicKey: $privateKey->public_key);
|
||||
$this->dispatch('success', 'Private key created successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -784,21 +784,21 @@ class Show extends Component
|
||||
|
||||
public function loadHetznerTokens(): void
|
||||
{
|
||||
$this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
$this->availableHetznerTokens = CloudProviderToken::where('team_id', $this->server->team_id)
|
||||
->where('provider', 'hetzner')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function loadVultrTokens(): void
|
||||
{
|
||||
$this->availableVultrTokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
$this->availableVultrTokens = CloudProviderToken::where('team_id', $this->server->team_id)
|
||||
->where('provider', 'vultr')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function loadDigitalOceanTokens(): void
|
||||
{
|
||||
$this->availableDigitalOceanTokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
$this->availableDigitalOceanTokens = CloudProviderToken::where('team_id', $this->server->team_id)
|
||||
->where('provider', 'digitalocean')
|
||||
->get();
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class Show extends Component
|
||||
'is_literal' => $data['is_literal'],
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'type' => 'environment',
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->project->team_id,
|
||||
]);
|
||||
$this->environment->refresh();
|
||||
$this->getDevView();
|
||||
@@ -76,10 +76,10 @@ class Show extends Component
|
||||
|
||||
private function formatEnvironmentVariables($variables)
|
||||
{
|
||||
$isMember = auth()->user()?->isMember();
|
||||
$canViewValues = auth()->user()?->isAdminOfTeam($this->project->team_id) ?? false;
|
||||
|
||||
return $variables->map(function ($item) use ($isMember) {
|
||||
if ($isMember) {
|
||||
return $variables->map(function ($item) use ($canViewValues) {
|
||||
if (! $canViewValues) {
|
||||
return "$item->key=(Hidden, only admins can view)";
|
||||
}
|
||||
if ($item->is_shown_once) {
|
||||
@@ -167,7 +167,7 @@ class Show extends Component
|
||||
'is_multiline' => false,
|
||||
'is_literal' => false,
|
||||
'type' => 'environment',
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->project->team_id,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class Show extends Component
|
||||
'is_literal' => $data['is_literal'],
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'type' => 'project',
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->project->team_id,
|
||||
]);
|
||||
$this->project->refresh();
|
||||
$this->getDevView();
|
||||
@@ -74,10 +74,10 @@ class Show extends Component
|
||||
|
||||
private function formatEnvironmentVariables($variables)
|
||||
{
|
||||
$isMember = auth()->user()?->isMember();
|
||||
$canViewValues = auth()->user()?->isAdminOfTeam($this->project->team_id) ?? false;
|
||||
|
||||
return $variables->map(function ($item) use ($isMember) {
|
||||
if ($isMember) {
|
||||
return $variables->map(function ($item) use ($canViewValues) {
|
||||
if (! $canViewValues) {
|
||||
return "$item->key=(Hidden, only admins can view)";
|
||||
}
|
||||
if ($item->is_shown_once) {
|
||||
@@ -159,7 +159,7 @@ class Show extends Component
|
||||
'is_multiline' => false,
|
||||
'is_literal' => false,
|
||||
'type' => 'project',
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->project->team_id,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class Show extends Component
|
||||
'is_literal' => $data['is_literal'],
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'type' => 'server',
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->server->team_id,
|
||||
]);
|
||||
$this->server->refresh();
|
||||
$this->getDevView();
|
||||
@@ -75,10 +75,10 @@ class Show extends Component
|
||||
|
||||
private function formatEnvironmentVariables($variables)
|
||||
{
|
||||
$isMember = auth()->user()?->isMember();
|
||||
$canViewValues = auth()->user()?->isAdminOfTeam($this->server->team_id) ?? false;
|
||||
|
||||
return $variables->map(function ($item) use ($isMember) {
|
||||
if ($isMember) {
|
||||
return $variables->map(function ($item) use ($canViewValues) {
|
||||
if (! $canViewValues) {
|
||||
return "$item->key=(Hidden, only admins can view)";
|
||||
}
|
||||
if ($item->is_shown_once) {
|
||||
@@ -173,7 +173,7 @@ class Show extends Component
|
||||
'is_multiline' => false,
|
||||
'is_literal' => false,
|
||||
'type' => 'server',
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->server->team_id,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class Index extends Component
|
||||
'is_literal' => $data['is_literal'],
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'type' => 'team',
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->team->refresh();
|
||||
$this->getDevView();
|
||||
@@ -68,10 +68,10 @@ class Index extends Component
|
||||
|
||||
private function formatEnvironmentVariables($variables)
|
||||
{
|
||||
$isMember = auth()->user()?->isMember();
|
||||
$canViewValues = auth()->user()?->isAdminOfTeam($this->team->id) ?? false;
|
||||
|
||||
return $variables->map(function ($item) use ($isMember) {
|
||||
if ($isMember) {
|
||||
return $variables->map(function ($item) use ($canViewValues) {
|
||||
if (! $canViewValues) {
|
||||
return "$item->key=(Hidden, only admins can view)";
|
||||
}
|
||||
if ($item->is_shown_once) {
|
||||
@@ -158,7 +158,7 @@ class Index extends Component
|
||||
'is_multiline' => false,
|
||||
'is_literal' => false,
|
||||
'type' => 'team',
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ class Change extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
if (! PrivateKey::ownedByCurrentTeam()->find($this->privateKeyId)) {
|
||||
if (! PrivateKey::where('team_id', $this->github_app->team_id)->find($this->privateKeyId)) {
|
||||
$this->dispatch('error', 'No private key found for this GitHub App.');
|
||||
|
||||
return;
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Traits\HasSecretManager;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Throwable;
|
||||
|
||||
class StandaloneClickhouse extends BaseModel
|
||||
{
|
||||
@@ -18,6 +19,8 @@ class StandaloneClickhouse extends BaseModel
|
||||
|
||||
protected array $auditExclude = ['last_online_at'];
|
||||
|
||||
public const DATA_DIRECTORY = '/var/lib/clickhouse';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
@@ -185,6 +188,57 @@ class StandaloneClickhouse extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Name of the unnamed Docker volume that holds this database's data, or null.
|
||||
*
|
||||
* Before v4.4, adding a file mount removed the data volume from the compose file, so the
|
||||
* container keeps its data in an unnamed volume. Recreating the container leaves that volume
|
||||
* behind and starts on the configured data volume, so the current data seems lost.
|
||||
*/
|
||||
public function anonymousDataVolume(): ?string
|
||||
{
|
||||
$server = data_get($this, 'destination.server');
|
||||
if (! $server?->isFunctional()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$format = '{{range .Mounts}}{{if eq .Destination "'.self::DATA_DIRECTORY.'"}}{{.Type}} {{.Name}}{{end}}{{end}}';
|
||||
try {
|
||||
$output = instant_remote_process_with_timeout([
|
||||
'docker inspect --format '.escapeshellarg($format).' '.escapeshellarg($this->uuid).' 2>/dev/null || true',
|
||||
], $server, false);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$type, $name] = array_pad(explode(' ', trim((string) $output), 2), 2, '');
|
||||
if ($type !== 'volume' || $name === '' || $this->persistentStorages()->where('name', $name)->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the unnamed volume that holds the current data as the data volume, so a restart keeps that data.
|
||||
*/
|
||||
public function keepAnonymousDataVolume(string $volumeName): void
|
||||
{
|
||||
$storage = $this->persistentStorages()->where('mount_path', self::DATA_DIRECTORY)->first();
|
||||
if ($storage) {
|
||||
$storage->update(['name' => $volumeName]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => $volumeName,
|
||||
'mount_path' => self::DATA_DIRECTORY,
|
||||
'resource_id' => $this->id,
|
||||
'resource_type' => $this->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteVolumes()
|
||||
{
|
||||
$persistentStorages = $this->persistentStorages()->get() ?? collect();
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Jobs\VolumeCloneJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
@@ -275,8 +276,11 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
$uuid = $overrides['uuid'] ?? new_public_id();
|
||||
$server = $destination->server;
|
||||
|
||||
if ($server->team_id !== currentTeam()->id) {
|
||||
throw new RuntimeException('Destination does not belong to the current team.');
|
||||
$teamId = $server->team_id;
|
||||
$sourceTeamId = $source->team()?->id;
|
||||
$environmentTeamId = Environment::query()->find($overrides['environment_id'] ?? $source->environment_id)?->project?->team_id;
|
||||
if ($sourceTeamId === null || $environmentTeamId === null || (int) $sourceTeamId !== (int) $teamId || (int) $environmentTeamId !== (int) $teamId) {
|
||||
throw new RuntimeException('The application, the target environment, and the destination must belong to the same team.');
|
||||
}
|
||||
|
||||
// Prepare name and URL
|
||||
@@ -342,7 +346,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
])->fill([
|
||||
'uuid' => new_public_id(),
|
||||
'application_id' => $newApplication->id,
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
$newTask->save();
|
||||
}
|
||||
|
||||
@@ -231,8 +231,11 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
|
||||
$serverTimezone = getServerTimezone(data_get($application, 'destination.server'));
|
||||
|
||||
// Members should never see debug logs, even if an admin enabled debug mode
|
||||
if ($is_debug_enabled && auth()->check() && auth()->user()->isMember()) {
|
||||
$is_debug_enabled = false;
|
||||
if ($is_debug_enabled && auth()->check()) {
|
||||
$teamId = $application?->team()?->id;
|
||||
if (is_null($teamId) || ! auth()->user()->isAdminOfTeam($teamId)) {
|
||||
$is_debug_enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
$logs = data_get($application_deployment_queue, 'logs');
|
||||
|
||||
@@ -280,7 +280,7 @@
|
||||
@endif
|
||||
@if ($buildPack !== 'dockercompose')
|
||||
@php
|
||||
$hasBuildServers = \App\Models\Server::buildServers(currentTeam()->id)->exists();
|
||||
$hasBuildServers = \App\Models\Server::buildServers($application->team()?->id)->exists();
|
||||
$buildServerOptions = [
|
||||
['value' => false, 'label' => 'Deployment server'],
|
||||
$hasBuildServers
|
||||
|
||||
@@ -2,6 +2,29 @@
|
||||
<form wire:submit="submit" class="flex flex-col gap-6">
|
||||
<x-unsaved-bar action="submit" />
|
||||
|
||||
@if ($anonymousDataVolume)
|
||||
<x-callout type="danger" title="Current data is not in the data volume">
|
||||
<p>Because of a bug in older Coolify versions, a file mount removed the data volume. This database
|
||||
keeps its current data in the unnamed Docker volume <code>{{ $anonymousDataVolume }}</code>.
|
||||
Starting or restarting it is blocked, because it would use the data volume and the current data
|
||||
would seem lost.</p>
|
||||
<p class="mt-2">An admin can click "Keep current data" to use this volume as the data volume. The previous data
|
||||
volume is not used anymore, but stays on the server. It can contain data from before the file
|
||||
mount was added.</p>
|
||||
@can('update', $database)
|
||||
<div class="mt-3">
|
||||
<x-modal-confirmation title="Keep current data?" buttonTitle="Keep current data"
|
||||
submitAction="keepCurrentDataVolume" :actions="[
|
||||
'Use the unnamed volume ' . $anonymousDataVolume . ' as the data volume of this database.',
|
||||
'Starts and restarts are allowed again and keep the current data.',
|
||||
'The previous data volume stays on the server and is not used anymore.',
|
||||
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||
step2ButtonText="Keep current data" />
|
||||
</div>
|
||||
@endcan
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
<x-application.settings-section title="Database details"
|
||||
description="Manage the identity and container image for this ClickHouse database.">
|
||||
<x-slot:actions>
|
||||
|
||||
@@ -268,7 +268,7 @@
|
||||
default => 'neutral',
|
||||
};
|
||||
$databaseBackupId = 'database:'.$databaseBackup->id;
|
||||
$databaseS3 = $databaseBackup->s3?->team_id === currentTeam()->id ? $databaseBackup->s3 : null;
|
||||
$databaseS3 = $databaseBackup->s3?->team_id === $serviceTeamId ? $databaseBackup->s3 : null;
|
||||
$databaseS3Tooltip = ! $databaseBackup->save_s3 ? 'S3 storage: Not configured' : ($databaseS3 ? 'S3 storage: '.$databaseS3->name.' (bucket: '.$databaseS3->bucket.')' : 'S3 storage: Unavailable');
|
||||
@endphp
|
||||
<div wire:key="database-backup-{{ $databaseBackup->uuid }}"
|
||||
@@ -308,7 +308,7 @@
|
||||
@foreach ($backups as $backup)
|
||||
@php
|
||||
$latestExecution = $backup->latestExecution;
|
||||
$volumeS3 = $backup->s3?->team_id === currentTeam()->id ? $backup->s3 : null;
|
||||
$volumeS3 = $backup->s3?->team_id === $serviceTeamId ? $backup->s3 : null;
|
||||
$volumeS3Tooltip = ! $backup->save_s3 ? 'S3 storage: Not configured' : ($volumeS3 ? 'S3 storage: '.$volumeS3->name.' (bucket: '.$volumeS3->bucket.')' : 'S3 storage: Unavailable');
|
||||
$status = $latestExecution?->status;
|
||||
$statusLabel = match ($status) {
|
||||
|
||||
@@ -59,9 +59,6 @@
|
||||
class="min-w-0 truncate text-[13px] font-medium text-neutral-950 dark:text-fg"
|
||||
title="{{ $form['name'] }}">{{ $form['name'] }}</span>
|
||||
</div>
|
||||
@if (blank($storage->host_path))
|
||||
<span class="block text-xs text-neutral-500 dark:text-fg-dim">Volume mount</span>
|
||||
@endif
|
||||
@if ($form['replacedExternalVolume'])
|
||||
<span class="block text-xs text-amber-800 dark:text-amber-300/90">
|
||||
Replaces the external volume '{{ $form['replacedExternalVolume'] }}'. Copy the data into the external volume, then delete this entry to use it.
|
||||
@@ -202,8 +199,6 @@
|
||||
<a href="{{ $storage->standaloneSqlite->link() }}"
|
||||
class="block text-xs text-neutral-500 underline underline-offset-2 hover:text-black dark:text-fg-dim dark:hover:text-fg">SQLite
|
||||
database {{ $storage->standaloneSqlite->name }}</a>
|
||||
@elseif (blank($storage->host_path))
|
||||
<span class="block text-xs text-neutral-500 dark:text-fg-dim">Volume mount</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -884,3 +885,50 @@ describe('GitHub Source Change Component', function () {
|
||||
->assertSee('Setup incomplete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub App name sync after the user switches the session team', function () {
|
||||
beforeEach(function () {
|
||||
Storage::fake('ssh-keys');
|
||||
$this->otherTeam = Team::factory()->create();
|
||||
$this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->appKey = PrivateKey::create(['name' => 'App Key', 'private_key' => validPrivateKey(), 'team_id' => $this->team->id]);
|
||||
$this->otherTeamKey = PrivateKey::create(['name' => 'Other Team Key', 'private_key' => validPrivateKey(), 'team_id' => $this->otherTeam->id]);
|
||||
$this->githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'private_key_id' => $this->appKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['date' => now()->toRfc7231String()]),
|
||||
'https://api.github.com/app' => Http::response(['slug' => 'synced-app']),
|
||||
]);
|
||||
|
||||
$this->component = Livewire::withQueryParams(['github_app_uuid' => $this->githubApp->uuid])->test(Change::class);
|
||||
session(['currentTeam' => $this->otherTeam]);
|
||||
});
|
||||
|
||||
test('rejects a private key of the session team', function () {
|
||||
$this->component->set('privateKeyId', $this->otherTeamKey->id)
|
||||
->call('updateGithubAppName')
|
||||
->assertDispatched('error', 'No private key found for this GitHub App.');
|
||||
|
||||
expect($this->githubApp->fresh()->private_key_id)->toBe($this->appKey->id);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('syncs the name with a private key of the GitHub App team', function () {
|
||||
$this->component->call('updateGithubAppName')
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->githubApp->fresh()->name)->toBe('synced-app');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,3 +168,77 @@ test('privateKeyId is locked so submit() cannot persist a client-supplied foreig
|
||||
Livewire::test(Source::class, ['application' => $this->applicationA])
|
||||
->set('privateKeyId', $this->victimPrivateKey->id);
|
||||
})->throws(CannotUpdateLockedPropertyException::class);
|
||||
|
||||
describe('after the user switches the session team', function () {
|
||||
beforeEach(function () {
|
||||
Http::fake([
|
||||
'https://api.github.com/repos/*' => Http::response(['id' => 123]),
|
||||
]);
|
||||
$this->teamB->members()->attach($this->userA->id, ['role' => 'owner']);
|
||||
$this->applicationA->update(['git_repository' => 'coollabsio/coolify']);
|
||||
|
||||
$this->ownKey = makePrivateKey('own-ssh-key', 'OWN_KEY_MATERIAL', 'own-fingerprint', $this->teamA->id);
|
||||
$this->ownGithubApp = GithubApp::create([
|
||||
'name' => 'own-github-app',
|
||||
'team_id' => $this->teamA->id,
|
||||
'app_id' => 1,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
]);
|
||||
$this->victimGithubApp->update(['app_id' => 2]);
|
||||
$this->systemWideGithubApp = GithubApp::create([
|
||||
'name' => 'system-wide-github-app',
|
||||
'team_id' => Team::factory()->create()->id,
|
||||
'app_id' => 3,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
'is_system_wide' => true,
|
||||
]);
|
||||
|
||||
$this->component = Livewire::test(Source::class, ['application' => $this->applicationA->fresh()]);
|
||||
session(['currentTeam' => $this->teamB]);
|
||||
});
|
||||
|
||||
test('lists only keys and sources of the application team and system-wide sources', function () {
|
||||
$component = Livewire::test(Source::class, ['application' => $this->applicationA->fresh()]);
|
||||
|
||||
expect($component->get('privateKeys')->pluck('id')->all())->toBe([$this->ownKey->id])
|
||||
->and($component->get('sources')->pluck('id')->sort()->values()->all())
|
||||
->toBe([$this->ownGithubApp->id, $this->systemWideGithubApp->id]);
|
||||
});
|
||||
|
||||
test('setPrivateKey rejects a key of the session team', function () {
|
||||
$this->component->call('setPrivateKey', $this->victimPrivateKey->id);
|
||||
|
||||
expect($this->applicationA->refresh()->private_key_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('setPrivateKey accepts a key of the application team', function () {
|
||||
$this->component->call('setPrivateKey', $this->ownKey->id);
|
||||
|
||||
expect($this->applicationA->refresh()->private_key_id)->toBe($this->ownKey->id);
|
||||
});
|
||||
|
||||
test('changeSource rejects a source of the session team', function () {
|
||||
try {
|
||||
$this->component->call('changeSource', $this->victimGithubApp->id, GithubApp::class);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->refresh()->source_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('changeSource accepts a source of the application team', function () {
|
||||
$this->component->call('changeSource', $this->ownGithubApp->id, GithubApp::class);
|
||||
|
||||
expect($this->applicationA->refresh()->source_id)->toBe($this->ownGithubApp->id);
|
||||
});
|
||||
|
||||
test('changeSource accepts a system-wide source', function () {
|
||||
$this->component->call('changeSource', $this->systemWideGithubApp->id, GithubApp::class);
|
||||
|
||||
expect($this->applicationA->refresh()->source_id)->toBe($this->systemWideGithubApp->id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\CloneMe;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutVite();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
Server::flushIdentityMap();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->teamA, ['role' => 'admin']);
|
||||
$this->user->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->firstOrFail();
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
$this->applicationA = Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => $this->destinationA->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->destinationB = StandaloneDocker::where('server_id', $this->serverB->id)->firstOrFail();
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
});
|
||||
|
||||
function cloneMeAfterTeamSwitch(object $test): Testable
|
||||
{
|
||||
$component = Livewire::test(CloneMe::class, [
|
||||
'project_uuid' => $test->projectA->uuid,
|
||||
'environment_uuid' => $test->environmentA->uuid,
|
||||
]);
|
||||
session(['currentTeam' => $test->teamB]);
|
||||
|
||||
return $component;
|
||||
}
|
||||
|
||||
test('lists only servers of the project team', function () {
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
|
||||
$component = Livewire::test(CloneMe::class, [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
]);
|
||||
|
||||
expect($component->get('servers')->pluck('id')->all())->toBe([$this->serverA->id]);
|
||||
});
|
||||
|
||||
test('project clone with a session team destination creates nothing after a team switch', function () {
|
||||
$projectCount = Project::count();
|
||||
$environmentCount = Environment::count();
|
||||
|
||||
cloneMeAfterTeamSwitch($this)
|
||||
->set('selectedDestination', $this->destinationB->uuid)
|
||||
->set('newName', 'zz-cross-team-clone')
|
||||
->call('clone', 'project')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(Project::count())->toBe($projectCount)
|
||||
->and(Environment::count())->toBe($environmentCount)
|
||||
->and(Application::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('environment clone with a session team destination creates nothing after a team switch', function () {
|
||||
$environmentCount = Environment::count();
|
||||
|
||||
cloneMeAfterTeamSwitch($this)
|
||||
->set('selectedDestination', $this->destinationB->uuid)
|
||||
->set('newName', 'zz-cross-team-env')
|
||||
->call('clone', 'environment')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(Environment::count())->toBe($environmentCount)
|
||||
->and(Application::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a member of the project team cannot clone after switching to a team they own', function () {
|
||||
$this->user->teams()->updateExistingPivot($this->teamA->id, ['role' => 'member']);
|
||||
$this->user->unsetRelation('teams');
|
||||
$projectCount = Project::count();
|
||||
|
||||
cloneMeAfterTeamSwitch($this)
|
||||
->set('selectedDestination', $this->destinationA->uuid)
|
||||
->set('newName', 'zz-member-clone')
|
||||
->call('clone', 'project')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(Project::count())->toBe($projectCount)
|
||||
->and(Application::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('project clone after a team switch stays in the source project team', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'pg-clone',
|
||||
'uuid' => new_public_id(),
|
||||
'postgres_password' => 'secret',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => $this->destinationA->getMorphClass(),
|
||||
'status' => 'exited',
|
||||
]);
|
||||
ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *', 'database_type' => $database->getMorphClass(), 'database_id' => $database->id,
|
||||
'team_id' => $this->teamA->id,
|
||||
]);
|
||||
|
||||
cloneMeAfterTeamSwitch($this)
|
||||
->set('selectedDestination', $this->destinationA->uuid)
|
||||
->set('newName', 'zz-same-team-clone')
|
||||
->call('clone', 'project')
|
||||
->assertNotDispatched('error')
|
||||
->assertRedirect();
|
||||
|
||||
$project = Project::where('name', 'zz-same-team-clone')->sole();
|
||||
$environment = $project->environments()->where('name', $this->environmentA->name)->sole();
|
||||
$clonedDatabase = StandalonePostgresql::where('environment_id', $environment->id)->sole();
|
||||
|
||||
expect($project->team_id)->toBe($this->teamA->id)
|
||||
->and($environment->applications()->count())->toBe(1)
|
||||
->and($clonedDatabase->scheduledBackups()->sole()->team_id)->toBe($this->teamA->id);
|
||||
});
|
||||
|
||||
test('a failing clone step leaves no partial project behind', function () {
|
||||
StandalonePostgresql::create([
|
||||
'name' => 'pg-fails',
|
||||
'uuid' => new_public_id(),
|
||||
'postgres_password' => 'secret',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => $this->destinationA->getMorphClass(),
|
||||
'status' => 'exited',
|
||||
]);
|
||||
StandalonePostgresql::created(fn () => throw new RuntimeException('Database clone failed.'));
|
||||
$projectCount = Project::count();
|
||||
$environmentCount = Environment::count();
|
||||
|
||||
Livewire::test(CloneMe::class, [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
])
|
||||
->set('selectedDestination', $this->destinationA->uuid)
|
||||
->set('newName', 'zz-failing-clone')
|
||||
->call('clone', 'project')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(Project::count())->toBe($projectCount)
|
||||
->and(Environment::count())->toBe($environmentCount)
|
||||
->and(Application::count())->toBe(1);
|
||||
});
|
||||
@@ -288,3 +288,59 @@ test('API shows env values for admin with read:sensitive token', function () {
|
||||
expect($unlocked)->not->toBeNull();
|
||||
expect($unlocked)->toHaveKey('value');
|
||||
});
|
||||
|
||||
// --- Session team differs from the resource team ---
|
||||
|
||||
test('member dev view hides values after switching to a team they own', function () {
|
||||
$ownTeam = Team::factory()->create();
|
||||
$this->member->teams()->attach($ownTeam, ['role' => 'owner']);
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $ownTeam]);
|
||||
|
||||
$component = Livewire::test(EnvironmentVariableAll::class, [
|
||||
'resource' => $this->application,
|
||||
])->call('switch');
|
||||
|
||||
expect($component->get('variables'))->not->toContain('secret-unlocked-value')
|
||||
->and($component->get('variables'))->toContain('UNLOCKED_VAR=(Hidden');
|
||||
$component->assertDontSee('secret-unlocked-value');
|
||||
});
|
||||
|
||||
test('admin dev view shows values while the session is on a team where they are a member', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$this->admin->teams()->attach($otherTeam, ['role' => 'member']);
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
|
||||
$component = Livewire::test(EnvironmentVariableAll::class, [
|
||||
'resource' => $this->application,
|
||||
])->call('switch');
|
||||
|
||||
expect($component->get('variables'))->toContain('UNLOCKED_VAR=secret-unlocked-value');
|
||||
});
|
||||
|
||||
test('user outside the resource team cannot open the dev view', function () {
|
||||
$outsider = User::factory()->create();
|
||||
$outsiderTeam = Team::factory()->create();
|
||||
$outsider->teams()->attach($outsiderTeam, ['role' => 'owner']);
|
||||
$this->actingAs($outsider);
|
||||
session(['currentTeam' => $outsiderTeam]);
|
||||
|
||||
Livewire::test(EnvironmentVariableAll::class, [
|
||||
'resource' => $this->application,
|
||||
])->call('switch')->assertForbidden();
|
||||
});
|
||||
|
||||
test('member keeps isValueHidden after switching to a team they own', function () {
|
||||
$ownTeam = Team::factory()->create();
|
||||
$this->member->teams()->attach($ownTeam, ['role' => 'owner']);
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $ownTeam]);
|
||||
|
||||
$component = Livewire::test(EnvironmentVariableShow::class, [
|
||||
'env' => $this->unlockedEnv,
|
||||
'type' => 'application',
|
||||
]);
|
||||
|
||||
expect($component->get('isValueHidden'))->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -53,3 +53,21 @@ test('member cannot create environment in project', function () {
|
||||
|
||||
expect(Environment::where('name', 'staging')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('member cannot create environment in project after switching to an owned team', function () {
|
||||
$ownTeam = Team::factory()->create();
|
||||
$this->member->teams()->attach($ownTeam, ['role' => 'owner']);
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$component = Livewire::test(Show::class, ['project_uuid' => $this->project->uuid]);
|
||||
|
||||
$this->member->load('teams');
|
||||
session(['currentTeam' => $ownTeam]);
|
||||
|
||||
$component->set('name', 'staging')
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(Environment::where('name', 'staging')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -5,11 +5,16 @@ use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -114,3 +119,143 @@ test('StandaloneDockerPolicy denies update for cross-team user', function () {
|
||||
test('StandaloneDockerPolicy allows update for same-team user', function () {
|
||||
expect($this->userA->can('update', $this->destinationA))->toBeTrue();
|
||||
});
|
||||
|
||||
describe('after the session team changes', function () {
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
$this->userA->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
$this->userA->unsetRelation('teams');
|
||||
$this->secondEnvironmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
});
|
||||
|
||||
function resourceOperationsAfterTeamSwitch(object $test, $resource): Testable
|
||||
{
|
||||
$component = Livewire::test(ResourceOperations::class, ['resource' => $resource]);
|
||||
session(['currentTeam' => $test->teamB]);
|
||||
|
||||
return $component;
|
||||
}
|
||||
|
||||
test('lists the projects and servers of the resource team', function () {
|
||||
session(['currentTeam' => $this->teamB]);
|
||||
|
||||
$component = Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]);
|
||||
|
||||
expect($component->get('projects')->pluck('id')->all())->toBe([$this->projectA->id])
|
||||
->and($component->get('servers')->pluck('id')->all())->toBe([$this->serverA->id]);
|
||||
});
|
||||
|
||||
test('cloneTo rejects a destination of the session team', function () {
|
||||
resourceOperationsAfterTeamSwitch($this, $this->applicationA)
|
||||
->call('cloneTo', $this->destinationB->uuid)
|
||||
->assertHasErrors('destination_id');
|
||||
|
||||
expect(Application::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('cloneTo rejects an environment and destination of another team', function () {
|
||||
resourceOperationsAfterTeamSwitch($this, $this->applicationA)
|
||||
->call('cloneTo', $this->destinationB->uuid, $this->environmentB->id)
|
||||
->assertHasErrors('environment_id');
|
||||
|
||||
expect(Application::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('moveTo rejects an environment of the session team', function () {
|
||||
resourceOperationsAfterTeamSwitch($this, $this->applicationA)
|
||||
->call('moveTo', $this->environmentB->id);
|
||||
|
||||
expect($this->applicationA->fresh()->environment_id)->toBe($this->environmentA->id);
|
||||
});
|
||||
|
||||
test('migrateTo rejects a destination of the session team', function () {
|
||||
resourceOperationsAfterTeamSwitch($this, $this->applicationA)
|
||||
->call('migrateTo', $this->destinationB->uuid)
|
||||
->assertHasErrors(['destination_id' => 'Destination not found.']);
|
||||
});
|
||||
|
||||
test('cloneTo and moveTo still work inside the resource team', function () {
|
||||
resourceOperationsAfterTeamSwitch($this, $this->applicationA)
|
||||
->call('cloneTo', $this->destinationA->uuid, $this->secondEnvironmentA->id)
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect();
|
||||
|
||||
$clone = Application::whereKeyNot($this->applicationA->id)->sole();
|
||||
expect($clone->environment_id)->toBe($this->secondEnvironmentA->id)
|
||||
->and($clone->destination_id)->toBe($this->destinationA->id);
|
||||
|
||||
resourceOperationsAfterTeamSwitch($this, $this->applicationA)
|
||||
->call('moveTo', $this->secondEnvironmentA->id)
|
||||
->assertRedirect();
|
||||
|
||||
expect($this->applicationA->fresh()->environment_id)->toBe($this->secondEnvironmentA->id);
|
||||
});
|
||||
|
||||
test('cloned application tasks belong to the resource team', function () {
|
||||
ScheduledTask::factory()->create(['application_id' => $this->applicationA->id, 'team_id' => $this->teamA->id]);
|
||||
|
||||
resourceOperationsAfterTeamSwitch($this, $this->applicationA)
|
||||
->call('cloneTo', $this->destinationA->uuid)
|
||||
->assertRedirect();
|
||||
|
||||
$clone = Application::whereKeyNot($this->applicationA->id)->sole();
|
||||
expect($clone->scheduled_tasks()->sole()->team_id)->toBe($this->teamA->id);
|
||||
});
|
||||
|
||||
test('cloned database backups belong to the resource team', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'pg', 'uuid' => new_public_id(), 'postgres_password' => 'secret', 'postgres_user' => 'postgres',
|
||||
'postgres_db' => 'postgres', 'environment_id' => $this->environmentA->id, 'status' => 'exited',
|
||||
'destination_id' => $this->destinationA->id, 'destination_type' => $this->destinationA->getMorphClass(),
|
||||
]);
|
||||
ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *', 'database_type' => $database->getMorphClass(), 'database_id' => $database->id,
|
||||
'team_id' => $this->teamA->id,
|
||||
]);
|
||||
|
||||
resourceOperationsAfterTeamSwitch($this, $database)
|
||||
->call('cloneTo', $this->destinationA->uuid)
|
||||
->assertRedirect();
|
||||
|
||||
$clone = StandalonePostgresql::whereKeyNot($database->id)->sole();
|
||||
expect($clone->scheduledBackups()->sole()->team_id)->toBe($this->teamA->id);
|
||||
});
|
||||
|
||||
test('cloned service tasks belong to the resource team', function () {
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environmentA->id, 'server_id' => $this->serverA->id,
|
||||
'destination_id' => $this->destinationA->id, 'destination_type' => $this->destinationA->getMorphClass(),
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
||||
]);
|
||||
ScheduledTask::factory()->create(['service_id' => $service->id, 'team_id' => $this->teamA->id]);
|
||||
|
||||
resourceOperationsAfterTeamSwitch($this, $service)
|
||||
->call('cloneTo', $this->destinationA->uuid)
|
||||
->assertRedirect();
|
||||
|
||||
$clone = Service::whereKeyNot($service->id)->sole();
|
||||
expect($clone->scheduled_tasks()->sole()->team_id)->toBe($this->teamA->id);
|
||||
});
|
||||
|
||||
test('a member of the resource team cannot clone or move after switching to an own team', function () {
|
||||
$member = User::factory()->create();
|
||||
$member->teams()->attach($this->teamA, ['role' => 'member']);
|
||||
$member->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
$this->actingAs($member);
|
||||
|
||||
resourceOperationsAfterTeamSwitch($this, $this->applicationA)
|
||||
->call('cloneTo', $this->destinationA->uuid)
|
||||
->call('moveTo', $this->secondEnvironmentA->id);
|
||||
|
||||
expect(Application::count())->toBe(1)
|
||||
->and($this->applicationA->fresh()->environment_id)->toBe($this->environmentA->id);
|
||||
});
|
||||
|
||||
test('clone_application rejects a destination outside the environment team', function () {
|
||||
session(['currentTeam' => $this->teamB]);
|
||||
|
||||
expect(fn () => clone_application($this->applicationA, $this->destinationB, ['environment_id' => $this->environmentA->id]))
|
||||
->toThrow(RuntimeException::class, 'same team');
|
||||
expect(Application::count())->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -434,11 +434,29 @@ it('defaults to the first available storage when multiple storages are available
|
||||
expect($backup->s3_storage_id)->toBe($firstS3->id);
|
||||
});
|
||||
|
||||
it('accepts the S3 storage scope passed to the component', function () {
|
||||
it('accepts a root team S3 storage for the instance database backup', function () {
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
$this->user->teams()->syncWithoutDetaching([$rootTeam->id => ['role' => 'owner']]);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$s3 = createS3StorageForBackupEditValidationTest(0);
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
$server = Server::factory()->create(['id' => 0, 'team_id' => 0, 'ip' => '127.0.0.1']);
|
||||
$database = new StandalonePostgresql;
|
||||
$database->forceFill([
|
||||
'id' => 0,
|
||||
'name' => 'coolify-db',
|
||||
'postgres_user' => 'coolify',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'coolify',
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'destination_id' => StandaloneDocker::where('server_id', $server->id)->firstOrFail()->id,
|
||||
'environment_id' => null,
|
||||
])->save();
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
'database_type' => StandalonePostgresql::class,
|
||||
'database_id' => 0,
|
||||
'team_id' => 0,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect([$s3])])
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Service\Index as ServiceIndex;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
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\Cache;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
Server::flushIdentityMap();
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->teams()->attach($this->teamA, ['role' => 'member']);
|
||||
$this->user->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
$this->serviceDatabase = ServiceDatabase::create([
|
||||
'service_id' => $this->service->id,
|
||||
'name' => 'zz-s3-list-db',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'custom_type' => 'postgresql',
|
||||
]);
|
||||
$this->serviceApplication = ServiceApplication::create([
|
||||
'service_id' => $this->service->id,
|
||||
'name' => 'zz-s3-list-app',
|
||||
'image' => 'nginx:alpine',
|
||||
]);
|
||||
$this->postgres = StandalonePostgresql::create([
|
||||
'name' => 'zz-s3-list-pg',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->ownS3 = createS3ForResourceTeamListTest($this->teamA, 'team-a-s3');
|
||||
createS3ForResourceTeamListTest($this->teamA, 'team-a-unusable-s3', false);
|
||||
createS3ForResourceTeamListTest($this->teamB, 'team-b-s3');
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
function createS3ForResourceTeamListTest(Team $team, string $name, bool $isUsable = true): S3Storage
|
||||
{
|
||||
return S3Storage::create([
|
||||
'name' => $name,
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'key',
|
||||
'secret' => 'secret',
|
||||
'bucket' => $name.'-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'is_usable' => $isUsable,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
}
|
||||
|
||||
function switchSessionTeamForResourceTeamListTest(Team $team): void
|
||||
{
|
||||
session(['currentTeam' => $team]);
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
test('embedded service database offers only S3 storages of the service team', function (string $sessionTeam) {
|
||||
switchSessionTeamForResourceTeamListTest($this->{$sessionTeam});
|
||||
|
||||
Livewire::test(ServiceIndex::class, ['serviceApplication' => $this->serviceDatabase->fresh(), 'embedded' => true])
|
||||
->assertSet('s3s', fn ($s3s) => $s3s->pluck('id')->all() === [$this->ownS3->id]);
|
||||
})->with(['same session team' => 'teamA', 'after a team switch' => 'teamB']);
|
||||
|
||||
test('embedded service application offers only S3 storages of the service team after a team switch', function () {
|
||||
switchSessionTeamForResourceTeamListTest($this->teamB);
|
||||
|
||||
Livewire::test(ServiceIndex::class, ['serviceApplication' => $this->serviceApplication->fresh(), 'embedded' => true])
|
||||
->assertSet('s3s', fn ($s3s) => $s3s->pluck('id')->all() === [$this->ownS3->id]);
|
||||
});
|
||||
|
||||
test('database backup page offers only usable S3 storages of the database team', function () {
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'team_id' => $this->teamA->id,
|
||||
'frequency' => '0 0 * * *',
|
||||
'database_id' => $this->postgres->id,
|
||||
'database_type' => $this->postgres->getMorphClass(),
|
||||
]);
|
||||
$url = route('project.database.backup.s3', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'database_uuid' => $this->postgres->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
$this->get($url)
|
||||
->assertOk()
|
||||
->assertSee('team-a-s3')
|
||||
->assertDontSee('team-a-unusable-s3')
|
||||
->assertDontSee('team-b-s3');
|
||||
|
||||
switchSessionTeamForResourceTeamListTest($this->teamB);
|
||||
$this->get($url)->assertRedirect(route('dashboard'));
|
||||
});
|
||||
@@ -190,3 +190,20 @@ it('shows Vultr search errors in the modal', function () {
|
||||
->assertSee('Failed to search Vultr instances:')
|
||||
->assertSee('invalid token');
|
||||
});
|
||||
|
||||
it('reloads only cloud provider tokens of the server team after the session team changes', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$sameTeamToken = CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'hetzner']);
|
||||
CloudProviderToken::factory()->create(['team_id' => $otherTeam->id, 'provider' => 'hetzner']);
|
||||
CloudProviderToken::factory()->create(['team_id' => $otherTeam->id, 'provider' => 'vultr']);
|
||||
CloudProviderToken::factory()->create(['team_id' => $otherTeam->id, 'provider' => 'digitalocean']);
|
||||
|
||||
$component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]);
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
$component->call('handleServerValidated');
|
||||
|
||||
expect($component->get('availableHetznerTokens')->pluck('id')->all())->toBe([$sameTeamToken->id])
|
||||
->and($component->get('availableVultrTokens'))->toBeEmpty()
|
||||
->and($component->get('availableDigitalOceanTokens'))->toBeEmpty();
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\CloudProviderToken\Show;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@@ -46,3 +48,40 @@ test('server cloud provider token cards show token descriptions', function () {
|
||||
->assertSee('Production Hetzner')
|
||||
->assertSee('Used for production servers in the EU region.');
|
||||
});
|
||||
|
||||
describe('after the user switches the session team', function () {
|
||||
beforeEach(function () {
|
||||
$this->withoutDefer();
|
||||
Http::fake(['*' => Http::response(['servers' => []])]);
|
||||
|
||||
$this->otherTeam = Team::factory()->create();
|
||||
$this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->sameTeamToken = CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'hetzner']);
|
||||
$this->otherTeamToken = CloudProviderToken::factory()->create(['team_id' => $this->otherTeam->id, 'provider' => 'hetzner']);
|
||||
|
||||
$this->component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]);
|
||||
session(['currentTeam' => $this->otherTeam]);
|
||||
});
|
||||
|
||||
test('lists only tokens of the server team', function () {
|
||||
$tokenIds = $this->component->call('loadTokens')->get('cloudProviderTokens')->pluck('id')->all();
|
||||
|
||||
expect($tokenIds)->toBe([$this->sameTeamToken->id]);
|
||||
});
|
||||
|
||||
test('rejects a token of the session team', function () {
|
||||
$this->component->call('setCloudProviderToken', $this->otherTeamToken->id)
|
||||
->assertDispatched('error', 'You are not allowed to use this token.');
|
||||
|
||||
expect($this->server->fresh()->cloud_provider_token_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('accepts a token of the server team and audits it for the server team', function () {
|
||||
$this->component->call('setCloudProviderToken', $this->sameTeamToken->id)
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->server->fresh()->cloud_provider_token_id)->toBe($this->sameTeamToken->id)
|
||||
->and(AuditEvent::query()->where('event', 'ui.server.cloud_token_assigned')->sole()->team_id)->toBe($this->team->id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Actions\Docker\GetContainersStatus;
|
||||
use App\Livewire\Project\Shared\Destination;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
@@ -256,3 +257,64 @@ describe('Destination::removeServer', function () {
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destination after the user switches the session team', function () {
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
$this->userA->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
foreach ([$this->serverA2, $this->serverB] as $server) {
|
||||
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
}
|
||||
|
||||
$this->component = Livewire::test(Destination::class, ['resource' => $this->applicationA]);
|
||||
session(['currentTeam' => $this->teamB]);
|
||||
});
|
||||
|
||||
test('lists only networks of the application team', function () {
|
||||
$networkIds = $this->component->call('loadData')->get('networks')->pluck('id');
|
||||
|
||||
expect($networkIds)->toContain($this->destinationA2->id)
|
||||
->not->toContain($this->destinationB->id);
|
||||
});
|
||||
|
||||
test('cannot attach a server of the session team to the application', function () {
|
||||
try {
|
||||
$this->component->call('addServer', $this->destinationB->id, $this->serverB->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('can attach a server of the application team', function () {
|
||||
$this->component->call('addServer', $this->destinationA2->id, $this->serverA2->id);
|
||||
|
||||
expect($this->applicationA->fresh()->additional_networks->pluck('id')->all())->toBe([$this->destinationA2->id]);
|
||||
});
|
||||
|
||||
test('cannot promote a network of the session team', function () {
|
||||
try {
|
||||
$this->component->call('promote', $this->destinationB->id, $this->serverB->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->destination_id)->toBe($this->destinationA->id);
|
||||
});
|
||||
|
||||
test('can promote a network of the application team', function () {
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
|
||||
|
||||
$this->component->call('promote', $this->destinationA2->id, $this->serverA2->id);
|
||||
|
||||
expect($this->applicationA->fresh()->destination_id)->toBe($this->destinationA2->id);
|
||||
});
|
||||
|
||||
test('cannot redeploy to a server of the session team', function () {
|
||||
try {
|
||||
$this->component->call('redeploy', $this->destinationB->id, $this->serverB->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect(ApplicationDeploymentQueue::query()->where('server_id', $this->serverB->id)->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,3 +154,28 @@ test('cancelling does not touch servers of another team', function () {
|
||||
expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.30', 'docker'))->toBeEmpty()
|
||||
->and($this->deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value);
|
||||
});
|
||||
|
||||
test('cancelling after switching to another team still cleans up and starts the next queued deployment', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$nextApplication = Application::factory()->create([
|
||||
'environment_id' => $this->application->environment_id,
|
||||
'destination_id' => $this->application->destination_id,
|
||||
'destination_type' => $this->application->destination_type,
|
||||
]);
|
||||
$queued = ApplicationDeploymentQueue::create([
|
||||
'application_id' => $nextApplication->id,
|
||||
'deployment_uuid' => 'cancel-cleanup-next',
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->application->destination_id,
|
||||
'status' => ApplicationDeploymentStatus::QUEUED->value,
|
||||
]);
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$component = Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]);
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
$component->call('cancel');
|
||||
|
||||
assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment);
|
||||
expect($queued->fresh()->status)->toBe(ApplicationDeploymentStatus::IN_PROGRESS->value);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Livewire\Project\Application\DeploymentNavbar;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
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 Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->ownTeam = Team::factory()->create();
|
||||
$this->member = User::factory()->create();
|
||||
$this->team->members()->attach($this->member->id, ['role' => 'member']);
|
||||
$this->ownTeam->members()->attach($this->member->id, ['role' => 'owner']);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
$this->application->settings->update(['is_debug_enabled' => true]);
|
||||
|
||||
$this->deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => $this->application->id,
|
||||
'deployment_uuid' => 'debug-visibility-uuid',
|
||||
'server_id' => $server->id,
|
||||
'destination_id' => $destination->id,
|
||||
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
||||
]);
|
||||
$this->deployment->addLogEntry('visible-line');
|
||||
$this->deployment->addLogEntry('hidden-debug-line', hidden: true);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
});
|
||||
|
||||
test('deployment log lines hide debug output from a member whose session is on a team they own', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->ownTeam]);
|
||||
|
||||
$lines = decode_remote_command_output($this->deployment->fresh())->pluck('line')->implode("\n");
|
||||
|
||||
expect($lines)->toContain('visible-line')
|
||||
->not->toContain('hidden-debug-line');
|
||||
});
|
||||
|
||||
test('deployment log lines show debug output to a resource team admin whose session is on another team', function () {
|
||||
$this->team->members()->updateExistingPivot($this->member->id, ['role' => 'admin']);
|
||||
$this->ownTeam->members()->updateExistingPivot($this->member->id, ['role' => 'member']);
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->ownTeam]);
|
||||
|
||||
$lines = decode_remote_command_output($this->deployment->fresh())->pluck('line')->implode("\n");
|
||||
|
||||
expect($lines)->toContain('hidden-debug-line');
|
||||
});
|
||||
|
||||
test('copied deployment logs hide debug output from a member after switching to a team they own', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$component = Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]);
|
||||
|
||||
session(['currentTeam' => $this->ownTeam]);
|
||||
|
||||
$markdown = $component->instance()->copyLogsToClipboard();
|
||||
|
||||
expect($markdown)->toContain('visible-line')
|
||||
->not->toContain('hidden-debug-line');
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Add;
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->resourceTeam = Team::factory()->create();
|
||||
$this->sessionTeam = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->teams()->attach($this->resourceTeam, ['role' => 'member']);
|
||||
$this->user->teams()->attach($this->sessionTeam, ['role' => 'owner']);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->resourceTeam->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->application = Application::factory()->create(['environment_id' => $this->environment->id]);
|
||||
|
||||
SharedEnvironmentVariable::create(['key' => 'RESOURCE_TEAM_VAR', 'value' => 'a', 'type' => 'team', 'team_id' => $this->resourceTeam->id]);
|
||||
SharedEnvironmentVariable::create(['key' => 'SESSION_TEAM_VAR', 'value' => 'b', 'type' => 'team', 'team_id' => $this->sessionTeam->id]);
|
||||
SharedEnvironmentVariable::create(['key' => 'RESOURCE_PROJECT_VAR', 'value' => 'c', 'type' => 'project', 'team_id' => $this->resourceTeam->id, 'project_id' => $this->project->id]);
|
||||
|
||||
$this->parameters = [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
];
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->sessionTeam]);
|
||||
});
|
||||
|
||||
test('add suggests shared variables of the resource team when the session team differs', function () {
|
||||
$component = Livewire::test(Add::class, ['resource' => $this->application])
|
||||
->set('parameters', $this->parameters);
|
||||
|
||||
$suggestions = $component->instance()->availableSharedVariables();
|
||||
|
||||
expect($suggestions['team'])->toBe(['RESOURCE_TEAM_VAR'])
|
||||
->and($suggestions['project'])->toBe(['RESOURCE_PROJECT_VAR']);
|
||||
});
|
||||
|
||||
test('show suggests shared variables of the resource team when the session team differs', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'APP_VAR',
|
||||
'value' => 'x',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application'])
|
||||
->set('parameters', $this->parameters);
|
||||
|
||||
$suggestions = $component->instance()->availableSharedVariables();
|
||||
|
||||
expect($suggestions['team'])->toBe(['RESOURCE_TEAM_VAR'])
|
||||
->and($suggestions['project'])->toBe(['RESOURCE_PROJECT_VAR']);
|
||||
});
|
||||
|
||||
test('add without a resource keeps suggesting the session team variables', function () {
|
||||
$component = Livewire::test(Add::class, ['shared' => true]);
|
||||
|
||||
expect($component->instance()->availableSharedVariables()['team'])->toBe(['SESSION_TEAM_VAR']);
|
||||
});
|
||||
@@ -344,3 +344,61 @@ it('keeps compose changes visible to an owner in the change list', function () {
|
||||
|
||||
expect(json_encode($component->get('configurationDiff')))->toContain('new-compose-value');
|
||||
});
|
||||
|
||||
it('redacts environment values for a team member whose session is on a team they own', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
$ownTeam = Team::factory()->create();
|
||||
$ownTeam->members()->attach($member->id, ['role' => 'owner']);
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $ownTeam]);
|
||||
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'new-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertDontSee('new-secret');
|
||||
|
||||
$envChange = collect(data_get($component->get('configurationDiff'), 'changes', []))
|
||||
->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN')
|
||||
|| str_contains((string) data_get($change, 'label'), 'API_TOKEN'));
|
||||
|
||||
expect($envChange)->not->toBeNull()
|
||||
->and(data_get($envChange, 'new_display_value'))->toBe('••••••••')
|
||||
->and(data_get($envChange, 'new_full_value'))->toBeNull();
|
||||
});
|
||||
|
||||
it('shows environment values to a resource team owner whose session is on a team where they are a member', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherTeam->members()->attach($this->user->id, ['role' => 'member']);
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'new-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]);
|
||||
|
||||
$envChange = collect(data_get($component->get('configurationDiff'), 'changes', []))
|
||||
->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN')
|
||||
|| str_contains((string) data_get($change, 'label'), 'API_TOKEN'));
|
||||
|
||||
expect(data_get($envChange, 'new_display_value'))->not->toBe('••••••••');
|
||||
});
|
||||
|
||||
@@ -6,7 +6,10 @@ use App\Jobs\ConfigureDnsRecordJob;
|
||||
use App\Jobs\ReleaseManagedDnsRecordsJob;
|
||||
use App\Jobs\ReleaseRemovedDnsHostnamesJob;
|
||||
use App\Livewire\Project\Application\Domains;
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Livewire\Project\Application\PreviewDomains;
|
||||
use App\Livewire\Project\Service\Domains as ServiceDomains;
|
||||
use App\Livewire\Project\Service\Index as ServiceIndex;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\DnsProviderZone;
|
||||
@@ -614,3 +617,64 @@ describe('hostname lock', function () {
|
||||
$heldLock->release();
|
||||
});
|
||||
});
|
||||
|
||||
describe('domain edits after the session team changes', function () {
|
||||
beforeEach(function () {
|
||||
Queue::fake([CheckDomainDnsJob::class, ReleaseRemovedDnsHostnamesJob::class]);
|
||||
fakeCloudflareDns();
|
||||
$this->sessionTeam = Team::factory()->create();
|
||||
$this->sessionTeam->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->user->unsetRelation('teams');
|
||||
});
|
||||
|
||||
function assertReleaseQueuedForTeam(int $teamId, string $resourceType, int $resourceId): void
|
||||
{
|
||||
Queue::assertPushed(ReleaseRemovedDnsHostnamesJob::class, fn (ReleaseRemovedDnsHostnamesJob $job): bool => $job->teamId === $teamId
|
||||
&& $job->resourceType === $resourceType
|
||||
&& $job->resourceId === $resourceId
|
||||
&& $job->hostnames === ['app.example.com']);
|
||||
}
|
||||
|
||||
test('the application general form queues the release for the resource team', function () {
|
||||
$application = createDnsTestApplication($this, 'https://app.example.com');
|
||||
app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
||||
|
||||
$component = Livewire::test(General::class, ['application' => $application]);
|
||||
session(['currentTeam' => $this->sessionTeam]);
|
||||
$component->set('fqdn', 'https://new.example.com')->call('submit')->assertHasNoErrors();
|
||||
|
||||
expect($application->fresh()->fqdn)->toBe('https://new.example.com');
|
||||
assertReleaseQueuedForTeam($this->team->id, $application->getMorphClass(), $application->id);
|
||||
});
|
||||
|
||||
test('the preview domain form queues the release for the resource team', function () {
|
||||
$application = createDnsTestApplication($this, 'https://main.example.com');
|
||||
$preview = ApplicationPreview::create([
|
||||
'application_id' => $application->id, 'pull_request_id' => 7,
|
||||
'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/7', 'fqdn' => 'https://app.example.com',
|
||||
]);
|
||||
app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $preview);
|
||||
|
||||
$component = Livewire::test(PreviewDomains::class, ['preview' => $preview]);
|
||||
session(['currentTeam' => $this->sessionTeam]);
|
||||
$component->call('startEdit', 0)
|
||||
->set('editingDomainParts.host', 'new.example.com')
|
||||
->call('updateDomain')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($preview->fresh()->fqdn)->toContain('new.example.com');
|
||||
assertReleaseQueuedForTeam($this->team->id, $preview->getMorphClass(), $preview->id);
|
||||
});
|
||||
|
||||
test('the service application form queues the release for the resource team', function () {
|
||||
$serviceApplication = editReleaseService($this, 'https://app.example.com');
|
||||
app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $serviceApplication);
|
||||
|
||||
$component = Livewire::test(ServiceIndex::class, ['serviceApplication' => $serviceApplication->fresh()]);
|
||||
session(['currentTeam' => $this->sessionTeam]);
|
||||
$component->set('fqdn', 'https://new.example.com')->call('submitApplication')->assertHasNoErrors();
|
||||
|
||||
expect($serviceApplication->fresh()->fqdn)->toBe('https://new.example.com');
|
||||
assertReleaseQueuedForTeam($this->team->id, $serviceApplication->getMorphClass(), $serviceApplication->id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,7 +191,7 @@ it('does not show a source path or removal action for a named volume', function
|
||||
|
||||
Livewire::test(All::class, ['resource' => $application])
|
||||
->assertDontSee('Directory mount')
|
||||
->assertSee('Volume mount')
|
||||
->assertDontSee('Volume mount')
|
||||
->assertDontSee('Remove Source Path');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Livewire\Project\Database\BackupEdit;
|
||||
use App\Livewire\Project\Service\BackupExecutions;
|
||||
use App\Livewire\Project\Service\VolumeBackup\Index as ServiceVolumeBackupIndex;
|
||||
use App\Livewire\Project\Shared\ScheduledTask\Add as AddScheduledTask;
|
||||
use App\Livewire\Project\Shared\Tags;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
Server::flushIdentityMap();
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
|
||||
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
$this->user->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
$this->service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'environment_id' => $environment->id,
|
||||
]);
|
||||
$this->serviceDatabase = ServiceDatabase::create([
|
||||
'service_id' => $this->service->id,
|
||||
'name' => 'team-switch-db',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'custom_type' => 'postgresql',
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
function createS3ForTeamSwitchTest(Team $team, string $name): S3Storage
|
||||
{
|
||||
return S3Storage::create([
|
||||
'name' => $name,
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'key',
|
||||
'secret' => 'secret',
|
||||
'bucket' => $name.'-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'is_usable' => true,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
}
|
||||
|
||||
test('tags of the session team are not listed or attached after a team switch', function () {
|
||||
$foreignTag = Tag::create(['name' => 'production', 'team_id' => $this->teamB->id]);
|
||||
$component = Livewire::test(Tags::class, ['resource' => $this->application]);
|
||||
session(['currentTeam' => $this->teamB]);
|
||||
|
||||
$component->call('loadTags')
|
||||
->assertSet('tags', fn ($tags) => $tags->isEmpty())
|
||||
->set('newTags', 'production')
|
||||
->call('submit');
|
||||
Livewire::test(Tags::class, ['resource' => $this->application])
|
||||
->call('addTag', (string) $foreignTag->id)
|
||||
->assertNotFound();
|
||||
|
||||
$attached = $this->application->tags()->get();
|
||||
expect($attached)->toHaveCount(1)
|
||||
->and($attached->first()->id)->not->toBe($foreignTag->id)
|
||||
->and($attached->first()->team_id)->toBe($this->teamA->id);
|
||||
});
|
||||
|
||||
test('deploy by tag skips resources of another team', function () {
|
||||
Queue::fake();
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->teamB->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'team-b-deploy',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['deploy'],
|
||||
'team_id' => $this->teamB->id,
|
||||
]);
|
||||
$this->app['auth']->forgetGuards();
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken])
|
||||
->postJson('/api/v1/deploy', ['tag' => 'production'])
|
||||
->assertOk()
|
||||
->assertJsonMissingPath('details');
|
||||
|
||||
expect(ApplicationDeploymentQueue::count())->toBe(0);
|
||||
Queue::assertNotPushed(ApplicationDeploymentJob::class);
|
||||
});
|
||||
|
||||
test('scheduled task belongs to the resource team after a team switch', function () {
|
||||
$component = Livewire::test(AddScheduledTask::class, [
|
||||
'id' => (string) $this->application->id,
|
||||
'type' => 'application',
|
||||
'containerNames' => collect(),
|
||||
]);
|
||||
session(['currentTeam' => $this->teamB]);
|
||||
|
||||
$component->set('name', 'team-switch-task')
|
||||
->set('command', 'echo ok')
|
||||
->set('frequency', '0 0 * * *')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(ScheduledTask::query()->sole()->team_id)->toBe($this->teamA->id);
|
||||
});
|
||||
|
||||
test('service backup schedule offers only S3 storages of the service team', function () {
|
||||
$ownS3 = createS3ForTeamSwitchTest($this->teamA, 'team-a-s3');
|
||||
createS3ForTeamSwitchTest($this->teamB, 'team-b-s3');
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'team_id' => $this->teamA->id,
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => $ownS3->id,
|
||||
'database_id' => $this->serviceDatabase->id,
|
||||
'database_type' => $this->serviceDatabase->getMorphClass(),
|
||||
]);
|
||||
$component = Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->service]);
|
||||
session(['currentTeam' => $this->teamB]);
|
||||
|
||||
$component->call('openSchedule', $backup->uuid)
|
||||
->assertSet('s3s', fn ($s3s) => $s3s->pluck('id')->all() === [$ownS3->id])
|
||||
->assertSee('S3 storage: team-a-s3 (bucket: team-a-s3-bucket)');
|
||||
});
|
||||
|
||||
test('backup edit keeps the S3 storage of the backup team when given a foreign list', function () {
|
||||
$ownS3 = createS3ForTeamSwitchTest($this->teamA, 'team-a-s3');
|
||||
$foreignS3 = createS3ForTeamSwitchTest($this->teamB, 'team-b-s3');
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'team_id' => $this->teamA->id,
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => $ownS3->id,
|
||||
'database_id' => $this->serviceDatabase->id,
|
||||
'database_type' => $this->serviceDatabase->getMorphClass(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->teamB->s3s])
|
||||
->call('instantSave');
|
||||
expect($backup->fresh()->s3_storage_id)->toBe($ownS3->id)
|
||||
->and($backup->fresh()->save_s3)->toBeTruthy();
|
||||
|
||||
$component->set('s3StorageId', $foreignS3->id)->call('instantSave');
|
||||
expect($backup->fresh()->s3_storage_id)->toBe($ownS3->id);
|
||||
});
|
||||
|
||||
test('service backup executions label the S3 storage of the service team after a team switch', function () {
|
||||
$ownS3 = createS3ForTeamSwitchTest($this->teamA, 'team-a-s3');
|
||||
$schedule = ScheduledDatabaseBackup::create([
|
||||
'team_id' => $this->teamA->id,
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => $ownS3->id,
|
||||
'database_id' => $this->serviceDatabase->id,
|
||||
'database_type' => $this->serviceDatabase->getMorphClass(),
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $schedule->id, 'status' => 'success']);
|
||||
$component = Livewire::test(BackupExecutions::class, ['service' => $this->service]);
|
||||
session(['currentTeam' => $this->teamB]);
|
||||
|
||||
$component->call('$refresh')
|
||||
->assertViewHas('executions', fn ($executions) => str_contains($executions->first()['s3_tooltip'], 'team-a-s3'));
|
||||
});
|
||||
@@ -108,3 +108,44 @@ test('adding a discovered swarm destination stores the selected network name', f
|
||||
|
||||
expect(SwarmDocker::where('server_id', $server->id)->where('network', 'customer-network')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* Makes the user a member of a new team that owns a usable server, with the session on that team.
|
||||
*/
|
||||
function destinationsMemberTeamServer(User $user, bool $swarm = false): Server
|
||||
{
|
||||
$memberTeam = Team::factory()->create();
|
||||
$user->teams()->attach($memberTeam, ['role' => 'member']);
|
||||
$user->load('teams');
|
||||
session(['currentTeam' => $memberTeam]);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $memberTeam->id]);
|
||||
$server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'is_swarm_manager' => $swarm]);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
test('member cannot add a destination on the owning team server after switching to an owned team', function (bool $swarm) {
|
||||
$server = destinationsMemberTeamServer($this->user, $swarm);
|
||||
|
||||
$component = Livewire::test(Destinations::class, ['server_uuid' => $server->uuid]);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$component->call('add', 'customer-network')->assertForbidden();
|
||||
|
||||
expect(StandaloneDocker::where('network', 'customer-network')->exists())->toBeFalse()
|
||||
->and(SwarmDocker::where('network', 'customer-network')->exists())->toBeFalse();
|
||||
})->with(['standalone' => false, 'swarm' => true]);
|
||||
|
||||
test('member cannot create a docker network on the owning team server after switching to an owned team', function () {
|
||||
$server = destinationsMemberTeamServer($this->user);
|
||||
|
||||
$component = Livewire::test(Docker::class, ['server_id' => (string) $server->id]);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$component->set('network', 'customer-network')
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(StandaloneDocker::where('network', 'customer-network')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Js;
|
||||
use Illuminate\Support\Once;
|
||||
@@ -86,3 +87,52 @@ test('server private key cards include a copy public key button', function () {
|
||||
->assertSee('Alternative SSH Key')
|
||||
->assertSeeHtml('copyPublicKeyToClipboard('.Js::from($alternativeKey->public_key)->toHtml().')');
|
||||
});
|
||||
|
||||
describe('after the user switches the session team', function () {
|
||||
beforeEach(function () {
|
||||
Process::fake();
|
||||
Storage::fake('ssh-mux');
|
||||
|
||||
$this->otherTeam = Team::factory()->create();
|
||||
$this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->otherTeamKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->otherTeam->id,
|
||||
'private_key' => PrivateKey::generateNewKeyPair('ed25519')['private_key'],
|
||||
]);
|
||||
$this->sameTeamKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key' => PrivateKey::generateNewKeyPair('ed25519')['private_key'],
|
||||
]);
|
||||
|
||||
$this->component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]);
|
||||
session(['currentTeam' => $this->otherTeam]);
|
||||
Once::flush();
|
||||
});
|
||||
|
||||
test('lists only keys of the server team', function () {
|
||||
$keyIds = $this->component->call('generatePrivateKey', 'ed25519')->get('privateKeys')->pluck('id');
|
||||
|
||||
expect($keyIds)->toContain($this->sameTeamKey->id)
|
||||
->not->toContain($this->otherTeamKey->id);
|
||||
});
|
||||
|
||||
test('generates the key in the server team', function () {
|
||||
$this->component->call('generatePrivateKey', 'ed25519');
|
||||
|
||||
expect(PrivateKey::query()->latest('id')->firstOrFail()->team_id)->toBe($this->team->id);
|
||||
});
|
||||
|
||||
test('rejects a key of the session team', function () {
|
||||
$this->component->call('setPrivateKey', $this->otherTeamKey->id)
|
||||
->assertDispatched('error', 'You are not allowed to use this private key.');
|
||||
|
||||
expect($this->server->fresh()->private_key_id)->toBe($this->currentPrivateKey->id);
|
||||
});
|
||||
|
||||
test('accepts a key of the server team', function () {
|
||||
$this->component->call('setPrivateKey', $this->sameTeamKey->id)
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->server->fresh()->private_key_id)->toBe($this->sameTeamKey->id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -228,3 +229,61 @@ test('server shared variable values are only visible to admins', function () {
|
||||
|
||||
expect(json_encode($component->snapshot).$component->html())->not->toContain('stored-server-variable-value');
|
||||
});
|
||||
|
||||
/**
|
||||
* Mounts as a member of $this->team, then switches the session to a team the user owns.
|
||||
*/
|
||||
function sharedVarsSwitchToOwnedTeam(User $user): Team
|
||||
{
|
||||
$ownTeam = Team::factory()->create();
|
||||
$user->teams()->attach($ownTeam, ['role' => 'owner']);
|
||||
$user->load('teams');
|
||||
session(['currentTeam' => $ownTeam]);
|
||||
Cache::flush();
|
||||
|
||||
return $ownTeam;
|
||||
}
|
||||
|
||||
dataset('shared variable scopes', [
|
||||
'project' => fn () => [App\Livewire\SharedVariables\Project\Show::class, ['project_uuid' => test()->project->uuid], ['type' => 'project', 'project_id' => test()->project->id]],
|
||||
'environment' => fn () => [Show::class, ['project_uuid' => test()->project->uuid, 'environment_uuid' => test()->environment->uuid], ['type' => 'environment', 'environment_id' => test()->environment->id, 'project_id' => test()->project->id]],
|
||||
'server' => function () {
|
||||
$server = Server::factory()->create(['team_id' => test()->team->id]);
|
||||
|
||||
return [App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $server->uuid], ['type' => 'server', 'server_id' => $server->id]];
|
||||
},
|
||||
'team' => fn () => [Index::class, [], ['type' => 'team']],
|
||||
]);
|
||||
|
||||
test('shared variable values stay hidden from a member of the owning team after switching to an owned team', function (array $scope) {
|
||||
[$component, $params, $attributes] = $scope;
|
||||
SharedEnvironmentVariable::create([...$attributes, 'key' => 'ZZ_SECRET', 'value' => 'owning-team-secret', 'team_id' => $this->team->id]);
|
||||
$this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']);
|
||||
$this->user->refresh();
|
||||
|
||||
$livewire = Livewire::test($component, $params)
|
||||
->assertSet('variables', 'ZZ_SECRET=(Hidden, only admins can view)');
|
||||
|
||||
$ownTeam = sharedVarsSwitchToOwnedTeam($this->user);
|
||||
expect(currentTeam()->id)->toBe($ownTeam->id);
|
||||
|
||||
$livewire->call('switch')
|
||||
->call('refreshEnvs')
|
||||
->assertSet('variables', 'ZZ_SECRET=(Hidden, only admins can view)');
|
||||
|
||||
expect(json_encode($livewire->snapshot).$livewire->html())->not->toContain('owning-team-secret');
|
||||
})->with('shared variable scopes');
|
||||
|
||||
test('shared variables created after switching teams belong to the owning team', function (array $scope) {
|
||||
[$component, $params] = $scope;
|
||||
|
||||
$livewire = Livewire::test($component, $params);
|
||||
sharedVarsSwitchToOwnedTeam($this->user);
|
||||
|
||||
$livewire->call('saveKey', ['key' => 'ZZ_SAVED', 'value' => 'one', 'is_multiline' => false, 'is_literal' => false])
|
||||
->set('variables', "ZZ_SAVED=one\nZZ_BULK=two")
|
||||
->call('submit');
|
||||
|
||||
expect(SharedEnvironmentVariable::whereIn('key', ['ZZ_SAVED', 'ZZ_BULK'])->pluck('team_id', 'key')->sortKeys()->all())
|
||||
->toBe(['ZZ_BULK' => $this->team->id, 'ZZ_SAVED' => $this->team->id]);
|
||||
})->with('shared variable scopes');
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\RestartDatabase;
|
||||
use App\Actions\Database\StartClickhouse;
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Jobs\DatabaseStartJob;
|
||||
use App\Livewire\Project\Database\Clickhouse\General as ClickhouseGeneral;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false]);
|
||||
Process::fake();
|
||||
Queue::fake();
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$this->team = $team;
|
||||
$this->owner = User::factory()->create();
|
||||
$this->member = User::factory()->create();
|
||||
$team->members()->attach($this->owner->id, ['role' => 'owner']);
|
||||
$team->members()->attach($this->member->id, ['role' => 'member']);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
|
||||
]);
|
||||
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function startedClickhouseCompose(): array
|
||||
{
|
||||
$compose = null;
|
||||
Process::assertRan(function ($process) use (&$compose) {
|
||||
if (preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d \| (?:sudo )?tee \S+\/docker-compose\.yml/", $process->command, $matches)) {
|
||||
$compose = Yaml::parse(base64_decode($matches[1]));
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return $compose;
|
||||
}
|
||||
|
||||
it('keeps the data volume when a file mount is added', function () {
|
||||
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
|
||||
LocalFileVolume::create([
|
||||
'fs_path' => database_configuration_dir().'/'.$database->uuid.'/etc/clickhouse-server/users.d/extra.xml',
|
||||
'mount_path' => '/etc/clickhouse-server/users.d/extra.xml',
|
||||
'content' => '<clickhouse></clickhouse>',
|
||||
'is_directory' => false,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
]);
|
||||
|
||||
StartClickhouse::run($database->fresh(), activity()->log('Starting database'));
|
||||
|
||||
expect(startedClickhouseCompose()['services'][$database->uuid]['volumes'])->toBe([
|
||||
"clickhouse-data-{$database->uuid}:/var/lib/clickhouse",
|
||||
database_configuration_dir()."/{$database->uuid}/etc/clickhouse-server/users.d/extra.xml:/etc/clickhouse-server/users.d/extra.xml",
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Fake the remote `docker inspect` so the container's data directory is mounted from $volumeName.
|
||||
*/
|
||||
function fakeClickhouseDataMount(string $volumeName): void
|
||||
{
|
||||
Process::fake(fn ($process) => Process::result(
|
||||
output: str_contains($process->command, 'docker inspect') ? "volume {$volumeName}" : 'OK',
|
||||
));
|
||||
}
|
||||
|
||||
function ranRemoteCommandContaining(string $needle): bool
|
||||
{
|
||||
$ran = false;
|
||||
Process::assertRan(function ($process) use ($needle, &$ran) {
|
||||
$ran = $ran || str_contains($process->command, $needle);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return $ran;
|
||||
}
|
||||
|
||||
it('blocks start and restart while the current data is in an unnamed volume', function () {
|
||||
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
|
||||
fakeClickhouseDataMount('3f1c0ffee');
|
||||
|
||||
expect(StartDatabase::run($database->fresh()))->toContain('unnamed Docker volume 3f1c0ffee')
|
||||
->and(RestartDatabase::run($database->fresh()))->toContain('unnamed Docker volume 3f1c0ffee')
|
||||
->and(ranRemoteCommandContaining("docker rm -f {$database->uuid}"))->toBeFalse();
|
||||
Queue::assertNotPushed(DatabaseStartJob::class);
|
||||
});
|
||||
|
||||
it('starts a database that uses its data volume', function () {
|
||||
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
|
||||
fakeClickhouseDataMount("clickhouse-data-{$database->uuid}");
|
||||
|
||||
expect(StartDatabase::run($database->fresh()))->toBeInstanceOf(Activity::class);
|
||||
Queue::assertPushed(DatabaseStartJob::class);
|
||||
});
|
||||
|
||||
it('keeps the container on stop so the unnamed volume stays linked, except on delete', function (bool $keepAnonymousDataVolume, bool $removesContainer) {
|
||||
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
|
||||
fakeClickhouseDataMount('3f1c0ffee');
|
||||
|
||||
StopDatabase::run($database->fresh(), dockerCleanup: false, keepAnonymousDataVolume: $keepAnonymousDataVolume);
|
||||
|
||||
expect(ranRemoteCommandContaining("docker rm -f {$database->uuid}"))->toBe($removesContainer);
|
||||
})->with([
|
||||
'stop' => [true, false],
|
||||
'delete' => [false, true],
|
||||
]);
|
||||
|
||||
it('lets an admin keep the current data volume and warns about it', function () {
|
||||
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
|
||||
fakeClickhouseDataMount('3f1c0ffee');
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ClickhouseGeneral::class, ['database' => $database->fresh()])
|
||||
->assertSet('anonymousDataVolume', '3f1c0ffee')
|
||||
->assertSee('Keep current data')
|
||||
->call('keepCurrentDataVolume')
|
||||
->assertSet('anonymousDataVolume', null);
|
||||
|
||||
expect($database->persistentStorages()->where('mount_path', StandaloneClickhouse::DATA_DIRECTORY)->value('name'))->toBe('3f1c0ffee')
|
||||
->and(StartDatabase::prerequisiteError($database->fresh()))->toBeNull();
|
||||
});
|
||||
|
||||
it('does not let a member keep the current data volume', function () {
|
||||
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
|
||||
fakeClickhouseDataMount('3f1c0ffee');
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ClickhouseGeneral::class, ['database' => $database->fresh()])
|
||||
->assertSee('Current data is not in the data volume')
|
||||
->call('keepCurrentDataVolume');
|
||||
|
||||
expect($database->persistentStorages()->where('mount_path', StandaloneClickhouse::DATA_DIRECTORY)->value('name'))
|
||||
->toBe("clickhouse-data-{$database->uuid}");
|
||||
});
|
||||
|
||||
it('reads the data mount with sudo on a non-root server', function () {
|
||||
$this->server->update(['user' => 'cooluser']);
|
||||
$database = create_standalone_clickhouse($this->environment->id, $this->destination);
|
||||
fakeClickhouseDataMount('3f1c0ffee');
|
||||
|
||||
expect($database->fresh()->anonymousDataVolume())->toBe('3f1c0ffee')
|
||||
->and(ranRemoteCommandContaining("sudo docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/var/lib/clickhouse\"}}{{.Type}} {{.Name}}{{end}}{{end}}' '{$database->uuid}' 2>/dev/null || sudo true"))->toBeTrue();
|
||||
});
|
||||
@@ -649,6 +649,26 @@ describe('DELETE /api/v1/databases/{uuid}/storages/{storage_uuid}', function ()
|
||||
$response->assertStatus(200);
|
||||
expect(LocalPersistentVolume::find($vol->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('deletes a file storage', function () {
|
||||
$db = createTestDatabase($this);
|
||||
|
||||
$vol = LocalFileVolume::create([
|
||||
'fs_path' => database_configuration_dir().'/'.$db->uuid.'/postgres/postgresql.conf',
|
||||
'mount_path' => '/postgres/postgresql.conf',
|
||||
'content' => 'listen_addresses = "*"',
|
||||
'is_directory' => false,
|
||||
'resource_id' => $db->id,
|
||||
'resource_type' => $db->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->deleteJson("/api/v1/databases/{$db->uuid}/storages/{$vol->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect(LocalFileVolume::find($vol->id))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects host paths when creating persistent storage through the API', function (string $resourceType) {
|
||||
|
||||
@@ -79,3 +79,15 @@ test('member cannot create a tag through resource settings', function () {
|
||||
->assertDispatched('error');
|
||||
expect(Tag::where('name', 'member-tag')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('typing the name of an existing team tag attaches that tag', function () {
|
||||
$existing = Tag::create(['name' => 'existing-tag', 'team_id' => $this->team->id]);
|
||||
|
||||
Livewire::test(Tags::class, ['resource' => $this->application])
|
||||
->set('newTags', 'existing-tag')
|
||||
->call('submit')
|
||||
->assertNotDispatched('error');
|
||||
|
||||
expect($this->application->tags()->pluck('tags.id')->all())->toBe([$existing->id])
|
||||
->and(Tag::where('name', 'existing-tag')->count())->toBe(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user