diff --git a/infection.json5 b/infection.json5 index d557b0291..7cc6a4a27 100644 --- a/infection.json5 +++ b/infection.json5 @@ -17,7 +17,10 @@ "\\$this->logger.*", // Cache TTLs only affect expiry timing, which is not observable in a // unit test without manipulating the clock. - ".*->expiresAfter\\(.*" + ".*->expiresAfter\\(.*", + // Checklist progress items only render via the spinner, which is + // disabled without a TTY, so they are not observable in a unit test. + "\\$this->checklist->(addItem|completePreviousItem)\\(.*" ] }, "timeout": 30, diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index 250214398..a0414a2ea 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -540,6 +540,64 @@ private function rsyncFilesFromCloud(EnvironmentResponse $chosenEnvironment, Clo $this->rsyncFiles($sourceDir, $destinationDir, $outputCallback); } + protected function pullFilesArchive(InputInterface $input, OutputInterface $output, EnvironmentResponse $sourceEnvironment): void + { + $this->checklist->addItem('Copying Drupal\'s public files from the Cloud Platform'); + $site = $this->determineSite($sourceEnvironment, $input); + $this->downloadFilesArchiveFromCloud($sourceEnvironment, $this->getOutputCallback($output, $this->checklist), $site); + $this->checklist->completePreviousItem(); + } + + /** + * Download the environment's files directory as a gzipped tarball streamed + * over SSH and extract it into the local files directory. + * + * Unlike rsync, this requires no rsync binary on the local machine (only + * ssh and tar) and involves a single, fixed remote command. + * + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function downloadFilesArchiveFromCloud(EnvironmentResponse $chosenEnvironment, Closure $outputCallback, string $site): void + { + $sourceDir = $this->getCloudFilesDir($chosenEnvironment, $site); + $destinationDir = $this->getLocalFilesDir($site); + $this->localMachineHelper->checkRequiredBinariesExist(['ssh', 'tar']); + $this->localMachineHelper->getFilesystem()->mkdir($destinationDir); + + $tarballPath = tempnam(sys_get_temp_dir(), 'acli-files-'); + if ($tarballPath === false) { + throw new AcquiaCliException('Unable to create a temporary file for the downloaded archive.'); + } + + // The remote tar streams the archive to stdout and the redirect writes + // it locally, so the process exit code is the remote command's. + $command = 'ssh -o StrictHostKeyChecking=accept-new "${:SSH_URL}" "${:REMOTE_COMMAND}" > "${:TARBALL_PATH}"'; + $env = [ + 'REMOTE_COMMAND' => "tar -C $sourceDir -czf - .", + 'SSH_URL' => $chosenEnvironment->sshUrl, + 'TARBALL_PATH' => $tarballPath, + ]; + try { + $process = $this->localMachineHelper->executeFromCmd($command, $outputCallback, null, false, null, $env); + if (!$process->isSuccessful()) { + throw new AcquiaCliException('Unable to download files. {message}', ['message' => $process->getErrorOutput()]); + } + + $process = $this->localMachineHelper->execute([ + 'tar', + '-xzf', + $tarballPath, + '-C', + $destinationDir, + ], $outputCallback, null, false); + if (!$process->isSuccessful()) { + throw new AcquiaCliException('Unable to extract files. {message}', ['message' => $process->getErrorOutput()]); + } + } finally { + $this->localMachineHelper->getFilesystem()->remove($tarballPath); + } + } + protected function determineCloneProject(OutputInterface $output): bool { $finder = $this->localMachineHelper->getFinder() diff --git a/src/Command/Pull/PullFilesArchiveCommand.php b/src/Command/Pull/PullFilesArchiveCommand.php new file mode 100644 index 000000000..52a045947 --- /dev/null +++ b/src/Command/Pull/PullFilesArchiveCommand.php @@ -0,0 +1,35 @@ +acceptEnvironmentId() + ->acceptSite() + ->acceptSiteInstanceId(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->setDirAndRequireProjectCwd($input); + + $sourceEnvironment = $this->determineEnvironment($input, $output, true); + + $this->pullFilesArchive($input, $output, $sourceEnvironment); + + return Command::SUCCESS; + } +} diff --git a/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index e0ad1a608..5a28d910c 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -89,6 +89,7 @@ private function getEnd(): string pull:code Copy code from a Cloud Platform environment pull:database [pull:db] Import database backup from a Cloud Platform environment pull:files Copy Drupal public files from a Cloud Platform environment to your local environment + pull:files-archive Copy Drupal public files from a Cloud Platform environment to your local environment as a tar archive streamed over SSH (does not require rsync) pull:run-scripts Execute post pull scripts push push:artifact Build and push a code artifact to a Cloud Platform environment diff --git a/tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php b/tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php new file mode 100644 index 000000000..86d8ed292 --- /dev/null +++ b/tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php @@ -0,0 +1,188 @@ +httpClientProphecy = $this->prophet->prophesize(Client::class); + + return new PullFilesArchiveCommand( + $this->localMachineHelper, + $this->datastoreCloud, + $this->datastoreAcli, + $this->cloudCredentials, + $this->telemetryHelper, + $this->acliRepoRoot, + $this->clientServiceProphecy->reveal(), + $this->sshHelper, + $this->sshDir, + $this->logger, + $this->selfUpdateManager, + $this->httpClientProphecy->reveal() + ); + } + + /** + * @throws \Exception + */ + public function testPullFilesArchiveCloud(): void + { + $applicationsResponse = $this->mockApplicationsRequest(); + $this->mockApplicationRequest(); + $environmentsResponse = $this->mockEnvironmentsRequest($applicationsResponse); + $selectedEnvironment = $environmentsResponse->_embedded->items[0]; + $sshHelper = $this->mockSshHelper(); + $this->mockGetCloudSites($sshHelper, $selectedEnvironment); + $localMachineHelper = $this->mockLocalMachineHelper(); + $parts = explode('.', $selectedEnvironment->ssh_url); + $sitegroup = reset($parts); + $this->mockExecuteFilesArchiveDownload( + $localMachineHelper, + $selectedEnvironment, + '/mnt/files/' . $sitegroup . '.' . $selectedEnvironment->name . '/sites/default/files', + $this->projectDir . '/docroot/sites/default/files' + ); + + $this->command->sshHelper = $sshHelper->reveal(); + + $inputs = [ + // Would you like Acquia CLI to search for a Cloud application that matches your local git config? + 'n', + // Select a Cloud Platform application: + 0, + // Would you like to link the project at ... ? + 'n', + // Choose an Acquia environment: + 0, + // Choose site from which to copy files: + 0, + ]; + + $this->executeCommand([], $inputs); + + $output = $this->getDisplay(); + + $this->assertStringContainsString('Select a Cloud Platform application', $output); + $this->assertStringContainsString('[0] Sample application 1', $output); + $this->assertStringContainsString('Choose a Cloud Platform environment', $output); + $this->assertStringContainsString('[0] Dev, dev (vcs: master)', $output); + // Production environments must be offered (determineEnvironment is + // called with $allowProduction = true). + $this->assertStringContainsString('Production, prod', $output); + } + + /** + * @throws \Exception + */ + public function testPullFilesArchiveCloudDownloadFails(): void + { + $applicationsResponse = $this->mockApplicationsRequest(); + $this->mockApplicationRequest(); + $environmentsResponse = $this->mockEnvironmentsRequest($applicationsResponse); + $selectedEnvironment = $environmentsResponse->_embedded->items[0]; + $sshHelper = $this->mockSshHelper(); + $this->mockGetCloudSites($sshHelper, $selectedEnvironment); + $localMachineHelper = $this->mockLocalMachineHelper(); + $localMachineHelper->checkRequiredBinariesExist(['ssh', 'tar']) + ->shouldBeCalled(); + $fileSystem = $this->prophet->prophesize(Filesystem::class); + $localMachineHelper->getFilesystem() + ->willReturn($fileSystem->reveal()) + ->shouldBeCalled(); + $fileSystem->mkdir(Argument::type('string')) + ->shouldBeCalled(); + // The temp tarball must be cleaned up even when the download fails. + $fileSystem->remove(Argument::type('string')) + ->shouldBeCalled(); + $failedProcess = $this->mockProcess(false); + $localMachineHelper->executeFromCmd( + Argument::containingString('"${:REMOTE_COMMAND}"'), + Argument::type('callable'), + null, + false, + null, + Argument::type('array') + ) + ->willReturn($failedProcess->reveal()) + ->shouldBeCalled(); + + $this->command->sshHelper = $sshHelper->reveal(); + + $inputs = [ + // Would you like Acquia CLI to search for a Cloud application that matches your local git config? + 'n', + // Select a Cloud Platform application: + 0, + // Would you like to link the project at ... ? + 'n', + // Choose an Acquia environment: + 0, + // Choose site from which to copy files: + 0, + ]; + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Unable to download files. error'); + $this->executeCommand([], $inputs); + } + + protected function mockExecuteFilesArchiveDownload( + LocalMachineHelper|ObjectProphecy $localMachineHelper, + mixed $environment, + string $sourceDir, + string $destinationDir + ): void { + $process = $this->mockProcess(); + $localMachineHelper->checkRequiredBinariesExist(['ssh', 'tar']) + ->shouldBeCalled(); + $fileSystem = $this->prophet->prophesize(Filesystem::class); + $localMachineHelper->getFilesystem() + ->willReturn($fileSystem->reveal()) + ->shouldBeCalled(); + $fileSystem->mkdir($destinationDir) + ->shouldBeCalled(); + // Note: tempnam() truncates the prefix to three characters on Windows, + // so the temp path cannot be matched more precisely than "a string". + $fileSystem->remove(Argument::type('string')) + ->shouldBeCalled(); + $localMachineHelper->executeFromCmd( + Argument::containingString('ssh -o StrictHostKeyChecking=accept-new "${:SSH_URL}" "${:REMOTE_COMMAND}"'), + Argument::type('callable'), + null, + false, + null, + Argument::that(static function (array $env) use ($environment, $sourceDir): bool { + return $env['SSH_URL'] === $environment->ssh_url + && $env['REMOTE_COMMAND'] === "tar -C $sourceDir -czf - ."; + }) + ) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $localMachineHelper->execute( + Argument::that(static function (array $command) use ($destinationDir): bool { + return $command[0] === 'tar' + && $command[1] === '-xzf' + && $command[3] === '-C' + && $command[4] === $destinationDir; + }), + Argument::type('callable'), + null, + false + ) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + } +}