Skip to content

Commit cf85f07

Browse files
feat(api): add S3-backed screenshot storage (#147)
* feat(api): add S3-backed screenshot storage Support both filesystem and S3-compatible storage for screenshots, and default the Docker API stack to Garage-backed object storage so screenshot persistence is production-like by default. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(api): import Version in worker tasks Add the missing Version import so ruff and mypy pass after the S3 screenshot storage changes, and refresh uv.lock for boto3. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(api): isolate screenshot storage brick Move S3 screenshot storage into an API-only polylith brick so ecoindex_cli and ecoindex_scraper no longer inherit boto3 and config dependencies from the shared utils package. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: update uv.lock Refresh locked dependency versions after running uv lock. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(api): replace Garage with RustFS for screenshot storage Use RustFS as the local S3-compatible backend, consolidate dev infra startup into a single script, and ensure the screenshot bucket exists. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(test): stub head_bucket in S3 screenshot storage test The persist_screenshot flow now checks bucket existence before upload. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 84e4154 commit cf85f07

19 files changed

Lines changed: 1167 additions & 484 deletions

File tree

bases/ecoindex/backend/routers/ecoindex.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from os import getcwd
21
from typing import Annotated
32

43
from ecoindex.backend.models.dependencies_parameters.dates import DateRangeParameters
@@ -22,6 +21,12 @@
2221
)
2322
from ecoindex.models import example_ecoindex_not_found, example_file_not_found
2423
from ecoindex.models.enums import Version
24+
from ecoindex.screenshot_storage import (
25+
get_screenshot_local_path,
26+
is_s3_screenshot_storage,
27+
read_screenshot,
28+
screenshot_exists,
29+
)
2530
from fastapi import APIRouter, Depends, HTTPException, Response, status
2631
from fastapi.params import Query
2732
from fastapi.responses import FileResponse
@@ -128,8 +133,21 @@ async def get_screenshot_endpoint(
128133
id: IdParameter,
129134
version: VersionParameter = Version.v1,
130135
):
136+
if not screenshot_exists(version=version.value, screenshot_id=str(id)):
137+
raise HTTPException(
138+
status_code=status.HTTP_404_NOT_FOUND,
139+
detail=f"Screenshot {version.value}/{id}.webp does not exist.",
140+
)
141+
142+
if is_s3_screenshot_storage():
143+
return Response(
144+
content=read_screenshot(version=version.value, screenshot_id=str(id)),
145+
headers={"Content-Disposition": f'inline; filename="{id}.webp"'},
146+
media_type="image/webp",
147+
)
148+
131149
return FileResponse(
132-
path=f"{getcwd()}/screenshots/{version.value}/{id}.webp",
150+
path=get_screenshot_local_path(version=version.value, screenshot_id=str(id)),
133151
filename=f"{id}.webp",
134152
content_disposition_type="inline",
135153
media_type="image/webp",

bases/ecoindex/worker/tasks.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from asyncio import run
2-
from os import getcwd
32
from urllib.parse import urlparse
43
from uuid import UUID
54

@@ -18,10 +17,14 @@
1817
EcoindexTimeout,
1918
)
2019
from ecoindex.models import ScreenShot, WindowSize
21-
from ecoindex.models.enums import TaskStatus
20+
from ecoindex.models.enums import TaskStatus, Version
2221
from ecoindex.models.tasks import QueueTaskError, QueueTaskResult
2322
from ecoindex.monitoring import capture_task_failure, init_sentry
2423
from ecoindex.scraper.scrap import EcoindexScraper
24+
from ecoindex.screenshot_storage import (
25+
get_screenshot_local_folder,
26+
persist_screenshot,
27+
)
2528
from playwright._impl._errors import Error as WebDriverException
2629
from rq import get_current_job
2730

@@ -59,26 +62,34 @@ async def async_ecoindex_task(
5962
custom_headers: dict[str, str],
6063
) -> QueueTaskResult:
6164
try:
65+
settings = Settings()
6266
session_generator = get_session()
6367
session = await session_generator.__anext__()
68+
screenshot = (
69+
ScreenShot(
70+
id=str(task_id),
71+
folder=get_screenshot_local_folder(version=Version.v1.value),
72+
)
73+
if settings.ENABLE_SCREENSHOT
74+
else None
75+
)
6476

6577
await check_quota(session=session, host=urlparse(url=url).netloc)
6678

6779
ecoindex = await EcoindexScraper(
6880
url=url,
6981
window_size=WindowSize(height=height, width=width),
70-
wait_after_scroll=Settings().WAIT_AFTER_SCROLL,
71-
wait_before_scroll=Settings().WAIT_BEFORE_SCROLL,
72-
screenshot=ScreenShot(
73-
id=str(task_id), folder=f"{getcwd()}/screenshots/v1"
74-
)
75-
if Settings().ENABLE_SCREENSHOT
76-
else None,
77-
screenshot_gid=Settings().SCREENSHOTS_GID,
78-
screenshot_uid=Settings().SCREENSHOTS_UID,
82+
wait_after_scroll=settings.WAIT_AFTER_SCROLL,
83+
wait_before_scroll=settings.WAIT_BEFORE_SCROLL,
84+
screenshot=screenshot,
85+
screenshot_gid=settings.SCREENSHOTS_GID,
86+
screenshot_uid=settings.SCREENSHOTS_UID,
7987
custom_headers=custom_headers,
8088
).get_page_analysis()
8189

90+
if screenshot:
91+
persist_screenshot(screenshot=screenshot, version=Version.v1.value)
92+
8293
db_result = await save_ecoindex_result_db(
8394
session=session,
8495
id=task_id,

components/ecoindex/config/settings.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ class Settings(BaseSettings):
2626
RQ_JOB_TIMEOUT: int = 600
2727
RQ_RESULT_TTL: int = 86400
2828
RQ_WORKERS: int = 3
29+
SCREENSHOT_FILESYSTEM_PATH: str = "./screenshots"
30+
SCREENSHOT_STORAGE_TYPE: str = "filesystem"
31+
SCREENSHOT_S3_ACCESS_KEY_ID: str = ""
32+
SCREENSHOT_S3_BUCKET: str = ""
33+
SCREENSHOT_S3_ENDPOINT_URL: str = ""
34+
SCREENSHOT_S3_FORCE_PATH_STYLE: bool = True
35+
SCREENSHOT_S3_PREFIX: str = "screenshots"
36+
SCREENSHOT_S3_REGION: str = "us-east-1"
37+
SCREENSHOT_S3_SECRET_ACCESS_KEY: str = ""
2938
SCREENSHOTS_GID: int | None = None
3039
SCREENSHOTS_UID: int | None = None
3140
TZ: str = "Europe/Paris"

components/ecoindex/models/response_examples.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"application/json": {
1919
"example": {
2020
"detail": (
21-
"File at path screenshots/v0/"
21+
"Screenshot v1/"
2222
"550cdf8c-9c4c-4f8a-819d-cb69d0866fe1.webp does not exist."
2323
)
2424
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from ecoindex.screenshot_storage.storage import (
2+
get_screenshot_local_folder,
3+
get_screenshot_local_path,
4+
get_screenshot_object_key,
5+
is_s3_screenshot_storage,
6+
persist_screenshot,
7+
read_screenshot,
8+
screenshot_exists,
9+
)
10+
11+
__all__ = [
12+
"get_screenshot_local_folder",
13+
"get_screenshot_local_path",
14+
"get_screenshot_object_key",
15+
"is_s3_screenshot_storage",
16+
"persist_screenshot",
17+
"read_screenshot",
18+
"screenshot_exists",
19+
]
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
from os import getcwd
2+
from pathlib import Path
3+
4+
import boto3
5+
from botocore.client import Config
6+
from botocore.exceptions import ClientError
7+
from ecoindex.config.settings import Settings
8+
from ecoindex.models import ScreenShot
9+
10+
TEMPORARY_SCREENSHOT_ROOT = "/tmp/ecoindex-screenshots"
11+
12+
13+
def get_screenshot_storage_type() -> str:
14+
return Settings().SCREENSHOT_STORAGE_TYPE.lower()
15+
16+
17+
def is_s3_screenshot_storage() -> bool:
18+
return get_screenshot_storage_type() == "s3"
19+
20+
21+
def _get_root_path(path: str) -> Path:
22+
root_path = Path(path).expanduser()
23+
if root_path.is_absolute():
24+
return root_path
25+
return Path(getcwd()) / root_path
26+
27+
28+
def _get_s3_client():
29+
settings = Settings()
30+
if not settings.SCREENSHOT_S3_BUCKET:
31+
raise RuntimeError("SCREENSHOT_S3_BUCKET must be configured when using S3 storage.")
32+
33+
if not settings.SCREENSHOT_S3_ENDPOINT_URL:
34+
raise RuntimeError(
35+
"SCREENSHOT_S3_ENDPOINT_URL must be configured when using S3 storage."
36+
)
37+
38+
if not settings.SCREENSHOT_S3_ACCESS_KEY_ID:
39+
raise RuntimeError(
40+
"SCREENSHOT_S3_ACCESS_KEY_ID must be configured when using S3 storage."
41+
)
42+
43+
if not settings.SCREENSHOT_S3_SECRET_ACCESS_KEY:
44+
raise RuntimeError(
45+
"SCREENSHOT_S3_SECRET_ACCESS_KEY must be configured when using S3 storage."
46+
)
47+
48+
return boto3.client(
49+
"s3",
50+
endpoint_url=settings.SCREENSHOT_S3_ENDPOINT_URL,
51+
region_name=settings.SCREENSHOT_S3_REGION,
52+
aws_access_key_id=settings.SCREENSHOT_S3_ACCESS_KEY_ID,
53+
aws_secret_access_key=settings.SCREENSHOT_S3_SECRET_ACCESS_KEY,
54+
config=Config(
55+
s3={
56+
"addressing_style": (
57+
"path" if settings.SCREENSHOT_S3_FORCE_PATH_STYLE else "auto"
58+
)
59+
}
60+
),
61+
)
62+
63+
64+
def get_screenshot_local_folder(version: str) -> str:
65+
if is_s3_screenshot_storage():
66+
return str(Path(TEMPORARY_SCREENSHOT_ROOT) / version)
67+
68+
settings = Settings()
69+
return str(_get_root_path(settings.SCREENSHOT_FILESYSTEM_PATH) / version)
70+
71+
72+
def get_screenshot_local_path(version: str, screenshot_id: str) -> str:
73+
return str(Path(get_screenshot_local_folder(version)) / f"{screenshot_id}.webp")
74+
75+
76+
def get_screenshot_object_key(version: str, screenshot_id: str) -> str:
77+
settings = Settings()
78+
prefix = settings.SCREENSHOT_S3_PREFIX.strip("/")
79+
filename = f"{version}/{screenshot_id}.webp"
80+
return f"{prefix}/{filename}" if prefix else filename
81+
82+
83+
def persist_screenshot(screenshot: ScreenShot, version: str) -> None:
84+
if not is_s3_screenshot_storage():
85+
return
86+
87+
settings = Settings()
88+
screenshot_path = Path(screenshot.get_webp())
89+
client = _get_s3_client()
90+
bucket = settings.SCREENSHOT_S3_BUCKET
91+
try:
92+
client.head_bucket(Bucket=bucket)
93+
except ClientError as exc:
94+
error_code = exc.response.get("Error", {}).get("Code")
95+
if error_code not in {"404", "NoSuchBucket", "NotFound"}:
96+
raise
97+
client.create_bucket(Bucket=bucket)
98+
99+
client.upload_file(
100+
str(screenshot_path),
101+
bucket,
102+
get_screenshot_object_key(version=version, screenshot_id=screenshot.id),
103+
ExtraArgs={"ContentType": "image/webp"},
104+
)
105+
screenshot_path.unlink(missing_ok=True)
106+
107+
108+
def screenshot_exists(version: str, screenshot_id: str) -> bool:
109+
if not is_s3_screenshot_storage():
110+
return Path(get_screenshot_local_path(version, screenshot_id)).is_file()
111+
112+
settings = Settings()
113+
try:
114+
_get_s3_client().head_object(
115+
Bucket=settings.SCREENSHOT_S3_BUCKET,
116+
Key=get_screenshot_object_key(version=version, screenshot_id=screenshot_id),
117+
)
118+
return True
119+
except ClientError as exc:
120+
error_code = exc.response.get("Error", {}).get("Code")
121+
if error_code in {"404", "NoSuchKey", "NotFound"}:
122+
return False
123+
raise
124+
125+
126+
def read_screenshot(version: str, screenshot_id: str) -> bytes:
127+
if not is_s3_screenshot_storage():
128+
return Path(get_screenshot_local_path(version, screenshot_id)).read_bytes()
129+
130+
settings = Settings()
131+
response = _get_s3_client().get_object(
132+
Bucket=settings.SCREENSHOT_S3_BUCKET,
133+
Key=get_screenshot_object_key(version=version, screenshot_id=screenshot_id),
134+
)
135+
return response["Body"].read()

projects/ecoindex_api/.env.template

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@
99
# DEBUG=1
1010
# ENABLE_SCREENSHOT=1
1111
# EXCLUDED_HOSTS='["localhost","127.0.0.1"]'
12+
# SCREENSHOT_FILESYSTEM_PATH=/code/screenshots
13+
# SCREENSHOT_STORAGE_TYPE=s3
14+
# SCREENSHOT_S3_ACCESS_KEY_ID=ecoindex-access-key
15+
# SCREENSHOT_S3_BUCKET=ecoindex-screenshots
16+
# SCREENSHOT_S3_ENDPOINT_URL=http://localhost:9000
17+
# SCREENSHOT_S3_FORCE_PATH_STYLE=1
18+
# SCREENSHOT_S3_PREFIX=screenshots
19+
# SCREENSHOT_S3_REGION=us-east-1
20+
# SCREENSHOT_S3_SECRET_ACCESS_KEY=ecoindex-secret-key-change-me
1221
# SENTRY_DSN=
1322
# SENTRY_ENVIRONMENT=production
1423
# SENTRY_TRACES_SAMPLE_RATE=0.1

0 commit comments

Comments
 (0)