diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 94417254..42050b61 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -10,12 +10,15 @@ use Appwrite\Enums\Framework; use Appwrite\Enums\PasswordHash; use Appwrite\Enums\ProjectProtocolId; +use Appwrite\Enums\ProxyResourceType; use Appwrite\Enums\Runtime; use Appwrite\Enums\SmtpEncryption; +use Appwrite\Enums\StatusCode; use Appwrite\InputFile; use Appwrite\Services\Functions; use Appwrite\Services\Messaging; use Appwrite\Services\Project; +use Appwrite\Services\Proxy; use Appwrite\Services\Sites; use Appwrite\Services\Storage; use Appwrite\Services\Teams; @@ -51,6 +54,7 @@ use Utopia\Migration\Resources\Database\Index; use Utopia\Migration\Resources\Database\Row; use Utopia\Migration\Resources\Database\Table; +use Utopia\Migration\Resources\Domains\Rule; use Utopia\Migration\Resources\Functions\Deployment; use Utopia\Migration\Resources\Functions\EnvVar; use Utopia\Migration\Resources\Functions\Func; @@ -106,6 +110,7 @@ class Appwrite extends Destination private Functions $functions; private Messaging $messaging; private Project $project; + private Proxy $proxy; private Sites $sites; private Storage $storage; private Teams $teams; @@ -191,6 +196,7 @@ public function __construct( $this->functions = new Functions($this->client); $this->messaging = new Messaging($this->client); $this->project = new Project($this->client); + $this->proxy = new Proxy($this->client); $this->sites = new Sites($this->client); $this->storage = new Storage($this->client); $this->teams = new Teams($this->client); @@ -302,6 +308,9 @@ public static function getSupportedResources(): array // Backups Resource::TYPE_BACKUP_POLICY, + + // Domains + Resource::TYPE_RULE, ]; } @@ -461,6 +470,7 @@ protected function import(array $resources, callable $callback): void Transfer::GROUP_INTEGRATIONS => $this->importIntegrationsResource($resource), Transfer::GROUP_BACKUPS => $this->importBackupResource($resource), Transfer::GROUP_PROJECTS => $this->importProjectsResource($resource), + Transfer::GROUP_DOMAINS => $this->importDomainsResource($resource), default => throw new \Exception('Invalid resource group', Exception::CODE_VALIDATION), }; } catch (\Throwable $e) { @@ -3156,6 +3166,25 @@ public function importProjectsResource(Resource $resource): Resource return $resource; } + public function importDomainsResource(Resource $resource): Resource + { + switch ($resource->getName()) { + case Resource::TYPE_RULE: + /** @var Rule $resource */ + $success = $this->createRule($resource); + if (!$success) { + return $resource; + } + break; + } + + if ($resource->getStatus() !== Resource::STATUS_SKIPPED) { + $resource->setStatus(Resource::STATUS_SUCCESS); + } + + return $resource; + } + protected function createProjectVariable(ProjectVariable $resource): bool { $existing = $this->dbForProject->findOne('variables', [ @@ -3280,6 +3309,91 @@ protected function createSMTP(SMTP $resource): bool return true; } + /** + * Auto-generated rules (default `.appwrite.network` domains for functions/sites) + * are recreated automatically on the destination when the parent Function/Site + * is migrated, so only manual rules need to be imported. + * + * Function/site IDs are preserved across migration, so the source + * `deploymentResourceId` is passed through directly. + */ + protected function createRule(Rule $resource): bool + { + if ($resource->getTrigger() !== 'manual') { + $resource->setStatus(Resource::STATUS_SKIPPED, 'Auto-generated rule, recreated by parent resource migration'); + return false; + } + + $type = $resource->getType(); + $deploymentResourceType = $resource->getDeploymentResourceType(); + $branch = $resource->getDeploymentVcsProviderBranch(); + + try { + switch ($type) { + case 'api': + $this->proxy->createAPIRule($resource->getDomain()); + break; + + case 'redirect': + $statusCode = match ($resource->getRedirectStatusCode()) { + 301 => StatusCode::MOVEDPERMANENTLY301(), + 302 => StatusCode::FOUND302(), + 307 => StatusCode::TEMPORARYREDIRECT307(), + 308 => StatusCode::PERMANENTREDIRECT308(), + default => StatusCode::MOVEDPERMANENTLY301(), + }; + + $resourceType = $deploymentResourceType === 'site' + ? ProxyResourceType::SITE() + : ProxyResourceType::FUNCTIONMODEL(); + + $this->proxy->createRedirectRule( + $resource->getDomain(), + $resource->getRedirectUrl(), + $statusCode, + $resource->getDeploymentResourceId(), + $resourceType, + ); + break; + + case 'deployment': + if ($deploymentResourceType === 'function') { + $this->proxy->createFunctionRule( + $resource->getDomain(), + $resource->getDeploymentResourceId(), + $branch !== '' ? $branch : null, + ); + } elseif ($deploymentResourceType === 'site') { + $this->proxy->createSiteRule( + $resource->getDomain(), + $resource->getDeploymentResourceId(), + $branch !== '' ? $branch : null, + ); + } else { + $resource->setStatus(Resource::STATUS_SKIPPED, 'Unsupported deployment resource type "' . $deploymentResourceType . '"'); + return false; + } + break; + + default: + $resource->setStatus(Resource::STATUS_SKIPPED, 'Unsupported rule type "' . $type . '"'); + return false; + } + } catch (AppwriteException $e) { + // 409 means the domain is owned by another project/organization — the + // user has to release it there before re-running. Surface as a warning, + // not an error, so the rest of the migration continues. + if ($e->getCode() === 409) { + $resource->setStatus(Resource::STATUS_WARNING, 'Domain "' . $resource->getDomain() . '" is owned by another project. Remove it there and re-run the migration.'); + return false; + } + + throw $e; + } + + return true; + } + protected function createWebhook(Webhook $resource): bool { $existing = $this->dbForPlatform->findOne('webhooks', [ diff --git a/src/Migration/Resource.php b/src/Migration/Resource.php index 0c56694b..242e2e87 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -87,6 +87,9 @@ abstract class Resource implements \JsonSerializable public const TYPE_PROJECT_LABELS = 'project-labels'; public const TYPE_PROJECT_SERVICES = 'project-services'; + // Domains + public const TYPE_RULE = 'rule'; + // Messaging public const TYPE_SUBSCRIBER = 'subscriber'; public const TYPE_MESSAGE = 'message'; @@ -135,6 +138,7 @@ abstract class Resource implements \JsonSerializable self::TYPE_PROJECT_PROTOCOLS, self::TYPE_PROJECT_LABELS, self::TYPE_PROJECT_SERVICES, + self::TYPE_RULE, self::TYPE_PROVIDER, self::TYPE_TOPIC, self::TYPE_SUBSCRIBER, diff --git a/src/Migration/Resources/Domains/Rule.php b/src/Migration/Resources/Domains/Rule.php new file mode 100644 index 00000000..d4017d66 --- /dev/null +++ b/src/Migration/Resources/Domains/Rule.php @@ -0,0 +1,117 @@ +id = $id; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + } + + /** + * @param array $array + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + $array['domain'], + $array['type'], + $array['trigger'] ?? 'manual', + (string) ($array['redirectUrl'] ?? ''), + (int) ($array['redirectStatusCode'] ?? 0), + (string) ($array['deploymentResourceType'] ?? ''), + (string) ($array['deploymentResourceId'] ?? ''), + (string) ($array['deploymentVcsProviderBranch'] ?? ''), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'domain' => $this->domain, + 'type' => $this->type, + 'trigger' => $this->trigger, + 'redirectUrl' => $this->redirectUrl, + 'redirectStatusCode' => $this->redirectStatusCode, + 'deploymentResourceType' => $this->deploymentResourceType, + 'deploymentResourceId' => $this->deploymentResourceId, + 'deploymentVcsProviderBranch' => $this->deploymentVcsProviderBranch, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_RULE; + } + + public function getGroup(): string + { + return Transfer::GROUP_DOMAINS; + } + + public function getDomain(): string + { + return $this->domain; + } + + public function getType(): string + { + return $this->type; + } + + public function getTrigger(): string + { + return $this->trigger; + } + + public function getRedirectUrl(): string + { + return $this->redirectUrl; + } + + public function getRedirectStatusCode(): int + { + return $this->redirectStatusCode; + } + + public function getDeploymentResourceType(): string + { + return $this->deploymentResourceType; + } + + public function getDeploymentResourceId(): string + { + return $this->deploymentResourceId; + } + + public function getDeploymentVcsProviderBranch(): string + { + return $this->deploymentVcsProviderBranch; + } +} diff --git a/src/Migration/Source.php b/src/Migration/Source.php index 9cf1d080..fae22726 100644 --- a/src/Migration/Source.php +++ b/src/Migration/Source.php @@ -61,6 +61,11 @@ public function getProjectsBatchSize(): int return static::$defaultBatchSize; } + public function getDomainsBatchSize(): int + { + return static::$defaultBatchSize; + } + /** * @param array $resources * @return void @@ -127,6 +132,7 @@ public function exportResources(array $resources): void Transfer::GROUP_INTEGRATIONS => Transfer::GROUP_INTEGRATIONS_RESOURCES, Transfer::GROUP_BACKUPS => Transfer::GROUP_BACKUPS_RESOURCES, Transfer::GROUP_PROJECTS => Transfer::GROUP_PROJECTS_RESOURCES, + Transfer::GROUP_DOMAINS => Transfer::GROUP_DOMAINS_RESOURCES, ]; foreach ($mapping as $group => $resources) { @@ -170,6 +176,9 @@ public function exportResources(array $resources): void case Transfer::GROUP_PROJECTS: $this->exportGroupProjects($this->getProjectsBatchSize(), $resources); break; + case Transfer::GROUP_DOMAINS: + $this->exportGroupDomains($this->getDomainsBatchSize(), $resources); + break; } } } @@ -245,4 +254,12 @@ abstract protected function exportGroupBackups(int $batchSize, array $resources) * @param array $resources Resources to export */ abstract protected function exportGroupProjects(int $batchSize, array $resources): void; + + /** + * Export Domains Group + * + * @param int $batchSize + * @param array $resources Resources to export + */ + abstract protected function exportGroupDomains(int $batchSize, array $resources): void; } diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 72658951..c51230dd 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -11,6 +11,7 @@ use Appwrite\Services\Functions; use Appwrite\Services\Messaging; use Appwrite\Services\Project; +use Appwrite\Services\Proxy; use Appwrite\Services\Sites; use Appwrite\Services\Storage; use Appwrite\Services\TablesDB; @@ -58,6 +59,7 @@ use Utopia\Migration\Resources\Database\Row; use Utopia\Migration\Resources\Database\Table; use Utopia\Migration\Resources\Database\VectorsDB; +use Utopia\Migration\Resources\Domains\Rule; use Utopia\Migration\Resources\Functions\Deployment; use Utopia\Migration\Resources\Functions\EnvVar; use Utopia\Migration\Resources\Functions\Func; @@ -111,6 +113,8 @@ class Appwrite extends Source private Webhooks $webhooks; + private Proxy $proxy; + /** * @var callable(UtopiaDocument $database|null): UtopiaDatabase */ @@ -141,6 +145,7 @@ public function __construct( $this->sites = new Sites($this->client); $this->project = new Project($this->client); $this->webhooks = new Webhooks($this->client); + $this->proxy = new Proxy($this->client); $this->headers['x-appwrite-project'] = $this->projectId; $this->headers['x-appwrite-key'] = $this->key; @@ -237,6 +242,9 @@ public static function getSupportedResources(): array Resource::TYPE_PROJECT_PROTOCOLS, Resource::TYPE_PROJECT_LABELS, Resource::TYPE_PROJECT_SERVICES, + + // Domains + Resource::TYPE_RULE, ]; } @@ -278,6 +286,7 @@ public function report(array $resources = [], array $resourceIds = []): array $this->reportIntegrations($resources, $report, $resourceIds); $this->reportBackups($resources, $report, $resourceIds); $this->reportProjects($resources, $report, $resourceIds); + $this->reportDomains($resources, $report, $resourceIds); $report['version'] = $this->call( 'GET', @@ -1568,6 +1577,17 @@ protected function reportBackups(array $resources, array &$report, array $resour } } + private function reportDomains(array $resources, array &$report, array $resourceIds = []): void + { + if (\in_array(Resource::TYPE_RULE, $resources)) { + try { + $report[Resource::TYPE_RULE] = $this->proxy->listRules([Query::limit(1)])->total; + } catch (\Throwable) { + $report[Resource::TYPE_RULE] = 0; + } + } + } + private function reportProjects(array $resources, array &$report, array $resourceIds = []): void { if (\in_array(Resource::TYPE_PROJECT_VARIABLE, $resources)) { @@ -1739,6 +1759,70 @@ private function exportSMTP(): void $this->callback([$smtp]); } + protected function exportGroupDomains(int $batchSize, array $resources): void + { + if (\in_array(Resource::TYPE_RULE, $resources)) { + try { + $this->exportRules($batchSize); + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_RULE, + Transfer::GROUP_DOMAINS, + message: $e->getMessage(), + code: (int) $e->getCode() ?: Exception::CODE_INTERNAL, + previous: $e + )); + } + } + } + + /** + * @throws AppwriteException + */ + private function exportRules(int $batchSize): void + { + $lastId = null; + + while (true) { + $queries = [Query::limit($batchSize)]; + + if ($lastId !== null) { + $queries[] = Query::cursorAfter($lastId); + } + + $response = $this->proxy->listRules($queries); + if ($response->total === 0) { + break; + } + + $rules = []; + + foreach ($response->rules as $rule) { + $rules[] = new Rule( + $rule->id, + $rule->domain, + $rule->type, + $rule->trigger, + $rule->redirectUrl, + $rule->redirectStatusCode, + $rule->deploymentResourceType ? (string) $rule->deploymentResourceType : '', + $rule->deploymentResourceId, + $rule->deploymentVcsProviderBranch, + createdAt: $rule->createdAt, + updatedAt: $rule->updatedAt, + ); + + $lastId = $rule->id; + } + + $this->callback($rules); + + if (count($response->rules) < $batchSize) { + break; + } + } + } + /** * @throws AppwriteException */ diff --git a/src/Migration/Sources/CSV.php b/src/Migration/Sources/CSV.php index 2ddd31db..411a096d 100644 --- a/src/Migration/Sources/CSV.php +++ b/src/Migration/Sources/CSV.php @@ -445,6 +445,11 @@ protected function exportGroupProjects(int $batchSize, array $resources): void throw new \Exception('Not Implemented'); } + protected function exportGroupDomains(int $batchSize, array $resources): void + { + throw new \Exception('Not Implemented'); + } + /** * @param callable(resource $stream, string $delimiter): void $callback * @return void diff --git a/src/Migration/Sources/Firebase.php b/src/Migration/Sources/Firebase.php index 510b86d0..9f056e23 100644 --- a/src/Migration/Sources/Firebase.php +++ b/src/Migration/Sources/Firebase.php @@ -832,4 +832,9 @@ protected function exportGroupProjects(int $batchSize, array $resources): void { throw new \Exception('Not implemented'); } + + protected function exportGroupDomains(int $batchSize, array $resources): void + { + throw new \Exception('Not implemented'); + } } diff --git a/src/Migration/Sources/JSON.php b/src/Migration/Sources/JSON.php index d1d8ccbc..46124a09 100644 --- a/src/Migration/Sources/JSON.php +++ b/src/Migration/Sources/JSON.php @@ -219,6 +219,11 @@ protected function exportGroupProjects(int $batchSize, array $resources): void throw new \Exception('Not Implemented'); } + protected function exportGroupDomains(int $batchSize, array $resources): void + { + throw new \Exception('Not Implemented'); + } + /** * @throws \Exception */ diff --git a/src/Migration/Sources/NHost.php b/src/Migration/Sources/NHost.php index 6fd011e9..9ac91693 100644 --- a/src/Migration/Sources/NHost.php +++ b/src/Migration/Sources/NHost.php @@ -967,4 +967,9 @@ protected function exportGroupProjects(int $batchSize, array $resources): void { throw new \Exception('Not Implemented'); } + + protected function exportGroupDomains(int $batchSize, array $resources): void + { + throw new \Exception('Not Implemented'); + } } diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index b446446b..8b3d1638 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -30,6 +30,8 @@ class Transfer public const GROUP_PROJECTS = 'projects'; + public const GROUP_DOMAINS = 'domains'; + public const GROUP_AUTH_RESOURCES = [ Resource::TYPE_USER, Resource::TYPE_TEAM, @@ -109,6 +111,10 @@ class Transfer Resource::TYPE_BACKUP_POLICY, ]; + public const GROUP_DOMAINS_RESOURCES = [ + Resource::TYPE_RULE, + ]; + public const GROUP_MESSAGING_RESOURCES = [ Resource::TYPE_PROVIDER, Resource::TYPE_TOPIC, @@ -153,6 +159,9 @@ class Transfer Resource::TYPE_PROJECT_LABELS, Resource::TYPE_PROJECT_SERVICES, + // Domains + Resource::TYPE_RULE, + // legacy Resource::TYPE_DOCUMENT, Resource::TYPE_ATTRIBUTE, @@ -439,6 +448,7 @@ public static function extractServices(array $services): array self::GROUP_MESSAGING => array_merge($resources, self::GROUP_MESSAGING_RESOURCES), self::GROUP_BACKUPS => array_merge($resources, self::GROUP_BACKUPS_RESOURCES), self::GROUP_PROJECTS => array_merge($resources, self::GROUP_PROJECTS_RESOURCES), + self::GROUP_DOMAINS => array_merge($resources, self::GROUP_DOMAINS_RESOURCES), default => throw new \Exception('No service group found'), }; } diff --git a/tests/Migration/Unit/Adapters/MockDestination.php b/tests/Migration/Unit/Adapters/MockDestination.php index 2d09fe6b..ae594eac 100644 --- a/tests/Migration/Unit/Adapters/MockDestination.php +++ b/tests/Migration/Unit/Adapters/MockDestination.php @@ -58,6 +58,7 @@ public static function getSupportedResources(): array Resource::TYPE_TOPIC, Resource::TYPE_SUBSCRIBER, Resource::TYPE_MESSAGE, + Resource::TYPE_RULE, ]; } diff --git a/tests/Migration/Unit/Adapters/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index ed86d398..88aade5b 100644 --- a/tests/Migration/Unit/Adapters/MockSource.php +++ b/tests/Migration/Unit/Adapters/MockSource.php @@ -88,6 +88,7 @@ public static function getSupportedResources(): array Resource::TYPE_SUBSCRIBER, Resource::TYPE_MESSAGE, Resource::TYPE_BACKUP_POLICY, + Resource::TYPE_RULE, // legacy Resource::TYPE_DOCUMENT, @@ -241,4 +242,15 @@ protected function exportGroupProjects(int $batchSize, array $resources): void $this->handleResourceTransfer(Transfer::GROUP_PROJECTS, $resource); } } + + protected function exportGroupDomains(int $batchSize, array $resources): void + { + foreach (Transfer::GROUP_DOMAINS_RESOURCES as $resource) { + if (!\in_array($resource, $resources)) { + continue; + } + + $this->handleResourceTransfer(Transfer::GROUP_DOMAINS, $resource); + } + } }