diff --git a/composer.lock b/composer.lock index 68ce6c6b4a..b5abc5da81 100644 --- a/composer.lock +++ b/composer.lock @@ -5215,16 +5215,16 @@ }, { "name": "utopia-php/vcs", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "058049326e04a2a0c2f0ce8ad00c7e84825aba14" + "reference": "92a1650824ba0c5e6a1bc46e622ac87c50a08920" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/058049326e04a2a0c2f0ce8ad00c7e84825aba14", - "reference": "058049326e04a2a0c2f0ce8ad00c7e84825aba14", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/92a1650824ba0c5e6a1bc46e622ac87c50a08920", + "reference": "92a1650824ba0c5e6a1bc46e622ac87c50a08920", "shasum": "" }, "require": { @@ -5258,9 +5258,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/2.0.0" + "source": "https://github.com/utopia-php/vcs/tree/2.0.1" }, - "time": "2026-02-25T11:36:45+00:00" + "time": "2026-02-27T12:18:49+00:00" }, { "name": "utopia-php/websocket", @@ -6432,11 +6432,11 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.32", + "version": "1.12.33", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", - "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", + "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", "shasum": "" }, "require": { @@ -6481,7 +6481,7 @@ "type": "github" } ], - "time": "2025-09-30T10:16:31+00:00" + "time": "2026-02-28T20:30:03+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 2e2600a36a..5bd029b0f3 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -340,6 +340,8 @@ class Migrations extends Action 'files.write', 'functions.read', 'functions.write', + 'sites.read', + 'sites.write', 'tokens.read', 'tokens.write', ] diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 49c61dcc66..5f9f8ba56d 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -473,6 +473,38 @@ abstract class Format break; } break; + case 'migrations': + switch ($method) { + case 'createAppwriteMigration': + case 'getAppwriteReport': + switch ($param) { + case 'resources': + return 'AppwriteMigrationResource'; + } + break; + case 'createFirebaseMigration': + case 'getFirebaseReport': + switch ($param) { + case 'resources': + return 'FirebaseMigrationResource'; + } + break; + case 'createSupabaseMigration': + case 'getSupabaseReport': + switch ($param) { + case 'resources': + return 'SupabaseMigrationResource'; + } + break; + case 'createNHostMigration': + case 'getNHostReport': + switch ($param) { + case 'resources': + return 'NHostMigrationResource'; + } + break; + } + break; case 'project': switch ($method) { case 'getUsage': diff --git a/src/Appwrite/Utopia/Response/Model/MigrationReport.php b/src/Appwrite/Utopia/Response/Model/MigrationReport.php index 56d17d3f7f..7ebc22d22e 100644 --- a/src/Appwrite/Utopia/Response/Model/MigrationReport.php +++ b/src/Appwrite/Utopia/Response/Model/MigrationReport.php @@ -53,6 +53,12 @@ class MigrationReport extends Model 'default' => 0, 'example' => 20, ]) + ->addRule(Resource::TYPE_SITE, [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of sites to be migrated.', + 'default' => 0, + 'example' => 5, + ]) ->addRule('size', [ 'type' => self::TYPE_INTEGER, 'description' => 'Size of files to be migrated in mb.', diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 7524fad4b3..c63c75d705 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -8,6 +8,7 @@ use Tests\E2E\Client; use Tests\E2E\General\UsageTest; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Services\Functions\FunctionsBase; +use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; @@ -1039,6 +1040,158 @@ trait MigrationsBase ]); } + /** + * Sites + */ + public function testAppwriteMigrationSite(): void + { + $site = $this->client->call(Client::METHOD_POST, '/sites', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'siteId' => ID::unique(), + 'name' => 'Test Site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'adapter' => 'static', + 'outputDirectory' => './', + ]); + + $this->assertEquals(201, $site['headers']['status-code'], 'Create site failed: ' . json_encode($site['body'], JSON_PRETTY_PRINT)); + $this->assertNotEmpty($site['body']['$id']); + + $siteId = $site['body']['$id']; + + // Create deployment + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'code' => $this->packageSite('static'), + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + + $deploymentId = $deployment['body']['$id']; + + // Wait for deployment to be ready + $this->assertEventually(function () use ($siteId, $deploymentId) { + $response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('ready', $response['body']['status'], 'Deployment status is not ready, deployment: ' . json_encode($response['body'], JSON_PRETTY_PRINT)); + }, 300000, 500); + + // Create environment variable + $variable = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/variables', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + ]); + + $this->assertEquals(201, $variable['headers']['status-code']); + + // Perform migration + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_SITE, + Resource::TYPE_SITE_DEPLOYMENT, + Resource::TYPE_SITE_VARIABLE, + ], + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertEquals([Resource::TYPE_SITE, Resource::TYPE_SITE_DEPLOYMENT, Resource::TYPE_SITE_VARIABLE], $result['resources']); + + foreach ([Resource::TYPE_SITE, Resource::TYPE_SITE_DEPLOYMENT, Resource::TYPE_SITE_VARIABLE] as $resource) { + $this->assertArrayHasKey($resource, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][$resource]['error']); + $this->assertEquals(0, $result['statusCounters'][$resource]['pending']); + $this->assertEquals(1, $result['statusCounters'][$resource]['success']); + $this->assertEquals(0, $result['statusCounters'][$resource]['processing']); + $this->assertEquals(0, $result['statusCounters'][$resource]['warning']); + } + + // Verify site in destination + $response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertEquals($siteId, $response['body']['$id']); + $this->assertEquals('Test Site', $response['body']['name']); + $this->assertEquals('node-22', $response['body']['buildRuntime']); + $this->assertEquals('other', $response['body']['framework']); + $this->assertEquals('static', $response['body']['adapter']); + + // Verify deployment in destination + $this->assertEventually(function () use ($siteId) { + $deployments = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $deployments['headers']['status-code']); + $this->assertNotEmpty($deployments['body']); + $this->assertEquals(1, $deployments['body']['total']); + $this->assertEquals('ready', $deployments['body']['deployments'][0]['status'], 'Deployment status is not ready, deployment: ' . json_encode($deployments['body']['deployments'][0], JSON_PRETTY_PRINT)); + }, 100000, 500); + + // Verify variable in destination + $variables = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/variables', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $variables['headers']['status-code']); + $this->assertEquals(1, $variables['body']['total']); + $this->assertEquals('TEST_VAR', $variables['body']['variables'][0]['key']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + } + + private function packageSite(string $site): CURLFile + { + $stdout = ''; + $stderr = ''; + $folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site"; + $tarPath = "$folderPath/code.tar.gz"; + + Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + + return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath)); + } + /** * Import documents from a CSV file. */