Merge pull request #11110 from appwrite/feat-async-screenshots

Feat: Async screenshots
This commit is contained in:
Matej Bačo 2026-01-09 14:35:20 +01:00 committed by GitHub
commit 445d7495a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 450 additions and 204 deletions

View file

@ -77,6 +77,7 @@ RUN chmod +x /usr/local/bin/doctor && \
chmod +x /usr/local/bin/queue-count-success && \
chmod +x /usr/local/bin/worker-audits && \
chmod +x /usr/local/bin/worker-builds && \
chmod +x /usr/local/bin/worker-screenshots && \
chmod +x /usr/local/bin/worker-certificates && \
chmod +x /usr/local/bin/worker-databases && \
chmod +x /usr/local/bin/worker-deletes && \

View file

@ -11,6 +11,7 @@ use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\Migration;
use Appwrite\Event\Screenshot;
use Appwrite\Event\StatsResources;
use Appwrite\Event\StatsUsage;
use Appwrite\Event\Webhook;
@ -955,6 +956,7 @@ App::get('/v1/health/queue/failed/:name')
System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME),
System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME),
System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME),
System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME),
System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME),
System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME)
]), 'The name of the queue')
@ -972,6 +974,7 @@ App::get('/v1/health/queue/failed/:name')
->inject('queueForBuilds')
->inject('queueForMessaging')
->inject('queueForMigrations')
->inject('queueForScreenshots')
->action(function (
string $name,
int|string $threshold,
@ -987,7 +990,8 @@ App::get('/v1/health/queue/failed/:name')
Certificate $queueForCertificates,
Build $queueForBuilds,
Messaging $queueForMessaging,
Migration $queueForMigrations
Migration $queueForMigrations,
Screenshot $queueForScreenshots,
) {
$threshold = \intval($threshold);
@ -1003,6 +1007,7 @@ App::get('/v1/health/queue/failed/:name')
System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME) => $queueForWebhooks,
System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $queueForCertificates,
System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $queueForBuilds,
System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $queueForScreenshots,
System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging,
System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $queueForMigrations,
};

View file

@ -15,6 +15,7 @@ use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\Migration;
use Appwrite\Event\Realtime;
use Appwrite\Event\Screenshot;
use Appwrite\Event\StatsResources;
use Appwrite\Event\StatsUsage;
use Appwrite\Event\Webhook;
@ -129,6 +130,9 @@ App::setResource('queueForMails', function (Publisher $publisher) {
App::setResource('queueForBuilds', function (Publisher $publisher) {
return new Build($publisher);
}, ['publisher']);
App::setResource('queueForScreenshots', function (Publisher $publisher) {
return new Screenshot($publisher);
}, ['publisher']);
App::setResource('queueForDatabase', function (Publisher $publisher) {
return new EventDatabase($publisher);
}, ['publisher']);

View file

@ -14,6 +14,7 @@ use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\Migration;
use Appwrite\Event\Realtime;
use Appwrite\Event\Screenshot;
use Appwrite\Event\StatsUsage;
use Appwrite\Event\Webhook;
use Appwrite\Platform\Appwrite;
@ -307,6 +308,10 @@ Server::setResource('queueForBuilds', function (Publisher $publisher) {
return new Build($publisher);
}, ['publisher']);
Server::setResource('queueForScreenshots', function (Publisher $publisher) {
return new Screenshot($publisher);
}, ['publisher']);
Server::setResource('queueForDeletes', function (Publisher $publisher) {
return new Delete($publisher);
}, ['publisher']);

3
bin/worker-screenshots Normal file
View file

@ -0,0 +1,3 @@
#!/bin/sh
exec php /usr/src/code/app/worker.php screenshots "$@"

View file

@ -466,14 +466,12 @@ services:
- appwrite-functions:/storage/functions:rw
- appwrite-sites:/storage/sites:rw
- appwrite-builds:/storage/builds:rw
- appwrite-uploads:/storage/uploads:rw
- ./app:/usr/src/code/app
- ./src:/usr/src/code/src
depends_on:
- redis
- mariadb
environment:
- _APP_BROWSER_HOST
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
@ -529,6 +527,65 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
appwrite-worker-screenshots:
entrypoint: worker-screenshots
<<: *x-logging
container_name: appwrite-worker-screenshots
image: appwrite-dev
networks:
- appwrite
volumes:
- appwrite-uploads:/storage/uploads:rw
- ./app:/usr/src/code/app
- ./src:/usr/src/code/src
depends_on:
- redis
- mariadb
environment:
# Specific
- _APP_BROWSER_HOST
# Basic
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_LOGGING_CONFIG
# Database
- _APP_OPENSSL_KEY_V1
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_DB_HOST
- _APP_DB_PORT
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DATABASE_SHARED_TABLES
# Storage
- _APP_STORAGE_DEVICE
- _APP_STORAGE_S3_ACCESS_KEY
- _APP_STORAGE_S3_SECRET
- _APP_STORAGE_S3_REGION
- _APP_STORAGE_S3_BUCKET
- _APP_STORAGE_S3_ENDPOINT
- _APP_STORAGE_DO_SPACES_ACCESS_KEY
- _APP_STORAGE_DO_SPACES_SECRET
- _APP_STORAGE_DO_SPACES_REGION
- _APP_STORAGE_DO_SPACES_BUCKET
- _APP_STORAGE_BACKBLAZE_ACCESS_KEY
- _APP_STORAGE_BACKBLAZE_SECRET
- _APP_STORAGE_BACKBLAZE_REGION
- _APP_STORAGE_BACKBLAZE_BUCKET
- _APP_STORAGE_LINODE_ACCESS_KEY
- _APP_STORAGE_LINODE_SECRET
- _APP_STORAGE_LINODE_REGION
- _APP_STORAGE_LINODE_BUCKET
- _APP_STORAGE_WASABI_ACCESS_KEY
- _APP_STORAGE_WASABI_SECRET
- _APP_STORAGE_WASABI_REGION
- _APP_STORAGE_WASABI_BUCKET
extra_hosts:
- "host.docker.internal:host-gateway"
appwrite-worker-certificates:
entrypoint: worker-certificates
<<: *x-logging

View file

@ -39,6 +39,9 @@ class Event
public const BUILDS_QUEUE_NAME = 'v1-builds';
public const BUILDS_CLASS_NAME = 'BuildsV1';
public const SCREENSHOTS_QUEUE_NAME = 'v1-screenshots';
public const SCREENSHOTS_CLASS_NAME = 'ScreenshotsV1';
public const MESSAGING_QUEUE_NAME = 'v1-messaging';
public const MESSAGING_CLASS_NAME = 'MessagingV1';

View file

@ -0,0 +1,50 @@
<?php
namespace Appwrite\Event;
use Utopia\Config\Config;
use Utopia\Queue\Publisher;
use Utopia\System\System;
class Screenshot extends Event
{
protected string $deploymentId = '';
public function __construct(protected Publisher $publisher)
{
parent::__construct($publisher);
$this
->setQueue(System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME))
->setClass(System::getEnv('_APP_SCREENSHOTS_CLASS_NAME', Event::SCREENSHOTS_CLASS_NAME));
}
public function setDeploymentId(string $deploymentId): self
{
$this->deploymentId = $deploymentId;
return $this;
}
protected function preparePayload(): array
{
$platform = $this->platform;
if (empty($platform)) {
$platform = Config::getParam('platform', []);
}
return [
'project' => $this->project,
'deploymentId' => $this->deploymentId,
'platform' => $platform,
];
}
public function reset(): self
{
$this->deploymentId = '';
parent::reset();
return $this;
}
}

View file

@ -3,6 +3,7 @@
namespace Appwrite\Platform\Modules\Functions\Services;
use Appwrite\Platform\Modules\Functions\Workers\Builds;
use Appwrite\Platform\Modules\Functions\Workers\Screenshots;
use Utopia\Platform\Service;
class Workers extends Service
@ -11,5 +12,6 @@ class Workers extends Service
{
$this->type = Service::TYPE_WORKER;
$this->addAction(Builds::getName(), new Builds());
$this->addAction(Screenshots::getName(), new Screenshots());
}
}

View file

@ -6,10 +6,9 @@ use Ahc\Jwt\JWT;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Realtime;
use Appwrite\Event\Screenshot;
use Appwrite\Event\StatsUsage;
use Appwrite\Event\Webhook;
use Appwrite\Permission;
use Appwrite\Role;
use Appwrite\Utopia\Response\Model\Deployment;
use Appwrite\Vcs\Comment;
use Exception;
@ -25,24 +24,19 @@ use Utopia\Database\Exception\Conflict;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Exception\Restricted;
use Utopia\Database\Exception\Structure;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Detector\Detection\Rendering\SSR;
use Utopia\Detector\Detection\Rendering\XStatic;
use Utopia\Detector\Detector\Rendering;
use Utopia\Fetch\Client as FetchClient;
use Utopia\Logger\Log;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
use Utopia\Storage\Compression\Compression;
use Utopia\Storage\Device;
use Utopia\Storage\Device\Local;
use Utopia\System\System;
use Utopia\VCS\Adapter\Git\GitHub;
use function Swoole\Coroutine\batch;
class Builds extends Action
{
public static function getName(): string
@ -62,6 +56,7 @@ class Builds extends Action
->inject('project')
->inject('dbForPlatform')
->inject('queueForEvents')
->inject('queueForScreenshots')
->inject('queueForWebhooks')
->inject('queueForFunctions')
->inject('queueForRealtime')
@ -83,6 +78,7 @@ class Builds extends Action
* @param Document $project
* @param Database $dbForPlatform
* @param Event $queueForEvents
* @param Screenshot $queueForScreenshots
* @param Webhook $queueForWebhooks
* @param Func $queueForFunctions
* @param Realtime $queueForRealtime
@ -103,6 +99,7 @@ class Builds extends Action
Document $project,
Database $dbForPlatform,
Event $queueForEvents,
Screenshot $queueForScreenshots,
Webhook $queueForWebhooks,
Func $queueForFunctions,
Realtime $queueForRealtime,
@ -143,6 +140,7 @@ class Builds extends Action
$deviceForFunctions,
$deviceForSites,
$deviceForFiles,
$queueForScreenshots,
$queueForWebhooks,
$queueForFunctions,
$queueForRealtime,
@ -172,6 +170,7 @@ class Builds extends Action
* @param Device $deviceForFunctions
* @param Device $deviceForSites
* @param Device $deviceForFiles
* @param Screenshot $queueForScreenshots
* @param Webhook $queueForWebhooks
* @param Func $queueForFunctions
* @param Realtime $queueForRealtime
@ -196,6 +195,7 @@ class Builds extends Action
Device $deviceForFunctions,
Device $deviceForSites,
Device $deviceForFiles,
Screenshot $queueForScreenshots,
Webhook $queueForWebhooks,
Func $queueForFunctions,
Realtime $queueForRealtime,
@ -913,187 +913,6 @@ class Builds extends Action
Console::log('Build details stored');
$this->afterBuildSuccess($queueForRealtime, $dbForProject, $deployment, $runtime, $adapter);
$logs = $deployment->getAttribute('buildLogs', '');
/** Screenshot site */
if ($resource->getCollection() === 'sites') {
Console::log('Site screenshot started');
$date = \date('H:i:s');
$logs .= "[$date] [appwrite] Screenshot capturing started. \n";
$deployment->setAttribute('buildLogs', $logs);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
$queueForRealtime
->setPayload($deployment->getArrayCopy())
->trigger();
try {
$rule = Authorization::skip(fn () => $dbForPlatform->findOne('rules', [
Query::equal("projectInternalId", [$project->getSequence()]),
Query::equal("type", ["deployment"]),
Query::equal('deploymentInternalId', [$deployment->getSequence()]),
]));
if ($rule->isEmpty()) {
throw new \Exception("Rule for build not found");
}
$client = new FetchClient();
$client->setTimeout(\intval($resource->getAttribute('timeout', '15')) * 1000);
$client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON);
$bucket = Authorization::skip(fn () => $dbForPlatform->getDocument('buckets', 'screenshots'));
$configs = [
'screenshotLight' => [
'headers' => [ 'x-appwrite-hostname' => $rule->getAttribute('domain') ],
'url' => 'http://appwrite/?appwrite-preview=1&appwrite-theme=light',
'theme' => 'light'
],
'screenshotDark' => [
'headers' => [ 'x-appwrite-hostname' => $rule->getAttribute('domain') ],
'url' => 'http://appwrite/?appwrite-preview=1&appwrite-theme=dark',
'theme' => 'dark'
],
];
$jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 0);
$apiKey = $jwtObj->encode([
'hostnameOverride' => true,
'disabledMetrics' => [
METRIC_EXECUTIONS,
METRIC_EXECUTIONS_COMPUTE,
METRIC_EXECUTIONS_MB_SECONDS,
METRIC_NETWORK_REQUESTS,
METRIC_NETWORK_INBOUND,
METRIC_NETWORK_OUTBOUND,
str_replace(["{resourceType}"], [RESOURCE_TYPE_SITES], METRIC_RESOURCE_TYPE_EXECUTIONS),
str_replace(["{resourceType}"], [RESOURCE_TYPE_SITES], METRIC_RESOURCE_TYPE_EXECUTIONS_COMPUTE),
str_replace(["{resourceType}"], [RESOURCE_TYPE_SITES], METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS),
str_replace(["{resourceType}", "{resourceInternalId}"], [RESOURCE_TYPE_SITES, $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS),
str_replace(["{resourceType}", "{resourceInternalId}"], [RESOURCE_TYPE_SITES, $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_COMPUTE),
str_replace(["{resourceType}", "{resourceInternalId}"], [RESOURCE_TYPE_SITES, $resource->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS),
],
'bannerDisabled' => true,
'projectCheckDisabled' => true,
'previewAuthDisabled' => true,
'deploymentStatusIgnored' => true
]);
$screenshotError = null;
$screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $resource, $client, &$screenshotError) {
return function () use ($key, $configs, $apiKey, $resource, $client, &$screenshotError) {
try {
$config = $configs[$key];
$config['headers'] = \array_merge($config['headers'] ?? [], [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey
]);
$config['sleep'] = 3000;
$frameworks = Config::getParam('frameworks', []);
$framework = $frameworks[$resource->getAttribute('framework', '')] ?? null;
if (!is_null($framework)) {
$config['sleep'] = $framework['screenshotSleep'];
}
$browserEndpoint = System::getEnv('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1');
$fetchResponse = $client->fetch(
url: $browserEndpoint . '/screenshots',
method: 'POST',
body: $config
);
if ($fetchResponse->getStatusCode() >= 400) {
throw new \Exception($fetchResponse->getBody());
}
$screenshot = $fetchResponse->getBody();
return ['key' => $key, 'screenshot' => $screenshot];
} catch (\Throwable $th) {
$screenshotError = $th->getMessage();
return;
}
};
}, \array_keys($configs)));
if (!\is_null($screenshotError)) {
throw new \Exception($screenshotError);
}
$mimeType = "image/png";
foreach ($screenshots as $data) {
$key = $data['key'];
$screenshot = $data['screenshot'];
$fileId = ID::unique();
$fileName = $fileId . '.png';
$path = $deviceForFiles->getPath($fileName);
$path = str_ireplace($deviceForFiles->getRoot(), $deviceForFiles->getRoot() . DIRECTORY_SEPARATOR . $bucket->getId(), $path); // Add bucket id to path after root
$success = $deviceForFiles->write($path, $screenshot, $mimeType);
if (!$success) {
throw new \Exception("Screenshot failed to save");
}
$teamId = $project->getAttribute('teamId', '');
$file = new Document([
'$id' => $fileId,
'$permissions' => [
Permission::read(Role::team(ID::custom($teamId))),
],
'bucketId' => $bucket->getId(),
'bucketInternalId' => $bucket->getSequence(),
'name' => $fileName,
'path' => $path,
'signature' => $deviceForFiles->getFileHash($path),
'mimeType' => $mimeType,
'sizeOriginal' => \strlen($screenshot),
'sizeActual' => $deviceForFiles->getFileSize($path),
'algorithm' => Compression::NONE,
'comment' => '',
'chunksTotal' => 1,
'chunksUploaded' => 1,
'openSSLVersion' => null,
'openSSLCipher' => null,
'openSSLTag' => null,
'openSSLIV' => null,
'search' => implode(' ', [$fileId, $fileName]),
'metadata' => ['content_type' => $mimeType],
]);
Authorization::skip(fn () => $dbForPlatform->createDocument('bucket_' . $bucket->getSequence(), $file));
$deployment->setAttribute($key, $fileId);
}
$logs = $deployment->getAttribute('buildLogs', '');
$date = \date('H:i:s');
$logs .= "[$date] [appwrite] Screenshot capturing finished. \n";
$deployment->setAttribute('buildLogs', $logs);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
$queueForRealtime
->setPayload($deployment->getArrayCopy())
->trigger();
} catch (\Throwable $th) {
Console::warning("Screenshot failed to generate:");
Console::warning($th->getMessage());
Console::warning($th->getTraceAsString());
$logs = $deployment->getAttribute('buildLogs', '');
$date = \date('H:i:s');
$logs .= "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n";
$deployment->setAttribute('buildLogs', $logs);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
}
Console::log('Site screenshot finished');
}
$logs = $deployment->getAttribute('buildLogs', '');
$date = \date('H:i:s');
@ -1114,6 +933,16 @@ class Builds extends Action
$this->runGitAction('ready', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform);
}
/** Screenshot site */
if ($resource->getCollection() === 'sites') {
$queueForScreenshots
->setDeploymentId($deployment->getId())
->setProject($project)
->trigger();
Console::log('Site screenshot queued');
}
/** Set auto deploy */
$activateBuild = false;
if ($deployment->getAttribute('activate') === true) {
@ -1171,8 +1000,6 @@ class Builds extends Action
'live' => true,
'deploymentId' => $deployment->getId(),
'deploymentInternalId' => $deployment->getSequence(),
'deploymentScreenshotDark' => $deployment->getAttribute('screenshotDark', ''),
'deploymentScreenshotLight' => $deployment->getAttribute('screenshotLight', ''),
'deploymentCreatedAt' => $deployment->getCreatedAt(),
]));
$queries = [
@ -1265,9 +1092,10 @@ class Builds extends Action
$endTime = DateTime::now();
$durationEnd = \microtime(true);
$deployment->setAttribute('buildEndedAt', $endTime);
$deployment->setAttribute('buildDuration', \intval(\ceil($durationEnd - $durationStart)));
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment);
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'buildEndedAt' => $endTime,
'buildDuration' => \intval(\ceil($durationEnd - $durationStart)),
]));
$queueForRealtime
->setPayload($deployment->getArrayCopy())
->trigger();

View file

@ -0,0 +1,281 @@
<?php
namespace Appwrite\Platform\Modules\Functions\Workers;
use Ahc\Jwt\JWT;
use Appwrite\Event\Realtime;
use Appwrite\Permission;
use Appwrite\Role;
use Exception;
use Utopia\CLI\Console;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Fetch\Client as FetchClient;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
use Utopia\Storage\Compression\Compression;
use Utopia\Storage\Device;
use Utopia\System\System;
use function Swoole\Coroutine\batch;
class Screenshots extends Action
{
public static function getName(): string
{
return 'screenshots';
}
/**
* @throws Exception
*/
public function __construct()
{
$this
->desc('Screenshots worker')
->groups(['screenshots'])
->inject('message')
->inject('queueForRealtime')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('project')
->inject('deviceForFiles')
->callback($this->action(...));
}
public function action(
Message $message,
Realtime $queueForRealtime,
Database $dbForPlatform,
Database $dbForProject,
Document $project,
Device $deviceForFiles
): void {
Console::log('Screenshot action started');
$payload = $message->getPayload() ?? [];
if (empty($payload)) {
throw new \Exception('Missing payload');
}
Console::log('Site screenshot started');
$deploymentId = $payload['deploymentId'] ?? null;
$deployment = $dbForProject->getDocument('deployments', $deploymentId);
if ($deployment->isEmpty()) {
throw new \Exception('Deployment not found');
}
$siteId = $deployment->getAttribute('resourceId');
$site = $dbForProject->getDocument('sites', $siteId);
if ($site->isEmpty()) {
throw new \Exception('Site not found');
}
// Realtime preparation
$event = "sites.[siteId].deployments.[deploymentId].update";
$queueForRealtime
->setSubscribers(['console'])
->setProject($project)
->setEvent($event)
->setParam('siteId', $site->getId())
->setParam('deploymentId', $deployment->getId());
$date = \date('H:i:s');
$this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing started. \n");
try {
$rule = $dbForPlatform->findOne('rules', [
Query::equal("projectInternalId", [$project->getSequence()]),
Query::equal("type", ["deployment"]),
Query::equal('deploymentInternalId', [$deployment->getSequence()]),
]);
if ($rule->isEmpty()) {
throw new \Exception("Rule for deployment not found");
}
$client = new FetchClient();
$client->setTimeout(\intval($site->getAttribute('timeout', '15')) * 1000);
$client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON);
$bucket = $dbForPlatform->getDocument('buckets', 'screenshots');
if ($bucket->isEmpty()) {
throw new \Exception('Bucket not found');
}
$configs = [
'screenshotLight' => [
'headers' => [ 'x-appwrite-hostname' => $rule->getAttribute('domain') ],
'url' => 'http://appwrite/?appwrite-preview=1&appwrite-theme=light',
'theme' => 'light'
],
'screenshotDark' => [
'headers' => [ 'x-appwrite-hostname' => $rule->getAttribute('domain') ],
'url' => 'http://appwrite/?appwrite-preview=1&appwrite-theme=dark',
'theme' => 'dark'
],
];
$jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 0);
$apiKey = $jwtObj->encode([
'hostnameOverride' => true,
'disabledMetrics' => [
METRIC_EXECUTIONS,
METRIC_EXECUTIONS_COMPUTE,
METRIC_EXECUTIONS_MB_SECONDS,
METRIC_NETWORK_REQUESTS,
METRIC_NETWORK_INBOUND,
METRIC_NETWORK_OUTBOUND,
str_replace(["{resourceType}"], [RESOURCE_TYPE_SITES], METRIC_RESOURCE_TYPE_EXECUTIONS),
str_replace(["{resourceType}"], [RESOURCE_TYPE_SITES], METRIC_RESOURCE_TYPE_EXECUTIONS_COMPUTE),
str_replace(["{resourceType}"], [RESOURCE_TYPE_SITES], METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS),
str_replace(["{resourceType}", "{resourceInternalId}"], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS),
str_replace(["{resourceType}", "{resourceInternalId}"], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_COMPUTE),
str_replace(["{resourceType}", "{resourceInternalId}"], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS),
],
'bannerDisabled' => true,
'projectCheckDisabled' => true,
'previewAuthDisabled' => true,
'deploymentStatusIgnored' => true
]);
$screenshotError = null;
$screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $site, $client, &$screenshotError) {
return function () use ($key, $configs, $apiKey, $site, $client, &$screenshotError) {
try {
$config = $configs[$key];
$config['headers'] = \array_merge($config['headers'] ?? [], [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey
]);
$config['sleep'] = 3000;
$frameworks = Config::getParam('frameworks', []);
$framework = $frameworks[$site->getAttribute('framework', '')] ?? null;
if (!is_null($framework)) {
$config['sleep'] = $framework['screenshotSleep'];
}
$browserEndpoint = System::getEnv('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1');
$fetchResponse = $client->fetch(
url: $browserEndpoint . '/screenshots',
method: 'POST',
body: $config
);
if ($fetchResponse->getStatusCode() >= 400) {
throw new \Exception($fetchResponse->getBody());
}
$screenshot = $fetchResponse->getBody();
return ['key' => $key, 'screenshot' => $screenshot];
} catch (\Throwable $th) {
$screenshotError = $th->getMessage();
return;
}
};
}, \array_keys($configs)));
if (!\is_null($screenshotError)) {
throw new \Exception($screenshotError);
}
$mimeType = "image/png";
$updates = new Document([]);
foreach ($screenshots as $data) {
$key = $data['key'];
$screenshot = $data['screenshot'];
$fileId = ID::unique();
$fileName = $fileId . '.png';
$path = $deviceForFiles->getPath($fileName);
$path = str_ireplace($deviceForFiles->getRoot(), $deviceForFiles->getRoot() . DIRECTORY_SEPARATOR . $bucket->getId(), $path); // Add bucket id to path after root
$success = $deviceForFiles->write($path, $screenshot, $mimeType);
if (!$success) {
throw new \Exception("Screenshot failed to save");
}
$teamId = $project->getAttribute('teamId', '');
$file = new Document([
'$id' => $fileId,
'$permissions' => [
Permission::read(Role::team(ID::custom($teamId))),
],
'bucketId' => $bucket->getId(),
'bucketInternalId' => $bucket->getSequence(),
'name' => $fileName,
'path' => $path,
'signature' => $deviceForFiles->getFileHash($path),
'mimeType' => $mimeType,
'sizeOriginal' => \strlen($screenshot),
'sizeActual' => $deviceForFiles->getFileSize($path),
'algorithm' => Compression::NONE,
'comment' => '',
'chunksTotal' => 1,
'chunksUploaded' => 1,
'openSSLVersion' => null,
'openSSLCipher' => null,
'openSSLTag' => null,
'openSSLIV' => null,
'search' => implode(' ', [$fileId, $fileName]),
'metadata' => ['content_type' => $mimeType],
]);
$dbForPlatform->createDocument('bucket_' . $bucket->getSequence(), $file);
$updates->setAttribute($key, $fileId);
}
$date = \date('H:i:s');
$this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing finished. \n");
// Apply screenshot properties
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $updates);
$queueForRealtime
->setPayload($deployment->getArrayCopy())
->trigger();
$site = $dbForProject->updateDocument('sites', $site->getId(), new Document([
'deploymentScreenshotDark' => $deployment->getAttribute('screenshotDark', ''),
'deploymentScreenshotLight' => $deployment->getAttribute('screenshotLight', ''),
]));
} catch (\Throwable $th) {
Console::warning("Screenshot failed to generate:");
Console::warning($th->getMessage());
Console::warning($th->getTraceAsString());
$date = \date('H:i:s');
$this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n");
throw $th;
}
}
protected function appendToLogs(Database $dbForProject, string $deploymentId, Realtime $queueForRealtime, string $logs)
{
$deployment = $dbForProject->getDocument('deployments', $deploymentId);
$buildLogs = $deployment->getAttribute('buildLogs', '');
$buildLogs .= $logs;
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'buildLogs' => $buildLogs
]));
$queueForRealtime
->setPayload($deployment->getArrayCopy())
->trigger();
}
}

View file

@ -54,15 +54,22 @@ class SitesConsoleClientTest extends Scope
$this->assertStringContainsString("Themed website", $response['body']);
$this->assertStringContainsString("@media (prefers-color-scheme: dark)", $response['body']);
$deployment = $this->getDeployment($siteId, $deploymentId);
$this->assertEquals(200, $deployment['headers']['status-code']);
$this->assertNotEmpty($deployment['body']['screenshotLight']);
$this->assertNotEmpty($deployment['body']['screenshotDark']);
$deployment = null;
$site = null;
$this->assertEventually(function () use ($siteId, $deploymentId, &$deployment, &$site) {
$deployment = $this->getDeployment($siteId, $deploymentId);
$this->assertEquals(200, $deployment['headers']['status-code']);
$this->assertNotEmpty($deployment['body']['screenshotLight']);
$this->assertNotEmpty($deployment['body']['screenshotDark']);
$site = $this->getSite($siteId);
$this->assertEquals(200, $site['headers']['status-code']);
$this->assertEquals($deployment['body']['screenshotLight'], $site['body']['deploymentScreenshotLight']);
$this->assertEquals($deployment['body']['screenshotDark'], $site['body']['deploymentScreenshotDark']);
$site = $this->getSite($siteId);
$this->assertEquals(200, $site['headers']['status-code']);
$this->assertEquals($deployment['body']['screenshotLight'], $site['body']['deploymentScreenshotLight']);
$this->assertEquals($deployment['body']['screenshotDark'], $site['body']['deploymentScreenshotDark']);
});
$this->assertNotNull($site);
$this->assertNotNull($deployment);
$screenshotId = $deployment['body']['screenshotLight'];
$file = $this->client->call(Client::METHOD_GET, "/storage/buckets/screenshots/files/$screenshotId/view?project=console", array_merge($this->getHeaders(), [