Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions src/Command/Pull/PullCommandBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
35 changes: 35 additions & 0 deletions src/Command/Pull/PullFilesArchiveCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Acquia\Cli\Command\Pull;

use Acquia\Cli\Attribute\RequireAuth;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[RequireAuth]
#[AsCommand(name: 'pull:files-archive', description: 'Copy Drupal public files from a Cloud Platform environment to your local environment as a tar archive streamed over SSH (does not require rsync)')]
final class PullFilesArchiveCommand extends PullCommandBase
{
protected function configure(): void
{
$this
->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;
}
}
1 change: 1 addition & 0 deletions tests/phpunit/src/Application/KernelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
188 changes: 188 additions & 0 deletions tests/phpunit/src/Commands/Pull/PullFilesArchiveCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<?php

declare(strict_types=1);

namespace Acquia\Cli\Tests\Commands\Pull;

use Acquia\Cli\Command\CommandBase;
use Acquia\Cli\Command\Pull\PullFilesArchiveCommand;
use Acquia\Cli\Exception\AcquiaCliException;
use Acquia\Cli\Helpers\LocalMachineHelper;
use GuzzleHttp\Client;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Symfony\Component\Filesystem\Filesystem;

class PullFilesArchiveCommandTest extends PullCommandTestBase
{
protected function createCommand(): CommandBase
{
$this->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();
}
}
Loading