Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
faaad8a
validating backup link before importing database
kalindi-adhiya Jan 28, 2026
37e199c
test failing fix
kalindi-adhiya Jan 28, 2026
0a58a1d
code cov improvement
kalindi-adhiya Jan 28, 2026
0f22eba
code cov improvment
kalindi-adhiya Jan 28, 2026
343becc
code cov improvement
kalindi-adhiya Jan 28, 2026
3bd4c75
code cov fix
kalindi-adhiya Jan 28, 2026
4275303
corrected import
kalindi-adhiya Jan 28, 2026
39a8333
updated testcases for windows
kalindi-adhiya Jan 29, 2026
c767a92
widows test fix
kalindi-adhiya Jan 29, 2026
ccb527a
windows test failing: fix
kalindi-adhiya Jan 29, 2026
4433969
windows test fix
kalindi-adhiya Jan 29, 2026
c293862
windows test fix
kalindi-adhiya Jan 30, 2026
db07310
Merge branch 'main' into CLI-1631
jigar-shah-acquia Feb 6, 2026
c6ab7ff
addressed co pilot reviews
kalindi-adhiya Feb 9, 2026
394f17e
addressed reviews: removed gzip and empty file validations
kalindi-adhiya Feb 10, 2026
95d55fe
mutation fix
kalindi-adhiya Feb 10, 2026
e9198b3
mutation fix
kalindi-adhiya Feb 10, 2026
79f56b7
mutation fix
kalindi-adhiya Feb 10, 2026
eee2337
mutation fix
kalindi-adhiya Feb 10, 2026
32e4306
mutation fix
kalindi-adhiya Feb 10, 2026
549cebf
windows test fix
kalindi-adhiya Feb 10, 2026
262088b
mutation fix
kalindi-adhiya Feb 10, 2026
3b22ba8
mutation fix
kalindi-adhiya Feb 10, 2026
4dffde7
Update src/Command/Pull/PullCommandBase.php
kalindi-adhiya Feb 10, 2026
5c42e98
addressed co pilot comments
kalindi-adhiya Feb 10, 2026
051c5c6
mutation fix
kalindi-adhiya Feb 11, 2026
494e4cc
mutation fix
kalindi-adhiya Feb 11, 2026
77a4588
Merge branch 'main' into CLI-1631
kalindi-adhiya Feb 11, 2026
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
94 changes: 78 additions & 16 deletions src/Command/Pull/PullCommandBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use Exception;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\TransferStats;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;
use React\EventLoop\Loop;
Expand Down Expand Up @@ -68,6 +69,26 @@ public function __construct(
parent::__construct($this->localMachineHelper, $this->datastoreCloud, $this->datastoreAcli, $this->cloudCredentials, $this->telemetryHelper, $this->projectDir, $this->cloudApiClientService, $this->sshHelper, $this->sshDir, $logger, $this->selfUpdateManager);
}

/**
* Get the backup download URL.
*/
protected function getBackupDownloadUrl(): ?UriInterface
{
return $this->backupDownloadUrl ?? null;
}

/**
* Set the backup download URL.
*/
protected function setBackupDownloadUrl(string|UriInterface $url): void
{
if (is_string($url)) {
$this->backupDownloadUrl = new \GuzzleHttp\Psr7\Uri($url);
} else {
$this->backupDownloadUrl = $url;
}
}

/**
* @see https://github.com/drush-ops/drush/blob/c21a5a24a295cc0513bfdecead6f87f1a2cf91a2/src/Sql/SqlMysql.php#L168
* @return string[]
Expand All @@ -94,22 +115,27 @@ private function listTablesQuoted(string $out): array
return $tables;
}

public static function getBackupPath(object $environment, DatabaseResponse $database, object $backupResponse): string
protected static function getBackupPath(object $environment, DatabaseResponse $database, object $backupResponse): string
{
// Databases have a machine name not exposed via the API; we can only
// approximately reconstruct it and match the filename you'd get downloading
// a backup from Cloud UI.
if ($database->flags->default) {
$dbMachineName = $database->name . $environment->name;
} else {
$dbMachineName = 'db' . $database->id;
}
$filename = implode('-', [
$environment->name,
$database->name,
$dbMachineName,
$backupResponse->completedAt,
]) . '.sql.gz';
if (PHP_OS_FAMILY === 'Windows') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why change this? I don't see anything in the parent ticket mentioning Windows compatibility

Copy link
Contributor Author

@kalindi-adhiya kalindi-adhiya Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@danepowell it was due to failing testcases specifically for windows

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that was meant for me, but I'm glad you found the problem!

// Use short filename to comply with 8.3 format and avoid long path issues.
// The hash-based filename still preserves the '.sql.gz' extension.
$hash = substr(md5($environment->name . $database->name . $dbMachineName . $backupResponse->completedAt), 0, 8);
$filename = $hash . '.sql.gz';
} else {
$completedAtFormatted = $backupResponse->completedAt;
$filename = implode('-', [
$environment->name,
$database->name,
$dbMachineName,
$completedAtFormatted,
]) . '.sql.gz';
}
return Path::join(sys_get_temp_dir(), $filename);
}

Expand Down Expand Up @@ -261,19 +287,21 @@ static function (mixed $totalBytes, mixed $downloadedBytes) use (&$progress, $ou
if ($codebaseUuid) {
// Download the backup file directly from the provided URL.
$downloadUrl = $backupResponse->links->download->href;
$this->httpClient->request('GET', $downloadUrl, [
$response = $this->httpClient->request('GET', $downloadUrl, [
'progress' => static function (mixed $totalBytes, mixed $downloadedBytes) use (&$progress, $output): void {
self::displayDownloadProgress($totalBytes, $downloadedBytes, $progress, $output);
},
'sink' => $localFilepath,
]);
$this->validateDownloadResponse($response, $localFilepath);
return $localFilepath;
}
$acquiaCloudClient->stream(
"get",
"/environments/$environment->uuid/databases/$database->name/backups/$backupResponse->id/actions/download",
$acquiaCloudClient->getOptions()
);
$this->validateDownloadedFile($localFilepath);
return $localFilepath;
} catch (RequestException $exception) {
// Deal with broken SSL certificates.
Expand Down Expand Up @@ -308,17 +336,51 @@ static function (mixed $totalBytes, mixed $downloadedBytes) use (&$progress, $ou
throw new AcquiaCliException('Could not download backup');
}

public function setBackupDownloadUrl(UriInterface $url): void
/**
* Validates the HTTP response from a database backup download request.
*
* @param string $localFilepath The local file path where the backup was downloaded
* @throws \Acquia\Cli\Exception\AcquiaCliException If the response is invalid
*/
private function validateDownloadResponse(ResponseInterface $response, string $localFilepath): void
{
$this->backupDownloadUrl = $url;
$statusCode = $response->getStatusCode();

// Check for successful HTTP response (any 2xx status).
if ($statusCode < 200 || $statusCode >= 300) {
// Clean up the potentially corrupted file.
if (file_exists($localFilepath)) {
$this->localMachineHelper->getFilesystem()->remove($localFilepath);
}
throw new AcquiaCliException(
'Database backup download failed with HTTP status {status}',
['status' => $statusCode]
);
}

// Validate the downloaded file.
$this->validateDownloadedFile($localFilepath);
}

private function getBackupDownloadUrl(): ?UriInterface
/**
* Validates that the downloaded backup file exists.
*
* @param string $localFilepath The local file path to validate
* @throws \Acquia\Cli\Exception\AcquiaCliException If the file is invalid
*/
private function validateDownloadedFile(string $localFilepath): void
{
return $this->backupDownloadUrl ?? null;
// Check if file exists.
if (!file_exists($localFilepath)) {
throw new AcquiaCliException(
'Database backup download failed: file was not created'
);
}

// File exists - assume it's valid since it comes from trusted Acquia API.
}

public static function displayDownloadProgress(mixed $totalBytes, mixed $downloadedBytes, mixed &$progress, OutputInterface $output): void
protected static function displayDownloadProgress(mixed $totalBytes, mixed $downloadedBytes, mixed &$progress, OutputInterface $output): void
{
if ($totalBytes > 0 && is_null($progress)) {
$progress = new ProgressBar($output, $totalBytes);
Expand Down
159 changes: 124 additions & 35 deletions tests/phpunit/src/Commands/Pull/PullCommandTestBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -311,13 +311,6 @@ protected function mockExecuteMySqlImport(
$process = $this->mockProcess($success);
$filePath = Path::join(sys_get_temp_dir(), "$env-$dbName-$dbMachineName-$createdAt.sql.gz");
$command = $pvExists ? 'pv "${:LOCAL_DUMP_FILEPATH}" --bytes --rate | gunzip | MYSQL_PWD="${:MYSQL_PASSWORD}" mysql --host="${:MYSQL_HOST}" --user="${:MYSQL_USER}" "${:MYSQL_DATABASE}"' : 'gunzip -c "${:LOCAL_DUMP_FILEPATH}" | MYSQL_PWD="${:MYSQL_PASSWORD}" mysql --host="${:MYSQL_HOST}" --user="${:MYSQL_USER}" "${:MYSQL_DATABASE}"';
$expectedEnv = [
'LOCAL_DUMP_FILEPATH' => $filePath,
'MYSQL_DATABASE' => $localDbName,
'MYSQL_HOST' => 'localhost',
'MYSQL_PASSWORD' => 'drupal',
'MYSQL_USER' => 'drupal',
];
// MySQL import command.
$localMachineHelper
->executeFromCmd(
Expand All @@ -328,14 +321,33 @@ protected function mockExecuteMySqlImport(
return $printOutput === false;
}),
null,
Argument::that(function ($env) use ($expectedEnv) {
if (!is_array($env)) {
Argument::that(function ($envVars) use ($localDbName) {
// On Windows, the filepath is in 8.3 format (hashed),
// so we can't do strict matching. We just verify that
// the required environment variables exist with expected values.
if (!is_array($envVars)) {
return false;
}
foreach ($expectedEnv as $k => $v) {
if (!array_key_exists($k, $env) || $env[$k] !== $v) {
return false;
}
// Check required env vars exist (values vary by platform for LOCAL_DUMP_FILEPATH)
if (!array_key_exists('LOCAL_DUMP_FILEPATH', $envVars)) {
return false;
}
// Verify the filepath ends with expected suffix and is a valid gzip file.
if (!str_ends_with($envVars['LOCAL_DUMP_FILEPATH'], '.sql.gz')) {
return false;
}
// Verify other required env vars.
if (!array_key_exists('MYSQL_DATABASE', $envVars) || $envVars['MYSQL_DATABASE'] !== $localDbName) {
return false;
}
if (!array_key_exists('MYSQL_HOST', $envVars) || $envVars['MYSQL_HOST'] !== 'localhost') {
return false;
}
if (!array_key_exists('MYSQL_PASSWORD', $envVars) || $envVars['MYSQL_PASSWORD'] !== 'drupal') {
return false;
}
if (!array_key_exists('MYSQL_USER', $envVars) || $envVars['MYSQL_USER'] !== 'drupal') {
return false;
}
return true;
})
Expand Down Expand Up @@ -387,7 +399,7 @@ public function mockGetBackup(mixed $environment): void
$this->mockDownloadBackup($databases[0], $environment, $backups[0]);
}

protected function mockDownloadBackup(object $database, object $environment, object $backup, int $curlCode = 0): object
protected function mockDownloadBackup(object $database, object $environment, object $backup, int $curlCode = 0, string $validationError = ''): object
{
if ($curlCode) {
$this->prophet->prophesize(StreamInterface::class);
Expand All @@ -405,7 +417,8 @@ protected function mockDownloadBackup(object $database, object $environment, obj
$domainsResponse = self::getMockResponseFromSpec('/environments/{environmentId}/domains', 'get', 200);
$this->clientProphecy->request('get', "/environments/$environment->id/domains")
->willReturn($domainsResponse->_embedded->items);
$this->command->setBackupDownloadUrl(new Uri('https://www.example.com/download-backup'));
$method = new \ReflectionMethod($this->command, 'setBackupDownloadUrl');
$method->invoke($this->command, new Uri('https://www.example.com/download-backup'));
} else {
$this->mockDownloadBackupResponse($environment, $database->name, 1);
}
Expand All @@ -414,13 +427,37 @@ protected function mockDownloadBackup(object $database, object $environment, obj
} else {
$dbMachineName = 'db' . $database->id;
}
$filename = implode('-', [
$environment->name,
$database->name,
$dbMachineName,
$backup->completedAt,
]) . '.sql.gz';
if (PHP_OS_FAMILY === 'Windows') {
// Use short filename to comply with 8.3 format and avoid long path issues.
$hash = substr(md5($environment->name . $database->name . $dbMachineName . $backup->completedAt), 0, 8);
$filename = $hash . '.sql.gz';
} else {
$completedAtFormatted = $backup->completedAt;
$filename = implode('-', [
$environment->name,
$database->name,
$dbMachineName,
$completedAtFormatted,
]) . '.sql.gz';
}
$localFilepath = Path::join(sys_get_temp_dir(), $filename);

// Create file based on validation error type.
switch ($validationError) {
case 'missing':
// Don't create a file to test missing file validation.
if (file_exists($localFilepath)) {
unlink($localFilepath);
}
break;
default:
// Create a valid gzip file for normal testing.
$content = 'Mock SQL dump content for testing';
$gzippedContent = gzencode($content);
file_put_contents($localFilepath, $gzippedContent);
break;
}

$this->clientProphecy->addOption('sink', $localFilepath)
->shouldBeCalled();
$this->clientProphecy->addOption('curl.options', [
Expand All @@ -435,14 +472,32 @@ protected function mockDownloadBackup(object $database, object $environment, obj

return $database;
}
protected function mockDownloadCodebaseBackup(object $database, string $url, object $backup, int $curlCode = 0): object

protected function mockDownloadCodebaseBackup(object $database, string $url, object $backup, int $curlCode = 0, string $validationError = '', int $httpStatusCode = 0): object
{
$filename = implode('-', [
'environment_3e8ecbec-ea7c-4260-8414-ef2938c859bc',
$database->name ?? 'example',
'dbexample',
'2025-04-01T13:01:06.603Z',
]) . '.sql.gz';
// Calculate dbMachineName the same way as getBackupPath.
$environment = (object) ['name' => 'environment_3e8ecbec-ea7c-4260-8414-ef2938c859bc'];
if (isset($database->flags->default) && $database->flags->default) {
$dbMachineName = $database->name . $environment->name;
} else {
$dbMachineName = 'db' . ($database->id ?? 'example');
}

$completedAtFormatted = $backup->completedAt ?? '2025-04-01T13:01:06.603Z';

// Use the same filename generation logic as getBackupPath() to ensure consistency.
// On Windows, use short filename to comply with 8.3 format and avoid long path issues.
if (PHP_OS_FAMILY === 'Windows') {
$hash = substr(md5($environment->name . ($database->name ?? 'example') . $dbMachineName . $completedAtFormatted), 0, 8);
$filename = $hash . '.sql.gz';
} else {
$filename = implode('-', [
$environment->name,
$database->name ?? 'example',
$dbMachineName,
$completedAtFormatted,
]) . '.sql.gz';
}
$localFilepath = Path::join(sys_get_temp_dir(), $filename);

// Cloud API client options are always set first.
Expand All @@ -461,7 +516,13 @@ protected function mockDownloadCodebaseBackup(object $database, string $url, obj

// Mock the HTTP client request for codebase downloads.
$downloadUrl = $backup->links->download->href ?? 'https://example.com/download-backup';
$response = $this->prophet->prophesize(ResponseInterface::class);
// Allow explicit HTTP status code override; otherwise infer from validationError.
if ($httpStatusCode !== 0) {
$statusCode = $httpStatusCode;
} else {
$statusCode = $validationError === 'http_error' ? 500 : 200;
}
$response = new \GuzzleHttp\Psr7\Response($statusCode);

$capturedOpts = null;
$this->httpClientProphecy
Expand All @@ -484,13 +545,41 @@ protected function mockDownloadCodebaseBackup(object $database, string $url, obj
return true;
})
)
->will(function () use (&$capturedOpts, $response): ResponseInterface {
->will(function () use (&$capturedOpts, $response, $localFilepath, $validationError): \Psr\Http\Message\ResponseInterface {
// Create file based on validation error type.
switch ($validationError) {
case 'missing':
// Don't create a file to test missing file validation.
if (file_exists($localFilepath)) {
unlink($localFilepath);
}
break;
case 'http_error':
// For HTTP error, create file that will be cleaned up.
$content = 'Mock SQL dump content for testing';
$gzippedContent = gzencode($content);
if ($gzippedContent !== false) {
file_put_contents($localFilepath, $gzippedContent);
}
break;
default:
// Create a valid gzip file for validation.
$content = 'Mock SQL dump content for testing';
$gzippedContent = gzencode($content);
if ($gzippedContent !== false) {
file_put_contents($localFilepath, $gzippedContent);
}
break;
}

// Simulate the download to force progress rendering.
$progress = $capturedOpts['progress'];
$progress(100, 0);
$progress(100, 50);
$progress(100, 100);
return $response->reveal();
if (isset($capturedOpts['progress'])) {
$progress = $capturedOpts['progress'];
$progress(100, 0);
$progress(100, 50);
$progress(100, 100);
}
return $response;
})
->shouldBeCalled();

Expand Down
Loading