From 72d2ba0159f2431593e6899be26709588d6f40b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 28 Feb 2025 12:04:17 +0100 Subject: [PATCH 1/2] Add missing download endpoints, ensure with tests --- docker-compose.yml | 2 +- .../Http/Deployments/Builds/Download/Get.php | 133 ++++++++++++++++++ .../Modules/Functions/Services/Http.php | 2 + .../e2e/Services/Functions/FunctionsBase.php | 20 +++ .../Functions/FunctionsConsoleClientTest.php | 40 ++++++ tests/e2e/Services/Sites/SitesBase.php | 20 +++ .../Services/Sites/SitesCustomServerTest.php | 42 +++++- 7 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Download/Get.php diff --git a/docker-compose.yml b/docker-compose.yml index 99d04f95fa..28ba2eb40b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -206,7 +206,7 @@ services: appwrite-console: <<: *x-logging container_name: appwrite-console - image: appwrite/console:5.3.0-sites-rc.15 + image: appwrite/console:5.3.0-sites-rc.16 restart: unless-stopped networks: - appwrite diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Download/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Download/Get.php new file mode 100644 index 0000000000..e3a5158572 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Download/Get.php @@ -0,0 +1,133 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId/build/download') + ->desc('Download build') + ->groups(['api', 'functions']) + ->label('scope', 'functions.read') + ->label('sdk', new Method( + namespace: 'functions', + name: 'getDeploymentBuildDownload', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('deploymentId', '', new UID(), 'Deployment ID.') + ->inject('response') + ->inject('request') + ->inject('dbForProject') + ->inject('deviceForBuilds') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $deploymentId, Response $response, Request $request, Database $dbForProject, Device $deviceForBuilds) + { + $function = $dbForProject->getDocument('functions', $functionId); + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + if ($deployment->isEmpty()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + if ($deployment->getAttribute('resourceId') !== $function->getId()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + $build = $dbForProject->getDocument('builds', $deployment->getAttribute('buildId')); + if ($build->isEmpty()) { + throw new Exception(Exception::BUILD_NOT_FOUND); + } + + $path = $build->getAttribute('path', ''); + if (!$deviceForBuilds->exists($path)) { + throw new Exception(Exception::BUILD_NOT_FOUND); + } + + $response + ->setContentType('application/gzip') + ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + (60 * 60 * 24 * 45)) . ' GMT') // 45 days cache + ->addHeader('X-Peak', \memory_get_peak_usage()) + ->addHeader('Content-Disposition', 'attachment; filename="' . $deploymentId . '.tar.gz"'); + + $size = $deviceForBuilds->getFileSize($path); + $rangeHeader = $request->getHeader('range'); + + if (!empty($rangeHeader)) { + $start = $request->getRangeStart(); + $end = $request->getRangeEnd(); + $unit = $request->getRangeUnit(); + + if ($end === null) { + $end = min(($start + MAX_OUTPUT_CHUNK_SIZE - 1), ($size - 1)); + } + + if ($unit !== 'bytes' || $start >= $end || $end >= $size) { + throw new Exception(Exception::STORAGE_INVALID_RANGE); + } + + $response + ->addHeader('Accept-Ranges', 'bytes') + ->addHeader('Content-Range', 'bytes ' . $start . '-' . $end . '/' . $size) + ->addHeader('Content-Length', $end - $start + 1) + ->setStatusCode(Response::STATUS_CODE_PARTIALCONTENT); + + $response->send($deviceForBuilds->read($path, $start, ($end - $start + 1))); + } + + if ($size > APP_STORAGE_READ_BUFFER) { + for ($i = 0; $i < ceil($size / MAX_OUTPUT_CHUNK_SIZE); $i++) { + $response->chunk( + $deviceForBuilds->read( + $path, + ($i * MAX_OUTPUT_CHUNK_SIZE), + min(MAX_OUTPUT_CHUNK_SIZE, $size - ($i * MAX_OUTPUT_CHUNK_SIZE)) + ), + (($i + 1) * MAX_OUTPUT_CHUNK_SIZE) >= $size + ); + } + } else { + $response->send($deviceForBuilds->read($path)); + } + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Services/Http.php b/src/Appwrite/Platform/Modules/Functions/Services/Http.php index 5ae77c5d6b..a17c80865e 100644 --- a/src/Appwrite/Platform/Modules/Functions/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Functions/Services/Http.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Functions\Services; use Appwrite\Platform\Modules\Functions\Http\Deployments\Builds\Create as CreateBuild; +use Appwrite\Platform\Modules\Functions\Http\Deployments\Builds\Download\Get as DownloadBuild; use Appwrite\Platform\Modules\Functions\Http\Deployments\Builds\Update as UpdateBuild; use Appwrite\Platform\Modules\Functions\Http\Deployments\Create as CreateDeployment; use Appwrite\Platform\Modules\Functions\Http\Deployments\Delete as DeleteDeployment; @@ -64,6 +65,7 @@ class Http extends Service $this->addAction(DownloadDeployment::getName(), new DownloadDeployment()); $this->addAction(CreateBuild::getName(), new CreateBuild()); $this->addAction(UpdateBuild::getName(), new UpdateBuild()); + $this->addAction(DownloadBuild::getName(), new DownloadBuild()); // Executions $this->addAction(CreateExecution::getName(), new CreateExecution()); diff --git a/tests/e2e/Services/Functions/FunctionsBase.php b/tests/e2e/Services/Functions/FunctionsBase.php index 167094aec7..c340032e5b 100644 --- a/tests/e2e/Services/Functions/FunctionsBase.php +++ b/tests/e2e/Services/Functions/FunctionsBase.php @@ -312,4 +312,24 @@ trait FunctionsBase return $domain; } + + protected function getDeploymentDownload(string $functionId, string $deploymentId): mixed + { + $response = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/deployments/' . $deploymentId . '/download', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + return $response; + } + + protected function getBuildDownload(string $functionId, string $deploymentId): mixed + { + $response = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/deployments/' . $deploymentId . '/build/download', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + return $response; + } } diff --git a/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php b/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php index 9b37fad394..5778e9f20a 100644 --- a/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php +++ b/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php @@ -497,4 +497,44 @@ class FunctionsConsoleClientTest extends Scope $this->cleanupFunction($functionId); } + + public function testFunctionDownload(): void + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'runtime' => 'node-18.0', + 'name' => 'Download Test', + 'entrypoint' => 'index.js', + 'logging' => false, + 'execute' => ['any'] + ]); + + $deploymentId = $this->setupDeployment($functionId, [ + 'entrypoint' => 'index.js', + 'code' => $this->packageFunction('node'), + 'activate' => true + ]); + + $this->assertNotEmpty($deploymentId); + + $response = $this->getDeploymentDownload($functionId, $deploymentId); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('application/gzip', $response['headers']['content-type']); + $this->assertGreaterThan(0, $response['headers']['content-length']); + $this->assertGreaterThan(0, \strlen($response['body'])); + + $deploymentMd5 = \md5($response['body']); + + $response = $this->getBuildDownload($functionId, $deploymentId); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('application/gzip', $response['headers']['content-type']); + $this->assertGreaterThan(0, $response['headers']['content-length']); + $this->assertGreaterThan(0, \strlen($response['body'])); + + $buildMd5 = \md5($response['body']); + + $this->assertNotEquals($deploymentMd5, $buildMd5); + + $this->cleanupFunction($functionId); + } } diff --git a/tests/e2e/Services/Sites/SitesBase.php b/tests/e2e/Services/Sites/SitesBase.php index 7d65e40db6..031716b24b 100644 --- a/tests/e2e/Services/Sites/SitesBase.php +++ b/tests/e2e/Services/Sites/SitesBase.php @@ -345,4 +345,24 @@ trait SitesBase return $domain; } + + protected function getDeploymentDownload(string $siteId, string $deploymentId): mixed + { + $response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId . '/download', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + return $response; + } + + protected function getBuildDownload(string $siteId, string $deploymentId): mixed + { + $response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId . '/build/download', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + return $response; + } } diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 7e941f1717..43d9fc173b 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -1634,5 +1634,45 @@ class SitesCustomServerTest extends Scope $this->cleanupSite($siteId); } - // TODO: Add tests for deletion of resources when site is deleted + public function testSiteDownload(): void + { + $siteId = $this->setupSite([ + 'buildRuntime' => 'ssr-22', + 'fallbackFile' => null, + 'framework' => 'other', + 'name' => 'Test Site', + 'adapter' => 'static', + 'outputDirectory' => './', + 'providerBranch' => 'main', + 'providerRootDirectory' => './', + 'siteId' => ID::unique() + ]); + + $deploymentId = $this->setupDeployment($siteId, [ + 'code' => $this->packageSite('static'), + 'activate' => true + ]); + + $this->assertNotEmpty($deploymentId); + + $response = $this->getDeploymentDownload($siteId, $deploymentId); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('application/gzip', $response['headers']['content-type']); + $this->assertGreaterThan(0, $response['headers']['content-length']); + $this->assertGreaterThan(0, \strlen($response['body'])); + + $deploymentMd5 = \md5($response['body']); + + $response = $this->getBuildDownload($siteId, $deploymentId); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('application/gzip', $response['headers']['content-type']); + $this->assertGreaterThan(0, $response['headers']['content-length']); + $this->assertGreaterThan(0, \strlen($response['body'])); + + $buildMd5 = \md5($response['body']); + + $this->assertNotEquals($deploymentMd5, $buildMd5); + + $this->cleanupSite($siteId); + } } From d75b601a7be610267105a5f6b52b24d96ab7fe42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 28 Feb 2025 12:10:24 +0100 Subject: [PATCH 2/2] Update src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Download/Get.php Co-authored-by: Khushboo Verma <43381712+vermakhushboo@users.noreply.github.com> --- .../Modules/Functions/Http/Deployments/Builds/Download/Get.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Download/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Download/Get.php index e3a5158572..b6f40acb2a 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Download/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Download/Get.php @@ -37,7 +37,7 @@ class Get extends Action namespace: 'functions', name: 'getDeploymentBuildDownload', description: <<