From 6e515e3cc486b2ec2d4d98db84c25b502701c2f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 22 May 2023 12:58:13 +0200 Subject: [PATCH] Copy Khushboo's integration from feat-peach-q1-kh --- .env | 5 +- app/config/collections.php | 305 ++++++++++- app/config/errors.php | 7 + app/config/services.php | 13 + app/controllers/api/functions.php | 107 +++- app/controllers/api/vcs.php | 481 ++++++++++++++++++ app/init.php | 1 + app/workers/builds.php | 129 ++++- app/workers/deletes.php | 62 ++- composer.json | 5 + docker-compose.yml | 10 +- src/Appwrite/Event/Build.php | 47 +- src/Appwrite/Extend/Exception.php | 3 + .../Specification/Format/OpenAPI3.php | 1 + .../Specification/Format/Swagger2.php | 1 + .../Validator/Queries/Installations.php | 20 + src/Appwrite/Utopia/Response.php | 13 + src/Appwrite/Utopia/Response/Model/Func.php | 12 + .../Utopia/Response/Model/Installation.php | 72 +++ .../Utopia/Response/Model/Repository.php | 52 ++ 20 files changed, 1335 insertions(+), 11 deletions(-) create mode 100644 app/controllers/api/vcs.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/Installations.php create mode 100644 src/Appwrite/Utopia/Response/Model/Installation.php create mode 100644 src/Appwrite/Utopia/Response/Model/Repository.php diff --git a/.env b/.env index aebffe00a..1562c2735 100644 --- a/.env +++ b/.env @@ -80,4 +80,7 @@ _APP_REGION=default _APP_DOCKER_HUB_USERNAME= _APP_DOCKER_HUB_PASSWORD= _APP_CONSOLE_GITHUB_SECRET= -_APP_CONSOLE_GITHUB_APP_ID= \ No newline at end of file +_APP_CONSOLE_GITHUB_APP_ID= +VCS_GITHUB_APP_NAME= +VCS_GITHUB_PRIVATE_KEY= +VCS_GITHUB_APP_ID= \ No newline at end of file diff --git a/app/config/collections.php b/app/config/collections.php index 338cee0e3..687b9e78e 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -1007,7 +1007,7 @@ $collections = [ [ '$id' => ID::custom('_key_region_resourceType_resourceUpdatedAt'), 'type' => Database::INDEX_KEY, - 'attributes' => ['region', 'resourceType','resourceUpdatedAt'], + 'attributes' => ['region', 'resourceType', 'resourceUpdatedAt'], 'lengths' => [], 'orders' => [], ], @@ -2221,6 +2221,178 @@ $collections = [ ], ], + 'vcs_installations' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('vcs_installations'), + 'name' => 'vcs_installations', + 'attributes' => [ + [ + '$id' => ID::custom('projectId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('projectInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('installationId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('organization'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('provider'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('accessToken'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['encrypt'] + ] + ], + 'indexes' => [], + ], + + 'vcs_repos' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('vcs_repos'), + 'name' => 'vcs_repos', + 'attributes' => [ + [ + '$id' => ID::custom('vcsInstallationId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [] + ], + [ + '$id' => ID::custom('vcsInstallationInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('projectId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [] + ], + [ + '$id' => ID::custom('projectInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('repositoryId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [] + ], + [ + '$id' => ID::custom('repositoryOwner'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [] + ], + [ + '$id' => ID::custom('resourceId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('resourceType'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [] + ] + ], + 'indexes' => [], + ], + 'functions' => [ '$collection' => ID::custom(Database::METADATA), '$id' => ID::custom('functions'), @@ -2258,6 +2430,48 @@ $collections = [ 'required' => true, 'array' => false, ], + [ + '$id' => ID::custom('vcsInstallationId'), + 'type' => Database::VAR_STRING, + 'signed' => true, + 'size' => 2048, + 'format' => '', + 'filters' => [], + 'required' => false, + 'array' => false, + ], + [ + '$id' => ID::custom('vcsInstallationInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('vcsRepoId'), + 'type' => Database::VAR_STRING, + 'signed' => true, + 'size' => 2048, + 'format' => '', + 'filters' => [], + 'required' => false, + 'array' => false, + ], + [ + '$id' => ID::custom('vcsRepoInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('logging'), 'type' => Database::VAR_BOOLEAN, @@ -2445,6 +2659,20 @@ $collections = [ 'lengths' => [], 'orders' => [Database::ORDER_ASC], ], + [ + '$id' => ID::custom('_key_vcsInstallationId'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['vcsInstallationId'], + 'lengths' => [768], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_vcsRepoId'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['vcsRepoId'], + 'lengths' => [768], + 'orders' => [Database::ORDER_ASC], + ], [ '$id' => ID::custom('_key_runtime'), 'type' => Database::INDEX_KEY, @@ -2601,6 +2829,77 @@ $collections = [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('type'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 2048, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('vcsInstallationId'), + 'type' => Database::VAR_STRING, + 'signed' => true, + 'size' => Database::LENGTH_KEY, + 'format' => '', + 'filters' => [], + 'required' => false, + 'array' => false, + ], + [ + '$id' => ID::custom('vcsInstallationInternalId'), + 'type' => Database::VAR_STRING, + 'signed' => true, + 'size' => Database::LENGTH_KEY, + 'format' => '', + 'filters' => [], + 'required' => false, + 'array' => false, + ], + [ + '$id' => ID::custom('vcsRepoId'), + 'type' => Database::VAR_STRING, + 'signed' => true, + 'size' => Database::LENGTH_KEY, + 'format' => '', + 'filters' => [], + 'required' => false, + 'array' => false, + ], + [ + '$id' => ID::custom('vcsRepoInternalId'), + 'type' => Database::VAR_STRING, + 'signed' => true, + 'size' => Database::LENGTH_KEY, + 'format' => '', + 'filters' => [], + 'required' => false, + 'array' => false, + ], + [ + '$id' => ID::custom('branch'), + 'type' => Database::VAR_STRING, + 'signed' => true, + 'size' => Database::LENGTH_KEY, + 'format' => '', + 'filters' => [], + 'required' => false, + 'array' => false, + ], + [ + '$id' => ID::custom('vcsCommentId'), + 'type' => Database::VAR_STRING, + 'signed' => true, + 'size' => 2048, + 'format' => '', + 'filters' => [], + 'required' => false, + 'array' => false, + ], [ '$id' => ID::custom('size'), 'type' => Database::VAR_INTEGER, @@ -3549,7 +3848,7 @@ $collections = [ 'array' => false, 'filters' => [], ], - ], + ], 'indexes' => [ [ '$id' => '_key_accessedAt', @@ -3878,7 +4177,7 @@ $collections = [ 'required' => true, 'default' => null, 'array' => false, - 'filters' => [ 'encrypt' ] + 'filters' => ['encrypt'] ], [ '$id' => ID::custom('search'), diff --git a/app/config/errors.php b/app/config/errors.php index f75bdee48..4cf0bf150 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -324,6 +324,13 @@ return [ 'code' => 416, ], + /** VCS */ + Exception::INSTALLATION_NOT_FOUND => [ + 'name' => Exception::INSTALLATION_NOT_FOUND, + 'description' => 'Installation with the requested ID could not be found.', + 'code' => 404, + ], + /** Functions */ Exception::FUNCTION_NOT_FOUND => [ 'name' => Exception::FUNCTION_NOT_FOUND, diff --git a/app/config/services.php b/app/config/services.php index 4be15030d..949e7a8c4 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -160,6 +160,19 @@ return [ 'optional' => true, 'icon' => '/images/services/users.png', ], + 'vcs' => [ + 'key' => 'vcs', + 'name' => 'VCS', + 'subtitle' => 'The VCS service allows you to interact with providers like GitHub, GitLab etc.', + 'description' => '', + 'controller' => 'api/vcs.php', + 'sdk' => false, + 'docs' => false, + 'docsUrl' => '', + 'tests' => false, + 'optional' => true, + 'icon' => '', + ], 'functions' => [ 'key' => 'functions', 'name' => 'Functions', diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index 27a3af939..2d4a22df4 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -80,6 +80,7 @@ App::post('/v1/functions') ->inject('events') ->inject('dbForConsole') ->action(function (string $functionId, string $name, array $execute, string $runtime, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $buildCommand, string $installCommand, Response $response, Database $dbForProject, Document $project, Document $user, Event $eventsInstance, Database $dbForConsole) { + // TODO: Add support to link to GitHub repos from createFunction as well $functionId = ($functionId == 'unique()') ? ID::unique() : $functionId; $function = $dbForProject->createDocument('functions', new Document([ @@ -522,13 +523,17 @@ App::put('/v1/functions/:functionId') ->param('entrypoint', '', new Text('1028'), 'Entrypoint File.', true) ->param('buildCommand', '', new Text('1028'), 'Build Command.', true) ->param('installCommand', '', new Text('1028'), 'Install Command.', true) + ->param('installationId', '', new Text(128, 0), 'Appwrite Installation ID for vcs deployment.', true) + ->param('repositoryId', '', new Text(128, 0), 'Repository ID of the repo linked to the function', true) + ->param('repositoryOwner', '', new Text(128, 0), 'Repository Owner of the repo linked to the function', true) ->inject('response') ->inject('dbForProject') + ->inject('dbForConsole') ->inject('project') ->inject('user') ->inject('events') ->inject('dbForConsole') - ->action(function (string $functionId, string $name, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $buildCommand, string $installCommand, Response $response, Database $dbForProject, Document $project, Document $user, Event $eventsInstance, Database $dbForConsole) { + ->action(function (string $functionId, string $name, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $buildCommand, string $installCommand, string $vcsInstallationId, string $repositoryId, string $repositoryOwner, Response $response, Database $dbForProject, Document $project, Document $user, Event $eventsInstance, Database $dbForConsole) { $function = $dbForProject->getDocument('functions', $functionId); @@ -536,8 +541,92 @@ App::put('/v1/functions/:functionId') throw new Exception(Exception::FUNCTION_NOT_FOUND); } + $installation = $dbForConsole->getDocument('vcs_installations', $vcsInstallationId, [ + Query::equal('projectInternalId', [$project->getInternalId()]) + ]); + + if (!empty($vcsInstallationId) && $installation->isEmpty()) { + throw new Exception(Exception::INSTALLATION_NOT_FOUND); + } + + if (!empty($vcsInstallationId) && empty($repositoryId)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID); // TODO: More specific error + } + + if (!empty($repositoryId) && empty($vcsInstallationId)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID); // TODO: More specific error + } + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + $enabled ??= $function->getAttribute('enabled', true); + $vcsRepoId = null; + $needToDeploy = false; + //if repo id was previously empty and non empty now, we need to create a new deployment for this function + $prevVcsRepoId = $function->getAttribute('vcsRepoId', ''); + if (empty($prevVcsRepoId) && !empty($repositoryId)) { + $needToDeploy = true; + } + + // activate the deployment for first run of a VCS repo + if ($needToDeploy) { + $deploymentId = ID::unique(); + $entrypoint = 'index.js'; //TODO: Read from function settings + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + + //Add document in VCS repos collection + $vcs_repos = new Document([ + '$id' => ID::unique(), + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'vcsInstallationId' => $installation->getId(), + 'vcsInstallationInternalId' => $installation->getInternalId(), + 'projectId' => $project->getId(), + 'projectInternalId' => $project->getInternalId(), + 'repositoryId' => $repositoryId, + 'repositoryOwner' => $repositoryOwner, + 'resourceId' => $functionId, + 'resourceType' => "function" + ]); + + $vcs_repos = $dbForConsole->createDocument('vcs_repos', $vcs_repos); + $vcsRepoId = $vcs_repos->getId(); + $vcsRepoInternalId = $vcs_repos->getInternalId(); + + $deployment = $dbForProject->createDocument('deployments', new Document([ + '$id' => $deploymentId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'resourceId' => $function->getId(), + 'resourceType' => 'functions', + 'entrypoint' => $entrypoint, + 'type' => "vcs", + 'vcsInstallationId' => $installation->getId(), + 'vcsInstallationInternalId' => $installation->getInternalId(), + 'vcsRepoId' => $vcsRepoId, + 'vcsRepoInternalId' => $vcsRepoInternalId, + 'branch' => "main", + 'search' => implode(' ', [$deploymentId, $entrypoint]), + 'activate' => true, + ])); + } + + // Disconnect repo + if (!empty($prevVcsRepoId) && empty($repositoryId)) { + $dbForConsole->deleteDocument('vcs_repos', $prevVcsRepoId); + $vcsRepoId = ''; + $vcsRepoInternalId = ''; + } + $function = $dbForProject->updateDocument('functions', $function->getId(), new Document(array_merge($function->getArrayCopy(), [ 'execute' => $execute, 'name' => $name, @@ -549,6 +638,10 @@ App::put('/v1/functions/:functionId') 'entrypoint' => $entrypoint, 'buildCommand' => $buildCommand, 'installCommand' => $installCommand, + 'vcsInstallationId' => $installation->getId(), + 'vcsInstallationInternalId' => $installation->getInternalId(), + 'vcsRepoId' => $vcsRepoId, + 'vcsRepoInternalId' => $vcsRepoInternalId, 'search' => implode(' ', [$functionId, $name, $function->getAttribute('runtime')]), ]))); @@ -563,6 +656,18 @@ App::put('/v1/functions/:functionId') $eventsInstance->setParam('functionId', $function->getId()); + if ($needToDeploy) { + $buildEvent = new Build(); + $buildEvent + ->setType(BUILD_TYPE_DEPLOYMENT) + ->setResource($function) + ->setDeployment($deployment) + ->setProject($project) + ->trigger(); + + //TODO: Add event? + } + $response->dynamic($function, Response::MODEL_FUNCTION); }); diff --git a/app/controllers/api/vcs.php b/app/controllers/api/vcs.php new file mode 100644 index 000000000..facd31049 --- /dev/null +++ b/app/controllers/api/vcs.php @@ -0,0 +1,481 @@ +desc('Install GitHub App') + ->groups(['api', 'vcs']) + ->label('scope', 'public') + ->label('origin', '*') + ->label('sdk.auth', []) + ->label('sdk.namespace', 'vcs') + ->label('sdk.method', 'createGitHubInstallation') + ->label('sdk.description', '') + ->label('sdk.response.code', Response::STATUS_CODE_MOVED_PERMANENTLY) + ->label('sdk.response.type', Response::CONTENT_TYPE_HTML) + ->label('sdk.methodType', 'webAuth') + ->inject('response') + ->inject('project') + ->action(function (Response $response, Document $project) { + $projectId = $project->getId(); + + $appName = App::getEnv('VCS_GITHUB_APP_NAME'); + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect("https://github.com/apps/$appName/installations/new?state=$projectId"); + }); + +App::get('/v1/vcs/github/incominginstallation') + ->desc('Capture installation id and state after GitHub App Installation') + ->groups(['api', 'vcs']) + ->label('scope', 'public') + ->param('installation_id', '', new Text(256), 'installation_id') + ->param('setup_action', '', new Text(256), 'setup_action') + ->param('state', '', new Text(256), 'state') + ->inject('request') + ->inject('response') + ->inject('dbForConsole') + ->action(function (string $installationId, string $setupAction, string $state, Request $request, Response $response, Database $dbForConsole) { + + $project = $dbForConsole->getDocument('projects', $state); + + if ($project->isEmpty()) { + $url = $request->getProtocol() . '://' . $request->getHostname() . "/"; + $response->redirect($url); + } + + $projectInternalId = $project->getInternalId(); + + $vcsInstallation = $dbForConsole->findOne('vcs_installations', [ + Query::equal('installationId', [$installationId]), + Query::equal('projectInternalId', [$projectInternalId]) + ]); + + if (!$vcsInstallation) { + $vcsInstallation = new Document([ + '$id' => ID::unique(), + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'installationId' => $installationId, + 'projectId' => $state, + 'projectInternalId' => $projectInternalId, + 'provider' => 'GitHub', + 'organization' => '(todo) My Awesome Organization', + 'accessToken' => null + ]); + + $vcsInstallation = $dbForConsole->createDocument('vcs_installations', $vcsInstallation); + } else { + $vcsInstallation = $vcsInstallation->setAttribute('organization', '(todo) My Awesome Organization'); + $vcsInstallation = $dbForConsole->updateDocument('vcs_installations', $vcsInstallation->getId(), $vcsInstallation); + } + + $url = $request->getProtocol() . '://' . $request->getHostname() . ":3000/console/project-$state/settings/git-installations"; + + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($url); + }); + +App::get('v1/vcs/github/installations/:installationId/repositories') + ->desc('List repositories') + ->groups(['api', 'vcs']) + ->label('scope', 'public') + ->label('sdk.namespace', 'vcs') + ->label('sdk.method', 'listRepositories') + ->label('sdk.description', '') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_REPOSITORY_LIST) + ->param('installationId', '', new Text(256), 'Installation Id') + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->inject('response') + ->inject('project') + ->inject('dbForConsole') + ->action(function (string $vcsInstallationId, string $search, Response $response, Document $project, Database $dbForConsole) { + if (empty($search)) { + $search = ""; + } + + $installation = $dbForConsole->getDocument('vcs_installations', $vcsInstallationId, [ + Query::equal('projectInternalId', [$project->getInternalId()]) + ]); + + if ($installation->isEmpty()) { + throw new Exception(Exception::INSTALLATION_NOT_FOUND); + } + + $installationId = $installation->getAttribute('installationId'); + $privateKey = App::getEnv('VCS_GITHUB_PRIVATE_KEY'); + $githubAppId = App::getEnv('VCS_GITHUB_APP_ID'); + $github = new GitHub(); + $github->initialiseVariables($installationId, $privateKey, $githubAppId); + + $page = 1; + $per_page = 100; // max limit of GitHub API + $repos = []; // Array to store all repositories + + // loop to store all repos in repos array + + do { + $repositories = $github->listRepositoriesForGitHubApp($page, $per_page); + $repos = array_merge($repos, $repositories); + $page++; + } while ($repositories == $per_page); + + // Filter repositories based on search parameter + if (!empty($search)) { + $repos = array_filter($repos, function ($repo) use ($search) { + $repoName = strtolower($repo['name']); + $searchTerm = strtolower($search); + return strpos($repoName, $searchTerm) !== false; + }); + } + // Sort repositories by last modified date in descending order + usort($repos, function ($repo1, $repo2) { + return strtotime($repo2['pushed_at']) - strtotime($repo1['pushed_at']); + }); + + // Limit the maximum results to 5 + $repos = array_slice($repos, 0, 5); + + $response->dynamic(new Document([ + 'repositories' => $repos, + 'total' => \count($repos), + ]), Response::MODEL_REPOSITORY_LIST); + }); + +App::post('/v1/vcs/github/incomingwebhook') + ->desc('Captures GitHub Webhook Events') + ->groups(['api', 'vcs']) + ->label('scope', 'public') + ->inject('request') + ->inject('response') + ->inject('dbForConsole') + ->inject('cache') + ->inject('db') + ->action( + function (Request $request, Response $response, Database $dbForConsole, mixed $cache, mixed $db) { + $cache = new Cache(new Redis($cache)); + $event = $request->getHeader('x-github-event', ''); + $payload = $request->getRawPayload(); + $github = new GitHub(); + $privateKey = App::getEnv('VCS_GITHUB_PRIVATE_KEY'); + $githubAppId = App::getEnv('VCS_GITHUB_APP_ID'); + $parsedPayload = $github->parseWebhookEventPayload($event, $payload); + + if ($event == $github::EVENT_PUSH) { + $branchName = $parsedPayload["branch"]; + $repositoryId = $parsedPayload["repositoryId"]; + $installationId = $parsedPayload["installationId"]; + $SHA = $parsedPayload["SHA"]; + $owner = $parsedPayload["owner"]; + + //find functionId from functions table + $resources = $dbForConsole->find('vcs_repos', [ + Query::equal('repositoryId', [$repositoryId]), + Query::limit(100), + ]); + + foreach ($resources as $resource) { + $resourceType = $resource->getAttribute('resourceType'); + + if ($resourceType == "function") { + // TODO: For cloud, we might have different $db + $dbForProject = new Database(new MariaDB($db), $cache); + $dbForProject->setDefaultDatabase(App::getEnv('_APP_DB_SCHEMA', 'appwrite')); + $dbForProject->setNamespace("_{$resource->getAttribute('projectInternalId')}"); + + $functionId = $resource->getAttribute('resourceId'); + //TODO: Why is Authorization::skip needed? + $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); + $projectId = $resource->getAttribute('projectId'); + //TODO: Why is Authorization::skip needed? + $project = Authorization::skip(fn () => $dbForConsole->getDocument('projects', $projectId)); + $deploymentId = ID::unique(); + $entrypoint = 'index.js'; //TODO: Read from function settings + $vcsRepoId = $resource->getId(); + $vcsRepoInternalId = $resource->getInternalId(); + $vcsInstallationId = $resource->getAttribute('vcsInstallationId'); + $vcsInstallationInternalId = $resource->getAttribute('vcsInstallationInternalId'); + $activate = false; + + if ($branchName == "main") { + $activate = true; + } + + $deployment = $dbForProject->createDocument('deployments', new Document([ + '$id' => $deploymentId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'resourceId' => $functionId, + 'resourceType' => 'functions', + 'entrypoint' => $entrypoint, + 'type' => "vcs", + 'vcsInstallationId' => $vcsInstallationId, + 'vcsInstallationInternalId' => $vcsInstallationInternalId, + 'vcsRepoId' => $vcsRepoId, + 'vcsRepoInternalId' => $vcsRepoInternalId, + 'branch' => $branchName, + 'search' => implode(' ', [$deploymentId, $entrypoint]), + 'activate' => $activate, + ])); + + $targetUrl = $request->getProtocol() . '://' . $request->getHostname() . ":3000/console/project-$projectId/functions/function-$functionId"; + + $buildEvent = new Build(); + $buildEvent + ->setType(BUILD_TYPE_DEPLOYMENT) + ->setResource($function) + ->setDeployment($deployment) + ->setProject($project) + ->setSHA($SHA) + ->setOwner($owner) + ->setTargetUrl($targetUrl) + ->trigger(); + + //TODO: Add event? + } + } + } elseif ($event == $github::EVENT_INSTALLATION) { + if ($parsedPayload["action"] == "deleted") { + // TODO: Use worker for this job instead + $installationId = $parsedPayload["installationId"]; + + $vcsInstallations = $dbForConsole->find('vcs_installations', [ + Query::equal('installationId', [$installationId]), + Query::limit(1000) + ]); + + foreach ($vcsInstallations as $installation) { + $vcsRepos = $dbForConsole->find('vcs_repos', [ + Query::equal('vcsInstallationId', [$installation->getId()]), + Query::limit(1000) + ]); + + foreach ($vcsRepos as $repo) { + $dbForConsole->deleteDocument('vcs_repos', $repo->getId()); + } + + $dbForConsole->deleteDocument('vcs_installations', $installation->getId()); + } + } + } elseif ($event == $github::EVENT_PULL_REQUEST) { + if ($parsedPayload["action"] == "opened" or $parsedPayload["action"] == "reopened") { + $startNewDeployment = false; + $branchName = $parsedPayload["branch"]; + $repositoryId = $parsedPayload["repositoryId"]; + $installationId = $parsedPayload["installationId"]; + $pullRequestNumber = $parsedPayload["pullRequestNumber"]; + $repositoryName = $parsedPayload["repositoryName"]; + $owner = $parsedPayload["owner"]; + $github->initialiseVariables($installationId, $privateKey, $githubAppId); + + $vcsRepos = $dbForConsole->find('vcs_repos', [ + Query::equal('repositoryId', [$repositoryId]), + Query::orderDesc('$createdAt') + ]); + + $dbForProject = new Database(new MariaDB($db), $cache); + $dbForProject->setDefaultDatabase(App::getEnv('_APP_DB_SCHEMA', 'appwrite')); + + if ($vcsRepos) { + $dbForProject->setNamespace("_{$vcsRepos[0]->getAttribute('projectInternalId')}"); + $vcsRepoId = $vcsRepos[0]->getId(); + $deployment = Authorization::skip(fn () => $dbForProject->find('deployments', [ + Query::equal('vcsRepoId', [$vcsRepoId]), + Query::equal('branch', [$branchName]), + Query::orderDesc('$createdAt') + ])); + + if ($deployment) { + $buildId = $deployment[0]->getAttribute('buildId'); + $build = Authorization::skip(fn () => $dbForProject->getDocument('builds', $buildId)); + $buildStatus = $build->getAttribute('status'); + $comment = "| Build Status |\r\n | --------------- |\r\n | $buildStatus |"; + $commentId = $github->addComment($owner, $repositoryName, $pullRequestNumber, $comment); + } else { + $startNewDeployment = true; + } + } else { + $startNewDeployment = true; + } + if ($startNewDeployment) { + $commentId = strval($github->addComment($owner, $repositoryName, $pullRequestNumber, "Build is not deployed yet 🚀")); + + foreach ($vcsRepos as $resource) { + $resourceType = $resource->getAttribute('resourceType'); + + if ($resourceType == "function") { + // TODO: For cloud, we might have different $db + $dbForProject->setNamespace("_{$resource->getAttribute('projectInternalId')}"); + + $functionId = $resource->getAttribute('resourceId'); + //TODO: Why is Authorization::skip needed? + $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); + $projectId = $resource->getAttribute('projectId'); + //TODO: Why is Authorization::skip needed? + $project = Authorization::skip(fn () => $dbForConsole->getDocument('projects', $projectId)); + $deploymentId = ID::unique(); + $entrypoint = 'index.js'; //TODO: Read from function settings + $vcsRepoId = $resource->getId(); + $vcsRepoInternalId = $resource->getInternalId(); + $vcsInstallationId = $resource->getAttribute('vcsInstallationId'); + $vcsInstallationInternalId = $resource->getAttribute('vcsInstallationInternalId'); + $activate = false; + + if ($branchName == "main") { + $activate = true; + } + + $deployment = $dbForProject->createDocument('deployments', new Document([ + '$id' => $deploymentId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'resourceId' => $functionId, + 'resourceType' => 'functions', + 'entrypoint' => $entrypoint, + 'type' => "vcs", + 'vcsInstallationId' => $vcsInstallationId, + 'vcsInstallationInternalId' => $vcsInstallationInternalId, + 'vcsRepoId' => $vcsRepoId, + 'vcsRepoInternalId' => $vcsRepoInternalId, + 'branch' => $branchName, + 'vcsCommentId' => $commentId, + 'search' => implode(' ', [$deploymentId, $entrypoint]), + 'activate' => $activate, + ])); + + $buildEvent = new Build(); + $buildEvent + ->setType(BUILD_TYPE_DEPLOYMENT) + ->setResource($function) + ->setDeployment($deployment) + ->setProject($project) + ->setOwner($owner) + ->trigger(); + + //TODO: Add event? + } + } + } + } + } + + $response->json($parsedPayload); + } + ); + +App::get('/v1/vcs/installations') + ->groups(['api', 'vcs']) + ->desc('List installations') + ->label('scope', 'public') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'vcs') + ->label('sdk.method', 'listInstallations') + ->label('sdk.description', '/docs/references/vcs/list-installations.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_INSTALLATION_LIST) + ->param('queries', [], new Installations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Installations::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->inject('response') + ->inject('project') + ->inject('dbForConsole') + ->action(function (array $queries, string $search, Response $response, Document $project, Database $dbForConsole) { + + $queries = Query::parseQueries($queries); + + $queries[] = Query::equal('projectInternalId', [$project->getInternalId()]); + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + // Get cursor document if there was a cursor query + $cursor = Query::getByType($queries, Query::TYPE_CURSORAFTER, Query::TYPE_CURSORBEFORE); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + $vcsInstallationId = $cursor->getValue(); + $cursorDocument = $dbForConsole->getDocument('vcs_installations', $vcsInstallationId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Installation '{$vcsInstallationId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + $response->dynamic(new Document([ + 'installations' => $dbForConsole->find('vcs_installations', $queries), + 'total' => $dbForConsole->count('vcs_installations', $filterQueries, APP_LIMIT_COUNT), + ]), Response::MODEL_INSTALLATION_LIST); + }); + +App::delete('/v1/vcs/installations/:installationId') + ->groups(['api', 'vcs']) + ->desc('Delete Installation') + ->label('scope', 'public') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'vcs') + ->label('sdk.method', 'deleteInstallation') + ->label('sdk.description', '/docs/references/vcs/delete-installation.md') + ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) + ->label('sdk.response.model', Response::MODEL_NONE) + ->param('installationId', '', new Text(256), 'Installation Id') + ->inject('response') + ->inject('project') + ->inject('dbForConsole') + ->inject('deletes') + ->action(function (string $vcsInstallationId, Response $response, Document $project, Database $dbForConsole, Delete $deletes) { + + $installation = $dbForConsole->getDocument('vcs_installations', $vcsInstallationId, [ + Query::equal('projectInternalId', [$project->getInternalId()]) + ]); + + if ($installation->isEmpty()) { + throw new Exception(Exception::INSTALLATION_NOT_FOUND); + } + + if (!$dbForConsole->deleteDocument('vcs_installations', $installation->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove installation from DB'); + } + + $deletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($installation); + + $response->noContent(); + }); diff --git a/app/init.php b/app/init.php index 34042d40c..6a09af7e1 100644 --- a/app/init.php +++ b/app/init.php @@ -152,6 +152,7 @@ const DELETE_TYPE_ABUSE = 'abuse'; const DELETE_TYPE_USAGE = 'usage'; const DELETE_TYPE_REALTIME = 'realtime'; const DELETE_TYPE_BUCKETS = 'buckets'; +const DELETE_TYPE_INSTALLATIONS = 'vcs_installations'; const DELETE_TYPE_RULES = 'rules'; const DELETE_TYPE_SESSIONS = 'sessions'; const DELETE_TYPE_CACHE_BY_TIMESTAMP = 'cacheByTimeStamp'; diff --git a/app/workers/builds.php b/app/workers/builds.php index 9f92e62b2..fbc873d75 100644 --- a/app/workers/builds.php +++ b/app/workers/builds.php @@ -16,6 +16,8 @@ use Utopia\Database\Document; use Utopia\Config\Config; use Utopia\Database\Validator\Authorization; use Utopia\Storage\Storage; +use Utopia\Database\Validator\Authorization; +use Utopia\VCS\Adapter\Git\GitHub; require_once __DIR__ . '/../init.php'; @@ -43,12 +45,15 @@ class BuildsV1 extends Worker $project = new Document($this->args['project'] ?? []); $resource = new Document($this->args['resource'] ?? []); $deployment = new Document($this->args['deployment'] ?? []); + $SHA = $this->args['SHA'] ?? ''; + $owner = $this->args['owner'] ?? ''; + $targetUrl = $this->args['targetUrl'] ?? ''; switch ($type) { case BUILD_TYPE_DEPLOYMENT: case BUILD_TYPE_RETRY: Console::info('Creating build for deployment: ' . $deployment->getId()); - $this->buildDeployment($project, $resource, $deployment); + $this->buildDeployment($project, $resource, $deployment, $SHA, $owner, $targetUrl); break; default: @@ -57,11 +62,12 @@ class BuildsV1 extends Worker } } - protected function buildDeployment(Document $project, Document $function, Document $deployment) + protected function buildDeployment(Document $project, Document $function, Document $deployment, string $SHA = '', string $owner = '', string $targetUrl = '') { global $register; $dbForProject = $this->getProjectDB($project); + $dbForConsole = $this->getConsoleDB(); $function = $dbForProject->getDocument('functions', $function->getId()); if ($function->isEmpty()) { @@ -80,7 +86,8 @@ class BuildsV1 extends Worker throw new Exception('Runtime "' . $function->getAttribute('runtime', '') . '" is not supported'); } - $connection = App::getEnv('_APP_CONNECTIONS_STORAGE', ''); /** @TODO : move this to the registry or someplace else */ + $connection = App::getEnv('_APP_CONNECTIONS_STORAGE', ''); + /** @TODO : move this to the registry or someplace else */ $device = Storage::DEVICE_LOCAL; try { $dsn = new DSN($connection); @@ -94,6 +101,94 @@ class BuildsV1 extends Worker $durationStart = \microtime(true); if (empty($buildId)) { $buildId = ID::unique(); + + $vcsInstallationId = $deployment->getAttribute('vcsInstallationId'); + $vcsRepoId = $deployment->getAttribute('vcsRepoId'); + $isVcsEnabled = $vcsRepoId !== null ? true : false; + $addComment = false; + + if ($isVcsEnabled) { + $vcsRepos = Authorization::skip(fn () => $dbForConsole + ->getDocument('vcs_repos', $vcsRepoId)); + $repositoryId = $vcsRepos->getAttribute('repositoryId'); + $owner = $vcsRepos->getAttribute('repositoryOwner'); + $vcsInstallations = Authorization::skip(fn () => $dbForConsole + ->getDocument('vcs_installations', $vcsInstallationId)); + $installationId = $vcsInstallations->getAttribute('installationId'); + + $privateKey = App::getEnv('VCS_GITHUB_PRIVATE_KEY'); + $githubAppId = App::getEnv('VCS_GITHUB_APP_ID'); + + $github = new GitHub(); + $github->initialiseVariables($installationId, $privateKey, $githubAppId); + $repositoryName = $github->getRepositoryName($repositoryId); + $branchName = $deployment->getAttribute('branch'); + $gitCloneCommand = $github->generateGitCloneCommand($owner, $repositoryId, $branchName); + $stdout = ''; + $stderr = ''; + Console::execute('mkdir /tmp/builds/' . $buildId, '', $stdout, $stderr); + Console::execute($gitCloneCommand . ' /tmp/builds/' . $buildId . '/code', '', $stdout, $stderr); + Console::execute('tar --exclude code.tar.gz -czf /tmp/builds/' . $buildId . '/code.tar.gz -C /tmp/builds/' . $buildId . '/code .', '', $stdout, $stderr); + + $deviceFunctions = $this->getFunctionsDevice($project->getId()); + + $fileName = 'code.tar.gz'; + $fileTmpName = '/tmp/builds/' . $buildId . '/code.tar.gz'; + + $deploymentId = $deployment->getId(); + $path = $deviceFunctions->getPath($deploymentId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION)); + + $result = $deviceFunctions->move($fileTmpName, $path); + + if (!$result) { + throw new \Exception("Unable to move file"); + } + + Console::execute('rm -rf /tmp/builds/' . $buildId, '', $stdout, $stderr); + + $build = $dbForProject->createDocument('builds', new Document([ + '$id' => $buildId, + '$permissions' => [], + 'startTime' => $startTime, + 'deploymentId' => $deployment->getId(), + 'status' => 'processing', + 'outputPath' => '', + 'runtime' => $function->getAttribute('runtime'), + 'source' => $path, + 'sourceType' => strtolower(App::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL)), + 'stdout' => '', + 'stderr' => '', + 'endTime' => null, + 'duration' => 0 + ])); + if ($SHA !== "" && $owner !== "") { + $github->updateCommitStatus($repositoryName, $SHA, $owner, "pending", "Deployment is being processed..", $targetUrl, "Appwrite Deployment"); + } + $commentId = $deployment->getAttribute('vcsCommentId'); + if ($commentId) { + $comment = "| Build Status |\r\n | --------------- |\r\n | Processing |"; + + $github->updateComment($owner, $repositoryName, $commentId, $comment); + $addComment = true; + } + } else { + $build = $dbForProject->createDocument('builds', new Document([ + '$id' => $buildId, + '$permissions' => [], + 'startTime' => $startTime, + 'deploymentId' => $deployment->getId(), + 'status' => 'processing', + 'outputPath' => '', + 'runtime' => $function->getAttribute('runtime'), + 'source' => $deployment->getAttribute('path'), + 'sourceType' => strtolower(App::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL)), + 'stdout' => '', + 'stderr' => '', + 'endTime' => null, + 'duration' => 0 + ])); + } + $build = $dbForProject->createDocument('builds', new Document([ '$id' => $buildId, '$permissions' => [], @@ -122,6 +217,14 @@ class BuildsV1 extends Worker $build->setAttribute('status', 'building'); $build = $dbForProject->updateDocument('builds', $buildId, $build); + if ($isVcsEnabled) { + $commentId = $deployment->getAttribute('vcsCommentId'); + if ($commentId) { + $comment = "| Build Status |\r\n | --------------- |\r\n | Building |"; + $github->updateComment($owner, $repositoryName, $commentId, $comment); + } + } + /** Trigger Webhook */ $deploymentModel = new Deployment(); @@ -167,6 +270,10 @@ class BuildsV1 extends Worker $source = $deployment->getAttribute('path'); + if ($isVcsEnabled) { + $source = $path; + } + $vars = array_reduce($function->getAttribute('vars', []), function (array $carry, Document $var) { $carry[$var->getAttribute('key')] = $var->getAttribute('value'); return $carry; @@ -214,6 +321,22 @@ class BuildsV1 extends Worker $build->setAttribute('stderr', $response['stderr']); $build->setAttribute('stdout', $response['stdout']); + if ($isVcsEnabled) { + $status = $response["status"]; + + if ($status === "ready" && $SHA !== "" && $owner !== "") { + $github->updateCommitStatus($repositoryName, $SHA, $owner, "success", "Deployment is successful!", $targetUrl, "Appwrite Deployment"); + } elseif ($status === "failed" && $SHA !== "" && $owner !== "") { + $github->updateCommitStatus($repositoryName, $SHA, $owner, "failure", "Deployment failed.", $targetUrl, "Appwrite Deployment"); + } + + $commentId = $deployment->getAttribute('vcsCommentId'); + if ($commentId) { + $comment = "| Build Status |\r\n | --------------- |\r\n | $status |"; + $github->updateComment($owner, $repositoryName, $commentId, $comment); + } + } + /* Also update the deployment buildTime */ $deployment->setAttribute('buildTime', $response['duration']); diff --git a/app/workers/deletes.php b/app/workers/deletes.php index 904824b59..736c00929 100644 --- a/app/workers/deletes.php +++ b/app/workers/deletes.php @@ -64,6 +64,10 @@ class DeletesV1 extends Worker break; case DELETE_TYPE_BUCKETS: $this->deleteBucket($document, $project); + break; + case DELETE_TYPE_INSTALLATIONS: + $this->deleteInstallation($document, $project); + break; case DELETE_TYPE_RULES: $this->deleteRule($document, $project); break; @@ -329,7 +333,7 @@ class DeletesV1 extends Worker 'teams', $teamId, // Ensure that total >= 0 - $team->setAttribute('total', \max($team->getAttribute('total', 0) - 1, 0)) + $team->setAttribute('total', \max($team->getAttribute('total', 0) - 1, 0)) ); } } @@ -626,6 +630,43 @@ class DeletesV1 extends Worker Console::info("Found {$count} projects " . ($executionEnd - $executionStart) . " seconds"); } + /** + * @param string $collection collectionID + * @param Query[] $queries + * @param Database $database + * @param callable $callback + */ + protected function listByGroup(string $collection, array $queries, Database $database, callable $callback = null): void + { + $count = 0; + $chunk = 0; + $limit = 50; + $results = []; + $sum = $limit; + + $executionStart = \microtime(true); + + while ($sum === $limit) { + $chunk++; + + $results = $database->find($collection, \array_merge([Query::limit($limit)], $queries)); + + $sum = count($results); + + foreach ($results as $document) { + if (is_callable($callback)) { + $callback($document); + } + + $count++; + } + } + + $executionEnd = \microtime(true); + + Console::info("Listed {$count} document by group in " . ($executionEnd - $executionStart) . " seconds"); + } + /** * @param string $collection collectionID * @param Query[] $queries @@ -737,6 +778,25 @@ class DeletesV1 extends Worker $device->deletePath($document->getId()); } + protected function deleteInstallation(Document $document, Document $project) + { + $dbForProject = $this->getProjectDB($projectId); + $dbForConsole = $this->getConsoleDB(); + + $this->listByGroup('functions', [ + Query::equal('vcsInstallationInternalId', [$document->getInternalId()]) + ], $dbForProject, function ($function) use ($dbForProject, $dbForConsole) { + $dbForConsole->deleteDocument('vcs_repos', $function->getAttribute('vcsRepoId')); + + $function = $function + ->setAttribute('vcsInstallationId', '') + ->setAttribute('vcsInstallationInternalId', '') + ->setAttribute('vcsRepoId', '') + ->setAttribute('vcsRepoInternalId', ''); + $dbForProject->updateDocument('functions', $function->getId(), $function); + }); + } + protected function deleteRuntimes(?Document $function, Document $project) { $executor = new Executor(App::getEnv('_APP_EXECUTOR_HOST')); diff --git a/composer.json b/composer.json index be6eed6e3..13fb79ab2 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,7 @@ "utopia-php/registry": "0.5.*", "utopia-php/storage": "0.13.*", "utopia-php/swoole": "0.5.*", + "utopia-php/vcs": "dev-feat-git-adapter as 0.1.99", "utopia-php/websocket": "0.1.0", "resque/php-resque": "1.3.6", "matomo/device-detector": "6.0.0", @@ -79,6 +80,10 @@ { "url": "https://github.com/appwrite/runtimes.git", "type": "git" + }, + { + "url": "https://github.com/utopia-php/vcs.git", + "type": "git" } ], "require-dev": { diff --git a/docker-compose.yml b/docker-compose.yml index 79fb57863..e56ce3e17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -177,6 +177,9 @@ services: - _APP_REGION - _APP_CONSOLE_GITHUB_APP_ID - _APP_CONSOLE_GITHUB_SECRET + - VCS_GITHUB_APP_NAME + - VCS_GITHUB_PRIVATE_KEY + - VCS_GITHUB_APP_ID appwrite-realtime: entrypoint: realtime @@ -382,7 +385,9 @@ services: image: appwrite-dev networks: - appwrite - volumes: + volumes: + - appwrite-functions:/storage/functions:rw + - appwrite-builds:/storage/builds:rw - ./app:/usr/src/code/app - ./src:/usr/src/code/src depends_on: @@ -412,6 +417,9 @@ services: - _APP_CONNECTIONS_STORAGE - _APP_LOGGING_PROVIDER - _APP_LOGGING_CONFIG + - VCS_GITHUB_APP_NAME + - VCS_GITHUB_PRIVATE_KEY + - VCS_GITHUB_APP_ID appwrite-worker-certificates: entrypoint: worker-certificates diff --git a/src/Appwrite/Event/Build.php b/src/Appwrite/Event/Build.php index 4d4b33811..a3df13854 100644 --- a/src/Appwrite/Event/Build.php +++ b/src/Appwrite/Event/Build.php @@ -10,12 +10,54 @@ class Build extends Event protected string $type = ''; protected ?Document $resource = null; protected ?Document $deployment = null; + protected string $SHA = ''; + protected string $owner = ''; + protected string $targetUrl = ''; public function __construct() { parent::__construct(Event::BUILDS_QUEUE_NAME, Event::BUILDS_CLASS_NAME); } + /** + * Sets commit SHA for the build event. + * + * @param string $SHA is the commit hash of the incoming commit + * @return self + */ + public function setSHA(string $SHA): self + { + $this->SHA = $SHA; + + return $this; + } + + /** + * Sets repository owner name for the build event. + * + * @param string $owner is the name of the repository owner + * @return self + */ + public function setOwner(string $owner): self + { + $this->owner = $owner; + + return $this; + } + + /** + * Sets redirect target url for the deployment + * + * @param string $targetUrl is the url that is to be set + * @return self + */ + public function setTargetUrl(string $targetUrl): self + { + $this->targetUrl = $targetUrl; + + return $this; + } + /** * Sets resource document for the build event. * @@ -97,7 +139,10 @@ class Build extends Event 'project' => $this->project, 'resource' => $this->resource, 'deployment' => $this->deployment, - 'type' => $this->type + 'type' => $this->type, + 'SHA' => $this->SHA, + 'owner' => $this->owner, + 'targetUrl' => $this->targetUrl ]); } } diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index d9521ec27..d29878ba2 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -106,6 +106,9 @@ class Exception extends \Exception public const STORAGE_INVALID_CONTENT_RANGE = 'storage_invalid_content_range'; public const STORAGE_INVALID_RANGE = 'storage_invalid_range'; + /** VCS */ + public const INSTALLATION_NOT_FOUND = 'installation_not_found'; + /** Functions */ public const FUNCTION_NOT_FOUND = 'function_not_found'; public const FUNCTION_RUNTIME_UNSUPPORTED = 'function_runtime_unsupported'; diff --git a/src/Appwrite/Specification/Format/OpenAPI3.php b/src/Appwrite/Specification/Format/OpenAPI3.php index fb7208c56..b2292695f 100644 --- a/src/Appwrite/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/Specification/Format/OpenAPI3.php @@ -334,6 +334,7 @@ class OpenAPI3 extends Format case 'Appwrite\Utopia\Database\Validator\Queries\Collections': case 'Appwrite\Utopia\Database\Validator\Queries\Databases': case 'Appwrite\Utopia\Database\Validator\Queries\Deployments': + case 'Appwrite\Utopia\Database\Validator\Queries\Installations': case 'Appwrite\Utopia\Database\Validator\Queries\Documents': case 'Appwrite\Utopia\Database\Validator\Queries\Executions': case 'Appwrite\Utopia\Database\Validator\Queries\Files': diff --git a/src/Appwrite/Specification/Format/Swagger2.php b/src/Appwrite/Specification/Format/Swagger2.php index a74e2d464..5e2d4594e 100644 --- a/src/Appwrite/Specification/Format/Swagger2.php +++ b/src/Appwrite/Specification/Format/Swagger2.php @@ -328,6 +328,7 @@ class Swagger2 extends Format case 'Appwrite\Utopia\Database\Validator\Queries\Collections': case 'Appwrite\Utopia\Database\Validator\Queries\Databases': case 'Appwrite\Utopia\Database\Validator\Queries\Deployments': + case 'Appwrite\Utopia\Database\Validator\Queries\Installations': case 'Appwrite\Utopia\Database\Validator\Queries\Documents': case 'Appwrite\Utopia\Database\Validator\Queries\Executions': case 'Appwrite\Utopia\Database\Validator\Queries\Files': diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Installations.php b/src/Appwrite/Utopia/Database/Validator/Queries/Installations.php new file mode 100644 index 000000000..85a8476d6 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Installations.php @@ -0,0 +1,20 @@ +setModel(new BaseList('Teams List', self::MODEL_TEAM_LIST, 'teams', self::MODEL_TEAM)) ->setModel(new BaseList('Memberships List', self::MODEL_MEMBERSHIP_LIST, 'memberships', self::MODEL_MEMBERSHIP)) ->setModel(new BaseList('Functions List', self::MODEL_FUNCTION_LIST, 'functions', self::MODEL_FUNCTION)) + ->setModel(new BaseList('Installations List', self::MODEL_INSTALLATION_LIST, 'installations', self::MODEL_INSTALLATION)) + ->setModel(new BaseList('Repositories List', self::MODEL_REPOSITORY_LIST, 'repositories', self::MODEL_REPOSITORY)) ->setModel(new BaseList('Runtimes List', self::MODEL_RUNTIME_LIST, 'runtimes', self::MODEL_RUNTIME)) ->setModel(new BaseList('Deployments List', self::MODEL_DEPLOYMENT_LIST, 'deployments', self::MODEL_DEPLOYMENT)) ->setModel(new BaseList('Executions List', self::MODEL_EXECUTION_LIST, 'executions', self::MODEL_EXECUTION)) @@ -314,6 +325,8 @@ class Response extends SwooleResponse ->setModel(new Team()) ->setModel(new Membership()) ->setModel(new Func()) + ->setModel(new Installation()) + ->setModel(new Repository()) ->setModel(new Runtime()) ->setModel(new Deployment()) ->setModel(new Execution()) diff --git a/src/Appwrite/Utopia/Response/Model/Func.php b/src/Appwrite/Utopia/Response/Model/Func.php index 96afde528..a0b533131 100644 --- a/src/Appwrite/Utopia/Response/Model/Func.php +++ b/src/Appwrite/Utopia/Response/Model/Func.php @@ -111,6 +111,18 @@ class Func extends Model 'default' => '', 'example' => 'npm install', ]) + ->addRule('repositoryId', [ + 'type' => self::TYPE_STRING, + 'description' => 'VCS Repository ID', + 'default' => false, + 'example' => '35493993', + ]) + ->addRule('vcsInstallationId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Function vcs installation id.', + 'default' => '', + 'example' => '644051bd6572792165cc', + ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/Installation.php b/src/Appwrite/Utopia/Response/Model/Installation.php new file mode 100644 index 000000000..842a91e51 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/Installation.php @@ -0,0 +1,72 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Function ID.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Function creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Function update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('provider', [ + 'type' => self::TYPE_STRING, + 'description' => 'Installation provider.', + 'default' => [], + 'example' => 'github', + 'array' => false, + ]) + ->addRule('organization', [ + 'type' => self::TYPE_STRING, + 'description' => 'Installation organization.', + 'default' => [], + 'example' => 'appwrite', + 'array' => false, + ]) + ->addRule('installationId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Provider installation ID.', + 'default' => '', + 'example' => '5322', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Installation'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_INSTALLATION; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Repository.php b/src/Appwrite/Utopia/Response/Model/Repository.php new file mode 100644 index 000000000..d498c7c09 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/Repository.php @@ -0,0 +1,52 @@ +addRule('id', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Repository ID.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('name', [ + 'type' => self::TYPE_STRING, + 'description' => 'Repository Name.', + 'default' => '', + 'example' => 'appwrite', + ]) + ->addRule('owner', [ + 'type' => self::TYPE_JSON, + 'description' => 'Repository Owner.', + 'default' => '', + 'example' => '{"login": "Example Owner"}', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Repository'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_REPOSITORY; + } +}