diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index 65441be5f4..de3ed4c7f2 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -567,6 +567,17 @@ return [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('deploymentRetention'), + 'type' => Database::VAR_INTEGER, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => 0, + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('deploymentInternalId'), 'type' => Database::VAR_STRING, @@ -1080,6 +1091,17 @@ return [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('deploymentRetention'), + 'type' => Database::VAR_INTEGER, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => 0, + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('deploymentInternalId'), 'type' => Database::VAR_STRING, diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 40ddae8f30..d129571e08 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -12,6 +12,7 @@ use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\UID; use Utopia\Locale\Locale; use Utopia\System\System; @@ -198,6 +199,48 @@ App::post('/v1/mock/api-key-unprefixed') ->dynamic($key, Response::MODEL_KEY); }); +App::post('/v1/mock/time-travels') + ->desc('Create a time-travel to change $createdAt') + ->groups(['mock', 'api']) + ->label('scope', 'public') + ->label('docs', false) + ->param('projectId', '', new UID(), 'Project ID.') + ->param('resourceType', '', new WhiteList(['deployment']), 'Type of resource.') + ->param('resourceId', '', new UID(), 'ID of resource.') + ->param('createdAt', '', new DatetimeValidator(), 'New value for $createdAt') + ->inject('response') + ->inject('getProjectDB') + ->inject('dbForPlatform') + ->action(function (string $projectId, string $resourceType, string $resourceId, string $createdAt, Response $response, callable $getProjectDB, Database $dbForPlatform) { + $isDevelopment = System::getEnv('_APP_ENV', 'development') === 'development'; + + if (!$isDevelopment) { + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); + } + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $collection = match($resourceType) { + 'deployment' => 'deployments', + default => throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED) + }; + + /** @var Database $dbForProject */ + $dbForProject = $getProjectDB($project); + + $update = new Document([ + '$createdAt' => $createdAt, + ]); + + $dbForProject->withPreserveDates(fn () => $dbForProject->updateDocument($collection, $resourceId, $update)); + + $response->noContent(); + }); + App::get('/v1/mock/github/callback') ->desc('Create installation document using GitHub installation id') ->groups(['mock', 'api', 'vcs']) diff --git a/app/init/constants.php b/app/init/constants.php index e11fdf9a54..9467b7275d 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -85,6 +85,7 @@ const APP_HOSTNAME_INTERNAL = 'appwrite'; const APP_COMPUTE_CPUS_DEFAULT = 0.5; const APP_COMPUTE_MEMORY_DEFAULT = 512; const APP_COMPUTE_SPECIFICATION_DEFAULT = Specification::S_1VCPU_512MB; +const APP_COMPUTE_DEPLOYMENT_MAX_RETENTION = 100 * 365; // 100 years const APP_PLATFORM_SERVER = 'server'; const APP_PLATFORM_CLIENT = 'client'; const APP_PLATFORM_CONSOLE = 'console'; diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 48947ac3e6..21cc9a4d31 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -109,6 +109,7 @@ class Create extends Base ->param('templateOwner', '', new Text(128, 0), 'The name of the owner of the template.', true, deprecated: true) ->param('templateRootDirectory', '', new Text(128, 0), 'Path to function code in the template repo.', true, deprecated: true) ->param('templateVersion', '', new Text(128, 0), 'Version (tag) for the repo linked to the function template.', true, deprecated: true) + ->param('deploymentRetention', 0, new Range(0, APP_COMPUTE_DEPLOYMENT_MAX_RETENTION), 'Days to keep non-active deployments before deletion. Value 0 means all deployments will be kept.', true) ->inject('response') ->inject('dbForProject') ->inject('timelimit') @@ -148,6 +149,7 @@ class Create extends Base string $templateOwner, string $templateRootDirectory, string $templateVersion, + int $deploymentRetention, Response $response, Database $dbForProject, callable $timelimit, @@ -218,6 +220,7 @@ class Create extends Base 'logging' => $logging, 'name' => $name, 'runtime' => $runtime, + 'deploymentRetention' => $deploymentRetention, 'deploymentInternalId' => '', 'deploymentId' => '', 'events' => $events, diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index df5dd3adf5..7e7878df28 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -101,6 +101,7 @@ class Update extends Base System::getEnv('_APP_COMPUTE_CPUS', 0), System::getEnv('_APP_COMPUTE_MEMORY', 0) ), 'Runtime specification for the function executions.', true, ['plan']) + ->param('deploymentRetention', 0, new Range(0, APP_COMPUTE_DEPLOYMENT_MAX_RETENTION), 'Days to keep non-active deployments before deletion. Value 0 means all deployments will be kept.', true) ->inject('request') ->inject('response') ->inject('dbForProject') @@ -133,6 +134,7 @@ class Update extends Base string $providerRootDirectory, string $buildSpecification, string $runtimeSpecification, + int $deploymentRetention, Request $request, Response $response, Database $dbForProject, @@ -222,7 +224,7 @@ class Update extends Base 'resourceId' => $function->getId(), 'resourceInternalId' => $function->getSequence(), 'resourceType' => 'function', - 'providerPullRequestIds' => [] + 'providerPullRequestIds' => [], ])); $repositoryId = $repository->getId(); @@ -275,6 +277,7 @@ class Update extends Base 'entrypoint' => $entrypoint, 'commands' => $commands, 'scopes' => $scopes, + 'deploymentRetention' => $deploymentRetention, 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getSequence(), 'providerRepositoryId' => $providerRepositoryId, diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php index f535c90de6..5243a88469 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php @@ -91,6 +91,7 @@ class Create extends Base System::getEnv('_APP_COMPUTE_CPUS', 0), System::getEnv('_APP_COMPUTE_MEMORY', 0) ), 'Runtime specification for the function SSR executions.', true, ['plan']) + ->param('deploymentRetention', 0, new Range(0, APP_COMPUTE_DEPLOYMENT_MAX_RETENTION), 'Days to keep non-active deployments before deletion. Value 0 means all deployments will be kept.', true) ->inject('response') ->inject('dbForProject') ->inject('project') @@ -120,6 +121,7 @@ class Create extends Base string $providerRootDirectory, string $buildSpecification, string $runtimeSpecification, + int $deploymentRetention, Response $response, Database $dbForProject, Document $project, @@ -154,6 +156,7 @@ class Create extends Base 'logging' => $logging, 'name' => $name, 'framework' => $framework, + 'deploymentRetention' => $deploymentRetention, 'deploymentInternalId' => '', 'deploymentId' => '', 'timeout' => $timeout, diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index e7c2df0c53..887b7f4aa6 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -95,6 +95,7 @@ class Update extends Base System::getEnv('_APP_COMPUTE_CPUS', 0), System::getEnv('_APP_COMPUTE_MEMORY', 0) ), 'Runtime specification for the function SSR executions.', true, ['plan']) + ->param('deploymentRetention', 0, new Range(0, APP_COMPUTE_DEPLOYMENT_MAX_RETENTION), 'Days to keep non-active deployments before deletion. Value 0 means all deployments will be kept.', true) ->inject('request') ->inject('response') ->inject('dbForProject') @@ -128,6 +129,7 @@ class Update extends Base string $providerRootDirectory, string $buildSpecification, string $runtimeSpecification, + int $deploymentRetention, Request $request, Response $response, Database $dbForProject, @@ -270,6 +272,7 @@ class Update extends Base 'logging' => $logging, 'live' => $live, 'timeout' => $timeout, + 'deploymentRetention' => $deploymentRetention, 'installCommand' => $installCommand, 'buildCommand' => $buildCommand, 'startCommand' => $startCommand, diff --git a/src/Appwrite/Platform/Tasks/Maintenance.php b/src/Appwrite/Platform/Tasks/Maintenance.php index f5785d0bb4..7b9da50e30 100644 --- a/src/Appwrite/Platform/Tasks/Maintenance.php +++ b/src/Appwrite/Platform/Tasks/Maintenance.php @@ -13,6 +13,7 @@ use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Platform\Action; use Utopia\System\System; +use Utopia\Validator\WhiteList; class Maintenance extends Action { @@ -25,6 +26,7 @@ class Maintenance extends Action { $this ->desc('Schedules maintenance tasks and publishes them to our queues') + ->param('type', 'loop', new WhiteList(['loop', 'trigger']), 'How to run task. "loop" is meant for container entrypoint, and "trigger" for manual execution.') ->inject('dbForPlatform') ->inject('console') ->inject('queueForCertificates') @@ -32,7 +34,7 @@ class Maintenance extends Action ->callback($this->action(...)); } - public function action(Database $dbForPlatform, Document $console, Certificate $queueForCertificates, Delete $queueForDeletes): void + public function action(string $type, Database $dbForPlatform, Document $console, Certificate $queueForCertificates, Delete $queueForDeletes): void { Console::title('Maintenance V1'); Console::success(APP_NAME . ' maintenance process v1 has started'); @@ -57,9 +59,7 @@ class Maintenance extends Action $delay = $next->getTimestamp() - $now->getTimestamp(); } - Console::info('Setting loop start time to ' . $next->format("Y-m-d H:i:s.v") . '. Delaying for ' . $delay . ' seconds.'); - - Console::loop(function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $queueForDeletes, $queueForCertificates) { + $action = function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $queueForDeletes, $queueForCertificates) { $time = DatabaseDateTime::now(); Console::info("[{$time}] Notifying workers with maintenance tasks every {$interval} seconds"); @@ -96,7 +96,17 @@ class Maintenance extends Action $this->notifyDeleteCache($cacheRetention, $queueForDeletes); $this->notifyDeleteSchedules($schedulesDeletionRetention, $queueForDeletes); $this->notifyDeleteCSVExports($queueForDeletes); - }, $interval, $delay); + }; + + if ($type === 'loop') { + Console::info('Setting loop start time to ' . $next->format("Y-m-d H:i:s.v") . '. Delaying for ' . $delay . ' seconds.'); + + Console::loop(function () use ($action) { + $action(); + }, $interval, $delay); + } elseif ($type === 'trigger') { + $action(); + } } private function notifyDeleteConnections(Delete $queueForDeletes): void diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 7df2770ac6..fcfaa4cc9c 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -6,6 +6,7 @@ use Appwrite\Auth\Auth; use Appwrite\Certificates\Adapter as CertificatesAdapter; use Appwrite\Deletes\Identities; use Appwrite\Deletes\Targets; +use Appwrite\Event\Delete as DeleteEvent; use Appwrite\Extend\Exception; use Executor\Executor; use Throwable; @@ -63,6 +64,7 @@ class Deletes extends Action ->inject('executionRetention') ->inject('auditRetention') ->inject('log') + ->inject('queueForDeletes') ->callback($this->action(...)); } @@ -85,7 +87,8 @@ class Deletes extends Action Executor $executor, string $executionRetention, string $auditRetention, - Log $log + Log $log, + DeleteEvent $queueForDeletes, ): void { $payload = $message->getPayload() ?? []; @@ -189,6 +192,7 @@ class Deletes extends Action $this->deleteUsageStats($project, $getProjectDB, $getLogsDB, $hourlyUsageRetentionDatetime); $this->deleteExpiredSessions($project, $getProjectDB); $this->deleteExpiredTransactions($project, $getProjectDB); + $this->deleteOldDeployments($queueForDeletes, $project, $getProjectDB); break; default: throw new \Exception('No delete operation for type: ' . \strval($type)); @@ -308,6 +312,61 @@ class Deletes extends Action Targets::delete($getProjectDB($project), Query::equal('sessionInternalId', [$session->getSequence()])); } + private function deleteOldDeployments(DeleteEvent $queueForDeletes, Document $project, callable $getProjectDB): void + { + /** @var Database $dbForProject */ + $dbForProject = $getProjectDB($project); + + $removalCallback = function (Document $resource) use ($dbForProject, $queueForDeletes, $project) { + $retention = $resource->getAttribute('deploymentRetention', 0); + + // 0 means unlimited - never delete + if ($retention === 0) { + return; + } + + $activeDeploymentId = $resource->getAttribute('deploymentId', ''); + + $queries = [ + Query::createdBefore(DateTime::addSeconds(new \DateTime(), -1 * $retention * 24 * 60 * 60)), + Query::equal('resourceInternalId', [$resource->getSequence()]), + Query::equal('resourceType', [$resource->getCollection()]), + Query::orderDesc('$createdAt'), + ]; + + if (!empty($activeDeploymentId)) { + $queries[] = Query::notEqual('$id', $activeDeploymentId); + } + + $this->deleteByGroup( + 'deployments', + $queries, + $dbForProject, + function (Document $deployment) use ($queueForDeletes, $project) { + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($deployment) + ->setProject($project) + ->trigger(); + } + ); + }; + + $this->listByGroup( + 'functions', + [], + $dbForProject, + $removalCallback + ); + + $this->listByGroup( + 'sites', + [], + $dbForProject, + $removalCallback + ); + } + /** * @param Document $project * @param callable $getProjectDB diff --git a/src/Appwrite/Utopia/Response/Model/Func.php b/src/Appwrite/Utopia/Response/Model/Func.php index 0d7e80849d..3aea364fe5 100644 --- a/src/Appwrite/Utopia/Response/Model/Func.php +++ b/src/Appwrite/Utopia/Response/Model/Func.php @@ -65,6 +65,12 @@ class Func extends Model 'default' => '', 'example' => 'python-3.8', ]) + ->addRule('deploymentRetention', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'How many days to keep the non-active deployments before they will be automatically deleted.', + 'default' => 0, + 'example' => 7, + ]) ->addRule('deploymentId', [ 'type' => self::TYPE_STRING, 'description' => 'Function\'s active deployment ID.', diff --git a/src/Appwrite/Utopia/Response/Model/Site.php b/src/Appwrite/Utopia/Response/Model/Site.php index 15605984f0..941b6104df 100644 --- a/src/Appwrite/Utopia/Response/Model/Site.php +++ b/src/Appwrite/Utopia/Response/Model/Site.php @@ -58,6 +58,12 @@ class Site extends Model 'default' => '', 'example' => 'react', ]) + ->addRule('deploymentRetention', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'How many days to keep the non-active deployments before they will be automatically deleted.', + 'default' => 0, + 'example' => 7, + ]) ->addRule('deploymentId', [ 'type' => self::TYPE_STRING, 'description' => 'Site\'s active deployment ID.', diff --git a/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php b/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php index 9dae8efdb4..2125e03bc0 100644 --- a/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php +++ b/tests/e2e/Services/Functions/FunctionsConsoleClientTest.php @@ -6,6 +6,7 @@ use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideConsole; +use Utopia\CLI\Console; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; @@ -537,4 +538,58 @@ class FunctionsConsoleClientTest extends Scope $this->cleanupFunction($functionId); } + + public function testFunctionDeploymentRetentionWithMaintenance(): void + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'deploymentRetention' => 180 + ]); + $this->assertNotEmpty($functionId); + + $deploymentIdInactive = $this->setupDeployment($functionId, [ + 'code' => $this->packageFunction('node'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentIdInactive); + + $deploymentIdInactiveOld = $this->setupDeployment($functionId, [ + 'code' => $this->packageFunction('node'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentIdInactiveOld); + + $deploymentIdActive = $this->setupDeployment($functionId, [ + 'code' => $this->packageFunction('node'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentIdActive); + + $response = $this->client->call(Client::METHOD_POST, '/mock/time-travels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => $this->getProject()['$id'], + 'resourceType' => 'deployment', + 'resourceId' => $deploymentIdInactiveOld, + 'createdAt' => '2020-01-01T00:00:00Z' // More than 180 days ago + ]); + $this->assertSame(204, $response['headers']['status-code']); + + $stdout = ''; + $stderr = ''; + $code = Console::execute("docker exec appwrite-task-maintenance maintenance --type=trigger", '', $stdout, $stderr); + $this->assertSame(0, $code, "Maintenance command failed with code $code: $stderr ($stdout)"); + + $this->assertEventually(function () use ($functionId) { + $response = $this->listDeployments($functionId); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + }); + + $this->cleanupFunction($functionId); + } } diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index d60737be09..a3746ad362 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -2629,4 +2629,119 @@ class FunctionsCustomServerTest extends Scope $this->cleanupFunction($functionId); } + + public function testFunctionDeploymentRetention(): void + { + $functionIds = []; + + // Default + $response = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + $functionIds[] = $response['body']['$id']; + + $response = $this->getFunction($response['body']['$id']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + // Success values + $response = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + 'deploymentRetention' => 0 + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + $functionIds[] = $response['body']['$id']; + + $response = $this->getFunction($response['body']['$id']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + $response = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + 'deploymentRetention' => 180 + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(180, $response['body']['deploymentRetention']); + $functionIds[] = $response['body']['$id']; + + $response = $this->getFunction($response['body']['$id']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(180, $response['body']['deploymentRetention']); + + // Failure values + $response = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + 'deploymentRetention' => 999999 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + $response = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + 'deploymentRetention' => -1 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + // Update flow + $response = $this->createFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test retention function', + 'runtime' => 'node-22', + 'deploymentRetention' => 180 + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(180, $response['body']['deploymentRetention']); + $functionIds[] = $response['body']['$id']; + $functionIdForUpdate = $response['body']['$id']; + + $response = $this->updateFunction($functionIdForUpdate, [ + 'name' => 'Test retention function', + 'deploymentRetention' => 90 + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(90, $response['body']['deploymentRetention']); + + $response = $this->getFunction($functionIdForUpdate); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(90, $response['body']['deploymentRetention']); + + $response = $this->updateFunction($functionIdForUpdate, [ + 'name' => 'Test retention function', + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + $response = $this->getFunction($functionIdForUpdate); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + // Failed update flow + $response = $this->updateFunction($functionIdForUpdate, [ + 'name' => 'Test retention function', + 'deploymentRetention' => -1 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + $response = $this->updateFunction($functionIdForUpdate, [ + 'name' => 'Test retention function', + 'deploymentRetention' => 999999 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + foreach ($functionIds as $functionId) { + $this->cleanupFunction($functionId); + } + } } diff --git a/tests/e2e/Services/Sites/SitesConsoleClientTest.php b/tests/e2e/Services/Sites/SitesConsoleClientTest.php index 227e36a50e..e5ffc6d9c4 100644 --- a/tests/e2e/Services/Sites/SitesConsoleClientTest.php +++ b/tests/e2e/Services/Sites/SitesConsoleClientTest.php @@ -6,6 +6,7 @@ use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideConsole; +use Utopia\CLI\Console; use Utopia\Database\Helpers\ID; class SitesConsoleClientTest extends Scope @@ -139,4 +140,58 @@ class SitesConsoleClientTest extends Scope $this->cleanupSite($siteId); } + + public function testSiteDeploymentRetentionWithMaintenance(): void + { + $siteId = $this->setupSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'deploymentRetention' => 180, + 'buildRuntime' => 'node-22', + ]); + $this->assertNotEmpty($siteId); + + $deploymentIdInactive = $this->setupDeployment($siteId, [ + 'code' => $this->packageSite('static'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentIdInactive); + + $deploymentIdInactiveOld = $this->setupDeployment($siteId, [ + 'code' => $this->packageSite('static'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentIdInactiveOld); + + $deploymentIdActive = $this->setupDeployment($siteId, [ + 'code' => $this->packageSite('static'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentIdActive); + + $response = $this->client->call(Client::METHOD_POST, '/mock/time-travels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => $this->getProject()['$id'], + 'resourceType' => 'deployment', + 'resourceId' => $deploymentIdInactiveOld, + 'createdAt' => '2020-01-01T00:00:00Z' // More than 180 days ago + ]); + $this->assertSame(204, $response['headers']['status-code']); + + $stdout = ''; + $stderr = ''; + $code = Console::execute("docker exec appwrite-task-maintenance maintenance --type=trigger", '', $stdout, $stderr); + $this->assertSame(0, $code, "Maintenance command failed with code $code: $stderr ($stdout)"); + + $this->assertEventually(function () use ($siteId) { + $response = $this->listDeployments($siteId); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + }); + + $this->cleanupSite($siteId); + } } diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 82f1639836..eb0900ed5e 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -3076,4 +3076,134 @@ class SitesCustomServerTest extends Scope $this->cleanupSite($siteId); } + + public function testSiteDeploymentRetention(): void + { + $siteIds = []; + + // Default + $response = $this->createSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + $siteIds[] = $response['body']['$id']; + + $response = $this->getSite($response['body']['$id']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + // Success values + $response = $this->createSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'deploymentRetention' => 0 + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + $siteIds[] = $response['body']['$id']; + + $response = $this->getSite($response['body']['$id']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + $response = $this->createSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'deploymentRetention' => 180 + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(180, $response['body']['deploymentRetention']); + $siteIds[] = $response['body']['$id']; + + $response = $this->getSite($response['body']['$id']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(180, $response['body']['deploymentRetention']); + + // Failure values + $response = $this->createSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'deploymentRetention' => 999999 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + $response = $this->createSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'deploymentRetention' => -1 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + // Update flow + $response = $this->createSite([ + 'siteId' => ID::unique(), + 'name' => 'Test retention site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'deploymentRetention' => 180 + ]); + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame(180, $response['body']['deploymentRetention']); + $siteIds[] = $response['body']['$id']; + $siteIdToUpdate = $response['body']['$id']; + + $response = $this->updateSite([ + '$id' => $siteIdToUpdate, + 'name' => 'Test retention site', + 'framework' => 'other', + 'deploymentRetention' => 90 + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(90, $response['body']['deploymentRetention']); + + $response = $this->getSite($siteIdToUpdate); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(90, $response['body']['deploymentRetention']); + + $response = $this->updateSite([ + '$id' => $siteIdToUpdate, + 'name' => 'Test retention site', + 'framework' => 'other', + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + $response = $this->getSite($siteIdToUpdate); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['deploymentRetention']); + + // Failed update flow + $response = $this->updateSite([ + '$id' => $siteIdToUpdate, + 'name' => 'Test retention site', + 'framework' => 'other', + 'deploymentRetention' => -1 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + $response = $this->updateSite([ + '$id' => $siteIdToUpdate, + 'name' => 'Test retention site', + 'framework' => 'other', + 'deploymentRetention' => 999999 + ]); + $this->assertSame(400, $response['headers']['status-code']); + + foreach ($siteIds as $siteId) { + $this->cleanupSite($siteId); + } + } }