From 7af2ef5b766d3eb34fa8f83f2c43c84c853f499a Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 21 May 2026 11:22:19 +0100 Subject: [PATCH 1/2] Add Custom Domains migration --- src/Migration/Destinations/Appwrite.php | 114 ++++++++++++++++++ src/Migration/Resource.php | 4 + src/Migration/Resources/Domains/Rule.php | 117 +++++++++++++++++++ src/Migration/Source.php | 17 +++ src/Migration/Sources/Appwrite.php | 84 +++++++++++++ src/Migration/Transfer.php | 10 ++ tests/Migration/Unit/Adapters/MockSource.php | 11 ++ 7 files changed, 357 insertions(+) create mode 100644 src/Migration/Resources/Domains/Rule.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index fc49a348..18be32f1 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -11,12 +11,15 @@ use Appwrite\Enums\PasswordHash; use Appwrite\Enums\ProjectProtocolId; use Appwrite\Enums\ProjectServiceId; +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; @@ -52,6 +55,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; @@ -107,6 +111,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; @@ -192,6 +197,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); @@ -303,6 +309,9 @@ public static function getSupportedResources(): array // Backups Resource::TYPE_BACKUP_POLICY, + + // Domains + Resource::TYPE_RULE, ]; } @@ -462,6 +471,7 @@ protected function import(array $resources, callable $callback): void Transfer::GROUP_INTEGRATIONS => $this->importIntegrationsResource($resource), Transfer::GROUP_BACKUPS => $this->importBackupResource($resource), Transfer::GROUP_SETTINGS => $this->importSettingsResource($resource), + Transfer::GROUP_DOMAINS => $this->importDomainsResource($resource), default => throw new \Exception('Invalid resource group', Exception::CODE_VALIDATION), }; } catch (\Throwable $e) { @@ -3157,6 +3167,25 @@ public function importSettingsResource(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', [ @@ -3295,6 +3324,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::FUNCTION(); + + $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 53020e1a..1a5e076b 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -87,6 +87,9 @@ abstract class Resource implements \JsonSerializable public const TYPE_SERVICES = 'services'; public const TYPE_SMTP = 'smtp'; + // 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_LABELS, self::TYPE_SERVICES, self::TYPE_SMTP, + 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 fe53f2cd..840f323b 100644 --- a/src/Migration/Source.php +++ b/src/Migration/Source.php @@ -61,6 +61,11 @@ public function getSettingsBatchSize(): 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_SETTINGS => Transfer::GROUP_SETTINGS_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_SETTINGS: $this->exportGroupSettings($this->getSettingsBatchSize(), $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 exportGroupSettings(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 6c80209d..1c13b69e 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -12,6 +12,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; @@ -59,6 +60,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; @@ -112,6 +114,8 @@ class Appwrite extends Source private Webhooks $webhooks; + private Proxy $proxy; + /** * @var callable(UtopiaDocument $database|null): UtopiaDatabase */ @@ -142,6 +146,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; @@ -238,6 +243,9 @@ public static function getSupportedResources(): array Resource::TYPE_LABELS, Resource::TYPE_SERVICES, Resource::TYPE_SMTP, + + // Domains + Resource::TYPE_RULE, ]; } @@ -279,6 +287,7 @@ public function report(array $resources = [], array $resourceIds = []): array $this->reportIntegrations($resources, $report, $resourceIds); $this->reportBackups($resources, $report, $resourceIds); $this->reportSettings($resources, $report, $resourceIds); + $this->reportDomains($resources, $report, $resourceIds); $report['version'] = $this->call( 'GET', @@ -1569,6 +1578,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 reportSettings(array $resources, array &$report, array $resourceIds = []): void { if (\in_array(Resource::TYPE_PROJECT_VARIABLE, $resources)) { @@ -1801,6 +1821,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: $e->getCode(), + 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/Transfer.php b/src/Migration/Transfer.php index 0ed5cb78..468a1a75 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -30,6 +30,8 @@ class Transfer public const GROUP_SETTINGS = 'settings'; + 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_SERVICES, Resource::TYPE_SMTP, + // Domains + Resource::TYPE_RULE, + // legacy Resource::TYPE_DOCUMENT, Resource::TYPE_ATTRIBUTE, @@ -438,6 +447,7 @@ public static function extractServices(array $services): array self::GROUP_DATABASES_VECTOR_DB => array_merge($resources, self::GROUP_VECTORSDB_RESOURCES), self::GROUP_MESSAGING => array_merge($resources, self::GROUP_MESSAGING_RESOURCES), self::GROUP_BACKUPS => array_merge($resources, self::GROUP_BACKUPS_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/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index 28002376..d3e515c9 100644 --- a/tests/Migration/Unit/Adapters/MockSource.php +++ b/tests/Migration/Unit/Adapters/MockSource.php @@ -240,4 +240,15 @@ protected function exportGroupSettings(int $batchSize, array $resources): void $this->handleResourceTransfer(Transfer::GROUP_SETTINGS, $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); + } + } } From 54c34cf43b5aadd8368e45ef72596a5e080a0327 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 21 May 2026 12:04:36 +0100 Subject: [PATCH 2/2] Stub exportGroupDomains in non-Appwrite sources --- src/Migration/Sources/CSV.php | 5 +++++ src/Migration/Sources/Firebase.php | 5 +++++ src/Migration/Sources/JSON.php | 5 +++++ src/Migration/Sources/NHost.php | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/src/Migration/Sources/CSV.php b/src/Migration/Sources/CSV.php index 22c9399b..4d67d551 100644 --- a/src/Migration/Sources/CSV.php +++ b/src/Migration/Sources/CSV.php @@ -445,6 +445,11 @@ protected function exportGroupSettings(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 2bea50de..b9211663 100644 --- a/src/Migration/Sources/Firebase.php +++ b/src/Migration/Sources/Firebase.php @@ -832,4 +832,9 @@ protected function exportGroupSettings(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 149ad66f..56464100 100644 --- a/src/Migration/Sources/JSON.php +++ b/src/Migration/Sources/JSON.php @@ -219,6 +219,11 @@ protected function exportGroupSettings(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 6c8fe0ac..a2fafc4d 100644 --- a/src/Migration/Sources/NHost.php +++ b/src/Migration/Sources/NHost.php @@ -967,4 +967,9 @@ protected function exportGroupSettings(int $batchSize, array $resources): void { throw new \Exception('Not Implemented'); } + + protected function exportGroupDomains(int $batchSize, array $resources): void + { + throw new \Exception('Not Implemented'); + } }