diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index 8b188590a5..6f76ef2420 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -1,46 +1,26 @@ alias('/v1/functions/:functionId/deployments/:deploymentId/builds/:buildId') - ->groups(['api', 'functions']) - ->desc('Rebuild deployment') - ->label('scope', 'functions.write') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('event', 'functions.[functionId].deployments.[deploymentId].update') - ->label('audits.event', 'deployment.update') - ->label('audits.resource', 'function/{request.functionId}') - ->label('sdk', new Method( - namespace: 'functions', - name: 'createBuild', - description: '/docs/references/functions/create-build.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ] - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('deploymentId', '', new UID(), 'Deployment ID.') - ->param('buildId', '', new UID(), 'Build unique ID.', true) // added as optional param for backward compatibility - ->inject('request') - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('queueForEvents') - ->inject('queueForBuilds') - ->inject('deviceForFunctions') - ->action(function (string $functionId, string $deploymentId, string $buildId, Request $request, Response $response, Database $dbForProject, Document $project, Event $queueForEvents, Build $queueForBuilds, Device $deviceForFunctions) { - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - $deployment = $dbForProject->getDocument('deployments', $deploymentId); - - if ($deployment->isEmpty()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - $path = $deployment->getAttribute('path'); - if (empty($path) || !$deviceForFunctions->exists($path)) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - $deploymentId = ID::unique(); - - $destination = $deviceForFunctions->getPath($deploymentId . '.' . \pathinfo('code.tar.gz', PATHINFO_EXTENSION)); - $deviceForFunctions->transfer($path, $destination, $deviceForFunctions); - - $deployment->removeAttribute('$internalId'); - $deployment = $dbForProject->createDocument('deployments', $deployment->setAttributes([ - '$internalId' => '', - '$id' => $deploymentId, - 'buildId' => '', - 'buildInternalId' => '', - 'path' => $destination, - 'entrypoint' => $function->getAttribute('entrypoint'), - 'commands' => $function->getAttribute('commands', ''), - 'search' => implode(' ', [$deploymentId, $function->getAttribute('entrypoint')]), - ])); - - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment); - - $queueForEvents - ->setParam('functionId', $function->getId()) - ->setParam('deploymentId', $deployment->getId()); - - $response->noContent(); - }); - -App::patch('/v1/functions/:functionId/deployments/:deploymentId/build') - ->groups(['api', 'functions']) - ->desc('Cancel deployment') - ->label('scope', 'functions.write') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('audits.event', 'deployment.update') - ->label('audits.resource', 'function/{request.functionId}') - ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) - ->label('sdk.namespace', 'functions') - ->label('sdk.method', 'updateDeploymentBuild') - ->label('sdk.response.code', Response::STATUS_CODE_OK) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_BUILD) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('deploymentId', '', new UID(), 'Deployment ID.') - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('queueForEvents') - ->action(function (string $functionId, string $deploymentId, Response $response, Database $dbForProject, Document $project, Event $queueForEvents) { - $function = $dbForProject->getDocument('functions', $functionId); - - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $deployment = $dbForProject->getDocument('deployments', $deploymentId); - - if ($deployment->isEmpty()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); - } - - $build = Authorization::skip(fn () => $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', ''))); - - if ($build->isEmpty()) { - $buildId = ID::unique(); - $build = $dbForProject->createDocument('builds', new Document([ - '$id' => $buildId, - '$permissions' => [], - 'startTime' => DateTime::now(), - 'deploymentInternalId' => $deployment->getInternalId(), - 'deploymentId' => $deployment->getId(), - 'status' => 'canceled', - 'path' => '', - 'runtime' => $function->getAttribute('runtime'), - 'source' => $deployment->getAttribute('path', ''), - 'sourceType' => '', - 'logs' => '', - 'duration' => 0, - 'size' => 0 - ])); - - $deployment->setAttribute('buildId', $build->getId()); - $deployment->setAttribute('buildInternalId', $build->getInternalId()); - $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); - } else { - if (\in_array($build->getAttribute('status'), ['ready', 'failed'])) { - throw new Exception(Exception::BUILD_ALREADY_COMPLETED); - } - - $startTime = new \DateTime($build->getAttribute('startTime')); - $endTime = new \DateTime('now'); - $duration = $endTime->getTimestamp() - $startTime->getTimestamp(); - - $build = $dbForProject->updateDocument('builds', $build->getId(), $build->setAttributes([ - 'endTime' => DateTime::now(), - 'duration' => $duration, - 'status' => 'canceled' - ])); - } - - $dbForProject->purgeCachedDocument('deployments', $deployment->getId()); - - try { - $executor = new Executor(App::getEnv('_APP_EXECUTOR_HOST')); - $executor->deleteRuntime($project->getId(), $deploymentId . "-build"); - } catch (\Throwable $th) { - // Don't throw if the deployment doesn't exist - if ($th->getCode() !== 404) { - throw $th; - } - } - - $queueForEvents - ->setParam('functionId', $function->getId()) - ->setParam('deploymentId', $deployment->getId()); - - $response->dynamic($build, Response::MODEL_BUILD); - }); - -App::post('/v1/functions/:functionId/executions') - ->groups(['api', 'functions']) - ->desc('Create execution') - ->label('scope', 'execution.write') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('event', 'functions.[functionId].executions.[executionId].create') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'createExecution', - description: '/docs/references/functions/create-execution.md', - auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_EXECUTION, - ) - ], - contentType: ContentType::MULTIPART, - requestType: 'application/json', - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('body', '', new Text(10485760, 0), 'HTTP body of execution. Default value is empty string.', true) - ->param('async', false, new Boolean(true), 'Execute code in the background. Default value is false.', true) - ->param('path', '/', new Text(2048), 'HTTP path of execution. Path can include query params. Default value is /', true) - ->param('method', 'POST', new Whitelist(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], true), 'HTTP method of execution. Default value is GET.', true) - ->param('headers', [], new AnyOf([new Assoc(), new Text(65535)], AnyOf::TYPE_MIXED), 'HTTP headers of execution. Defaults to empty.', true) - ->param('scheduledAt', null, new DatetimeValidator(requireDateInFuture: true, precision: DateTimeValidator::PRECISION_MINUTES, offset: 60), 'Scheduled execution time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. DateTime value must be in future with precision in minutes.', true) - ->inject('response') - ->inject('request') - ->inject('project') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('user') - ->inject('queueForEvents') - ->inject('queueForStatsUsage') - ->inject('queueForFunctions') - ->inject('geodb') - ->action(function (string $functionId, string $body, mixed $async, string $path, string $method, mixed $headers, ?string $scheduledAt, Response $response, Request $request, Document $project, Database $dbForProject, Database $dbForPlatform, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb) { - $async = \strval($async) === 'true' || \strval($async) === '1'; - - if (!$async && !is_null($scheduledAt)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Scheduled executions must run asynchronously. Set scheduledAt to a future date, or set async to true.'); - } - - /** - * @var array $headers - */ - $assocParams = ['headers']; - foreach ($assocParams as $assocParam) { - if (!empty('headers') && !is_array($$assocParam)) { - $$assocParam = \json_decode($$assocParam, true); - } - } - - $booleanParams = ['async']; - foreach ($booleanParams as $booleamParam) { - if (!empty($$booleamParam) && !is_bool($$booleamParam)) { - $$booleamParam = $$booleamParam === "true" ? true : false; - } - } - - // 'headers' validator - $validator = new Headers(); - if (!$validator->isValid($headers)) { - throw new Exception($validator->getDescription(), 400); - } - - $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $version = $function->getAttribute('version', 'v2'); - $runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []); - $spec = Config::getParam('runtime-specifications')[$function->getAttribute('specification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; - - $runtime = (isset($runtimes[$function->getAttribute('runtime', '')])) ? $runtimes[$function->getAttribute('runtime', '')] : null; - - if (\is_null($runtime)) { - throw new Exception(Exception::FUNCTION_RUNTIME_UNSUPPORTED, 'Runtime "' . $function->getAttribute('runtime', '') . '" is not supported'); - } - - $deployment = Authorization::skip(fn () => $dbForProject->getDocument('deployments', $function->getAttribute('deployment', ''))); - - if ($deployment->getAttribute('resourceId') !== $function->getId()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND, 'Deployment not found. Create a deployment before trying to execute a function'); - } - - if ($deployment->isEmpty()) { - throw new Exception(Exception::DEPLOYMENT_NOT_FOUND, 'Deployment not found. Create a deployment before trying to execute a function'); - } - - /** Check if build has completed */ - $build = Authorization::skip(fn () => $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', ''))); - if ($build->isEmpty()) { - throw new Exception(Exception::BUILD_NOT_FOUND); - } - - if ($build->getAttribute('status') !== 'ready') { - throw new Exception(Exception::BUILD_NOT_READY); - } - - $validator = new Authorization('execute'); - - if (!$validator->isValid($function->getAttribute('execute'))) { // Check if user has write access to execute function - throw new Exception(Exception::USER_UNAUTHORIZED, $validator->getDescription()); - } - - $jwt = ''; // initialize - if (!$user->isEmpty()) { // If userId exists, generate a JWT for function - $sessions = $user->getAttribute('sessions', []); - $current = new Document(); - - foreach ($sessions as $session) { - /** @var Utopia\Database\Document $session */ - if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too - $current = $session; - } - } - - if (!$current->isEmpty()) { - $jwtExpiry = $function->getAttribute('timeout', 900); - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); - $jwt = $jwtObj->encode([ - 'userId' => $user->getId(), - 'sessionId' => $current->getId(), - ]); - } - } - - $jwtExpiry = $function->getAttribute('timeout', 900); - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); - $apiKey = $jwtObj->encode([ - 'projectId' => $project->getId(), - 'scopes' => $function->getAttribute('scopes', []) - ]); - - $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; - $headers['x-appwrite-trigger'] = 'http'; - $headers['x-appwrite-user-id'] = $user->getId() ?? ''; - $headers['x-appwrite-user-jwt'] = $jwt ?? ''; - $headers['x-appwrite-country-code'] = ''; - $headers['x-appwrite-continent-code'] = ''; - $headers['x-appwrite-continent-eu'] = 'false'; - - $ip = $headers['x-real-ip'] ?? ''; - if (!empty($ip)) { - $record = $geodb->get($ip); - - if ($record) { - $eu = Config::getParam('locale-eu'); - - $headers['x-appwrite-country-code'] = $record['country']['iso_code'] ?? ''; - $headers['x-appwrite-continent-code'] = $record['continent']['code'] ?? ''; - $headers['x-appwrite-continent-eu'] = (\in_array($record['country']['iso_code'], $eu)) ? 'true' : 'false'; - } - } - - $headersFiltered = []; - foreach ($headers as $key => $value) { - if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_REQUEST)) { - $headersFiltered[] = ['name' => $key, 'value' => $value]; - } - } - - $executionId = ID::unique(); - - $status = $async ? 'waiting' : 'processing'; - - if (!is_null($scheduledAt)) { - $status = 'scheduled'; - } - - $execution = new Document([ - '$id' => $executionId, - '$permissions' => !$user->isEmpty() ? [Permission::read(Role::user($user->getId()))] : [], - 'resourceInternalId' => $function->getInternalId(), - 'resourceId' => $function->getId(), - 'resourceType' => 'functions', - 'deploymentInternalId' => $deployment->getInternalId(), - 'deploymentId' => $deployment->getId(), - 'trigger' => (!is_null($scheduledAt)) ? 'schedule' : 'http', - 'status' => $status, // waiting / processing / completed / failed / scheduled - 'responseStatusCode' => 0, - 'responseHeaders' => [], - 'requestPath' => $path, - 'requestMethod' => $method, - 'requestHeaders' => $headersFiltered, - 'errors' => '', - 'logs' => '', - 'duration' => 0.0, - 'search' => implode(' ', [$functionId, $executionId]), - ]); - - $queueForEvents - ->setParam('functionId', $function->getId()) - ->setParam('executionId', $execution->getId()) - ->setContext('function', $function); - - if ($async) { - if (is_null($scheduledAt)) { - $execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution)); - $queueForFunctions - ->setType('http') - ->setExecution($execution) - ->setFunction($function) - ->setBody($body) - ->setHeaders($headers) - ->setPath($path) - ->setMethod($method) - ->setJWT($jwt) - ->setProject($project) - ->setUser($user) - ->setParam('functionId', $function->getId()) - ->setParam('executionId', $execution->getId()) - ->trigger(); - } else { - $data = [ - 'headers' => $headers, - 'path' => $path, - 'method' => $method, - 'body' => $body, - 'userId' => $user->getId() - ]; - - $schedule = $dbForPlatform->createDocument('schedules', new Document([ - 'region' => System::getEnv('_APP_REGION', 'default'), - 'resourceType' => ScheduleExecutions::getSupportedResource(), - 'resourceId' => $execution->getId(), - 'resourceInternalId' => $execution->getInternalId(), - 'resourceUpdatedAt' => DateTime::now(), - 'projectId' => $project->getId(), - 'schedule' => $scheduledAt, - 'data' => $data, - 'active' => true, - ])); - - $execution = $execution - ->setAttribute('scheduleId', $schedule->getId()) - ->setAttribute('scheduleInternalId', $schedule->getInternalId()) - ->setAttribute('scheduledAt', $scheduledAt); - - $execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution)); - } - - return $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($execution, Response::MODEL_EXECUTION); - } - - $durationStart = \microtime(true); - - $vars = []; - - // V2 vars - if ($version === 'v2') { - $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', - 'APPWRITE_FUNCTION_DATA' => $body ?? '', - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' - ]); - } - - // Shared vars - foreach ($function->getAttribute('varsProject', []) as $var) { - $vars[$var->getAttribute('key')] = $var->getAttribute('value', ''); - } - - // Function vars - foreach ($function->getAttribute('vars', []) as $var) { - $vars[$var->getAttribute('key')] = $var->getAttribute('value', ''); - } - - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $hostname = System::getEnv('_APP_DOMAIN'); - $endpoint = $protocol . '://' . $hostname . "/v1"; - - // Appwrite vars - $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint, - 'APPWRITE_FUNCTION_ID' => $functionId, - 'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name'), - 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), - 'APPWRITE_FUNCTION_PROJECT_ID' => $project->getId(), - 'APPWRITE_FUNCTION_RUNTIME_NAME' => $runtime['name'] ?? '', - 'APPWRITE_FUNCTION_RUNTIME_VERSION' => $runtime['version'] ?? '', - 'APPWRITE_FUNCTION_CPUS' => $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, - 'APPWRITE_FUNCTION_MEMORY' => $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, - 'APPWRITE_VERSION' => APP_VERSION_STABLE, - 'APPWRITE_REGION' => $project->getAttribute('region'), - 'APPWRITE_DEPLOYMENT_TYPE' => $deployment->getAttribute('type', ''), - 'APPWRITE_VCS_REPOSITORY_ID' => $deployment->getAttribute('providerRepositoryId', ''), - 'APPWRITE_VCS_REPOSITORY_NAME' => $deployment->getAttribute('providerRepositoryName', ''), - 'APPWRITE_VCS_REPOSITORY_OWNER' => $deployment->getAttribute('providerRepositoryOwner', ''), - 'APPWRITE_VCS_REPOSITORY_URL' => $deployment->getAttribute('providerRepositoryUrl', ''), - 'APPWRITE_VCS_REPOSITORY_BRANCH' => $deployment->getAttribute('providerBranch', ''), - 'APPWRITE_VCS_REPOSITORY_BRANCH_URL' => $deployment->getAttribute('providerBranchUrl', ''), - 'APPWRITE_VCS_COMMIT_HASH' => $deployment->getAttribute('providerCommitHash', ''), - 'APPWRITE_VCS_COMMIT_MESSAGE' => $deployment->getAttribute('providerCommitMessage', ''), - 'APPWRITE_VCS_COMMIT_URL' => $deployment->getAttribute('providerCommitUrl', ''), - 'APPWRITE_VCS_COMMIT_AUTHOR_NAME' => $deployment->getAttribute('providerCommitAuthor', ''), - 'APPWRITE_VCS_COMMIT_AUTHOR_URL' => $deployment->getAttribute('providerCommitAuthorUrl', ''), - 'APPWRITE_VCS_ROOT_DIRECTORY' => $deployment->getAttribute('providerRootDirectory', ''), - ]); - - /** Execute function */ - $executor = new Executor(System::getEnv('_APP_EXECUTOR_HOST')); - try { - $version = $function->getAttribute('version', 'v2'); - $command = $runtime['startCommand']; - $command = $version === 'v2' ? '' : 'cp /tmp/code.tar.gz /mnt/code/code.tar.gz && nohup helpers/start.sh "' . $command . '"'; - $executionResponse = $executor->createExecution( - projectId: $project->getId(), - deploymentId: $deployment->getId(), - body: \strlen($body) > 0 ? $body : null, - variables: $vars, - timeout: $function->getAttribute('timeout', 0), - image: $runtime['image'], - source: $build->getAttribute('path', ''), - entrypoint: $deployment->getAttribute('entrypoint', ''), - version: $version, - path: $path, - method: $method, - headers: $headers, - runtimeEntrypoint: $command, - cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, - memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, - logging: $function->getAttribute('logging', true), - requestTimeout: 30 - ); - - $headersFiltered = []; - foreach ($executionResponse['headers'] as $key => $value) { - if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_RESPONSE)) { - $headersFiltered[] = ['name' => $key, 'value' => $value]; - } - } - - /** Update execution status */ - $status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed'; - $execution->setAttribute('status', $status); - $execution->setAttribute('responseStatusCode', $executionResponse['statusCode']); - $execution->setAttribute('responseHeaders', $headersFiltered); - $execution->setAttribute('logs', $executionResponse['logs']); - $execution->setAttribute('errors', $executionResponse['errors']); - $execution->setAttribute('duration', $executionResponse['duration']); - } catch (\Throwable $th) { - $durationEnd = \microtime(true); - - $execution - ->setAttribute('duration', $durationEnd - $durationStart) - ->setAttribute('status', 'failed') - ->setAttribute('responseStatusCode', 500) - ->setAttribute('errors', $th->getMessage() . '\nError Code: ' . $th->getCode()); - Console::error($th->getMessage()); - - if ($th instanceof AppwriteException) { - throw $th; - } - } finally { - $queueForStatsUsage - ->addMetric(METRIC_EXECUTIONS, 1) - ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS), 1) - ->addMetric(METRIC_EXECUTIONS_COMPUTE, (int)($execution->getAttribute('duration') * 1000)) // per project - ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_COMPUTE), (int)($execution->getAttribute('duration') * 1000)) // per function - ->addMetric(METRIC_EXECUTIONS_MB_SECONDS, (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) - ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_MB_SECONDS), (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) - ; - - $execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution)); - } - - $roles = Authorization::getRoles(); - $isPrivilegedUser = Auth::isPrivilegedUser($roles); - $isAppUser = Auth::isAppUser($roles); - - if (!$isPrivilegedUser && !$isAppUser) { - $execution->setAttribute('logs', ''); - $execution->setAttribute('errors', ''); - } - - $headers = []; - foreach (($executionResponse['headers'] ?? []) as $key => $value) { - $headers[] = ['name' => $key, 'value' => $value]; - } - - $execution->setAttribute('responseBody', $executionResponse['body'] ?? ''); - $execution->setAttribute('responseHeaders', $headers); - - $acceptTypes = \explode(', ', $request->getHeader('accept')); - foreach ($acceptTypes as $acceptType) { - if (\str_starts_with($acceptType, 'application/json') || \str_starts_with($acceptType, 'application/*')) { - $response->setContentType(Response::CONTENT_TYPE_JSON); - break; - } elseif (\str_starts_with($acceptType, 'multipart/form-data') || \str_starts_with($acceptType, 'multipart/*')) { - $response->setContentType(Response::CONTENT_TYPE_MULTIPART); - break; - } - } - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($execution, Response::MODEL_EXECUTION); - }); - -App::get('/v1/functions/:functionId/executions') - ->groups(['api', 'functions']) - ->desc('List executions') - ->label('scope', 'execution.read') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'listExecutions', - description: '/docs/references/functions/list-executions.md', - auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EXECUTION_LIST, - ) - ] - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('queries', [], new Executions(), '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(', ', Executions::ALLOWED_ATTRIBUTES), true) - ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->inject('response') - ->inject('dbForProject') - ->inject('mode') - ->action(function (string $functionId, array $queries, string $search, Response $response, Database $dbForProject, string $mode) { - $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - if (!empty($search)) { - $queries[] = Query::search('search', $search); - } - - // Set internal queries - $queries[] = Query::equal('resourceInternalId', [$function->getInternalId()]); - $queries[] = Query::equal('resourceType', ['functions']); - - /** - * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries - */ - $cursor = \array_filter($queries, function ($query) { - return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); - }); - $cursor = reset($cursor); - if ($cursor) { - /** @var Query $cursor */ - - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $executionId = $cursor->getValue(); - $cursorDocument = $dbForProject->getDocument('executions', $executionId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Execution '{$executionId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - - $results = $dbForProject->find('executions', $queries); - $total = $dbForProject->count('executions', $filterQueries, APP_LIMIT_COUNT); - - $roles = Authorization::getRoles(); - $isPrivilegedUser = Auth::isPrivilegedUser($roles); - $isAppUser = Auth::isAppUser($roles); - if (!$isPrivilegedUser && !$isAppUser) { - $results = array_map(function ($execution) { - $execution->setAttribute('logs', ''); - $execution->setAttribute('errors', ''); - return $execution; - }, $results); - } - - $response->dynamic(new Document([ - 'executions' => $results, - 'total' => $total, - ]), Response::MODEL_EXECUTION_LIST); - }); - -App::get('/v1/functions/:functionId/executions/:executionId') - ->groups(['api', 'functions']) - ->desc('Get execution') - ->label('scope', 'execution.read') - ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) - ->label('sdk', new Method( - namespace: 'functions', - name: 'getExecution', - description: '/docs/references/functions/get-execution.md', - auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EXECUTION, - ) - ] - )) - ->param('functionId', '', new UID(), 'Function ID.') - ->param('executionId', '', new UID(), 'Execution ID.') - ->inject('response') - ->inject('dbForProject') - ->inject('mode') - ->action(function (string $functionId, string $executionId, Response $response, Database $dbForProject, string $mode) { - $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - - $execution = $dbForProject->getDocument('executions', $executionId); - - if ($execution->getAttribute('resourceType') !== 'functions' && $execution->getAttribute('resourceInternalId') !== $function->getInternalId()) { - throw new Exception(Exception::EXECUTION_NOT_FOUND); - } - - if ($execution->isEmpty()) { - throw new Exception(Exception::EXECUTION_NOT_FOUND); - } - - $roles = Authorization::getRoles(); - $isPrivilegedUser = Auth::isPrivilegedUser($roles); - $isAppUser = Auth::isAppUser($roles); - if (!$isPrivilegedUser && !$isAppUser) { - $execution->setAttribute('logs', ''); - $execution->setAttribute('errors', ''); - } - - $response->dynamic($execution, Response::MODEL_EXECUTION); - }); - App::delete('/v1/functions/:functionId/executions/:executionId') ->groups(['api', 'functions']) ->desc('Delete execution') diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Create.php new file mode 100644 index 0000000000..1d85bcb11d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Create.php @@ -0,0 +1,112 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId/build') + ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/builds/:buildId') + ->desc('Rebuild deployment') + ->groups(['api', 'functions']) + ->label('scope', 'functions.write') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('event', 'functions.[functionId].deployments.[deploymentId].update') + ->label('audits.event', 'deployment.update') + ->label('audits.resource', 'function/{request.functionId}') + ->label('sdk', new Method( + namespace: 'functions', + name: 'createBuild', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('deploymentId', '', new UID(), 'Deployment ID.') + ->param('buildId', '', new UID(), 'Build unique ID.', true) // added as optional param for backward compatibility + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->inject('queueForBuilds') + ->inject('deviceForFunctions') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $deploymentId, string $buildId, Response $response, Database $dbForProject, Event $queueForEvents, Build $queueForBuilds, Device $deviceForFunctions) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + + if ($deployment->isEmpty()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + $path = $deployment->getAttribute('path'); + if (empty($path) || !$deviceForFunctions->exists($path)) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + $deploymentId = ID::unique(); + + $destination = $deviceForFunctions->getPath($deploymentId . '.' . \pathinfo('code.tar.gz', PATHINFO_EXTENSION)); + $deviceForFunctions->transfer($path, $destination, $deviceForFunctions); + + $deployment->removeAttribute('$internalId'); + $deployment = $dbForProject->createDocument('deployments', $deployment->setAttributes([ + '$internalId' => '', + '$id' => $deploymentId, + 'buildId' => '', + 'buildInternalId' => '', + 'path' => $destination, + 'entrypoint' => $function->getAttribute('entrypoint'), + 'commands' => $function->getAttribute('commands', ''), + 'search' => implode(' ', [$deploymentId, $function->getAttribute('entrypoint')]), + ])); + + $queueForBuilds + ->setType(BUILD_TYPE_DEPLOYMENT) + ->setResource($function) + ->setDeployment($deployment); + + $queueForEvents + ->setParam('functionId', $function->getId()) + ->setParam('deploymentId', $deployment->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Update.php new file mode 100644 index 0000000000..85edfd42ca --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Builds/Update.php @@ -0,0 +1,136 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId/build') + ->desc('Cancel deployment') + ->groups(['api', 'functions']) + ->label('scope', 'functions.write') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('audits.event', 'deployment.update') + ->label('audits.resource', 'function/{request.functionId}') + ->label('sdk', new Method( + namespace: 'functions', + name: 'updateDeploymentBuild', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('deploymentId', '', new UID(), 'Deployment ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('queueForEvents') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $deploymentId, Response $response, Database $dbForProject, Document $project, Event $queueForEvents) + { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + + if ($deployment->isEmpty()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND); + } + + $build = Authorization::skip(fn () => $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', ''))); + + if ($build->isEmpty()) { + $buildId = ID::unique(); + $build = $dbForProject->createDocument('builds', new Document([ + '$id' => $buildId, + '$permissions' => [], + 'startTime' => DateTime::now(), + 'deploymentInternalId' => $deployment->getInternalId(), + 'deploymentId' => $deployment->getId(), + 'status' => 'canceled', + 'path' => '', + 'runtime' => $function->getAttribute('runtime'), + 'source' => $deployment->getAttribute('path', ''), + 'sourceType' => '', + 'logs' => '', + 'duration' => 0, + 'size' => 0 + ])); + + $deployment->setAttribute('buildId', $build->getId()); + $deployment->setAttribute('buildInternalId', $build->getInternalId()); + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); + } else { + if (\in_array($build->getAttribute('status'), ['ready', 'failed'])) { + throw new Exception(Exception::BUILD_ALREADY_COMPLETED); + } + + $startTime = new \DateTime($build->getAttribute('startTime')); + $endTime = new \DateTime('now'); + $duration = $endTime->getTimestamp() - $startTime->getTimestamp(); + + $build = $dbForProject->updateDocument('builds', $build->getId(), $build->setAttributes([ + 'endTime' => DateTime::now(), + 'duration' => $duration, + 'status' => 'canceled' + ])); + } + + $dbForProject->purgeCachedDocument('deployments', $deployment->getId()); + + try { + $executor = new Executor(App::getEnv('_APP_EXECUTOR_HOST')); + $executor->deleteRuntime($project->getId(), $deploymentId . "-build"); + } catch (\Throwable $th) { + // Don't throw if the deployment doesn't exist + if ($th->getCode() !== 404) { + throw $th; + } + } + + $queueForEvents + ->setParam('functionId', $function->getId()) + ->setParam('deploymentId', $deployment->getId()); + + $response->dynamic($build, Response::MODEL_BUILD); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php new file mode 100644 index 0000000000..f522e2dc62 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -0,0 +1,474 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/functions/:functionId/executions') + ->desc('Create execution') + ->groups(['api', 'functions']) + ->label('scope', 'execution.write') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('event', 'functions.[functionId].executions.[executionId].create') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'createExecution', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('body', '', new Text(10485760, 0), 'HTTP body of execution. Default value is empty string.', true) + ->param('async', false, new Boolean(true), 'Execute code in the background. Default value is false.', true) + ->param('path', '/', new Text(2048), 'HTTP path of execution. Path can include query params. Default value is /', true) + ->param('method', 'POST', new Whitelist(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], true), 'HTTP method of execution. Default value is GET.', true) + ->param('headers', [], new AnyOf([new Assoc(), new Text(65535)], AnyOf::TYPE_MIXED), 'HTTP headers of execution. Defaults to empty.', true) + ->param('scheduledAt', null, new DatetimeValidator(requireDateInFuture: true, precision: DateTimeValidator::PRECISION_MINUTES, offset: 60), 'Scheduled execution time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. DateTime value must be in future with precision in minutes.', true) + ->inject('response') + ->inject('request') + ->inject('project') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('user') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->inject('queueForFunctions') + ->inject('geodb') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $body, mixed $async, string $path, string $method, mixed $headers, ?string $scheduledAt, Response $response, Request $request, Document $project, Database $dbForProject, Database $dbForPlatform, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Reader $geodb) + { + $async = \strval($async) === 'true' || \strval($async) === '1'; + + if (!$async && !is_null($scheduledAt)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Scheduled executions must run asynchronously. Set scheduledAt to a future date, or set async to true.'); + } + + /** + * @var array $headers + */ + $assocParams = ['headers']; + foreach ($assocParams as $assocParam) { + if (!empty('headers') && !is_array($$assocParam)) { + $$assocParam = \json_decode($$assocParam, true); + } + } + + $booleanParams = ['async']; + foreach ($booleanParams as $booleamParam) { + if (!empty($$booleamParam) && !is_bool($$booleamParam)) { + $$booleamParam = $$booleamParam === "true" ? true : false; + } + } + + // 'headers' validator + $validator = new Headers(); + if (!$validator->isValid($headers)) { + throw new Exception($validator->getDescription(), 400); + } + + $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); + + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $version = $function->getAttribute('version', 'v2'); + $runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []); + $spec = Config::getParam('runtime-specifications')[$function->getAttribute('specification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; + + $runtime = (isset($runtimes[$function->getAttribute('runtime', '')])) ? $runtimes[$function->getAttribute('runtime', '')] : null; + + if (\is_null($runtime)) { + throw new Exception(Exception::FUNCTION_RUNTIME_UNSUPPORTED, 'Runtime "' . $function->getAttribute('runtime', '') . '" is not supported'); + } + + $deployment = Authorization::skip(fn () => $dbForProject->getDocument('deployments', $function->getAttribute('deployment', ''))); + + if ($deployment->getAttribute('resourceId') !== $function->getId()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND, 'Deployment not found. Create a deployment before trying to execute a function'); + } + + if ($deployment->isEmpty()) { + throw new Exception(Exception::DEPLOYMENT_NOT_FOUND, 'Deployment not found. Create a deployment before trying to execute a function'); + } + + /** Check if build has completed */ + $build = Authorization::skip(fn () => $dbForProject->getDocument('builds', $deployment->getAttribute('buildId', ''))); + if ($build->isEmpty()) { + throw new Exception(Exception::BUILD_NOT_FOUND); + } + + if ($build->getAttribute('status') !== 'ready') { + throw new Exception(Exception::BUILD_NOT_READY); + } + + $validator = new Authorization('execute'); + + if (!$validator->isValid($function->getAttribute('execute'))) { // Check if user has write access to execute function + throw new Exception(Exception::USER_UNAUTHORIZED, $validator->getDescription()); + } + + $jwt = ''; // initialize + if (!$user->isEmpty()) { // If userId exists, generate a JWT for function + $sessions = $user->getAttribute('sessions', []); + $current = new Document(); + + foreach ($sessions as $session) { + /** @var Utopia\Database\Document $session */ + if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too + $current = $session; + } + } + + if (!$current->isEmpty()) { + $jwtExpiry = $function->getAttribute('timeout', 900); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); + $jwt = $jwtObj->encode([ + 'userId' => $user->getId(), + 'sessionId' => $current->getId(), + ]); + } + } + + $jwtExpiry = $function->getAttribute('timeout', 900); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); + $apiKey = $jwtObj->encode([ + 'projectId' => $project->getId(), + 'scopes' => $function->getAttribute('scopes', []) + ]); + + $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; + $headers['x-appwrite-trigger'] = 'http'; + $headers['x-appwrite-user-id'] = $user->getId() ?? ''; + $headers['x-appwrite-user-jwt'] = $jwt ?? ''; + $headers['x-appwrite-country-code'] = ''; + $headers['x-appwrite-continent-code'] = ''; + $headers['x-appwrite-continent-eu'] = 'false'; + + $ip = $headers['x-real-ip'] ?? ''; + if (!empty($ip)) { + $record = $geodb->get($ip); + + if ($record) { + $eu = Config::getParam('locale-eu'); + + $headers['x-appwrite-country-code'] = $record['country']['iso_code'] ?? ''; + $headers['x-appwrite-continent-code'] = $record['continent']['code'] ?? ''; + $headers['x-appwrite-continent-eu'] = (\in_array($record['country']['iso_code'], $eu)) ? 'true' : 'false'; + } + } + + $headersFiltered = []; + foreach ($headers as $key => $value) { + if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_REQUEST)) { + $headersFiltered[] = ['name' => $key, 'value' => $value]; + } + } + + $executionId = ID::unique(); + + $status = $async ? 'waiting' : 'processing'; + + if (!is_null($scheduledAt)) { + $status = 'scheduled'; + } + + $execution = new Document([ + '$id' => $executionId, + '$permissions' => !$user->isEmpty() ? [Permission::read(Role::user($user->getId()))] : [], + 'resourceInternalId' => $function->getInternalId(), + 'resourceId' => $function->getId(), + 'resourceType' => 'functions', + 'deploymentInternalId' => $deployment->getInternalId(), + 'deploymentId' => $deployment->getId(), + 'trigger' => (!is_null($scheduledAt)) ? 'schedule' : 'http', + 'status' => $status, // waiting / processing / completed / failed / scheduled + 'responseStatusCode' => 0, + 'responseHeaders' => [], + 'requestPath' => $path, + 'requestMethod' => $method, + 'requestHeaders' => $headersFiltered, + 'errors' => '', + 'logs' => '', + 'duration' => 0.0, + 'search' => implode(' ', [$functionId, $executionId]), + ]); + + $queueForEvents + ->setParam('functionId', $function->getId()) + ->setParam('executionId', $execution->getId()) + ->setContext('function', $function); + + if ($async) { + if (is_null($scheduledAt)) { + $execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution)); + $queueForFunctions + ->setType('http') + ->setExecution($execution) + ->setFunction($function) + ->setBody($body) + ->setHeaders($headers) + ->setPath($path) + ->setMethod($method) + ->setJWT($jwt) + ->setProject($project) + ->setUser($user) + ->setParam('functionId', $function->getId()) + ->setParam('executionId', $execution->getId()) + ->trigger(); + } else { + $data = [ + 'headers' => $headers, + 'path' => $path, + 'method' => $method, + 'body' => $body, + 'userId' => $user->getId() + ]; + + $schedule = $dbForPlatform->createDocument('schedules', new Document([ + 'region' => System::getEnv('_APP_REGION', 'default'), + 'resourceType' => ScheduleExecutions::getSupportedResource(), + 'resourceId' => $execution->getId(), + 'resourceInternalId' => $execution->getInternalId(), + 'resourceUpdatedAt' => DateTime::now(), + 'projectId' => $project->getId(), + 'schedule' => $scheduledAt, + 'data' => $data, + 'active' => true, + ])); + + $execution = $execution + ->setAttribute('scheduleId', $schedule->getId()) + ->setAttribute('scheduleInternalId', $schedule->getInternalId()) + ->setAttribute('scheduledAt', $scheduledAt); + + $execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution)); + } + + return $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($execution, Response::MODEL_EXECUTION); + } + + $durationStart = \microtime(true); + + $vars = []; + + // V2 vars + if ($version === 'v2') { + $vars = \array_merge($vars, [ + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_DATA' => $body ?? '', + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + ]); + } + + // Shared vars + foreach ($function->getAttribute('varsProject', []) as $var) { + $vars[$var->getAttribute('key')] = $var->getAttribute('value', ''); + } + + // Function vars + foreach ($function->getAttribute('vars', []) as $var) { + $vars[$var->getAttribute('key')] = $var->getAttribute('value', ''); + } + + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + $hostname = System::getEnv('_APP_DOMAIN'); + $endpoint = $protocol . '://' . $hostname . "/v1"; + + // Appwrite vars + $vars = \array_merge($vars, [ + 'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint, + 'APPWRITE_FUNCTION_ID' => $functionId, + 'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name'), + 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), + 'APPWRITE_FUNCTION_PROJECT_ID' => $project->getId(), + 'APPWRITE_FUNCTION_RUNTIME_NAME' => $runtime['name'] ?? '', + 'APPWRITE_FUNCTION_RUNTIME_VERSION' => $runtime['version'] ?? '', + 'APPWRITE_FUNCTION_CPUS' => $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, + 'APPWRITE_FUNCTION_MEMORY' => $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, + 'APPWRITE_VERSION' => APP_VERSION_STABLE, + 'APPWRITE_REGION' => $project->getAttribute('region'), + 'APPWRITE_DEPLOYMENT_TYPE' => $deployment->getAttribute('type', ''), + 'APPWRITE_VCS_REPOSITORY_ID' => $deployment->getAttribute('providerRepositoryId', ''), + 'APPWRITE_VCS_REPOSITORY_NAME' => $deployment->getAttribute('providerRepositoryName', ''), + 'APPWRITE_VCS_REPOSITORY_OWNER' => $deployment->getAttribute('providerRepositoryOwner', ''), + 'APPWRITE_VCS_REPOSITORY_URL' => $deployment->getAttribute('providerRepositoryUrl', ''), + 'APPWRITE_VCS_REPOSITORY_BRANCH' => $deployment->getAttribute('providerBranch', ''), + 'APPWRITE_VCS_REPOSITORY_BRANCH_URL' => $deployment->getAttribute('providerBranchUrl', ''), + 'APPWRITE_VCS_COMMIT_HASH' => $deployment->getAttribute('providerCommitHash', ''), + 'APPWRITE_VCS_COMMIT_MESSAGE' => $deployment->getAttribute('providerCommitMessage', ''), + 'APPWRITE_VCS_COMMIT_URL' => $deployment->getAttribute('providerCommitUrl', ''), + 'APPWRITE_VCS_COMMIT_AUTHOR_NAME' => $deployment->getAttribute('providerCommitAuthor', ''), + 'APPWRITE_VCS_COMMIT_AUTHOR_URL' => $deployment->getAttribute('providerCommitAuthorUrl', ''), + 'APPWRITE_VCS_ROOT_DIRECTORY' => $deployment->getAttribute('providerRootDirectory', ''), + ]); + + /** Execute function */ + $executor = new Executor(System::getEnv('_APP_EXECUTOR_HOST')); + try { + $version = $function->getAttribute('version', 'v2'); + $command = $runtime['startCommand']; + $command = $version === 'v2' ? '' : 'cp /tmp/code.tar.gz /mnt/code/code.tar.gz && nohup helpers/start.sh "' . $command . '"'; + $executionResponse = $executor->createExecution( + projectId: $project->getId(), + deploymentId: $deployment->getId(), + body: \strlen($body) > 0 ? $body : null, + variables: $vars, + timeout: $function->getAttribute('timeout', 0), + image: $runtime['image'], + source: $build->getAttribute('path', ''), + entrypoint: $deployment->getAttribute('entrypoint', ''), + version: $version, + path: $path, + method: $method, + headers: $headers, + runtimeEntrypoint: $command, + cpus: $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT, + memory: $spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, + logging: $function->getAttribute('logging', true), + requestTimeout: 30 + ); + + $headersFiltered = []; + foreach ($executionResponse['headers'] as $key => $value) { + if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_RESPONSE)) { + $headersFiltered[] = ['name' => $key, 'value' => $value]; + } + } + + /** Update execution status */ + $status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed'; + $execution->setAttribute('status', $status); + $execution->setAttribute('responseStatusCode', $executionResponse['statusCode']); + $execution->setAttribute('responseHeaders', $headersFiltered); + $execution->setAttribute('logs', $executionResponse['logs']); + $execution->setAttribute('errors', $executionResponse['errors']); + $execution->setAttribute('duration', $executionResponse['duration']); + } catch (\Throwable $th) { + $durationEnd = \microtime(true); + + $execution + ->setAttribute('duration', $durationEnd - $durationStart) + ->setAttribute('status', 'failed') + ->setAttribute('responseStatusCode', 500) + ->setAttribute('errors', $th->getMessage() . '\nError Code: ' . $th->getCode()); + Console::error($th->getMessage()); + + if ($th instanceof AppwriteException) { + throw $th; + } + } finally { + $queueForStatsUsage + ->addMetric(METRIC_EXECUTIONS, 1) + ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS), 1) + ->addMetric(METRIC_EXECUTIONS_COMPUTE, (int)($execution->getAttribute('duration') * 1000)) // per project + ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_COMPUTE), (int)($execution->getAttribute('duration') * 1000)) // per function + ->addMetric(METRIC_EXECUTIONS_MB_SECONDS, (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) + ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_MB_SECONDS), (int)(($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT) * $execution->getAttribute('duration', 0) * ($spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT))) + ; + + $execution = Authorization::skip(fn () => $dbForProject->createDocument('executions', $execution)); + } + + $roles = Authorization::getRoles(); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); + + if (!$isPrivilegedUser && !$isAppUser) { + $execution->setAttribute('logs', ''); + $execution->setAttribute('errors', ''); + } + + $headers = []; + foreach (($executionResponse['headers'] ?? []) as $key => $value) { + $headers[] = ['name' => $key, 'value' => $value]; + } + + $execution->setAttribute('responseBody', $executionResponse['body'] ?? ''); + $execution->setAttribute('responseHeaders', $headers); + + $acceptTypes = \explode(', ', $request->getHeader('accept')); + foreach ($acceptTypes as $acceptType) { + if (\str_starts_with($acceptType, 'application/json') || \str_starts_with($acceptType, 'application/*')) { + $response->setContentType(Response::CONTENT_TYPE_JSON); + break; + } elseif (\str_starts_with($acceptType, 'multipart/form-data') || \str_starts_with($acceptType, 'multipart/*')) { + $response->setContentType(Response::CONTENT_TYPE_MULTIPART); + break; + } + } + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($execution, Response::MODEL_EXECUTION); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php new file mode 100644 index 0000000000..da58d86414 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php @@ -0,0 +1,88 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/:functionId/executions/:executionId') + ->desc('Get execution') + ->groups(['api', 'functions']) + ->label('scope', 'execution.read') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'getExecution', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('executionId', '', new UID(), 'Execution ID.') + ->inject('response') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $functionId, string $executionId, Response $response, Database $dbForProject) + { + $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); + + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $execution = $dbForProject->getDocument('executions', $executionId); + + if ($execution->getAttribute('resourceType') !== 'functions' && $execution->getAttribute('resourceInternalId') !== $function->getInternalId()) { + throw new Exception(Exception::EXECUTION_NOT_FOUND); + } + + if ($execution->isEmpty()) { + throw new Exception(Exception::EXECUTION_NOT_FOUND); + } + + $roles = Authorization::getRoles(); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); + if (!$isPrivilegedUser && !$isAppUser) { + $execution->setAttribute('logs', ''); + $execution->setAttribute('errors', ''); + } + + $response->dynamic($execution, Response::MODEL_EXECUTION); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php new file mode 100644 index 0000000000..597e830dd3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php @@ -0,0 +1,135 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/functions/:functionId/executions') + ->desc('List executions') + ->groups(['api', 'functions']) + ->label('scope', 'execution.read') + ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) + ->label('sdk', new Method( + namespace: 'functions', + name: 'listExecutions', + description: <<param('functionId', '', new UID(), 'Function ID.') + ->param('queries', [], new Executions(), '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(', ', Executions::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->inject('response') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $functionId, array $queries, string $search, Response $response, Database $dbForProject) + { + $function = Authorization::skip(fn () => $dbForProject->getDocument('functions', $functionId)); + + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + // Set internal queries + $queries[] = Query::equal('resourceInternalId', [$function->getInternalId()]); + $queries[] = Query::equal('resourceType', ['functions']); + + /** + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $executionId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('executions', $executionId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Execution '{$executionId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + $results = $dbForProject->find('executions', $queries); + $total = $dbForProject->count('executions', $filterQueries, APP_LIMIT_COUNT); + + $roles = Authorization::getRoles(); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); + if (!$isPrivilegedUser && !$isAppUser) { + $results = array_map(function ($execution) { + $execution->setAttribute('logs', ''); + $execution->setAttribute('errors', ''); + return $execution; + }, $results); + } + + $response->dynamic(new Document([ + 'executions' => $results, + 'total' => $total, + ]), Response::MODEL_EXECUTION_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Services/Http.php b/src/Appwrite/Platform/Modules/Functions/Services/Http.php index 22f4715357..f8c412db2a 100644 --- a/src/Appwrite/Platform/Modules/Functions/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Functions/Services/Http.php @@ -2,6 +2,8 @@ namespace Appwrite\Platform\Modules\Functions\Services; +use Appwrite\Platform\Modules\Functions\Http\Deployments\Builds\Create as CreateBuild; +use Appwrite\Platform\Modules\Functions\Http\Deployments\Builds\Update as UpdateBuild; use Appwrite\Platform\Modules\Functions\Http\Deployments\Create as CreateDeployment; use Appwrite\Platform\Modules\Functions\Http\Deployments\Delete as DeleteDeployment; use Appwrite\Platform\Modules\Functions\Http\Deployments\Download\Get as DownloadDeployment; @@ -10,6 +12,9 @@ use Appwrite\Platform\Modules\Functions\Http\Deployments\Template\Create as Crea use Appwrite\Platform\Modules\Functions\Http\Deployments\Update as UpdateDeployment; use Appwrite\Platform\Modules\Functions\Http\Deployments\Vcs\Create as CreateVcsDeployment; use Appwrite\Platform\Modules\Functions\Http\Deployments\XList as ListDeployments; +use Appwrite\Platform\Modules\Functions\Http\Executions\Create as CreateExecution; +use Appwrite\Platform\Modules\Functions\Http\Executions\Get as GetExecution; +use Appwrite\Platform\Modules\Functions\Http\Executions\XList as ListExecutions; use Appwrite\Platform\Modules\Functions\Http\Functions\Create as CreateFunction; use Appwrite\Platform\Modules\Functions\Http\Functions\Delete as DeleteFunction; use Appwrite\Platform\Modules\Functions\Http\Functions\Get as GetFunction; @@ -49,6 +54,13 @@ class Http extends Service $this->addAction(CreateTemplateDeployment::getName(), new CreateTemplateDeployment()); $this->addAction(CreateVcsDeployment::getName(), new CreateVcsDeployment()); $this->addAction(DownloadDeployment::getName(), new DownloadDeployment()); + $this->addAction(CreateBuild::getName(), new CreateBuild()); + $this->addAction(UpdateBuild::getName(), new UpdateBuild()); + + // Executions + $this->addAction(CreateExecution::getName(), new CreateExecution()); + $this->addAction(GetExecution::getName(), new GetExecution()); + $this->addAction(ListExecutions::getName(), new ListExecutions()); // Usage $this->addAction(GetUsage::getName(), new GetUsage());