From 064c2d46ab1472573a25c48edefefdb24d72e41f Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Mon, 17 Aug 2026 16:03:33 -0400 Subject: [PATCH 1/3] Add pull:files-archive command Adds a pull:files-archive command that copies Drupal public files from a Cloud Platform environment by streaming a gzipped tarball over SSH and extracting it locally. Unlike pull:files, it does not require a local rsync binary (only ssh and tar), which makes it usable on Windows and in minimal CI containers, and it issues a single fixed remote command with straightforward failure modes: the download process exit code is the remote tar's, and extraction is a separate, separately-reported step. --- src/Command/Pull/PullCommandBase.php | 58 ++++++ src/Command/Pull/PullFilesArchiveCommand.php | 35 ++++ tests/phpunit/src/Application/KernelTest.php | 1 + .../Pull/PullFilesArchiveCommandTest.php | 167 ++++++++++++++++++ 4 files changed, 261 insertions(+) create mode 100644 src/Command/Pull/PullFilesArchiveCommand.php create mode 100644 tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php 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..033448824 --- /dev/null +++ b/tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php @@ -0,0 +1,167 @@ +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(); + $this->mockGetFilesystem($localMachineHelper); + $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); + } + + /** + * @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(); + $this->mockGetFilesystem($localMachineHelper); + $localMachineHelper->checkRequiredBinariesExist(['ssh', 'tar']) + ->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.'); + $this->executeCommand([], $inputs); + } + + protected function mockExecuteFilesArchiveDownload( + LocalMachineHelper|ObjectProphecy $localMachineHelper, + mixed $environment, + string $sourceDir, + string $destinationDir + ): void { + $process = $this->mockProcess(); + $localMachineHelper->checkRequiredBinariesExist(['ssh', 'tar']) + ->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(); + } +} From df2887057719fe13470b375ea642b66cfca3af96 Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Mon, 17 Aug 2026 16:19:32 -0400 Subject: [PATCH 2/3] Kill escaped mutants in pull:files-archive coverage - Assert production environments are offered (kills the TrueValue mutant on determineEnvironment's allowProduction argument). - Mock the Filesystem and require mkdir and temp-tarball removal (kills the mkdir MethodCallRemoval, the finally-block remove MethodCallRemoval, and the UnwrapFinally mutants). - Assert the interpolated exception message (kills the ArrayItem and ArrayItemRemoval mutants on the error context array). - Ignore Checklist addItem/completePreviousItem in Infection: they only render via the spinner, which is disabled without a TTY, so they are not observable in a unit test (same rationale as the existing logger ignore). --- infection.json5 | 5 ++++- .../Commands/Pull/PullFilesArchiveCommandTest.php | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) 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/tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php b/tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php index 033448824..c92471489 100644 --- a/tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php @@ -11,6 +11,7 @@ use GuzzleHttp\Client; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Filesystem\Filesystem; class PullFilesArchiveCommandTest extends PullCommandTestBase { @@ -46,7 +47,6 @@ public function testPullFilesArchiveCloud(): void $sshHelper = $this->mockSshHelper(); $this->mockGetCloudSites($sshHelper, $selectedEnvironment); $localMachineHelper = $this->mockLocalMachineHelper(); - $this->mockGetFilesystem($localMachineHelper); $parts = explode('.', $selectedEnvironment->ssh_url); $sitegroup = reset($parts); $this->mockExecuteFilesArchiveDownload( @@ -79,6 +79,9 @@ public function testPullFilesArchiveCloud(): void $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); } /** @@ -124,7 +127,7 @@ public function testPullFilesArchiveCloudDownloadFails(): void ]; $this->expectException(AcquiaCliException::class); - $this->expectExceptionMessage('Unable to download files.'); + $this->expectExceptionMessage('Unable to download files. error'); $this->executeCommand([], $inputs); } @@ -137,6 +140,14 @@ protected function mockExecuteFilesArchiveDownload( $process = $this->mockProcess(); $localMachineHelper->checkRequiredBinariesExist(['ssh', 'tar']) ->shouldBeCalled(); + $fileSystem = $this->prophet->prophesize(Filesystem::class); + $localMachineHelper->getFilesystem() + ->willReturn($fileSystem->reveal()) + ->shouldBeCalled(); + $fileSystem->mkdir($destinationDir) + ->shouldBeCalled(); + $fileSystem->remove(Argument::containingString('acli-files-')) + ->shouldBeCalled(); $localMachineHelper->executeFromCmd( Argument::containingString('ssh -o StrictHostKeyChecking=accept-new "${:SSH_URL}" "${:REMOTE_COMMAND}"'), Argument::type('callable'), From 12ae085a8178ceb1ac765e62e9204f7427dbe959 Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Mon, 17 Aug 2026 16:38:34 -0400 Subject: [PATCH 3/3] Fix Windows temp-path assertion and kill UnwrapFinally mutant tempnam() truncates the prefix to three characters on Windows, so the temp tarball path cannot be matched by prefix; match any string. Assert temp-tarball cleanup in the failure test as well, since the finally block is only observable when the download throws. --- .../Commands/Pull/PullFilesArchiveCommandTest.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php b/tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php index c92471489..86d8ed292 100644 --- a/tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php @@ -96,9 +96,17 @@ public function testPullFilesArchiveCloudDownloadFails(): void $sshHelper = $this->mockSshHelper(); $this->mockGetCloudSites($sshHelper, $selectedEnvironment); $localMachineHelper = $this->mockLocalMachineHelper(); - $this->mockGetFilesystem($localMachineHelper); $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}"'), @@ -146,7 +154,9 @@ protected function mockExecuteFilesArchiveDownload( ->shouldBeCalled(); $fileSystem->mkdir($destinationDir) ->shouldBeCalled(); - $fileSystem->remove(Argument::containingString('acli-files-')) + // 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}"'),