withoutVite(); Server::flushIdentityMap(); InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true])); config(['constants.ssh.mux_enabled' => false]); Bus::fake(); Queue::fake(); Process::fake(['*' => Process::result(output: '')]); $this->user = User::factory()->create(); $this->team = Team::factory()->create(); $this->user->teams()->attach($this->team, ['role' => 'owner']); $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = $this->project->environments()->first() ?? Environment::factory()->create(['project_id' => $this->project->id]); $this->service = Service::factory()->create([ 'docker_compose_raw' => CLONE_SERVICE_COMPOSE, 'environment_id' => $this->environment->id, 'server_id' => $server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $this->service->parse(); $this->actingAs($this->user); session(['currentTeam' => $this->team]); }); /** * The source and target volume names of the VolumeCloneJobs in the dispatched chain, in chain order. * * @return array */ function chainedServiceVolumeClones(): array { $first = Bus::dispatched(CallQueuedClosure::class)->first() ?? Bus::dispatched(VolumeCloneJob::class)->first(); expect($first)->not->toBeNull('No volume clone chain was dispatched.'); return collect([$first, ...array_map(fn (string $job) => unserialize($job), $first->chained)]) ->filter(fn ($job) => $job instanceof VolumeCloneJob) ->map(fn (VolumeCloneJob $job) => (fn () => [$this->sourceVolume, $this->targetVolume])->call($job)) ->values() ->all(); } /** * @return array */ function serviceVolumeNames(Service $service): array { $resources = $service->applications()->get()->concat($service->databases()->get()); return LocalPersistentVolume::query() ->where(function ($query) use ($resources) { foreach ($resources as $resource) { $query->orWhere(fn ($query) => $query ->where('resource_type', $resource->getMorphClass()) ->where('resource_id', $resource->id)); } }) ->orderBy('name') ->pluck('name') ->all(); } /** * @return array */ function expectedServiceVolumeClones(Service $source, Service $clone): array { return collect(['web-data', 'db-data']) ->map(fn (string $volume) => ["{$source->uuid}_{$volume}", "{$clone->uuid}_{$volume}"]) ->all(); } test('cloning a service from resource operations copies the data of its named volumes', function () { Livewire::test(ResourceOperations::class, ['resource' => $this->service]) ->set('cloneVolumeData', true) ->call('cloneTo', $this->destination->uuid) ->assertHasNoErrors(); $clone = Service::query()->whereKeyNot($this->service->id)->sole(); expect(chainedServiceVolumeClones())->toEqualCanonicalizing(expectedServiceVolumeClones($this->service, $clone)) ->and(serviceVolumeNames($clone))->toBe(["{$clone->uuid}_db-data", "{$clone->uuid}_web-data"]); }); test('cloning an environment copies the data of the named volumes of its services', function () { Livewire::test(CloneMe::class, [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid, ]) ->set('selectedDestination', $this->destination->uuid) ->set('newName', 'cloned-env') ->set('cloneVolumeData', true) ->call('clone', 'environment') ->assertHasNoErrors(); $clone = Service::query()->whereKeyNot($this->service->id)->sole(); expect(chainedServiceVolumeClones())->toEqualCanonicalizing(expectedServiceVolumeClones($this->service, $clone)) ->and(serviceVolumeNames($clone))->toBe(["{$clone->uuid}_db-data", "{$clone->uuid}_web-data"]); }); test('cloning a service through the API copies the data of its named volumes', function () { $plainTextToken = Str::random(40); $token = $this->user->tokens()->create([ 'name' => 'clone-service-test', 'token' => hash('sha256', $plainTextToken), 'abilities' => ['*'], 'team_id' => $this->team->id, ]); auth()->forgetGuards(); $this->withHeaders(['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]) ->postJson("/api/v1/services/{$this->service->uuid}/clone", [ 'destination_uuid' => $this->destination->uuid, 'clone_volumes' => true, ]) ->assertCreated(); $clone = Service::query()->whereKeyNot($this->service->id)->sole(); expect(chainedServiceVolumeClones())->toEqualCanonicalizing(expectedServiceVolumeClones($this->service, $clone)); }); test('cloning a service without volume data dispatches no volume copy', function () { Livewire::test(ResourceOperations::class, ['resource' => $this->service]) ->call('cloneTo', $this->destination->uuid) ->assertHasNoErrors(); $clone = Service::query()->whereKeyNot($this->service->id)->sole(); Bus::assertNotDispatched(CallQueuedClosure::class); Bus::assertNotDispatched(VolumeCloneJob::class); expect(serviceVolumeNames($clone))->toHaveCount(2); }); test('cloning a cloned service replaces the clone suffix instead of adding another one', function () { $this->service->update(['name' => 'Gitea Runner']); $source = $this->service; foreach (range(1, 10) as $ignored) { Livewire::test(ResourceOperations::class, ['resource' => $source]) ->call('cloneTo', $this->destination->uuid) ->assertHasNoErrors(); $source = Service::query()->latest('id')->firstOrFail(); } expect($source->name)->toBe("Gitea Runner-clone-{$source->uuid}"); }); test('cloning a service to another server keeps its generated database credentials and copies its volumes there', function () { $this->service->update(['docker_compose_raw' => <<<'YAML' services: freshrss: image: freshrss/freshrss:latest environment: - SERVICE_URL_FRESHRSS_80 - MARIADB_USER=${SERVICE_USER_MARIADB} - MARIADB_PASSWORD=${SERVICE_PASSWORD_MARIADB} volumes: - freshrss-data:/var/www/FreshRSS/data freshrss-db: image: mariadb:11 environment: - MYSQL_ROOT_PASSWORD=$SERVICE_PASSWORD_ROOT - MYSQL_USER=${SERVICE_USER_MARIADB} - MYSQL_PASSWORD=${SERVICE_PASSWORD_MARIADB} volumes: - mariadb-data:/var/lib/mysql YAML]); $this->service->parse(); $sourceCredentials = $this->service->environment_variables() ->whereIn('key', ['SERVICE_USER_MARIADB', 'SERVICE_PASSWORD_MARIADB', 'SERVICE_PASSWORD_ROOT']) ->pluck('value', 'key') ->all(); expect($sourceCredentials)->toHaveCount(3); $nodeB = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->service->server->private_key_id]); $nodeB->settings()->update(['is_reachable' => true, 'is_usable' => true]); $nodeBDestination = StandaloneDocker::where('server_id', $nodeB->id)->firstOrFail(); Livewire::test(ResourceOperations::class, ['resource' => $this->service]) ->set('cloneVolumeData', true) ->call('cloneTo', $nodeBDestination->uuid) ->assertHasNoErrors(); $clone = Service::query()->whereKeyNot($this->service->id)->sole(); $cloneCredentials = $clone->environment_variables() ->whereIn('key', array_keys($sourceCredentials)) ->pluck('value', 'key') ->all(); expect($clone->server_id)->toBe($nodeB->id) ->and($cloneCredentials)->toEqual($sourceCredentials) ->and($clone->environment_variables()->where('key', 'SERVICE_PASSWORD_MARIADB')->count())->toBe(1) ->and(chainedServiceVolumeClones())->toEqualCanonicalizing([ ["{$this->service->uuid}_freshrss-data", "{$clone->uuid}_freshrss-data"], ["{$this->service->uuid}_mariadb-data", "{$clone->uuid}_mariadb-data"], ]); });