نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
"""Caddy adapter for :class:`DomainRouterPort`.
|
||||
|
||||
Publishes tenant preview domains by maintaining a *managed snippet file* that the
|
||||
main Caddyfile imports, then reloading Caddy. The main Caddyfile is never edited
|
||||
by this adapter.
|
||||
|
||||
Why a snippet instead of editing the Caddyfile
|
||||
---------------------------------------------
|
||||
Rewriting an operator's Caddyfile means parsing and re-emitting a file we do not
|
||||
own; a mistake there takes down every site on the host. A separate file that
|
||||
Caddy imports by glob keeps the blast radius to tenant preview routes only.
|
||||
|
||||
Setup (one line, once)
|
||||
----------------------
|
||||
Add this to the Caddyfile and reload Caddy::
|
||||
|
||||
import /etc/caddy/agy-preview/*.caddy
|
||||
|
||||
The adapter refuses to run if that import is missing, because silently writing a
|
||||
file that Caddy never reads would report success for a domain that does not
|
||||
resolve.
|
||||
|
||||
Security rules enforced here, not only upstream
|
||||
-----------------------------------------------
|
||||
* the host must be a single label under ``PREVIEW_ROOT_DOMAIN``
|
||||
(:func:`validate_managed_host`) — this is what stops a crafted host from
|
||||
closing the site block and injecting directives
|
||||
* the proxy target must be loopback with a numeric port (:func:`validate_proxy_target`)
|
||||
— SSRF prevention
|
||||
* protected platform ports are refused (:func:`assert_not_protected_port`)
|
||||
* the rendered config is validated *before* it is loaded, and every failed
|
||||
change is rolled back to the previous file
|
||||
|
||||
The ``caddy`` binary runs on the platform host. That is a different trust domain
|
||||
from tenant code: tenant commands only ever run inside a sandbox adapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.application.ports import PreviewDomainInfo, ReverseProxyResult
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
from app.domain.platform.rules import (
|
||||
RuleViolation,
|
||||
assert_not_protected_port,
|
||||
assert_port_allocatable,
|
||||
build_host,
|
||||
validate_managed_host,
|
||||
validate_proxy_target,
|
||||
)
|
||||
|
||||
_BEGIN = "# >>> agy-managed: {host}"
|
||||
_END = "# <<< agy-managed: {host}"
|
||||
_BLOCK_RE = re.compile(
|
||||
r"# >>> agy-managed: (?P<host>\S+)\n(?P<body>.*?)# <<< agy-managed: (?P=host)\n",
|
||||
re.DOTALL,
|
||||
)
|
||||
_VALIDATE_TIMEOUT_SECONDS = 15
|
||||
_RELOAD_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
class CaddyDomainRouterAdapter:
|
||||
"""Reverse-proxy routes managed through a Caddy snippet file."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
caddyfile_path: str,
|
||||
root_domain: str,
|
||||
port_start: int = 8080,
|
||||
port_end: int = 8999,
|
||||
snippet_dir: str | None = None,
|
||||
caddy_binary: str = "caddy",
|
||||
) -> None:
|
||||
self.caddyfile_path = Path(caddyfile_path)
|
||||
self.root_domain = root_domain
|
||||
self.port_start = port_start
|
||||
self.port_end = port_end
|
||||
self.caddy_binary = caddy_binary
|
||||
|
||||
# Default to a sibling directory of the Caddyfile so the import glob is
|
||||
# a short, predictable path.
|
||||
self.snippet_dir = (
|
||||
Path(snippet_dir)
|
||||
if snippet_dir
|
||||
else self.caddyfile_path.parent / "agy-preview"
|
||||
)
|
||||
self.snippet_path = self.snippet_dir / "preview.caddy"
|
||||
|
||||
# -- port allocation -----------------------------------------------------
|
||||
|
||||
async def allocate_port(
|
||||
self, *, organization_id: str, project_id: str, service_name: str
|
||||
) -> int:
|
||||
"""Pick the lowest free port that is not already routed.
|
||||
|
||||
The database is the authoritative port ledger
|
||||
(``PreviewDomainRepository``); this only avoids handing out a port that a
|
||||
live route already occupies.
|
||||
"""
|
||||
in_use = {entry.port for entry in self._parse(self._read_snippet())}
|
||||
for port in range(self.port_start, self.port_end + 1):
|
||||
if port in in_use:
|
||||
continue
|
||||
try:
|
||||
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
|
||||
except RuleViolation:
|
||||
continue
|
||||
return port
|
||||
raise ProviderUnavailableError("پورت آزادی در بازهٔ تعیینشده وجود ندارد.")
|
||||
|
||||
async def claim_preview_domain(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
requested_subdomain: str,
|
||||
port: int,
|
||||
) -> PreviewDomainInfo:
|
||||
host = build_host(requested_subdomain, self.root_domain)
|
||||
result = await self.configure_reverse_proxy(host=host, port=port)
|
||||
if not result.ok:
|
||||
raise RuleViolation(result.message or "پیکربندی دامنه ناموفق بود.")
|
||||
return PreviewDomainInfo(host=host, port=port, url=result.url, status="active")
|
||||
|
||||
# -- routing -------------------------------------------------------------
|
||||
|
||||
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult:
|
||||
"""Create or replace the route for ``host`` and reload Caddy."""
|
||||
try:
|
||||
clean_host = validate_managed_host(host, self.root_domain)
|
||||
# Raises unless the target is loopback with a numeric port.
|
||||
validate_proxy_target(f"localhost:{port}")
|
||||
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
|
||||
assert_not_protected_port(port)
|
||||
except RuleViolation as exc:
|
||||
# A rejected rule is a caller error, not an outage.
|
||||
return ReverseProxyResult(ok=False, message=str(exc))
|
||||
|
||||
if not self._import_is_wired():
|
||||
return ReverseProxyResult(
|
||||
ok=False,
|
||||
message=(
|
||||
"فایل Caddyfile خط import مربوط به AGY را ندارد. "
|
||||
f"این خط را اضافه کنید: import {self.snippet_dir}/*.caddy"
|
||||
),
|
||||
)
|
||||
|
||||
previous = self._read_snippet()
|
||||
entries = [e for e in self._parse(previous) if e.host != clean_host]
|
||||
entries.append(_Entry(host=clean_host, port=port))
|
||||
updated = self._render(entries)
|
||||
|
||||
self._write_snippet(updated)
|
||||
error = await self._validate()
|
||||
if error is None:
|
||||
error = await self._reload()
|
||||
|
||||
if error is not None:
|
||||
# Roll back so a failed change never leaves a half-written config.
|
||||
self._write_snippet(previous)
|
||||
await self._reload()
|
||||
return ReverseProxyResult(ok=False, message=error)
|
||||
|
||||
return ReverseProxyResult(
|
||||
ok=True, message="مسیر دامنه ثبت و Caddy بارگذاری مجدد شد.", url=f"https://{clean_host}"
|
||||
)
|
||||
|
||||
async def remove_preview_domain(self, *, domain_id: str) -> None:
|
||||
"""Remove a route.
|
||||
|
||||
``domain_id`` is the host, matching how the application layer calls this
|
||||
(``remove_preview_domain(domain_id=record["host"])``).
|
||||
"""
|
||||
try:
|
||||
clean_host = validate_managed_host(domain_id, self.root_domain)
|
||||
except RuleViolation:
|
||||
# Not one of ours — nothing to remove, and not an error.
|
||||
return
|
||||
|
||||
previous = self._read_snippet()
|
||||
entries = [e for e in self._parse(previous) if e.host != clean_host]
|
||||
if len(entries) == len(self._parse(previous)):
|
||||
return
|
||||
|
||||
self._write_snippet(self._render(entries))
|
||||
error = await self._validate()
|
||||
if error is None:
|
||||
error = await self._reload()
|
||||
if error is not None:
|
||||
self._write_snippet(previous)
|
||||
await self._reload()
|
||||
|
||||
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]:
|
||||
"""Every managed route in the snippet.
|
||||
|
||||
NOT tenant-scoped: the snippet records hosts and ports, not projects, and
|
||||
``configure_reverse_proxy`` is not given a project id. The database
|
||||
(``PreviewDomainRepository``) is the authoritative, tenant-scoped ledger —
|
||||
never use this method for authorization.
|
||||
"""
|
||||
return [
|
||||
PreviewDomainInfo(host=e.host, port=e.port, url=f"https://{e.host}")
|
||||
for e in self._parse(self._read_snippet())
|
||||
]
|
||||
|
||||
# -- file handling -------------------------------------------------------
|
||||
|
||||
def _read_snippet(self) -> str:
|
||||
if not self.snippet_path.exists():
|
||||
return ""
|
||||
return self.snippet_path.read_text(encoding="utf-8")
|
||||
|
||||
def _write_snippet(self, content: str) -> None:
|
||||
"""Write atomically: a reader must never see a partial file."""
|
||||
try:
|
||||
self.snippet_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.snippet_path.with_suffix(".tmp")
|
||||
tmp.write_text(content, encoding="utf-8")
|
||||
tmp.replace(self.snippet_path)
|
||||
except OSError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"نوشتن فایل پیکربندی Caddy ناموفق بود: {exc}"
|
||||
) from exc
|
||||
|
||||
def _import_is_wired(self) -> bool:
|
||||
"""Confirm the Caddyfile actually imports our snippet."""
|
||||
try:
|
||||
text = self.caddyfile_path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return False
|
||||
needle = str(self.snippet_dir)
|
||||
return any(
|
||||
line.strip().startswith("import") and needle in line
|
||||
for line in text.splitlines()
|
||||
)
|
||||
|
||||
# -- rendering and parsing ----------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _render(entries: list[_Entry]) -> str:
|
||||
header = (
|
||||
"# This file is generated and owned by AGY (web preview domains).\n"
|
||||
"# Manual edits will be overwritten. Managed by CaddyDomainRouterAdapter.\n\n"
|
||||
)
|
||||
blocks = []
|
||||
for entry in sorted(entries, key=lambda e: e.host):
|
||||
blocks.append(
|
||||
f"{_BEGIN.format(host=entry.host)}\n"
|
||||
f"{entry.host} {{\n"
|
||||
f"\treverse_proxy localhost:{entry.port}\n"
|
||||
f"}}\n"
|
||||
f"{_END.format(host=entry.host)}\n"
|
||||
)
|
||||
return header + "\n".join(blocks)
|
||||
|
||||
@staticmethod
|
||||
def _parse(text: str) -> list[_Entry]:
|
||||
entries: list[_Entry] = []
|
||||
for match in _BLOCK_RE.finditer(text or ""):
|
||||
port_match = re.search(r"reverse_proxy\s+localhost:(\d+)", match.group("body"))
|
||||
if not port_match:
|
||||
continue
|
||||
entries.append(_Entry(host=match.group("host"), port=int(port_match.group(1))))
|
||||
return entries
|
||||
|
||||
# -- caddy process -------------------------------------------------------
|
||||
|
||||
async def _run(self, *args: str, timeout: int) -> tuple[int, str]:
|
||||
"""Run the ``caddy`` binary. Platform process, never tenant code."""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
self.caddy_binary,
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"اجرایی «{self.caddy_binary}» پیدا نشد؛ برای DOMAIN_ROUTER=caddy لازم است."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
return 1, "عملیات Caddy زمانبر شد."
|
||||
|
||||
combined = (stderr or stdout or b"").decode("utf-8", errors="replace").strip()
|
||||
return process.returncode or 0, combined
|
||||
|
||||
async def _validate(self) -> str | None:
|
||||
"""Return an error message, or ``None`` when the config is valid."""
|
||||
code, output = await self._run(
|
||||
"validate", "--config", str(self.caddyfile_path),
|
||||
timeout=_VALIDATE_TIMEOUT_SECONDS,
|
||||
)
|
||||
if code != 0:
|
||||
return f"پیکربندی Caddy نامعتبر است: {output[:400]}"
|
||||
return None
|
||||
|
||||
async def _reload(self) -> str | None:
|
||||
code, output = await self._run(
|
||||
"reload", "--config", str(self.caddyfile_path),
|
||||
timeout=_RELOAD_TIMEOUT_SECONDS,
|
||||
)
|
||||
if code != 0:
|
||||
return f"بارگذاری مجدد Caddy ناموفق بود: {output[:400]}"
|
||||
return None
|
||||
|
||||
|
||||
class _Entry:
|
||||
"""One managed route."""
|
||||
|
||||
__slots__ = ("host", "port")
|
||||
|
||||
def __init__(self, *, host: str, port: int) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, _Entry) and (self.host, self.port) == (other.host, other.port)
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Gitea adapter for :class:`GitProviderPort`.
|
||||
|
||||
Talked to over the HTTP API with an admin token. The token is held by this
|
||||
adapter only and is never placed on an object that a response could serialise.
|
||||
|
||||
Design decisions
|
||||
----------------
|
||||
|
||||
**The adapter is stateless.** It is handed ``owner`` / ``repo_name`` by the
|
||||
application layer instead of keeping a ``project_id`` map. A map would be lost on
|
||||
restart and could drift from ``ProjectRepository``, so the database stays the one
|
||||
source of truth for repository identity.
|
||||
|
||||
**Repositories live in one namespace, credentials are per project.** All
|
||||
repositories are created under a single owner (the configured admin user) so
|
||||
tenant repos cannot collide, and every project gets its *own* Gitea user that is
|
||||
added as a collaborator on that one repository. A leaked clone credential
|
||||
therefore reaches exactly one repository — least privilege rather than a shared
|
||||
platform token.
|
||||
|
||||
**Names are re-validated here.** The application layer already produces safe
|
||||
slugs, but the adapter must not depend on a caller behaving well: an unvalidated
|
||||
name is the classic path to a path-traversal or an unintended API path segment.
|
||||
|
||||
Reference: ``docs/current-infra-notes.md`` §4.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
import secrets
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.ports import (
|
||||
BranchInfo,
|
||||
CommitInfo,
|
||||
GitCredentials,
|
||||
MergeResult,
|
||||
PullRequestInfo,
|
||||
RepoInfo,
|
||||
RepoStatus,
|
||||
)
|
||||
from app.core.errors import ProviderUnavailableError, ValidationError
|
||||
|
||||
#: Gitea accepts ``[a-zA-Z0-9._-]`` for repository names; we are stricter and
|
||||
#: keep the platform's lowercase-slug convention.
|
||||
_REPO_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
|
||||
#: Gitea usernames: alphanumeric plus ``-``/``_``, at most 40 characters, and
|
||||
#: they may not start or end with a separator.
|
||||
_USERNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9_-]{1,38}[a-z0-9])$")
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 20.0
|
||||
#: Cloning/committing can be slower than a metadata read.
|
||||
WRITE_TIMEOUT_SECONDS = 60.0
|
||||
|
||||
|
||||
class GiteaGitAdapter:
|
||||
"""Git provider backed by a Gitea instance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
admin_user: str,
|
||||
admin_token: str,
|
||||
owner: str | None = None,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
if not base_url:
|
||||
raise ProviderUnavailableError(
|
||||
"GIT_PROVIDER=gitea نیازمند تنظیم GITEA_INTERNAL_URL یا GITEA_URL است."
|
||||
)
|
||||
if not admin_token:
|
||||
raise ProviderUnavailableError(
|
||||
"GIT_PROVIDER=gitea نیازمند تنظیم GITEA_ADMIN_TOKEN است."
|
||||
)
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.admin_user = admin_user
|
||||
self._admin_token = admin_token
|
||||
#: Namespace that owns every project repository.
|
||||
self.owner = owner or admin_user
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
# -- plumbing ------------------------------------------------------------
|
||||
|
||||
def _client(self, *, write: bool = False) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers={
|
||||
"Authorization": f"token {self._admin_token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
timeout=WRITE_TIMEOUT_SECONDS if write else self.timeout_seconds,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_repo_name(repo_name: str) -> str:
|
||||
clean = (repo_name or "").strip().lower()
|
||||
if not _REPO_NAME_RE.match(clean):
|
||||
raise ValidationError(
|
||||
"نام مخزن نامعتبر است؛ فقط حروف کوچک، عدد، نقطه، خط تیره و زیرخط مجاز است."
|
||||
)
|
||||
return clean
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_username(username: str) -> str:
|
||||
clean = (username or "").strip().lower()
|
||||
if not _USERNAME_RE.match(clean):
|
||||
raise ValidationError("نام کاربری مخزن نامعتبر است.")
|
||||
return clean
|
||||
|
||||
@staticmethod
|
||||
def _seg(value: str) -> str:
|
||||
"""Percent-encode a path segment so it cannot break out of the path."""
|
||||
return quote(value, safe="")
|
||||
|
||||
async def _send(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
write: bool = False,
|
||||
expect: tuple[int, ...] = (200, 201),
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Perform a request and translate transport failures.
|
||||
|
||||
A network problem must surface as ``ProviderUnavailableError`` (a 503),
|
||||
never as a raw ``httpx`` exception leaking through the API.
|
||||
"""
|
||||
try:
|
||||
async with self._client(write=write) as client:
|
||||
response = await client.request(method, path, **kwargs)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Gitea زمانبر شد.") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Gitea برقرار نشد.") from exc
|
||||
|
||||
if response.status_code not in expect:
|
||||
raise ProviderUnavailableError(
|
||||
f"Gitea پاسخ ناموفق داد ({response.status_code}) برای {method} {path}."
|
||||
)
|
||||
return response
|
||||
|
||||
async def _get_json(self, path: str, *, expect: tuple[int, ...] = (200,)) -> dict | list | None:
|
||||
"""GET that tolerates 404 by returning ``None``."""
|
||||
try:
|
||||
async with self._client() as client:
|
||||
response = await client.get(path)
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Gitea برقرار نشد.") from exc
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
if response.status_code not in expect:
|
||||
raise ProviderUnavailableError(
|
||||
f"Gitea پاسخ ناموفق داد ({response.status_code}) برای {path}."
|
||||
)
|
||||
return response.json()
|
||||
|
||||
# -- repository lifecycle ------------------------------------------------
|
||||
|
||||
async def ensure_project_repo(
|
||||
self, *, project_id: str, repo_name: str, description: str = ""
|
||||
) -> RepoInfo:
|
||||
name = self._sanitize_repo_name(repo_name)
|
||||
owner = self._sanitize_username(self.owner)
|
||||
path = f"/api/v1/repos/{self._seg(owner)}/{self._seg(name)}"
|
||||
|
||||
existing = await self._get_json(path)
|
||||
if existing is None:
|
||||
# The admin endpoint creates the repository inside the owner's
|
||||
# namespace, which a plain user token could not do.
|
||||
await self._send(
|
||||
"POST",
|
||||
f"/api/v1/admin/users/{self._seg(owner)}/repos",
|
||||
json={
|
||||
"name": name,
|
||||
"description": description,
|
||||
"private": True,
|
||||
"auto_init": True,
|
||||
"default_branch": "main",
|
||||
},
|
||||
)
|
||||
existing = await self._get_json(path)
|
||||
if existing is None:
|
||||
raise ProviderUnavailableError("ساخت مخزن در Gitea تأیید نشد.")
|
||||
|
||||
if not isinstance(existing, dict):
|
||||
raise ProviderUnavailableError("پاسخ Gitea برای مخزن قابل تفسیر نبود.")
|
||||
return self._to_repo_info(existing, fallback_name=name, fallback_owner=owner)
|
||||
|
||||
def _to_repo_info(self, payload: dict, *, fallback_name: str, fallback_owner: str) -> RepoInfo:
|
||||
owner = ((payload.get("owner") or {}).get("login")) or fallback_owner
|
||||
name = payload.get("name") or fallback_name
|
||||
return RepoInfo(
|
||||
owner=owner,
|
||||
repo_name=name,
|
||||
clone_url=payload.get("clone_url") or f"{self.base_url}/{owner}/{name}.git",
|
||||
web_url=payload.get("html_url") or f"{self.base_url}/{owner}/{name}",
|
||||
default_branch=payload.get("default_branch") or "main",
|
||||
# Never let a remote payload make a tenant repository public.
|
||||
private=True,
|
||||
)
|
||||
|
||||
async def ensure_credentials(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> GitCredentials:
|
||||
"""Create (or reset) a dedicated Gitea user for this project.
|
||||
|
||||
The user is added as a *collaborator on one repository only*, so the
|
||||
returned password cannot reach any other tenant's code.
|
||||
"""
|
||||
repo_owner = self._sanitize_username(owner or self.owner)
|
||||
name = self._sanitize_repo_name(repo_name)
|
||||
username = self._sanitize_username(f"proj-{project_id[:24]}")
|
||||
|
||||
# A long random password, generated fresh every call. Gitea hashes it.
|
||||
password = secrets.token_urlsafe(24)
|
||||
email = f"{username}@users.noreply.agy.invalid"
|
||||
|
||||
created = await self._send(
|
||||
"POST",
|
||||
"/api/v1/admin/users",
|
||||
expect=(200, 201, 422),
|
||||
json={
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"must_change_password": False,
|
||||
"visibility": "private",
|
||||
},
|
||||
)
|
||||
if created.status_code == 422:
|
||||
# Already exists — rotate its password instead of failing, so a
|
||||
# retried `ensure_credentials` stays idempotent.
|
||||
await self._send(
|
||||
"PATCH",
|
||||
f"/api/v1/admin/users/{self._seg(username)}",
|
||||
json={"password": password, "must_change_password": False},
|
||||
)
|
||||
|
||||
# Grant write access to exactly this repository.
|
||||
await self._send(
|
||||
"PUT",
|
||||
f"/api/v1/repos/{self._seg(repo_owner)}/{self._seg(name)}"
|
||||
f"/collaborators/{self._seg(username)}",
|
||||
json={"permission": "write"},
|
||||
)
|
||||
|
||||
return GitCredentials(username=username, password=password)
|
||||
|
||||
# -- branches and commits ------------------------------------------------
|
||||
|
||||
async def create_branch(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
from_branch: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> BranchInfo:
|
||||
base_path = self._repo_path(owner, repo_name)
|
||||
response = await self._send(
|
||||
"POST",
|
||||
f"{base_path}/branches",
|
||||
expect=(200, 201),
|
||||
json={"new_branch_name": branch_name, "old_branch_name": from_branch},
|
||||
)
|
||||
payload = response.json()
|
||||
commit = payload.get("commit") or {}
|
||||
return BranchInfo(name=payload.get("name", branch_name), sha=commit.get("id", ""))
|
||||
|
||||
async def commit_files(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
files: dict[str, str],
|
||||
message: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> CommitInfo:
|
||||
"""Commit every file, one Contents-API call each.
|
||||
|
||||
Gitea's contents endpoint handles a single file per request, so a
|
||||
multi-file change is a sequence of commits. Each update needs the blob's
|
||||
current ``sha``, which we read first.
|
||||
"""
|
||||
base_path = self._repo_path(owner, repo_name)
|
||||
last_sha = ""
|
||||
changed = 0
|
||||
|
||||
for raw_path, content in files.items():
|
||||
path = self._safe_content_path(raw_path)
|
||||
encoded = quote(path, safe="/")
|
||||
existing = await self._get_json(f"{base_path}/contents/{encoded}?ref={branch_name}")
|
||||
body = {
|
||||
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
||||
"message": message,
|
||||
"branch": branch_name,
|
||||
}
|
||||
if isinstance(existing, dict) and existing.get("sha"):
|
||||
body["sha"] = existing["sha"]
|
||||
response = await self._send(
|
||||
"PUT", f"{base_path}/contents/{encoded}", write=True, json=body
|
||||
)
|
||||
else:
|
||||
response = await self._send(
|
||||
"POST", f"{base_path}/contents/{encoded}", write=True, json=body
|
||||
)
|
||||
payload = response.json()
|
||||
last_sha = (payload.get("commit") or {}).get("sha") or last_sha
|
||||
changed += 1
|
||||
|
||||
return CommitInfo(
|
||||
sha=last_sha, branch=branch_name, message=message, files_changed=changed
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_content_path(raw_path: str) -> str:
|
||||
"""Reject absolute paths and traversal before they reach the URL."""
|
||||
clean = (raw_path or "").strip().lstrip("/")
|
||||
if not clean:
|
||||
raise ValidationError("مسیر فایل نامعتبر است.")
|
||||
segments = clean.split("/")
|
||||
if any(seg in {"", ".", ".."} for seg in segments):
|
||||
raise ValidationError("مسیر فایل نامعتبر است.")
|
||||
return "/".join(segments)
|
||||
|
||||
# -- pull requests -------------------------------------------------------
|
||||
|
||||
async def open_pull_request(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> PullRequestInfo:
|
||||
response = await self._send(
|
||||
"POST",
|
||||
f"{self._repo_path(owner, repo_name)}/pulls",
|
||||
expect=(200, 201),
|
||||
json={"head": head, "base": base, "title": title, "body": body},
|
||||
)
|
||||
payload = response.json()
|
||||
return PullRequestInfo(
|
||||
number=int(payload.get("number", 0)),
|
||||
url=payload.get("html_url", ""),
|
||||
state=payload.get("state", "open"),
|
||||
)
|
||||
|
||||
async def merge_pull_request(
|
||||
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
|
||||
) -> MergeResult:
|
||||
try:
|
||||
await self._send(
|
||||
"POST",
|
||||
f"{self._repo_path(owner, repo_name)}/pulls/{int(pr_number)}/merge",
|
||||
json={"Do": "merge"},
|
||||
)
|
||||
except ProviderUnavailableError as exc:
|
||||
# A merge conflict is a normal outcome, not an outage.
|
||||
return MergeResult(merged=False, message=str(exc))
|
||||
return MergeResult(merged=True, message="PR ادغام شد.")
|
||||
|
||||
# -- status --------------------------------------------------------------
|
||||
|
||||
async def get_repo_status(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> RepoStatus:
|
||||
base_path = self._repo_path(owner, repo_name)
|
||||
repo = await self._get_json(base_path)
|
||||
if repo is None:
|
||||
return RepoStatus(exists=False)
|
||||
if not isinstance(repo, dict):
|
||||
raise ProviderUnavailableError("پاسخ Gitea برای مخزن قابل تفسیر نبود.")
|
||||
|
||||
branches: list[str] = []
|
||||
branch_payload = await self._get_json(f"{base_path}/branches")
|
||||
if isinstance(branch_payload, list):
|
||||
branches = sorted(b.get("name", "") for b in branch_payload if b.get("name"))
|
||||
|
||||
open_prs = 0
|
||||
pr_payload = await self._get_json(f"{base_path}/pulls?state=open&limit=50")
|
||||
if isinstance(pr_payload, list):
|
||||
open_prs = len(pr_payload)
|
||||
|
||||
return RepoStatus(
|
||||
exists=True,
|
||||
private=True,
|
||||
default_branch=repo.get("default_branch") or "main",
|
||||
branches=branches,
|
||||
open_pull_requests=open_prs,
|
||||
)
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
|
||||
def _repo_path(self, owner: str, repo_name: str) -> str:
|
||||
"""Build ``/api/v1/repos/{owner}/{repo}`` from validated segments."""
|
||||
if not repo_name:
|
||||
raise ValidationError("نام مخزن مشخص نیست.")
|
||||
clean_owner = self._sanitize_username(owner or self.owner)
|
||||
clean_repo = self._sanitize_repo_name(repo_name)
|
||||
return f"/api/v1/repos/{self._seg(clean_owner)}/{self._seg(clean_repo)}"
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Mock adapters — every port has one so the stack runs with zero credentials.
|
||||
|
||||
These are not toys: they enforce the same contracts (timeouts, output caps,
|
||||
validation) as the real adapters, so switching a ``*_PROVIDER`` env var does not
|
||||
change application behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from app.application.ports import (
|
||||
BranchInfo,
|
||||
ChatMessage,
|
||||
CheckoutSession,
|
||||
CommandResult,
|
||||
CommitInfo,
|
||||
GitCredentials,
|
||||
MergeResult,
|
||||
ModelHealth,
|
||||
ModelResponse,
|
||||
Notification,
|
||||
PaymentResult,
|
||||
PreviewDomainInfo,
|
||||
PullRequestInfo,
|
||||
RepoInfo,
|
||||
RepoStatus,
|
||||
ReverseProxyResult,
|
||||
SessionInfo,
|
||||
SnapshotInfo,
|
||||
StoredObject,
|
||||
VectorHit,
|
||||
WorkspaceInfo,
|
||||
)
|
||||
from app.domain.ai_control.models import AiModel
|
||||
from app.domain.platform.rules import (
|
||||
assert_port_allocatable,
|
||||
build_host,
|
||||
validate_managed_host,
|
||||
validate_proxy_target,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockModelProviderAdapter:
|
||||
"""Deterministic, offline stand-in for an LLM.
|
||||
|
||||
Returns a canned Persian acknowledgement that names the task so tests can
|
||||
assert on it without a network call.
|
||||
"""
|
||||
|
||||
def __init__(self, latency_ms: int = 0) -> None:
|
||||
self._latency_ms = latency_ms
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: AiModel,
|
||||
credential=None,
|
||||
messages: list[ChatMessage],
|
||||
system: str = "",
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 2048,
|
||||
) -> ModelResponse:
|
||||
started = time.perf_counter()
|
||||
if self._latency_ms:
|
||||
await asyncio.sleep(self._latency_ms / 1000)
|
||||
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||
prompt_tokens = sum(len(m.content.split()) for m in messages)
|
||||
# The canned text must read like model output, not like a debug tag: the
|
||||
# provider/model identity is platform-internal and must never surface in
|
||||
# anything a customer can read (see ``test_run_payload_never_leaks_model_identity``).
|
||||
content = (
|
||||
"این پاسخ آزمایشی است. "
|
||||
f"درخواست دریافتی: {last_user[:280] or '—'}"
|
||||
)
|
||||
return ModelResponse(
|
||||
content=content,
|
||||
model_internal_name=model.internal_name,
|
||||
input_tokens=prompt_tokens + len(system.split()),
|
||||
output_tokens=len(content.split()),
|
||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||
raw={"mock": True},
|
||||
)
|
||||
|
||||
async def health(self) -> ModelHealth:
|
||||
return ModelHealth(ok=True, detail="mock provider")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockGitAdapter:
|
||||
"""In-memory Git provider. Nothing touches a real forge.
|
||||
|
||||
``owner`` / ``repo_name`` are accepted for interface parity with the Gitea
|
||||
adapter but ignored here — this adapter keys everything by ``project_id``.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._repos: dict[str, RepoInfo] = {}
|
||||
self._credentials: dict[str, GitCredentials] = {}
|
||||
self._branches: dict[str, dict[str, str]] = {}
|
||||
self._prs: dict[str, list[PullRequestInfo]] = {}
|
||||
|
||||
async def ensure_project_repo(
|
||||
self, *, project_id: str, repo_name: str, description: str = ""
|
||||
) -> RepoInfo:
|
||||
if project_id not in self._repos:
|
||||
self._repos[project_id] = RepoInfo(
|
||||
owner="mock",
|
||||
repo_name=repo_name,
|
||||
clone_url=f"https://git.example.invalid/mock/{repo_name}.git",
|
||||
web_url=f"https://git.example.invalid/mock/{repo_name}",
|
||||
default_branch="main",
|
||||
private=True,
|
||||
)
|
||||
self._branches[project_id] = {"main": hashlib.sha1(b"init").hexdigest()[:40]}
|
||||
self._prs[project_id] = []
|
||||
return self._repos[project_id]
|
||||
|
||||
async def ensure_credentials(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> GitCredentials:
|
||||
if project_id not in self._credentials:
|
||||
self._credentials[project_id] = GitCredentials(
|
||||
username=f"proj_{project_id[:8]}",
|
||||
password=secrets.token_urlsafe(12),
|
||||
)
|
||||
return self._credentials[project_id]
|
||||
|
||||
async def create_branch(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
from_branch: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> BranchInfo:
|
||||
branches = self._branches.setdefault(project_id, {"main": "0" * 40})
|
||||
base = branches.get(from_branch, "0" * 40)
|
||||
sha = hashlib.sha1(f"{base}:{branch_name}".encode()).hexdigest()[:40]
|
||||
branches[branch_name] = sha
|
||||
return BranchInfo(name=branch_name, sha=sha)
|
||||
|
||||
async def commit_files(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
branch_name: str,
|
||||
files: dict[str, str],
|
||||
message: str,
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> CommitInfo:
|
||||
digest = hashlib.sha1(
|
||||
f"{branch_name}:{message}:{sorted(files)}".encode()
|
||||
).hexdigest()[:40]
|
||||
self._branches.setdefault(project_id, {})[branch_name] = digest
|
||||
return CommitInfo(
|
||||
sha=digest, branch=branch_name, message=message, files_changed=len(files)
|
||||
)
|
||||
|
||||
async def open_pull_request(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
owner: str = "",
|
||||
repo_name: str = "",
|
||||
) -> PullRequestInfo:
|
||||
prs = self._prs.setdefault(project_id, [])
|
||||
number = len(prs) + 1
|
||||
repo = self._repos.get(project_id)
|
||||
web = repo.web_url if repo else "https://git.example.invalid/mock"
|
||||
pr = PullRequestInfo(number=number, url=f"{web}/pulls/{number}", state="open")
|
||||
prs.append(pr)
|
||||
return pr
|
||||
|
||||
async def merge_pull_request(
|
||||
self, *, project_id: str, pr_number: int, owner: str = "", repo_name: str = ""
|
||||
) -> MergeResult:
|
||||
prs = self._prs.setdefault(project_id, [])
|
||||
for pr in prs:
|
||||
if pr.number == pr_number:
|
||||
pr.state = "merged"
|
||||
return MergeResult(merged=True, message="ادغام شد (mock).")
|
||||
return MergeResult(merged=False, message="PR یافت نشد.")
|
||||
|
||||
async def get_repo_status(
|
||||
self, *, project_id: str, owner: str = "", repo_name: str = ""
|
||||
) -> RepoStatus:
|
||||
repo = self._repos.get(project_id)
|
||||
if repo is None:
|
||||
return RepoStatus(exists=False)
|
||||
return RepoStatus(
|
||||
exists=True,
|
||||
private=repo.private,
|
||||
default_branch=repo.default_branch,
|
||||
branches=sorted(self._branches.get(project_id, {}).keys()),
|
||||
open_pull_requests=sum(
|
||||
1 for pr in self._prs.get(project_id, []) if pr.state == "open"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockDomainRouterAdapter:
|
||||
"""In-memory port + subdomain ledger.
|
||||
|
||||
Applies the *same* validation rules as the real Caddy adapter, so tests that
|
||||
pass here describe behaviour the production adapter also guarantees.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
root_domain: str = "msa.artacloud.ir",
|
||||
port_start: int = 8080,
|
||||
port_end: int = 8999,
|
||||
) -> None:
|
||||
self.root_domain = root_domain
|
||||
self.port_start = port_start
|
||||
self.port_end = port_end
|
||||
self._ports: dict[tuple[str, str], int] = {}
|
||||
self._domains: dict[str, PreviewDomainInfo] = {}
|
||||
self._project_domains: dict[str, list[str]] = {}
|
||||
|
||||
async def allocate_port(
|
||||
self, *, organization_id: str, project_id: str, service_name: str
|
||||
) -> int:
|
||||
key = (project_id, service_name)
|
||||
if key in self._ports:
|
||||
return self._ports[key]
|
||||
used = set(self._ports.values())
|
||||
for port in range(self.port_start, self.port_end + 1):
|
||||
if port in used:
|
||||
continue
|
||||
try:
|
||||
assert_port_allocatable(port, start=self.port_start, end=self.port_end)
|
||||
except Exception:
|
||||
continue
|
||||
self._ports[key] = port
|
||||
return port
|
||||
raise RuntimeError("No available ports in the configured range.")
|
||||
|
||||
async def claim_preview_domain(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
requested_subdomain: str,
|
||||
port: int,
|
||||
) -> PreviewDomainInfo:
|
||||
host = build_host(requested_subdomain, self.root_domain)
|
||||
if host in self._domains:
|
||||
owner_domains = self._project_domains.get(project_id, [])
|
||||
if host not in owner_domains:
|
||||
raise ValueError(f"دامنهٔ {host} قبلاً ثبت شده است.")
|
||||
info = PreviewDomainInfo(
|
||||
host=host, port=port, url=f"https://{host}", status="active"
|
||||
)
|
||||
self._domains[host] = info
|
||||
self._project_domains.setdefault(project_id, []).append(host)
|
||||
return info
|
||||
|
||||
async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult:
|
||||
validate_managed_host(host, self.root_domain)
|
||||
validate_proxy_target(f"localhost:{port}")
|
||||
return ReverseProxyResult(ok=True, message="route ثبت شد (mock).", url=f"https://{host}")
|
||||
|
||||
async def remove_preview_domain(self, *, domain_id: str) -> None:
|
||||
for host, info in list(self._domains.items()):
|
||||
if info.host == domain_id or domain_id in self._project_domains.get(domain_id, []):
|
||||
self._domains.pop(host, None)
|
||||
self._domains.pop(domain_id, None)
|
||||
for hosts in self._project_domains.values():
|
||||
if domain_id in hosts:
|
||||
hosts.remove(domain_id)
|
||||
|
||||
async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]:
|
||||
return [
|
||||
self._domains[h]
|
||||
for h in self._project_domains.get(project_id, [])
|
||||
if h in self._domains
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LocalMockSandboxAdapter:
|
||||
"""Executes nothing. Returns plausible results and records commands.
|
||||
|
||||
``run_command`` still honours the timeout and output cap contract so callers
|
||||
cannot depend on behaviour the real adapters will not provide.
|
||||
"""
|
||||
|
||||
def __init__(self, max_output_bytes: int = 200_000) -> None:
|
||||
self._workspaces: dict[str, str] = {}
|
||||
self._sessions: dict[str, str] = {}
|
||||
self._files: dict[str, dict[str, str]] = {}
|
||||
self.commands: list[str] = []
|
||||
self.max_output_bytes = max_output_bytes
|
||||
|
||||
async def create_workspace(
|
||||
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
||||
) -> WorkspaceInfo:
|
||||
external_id = f"ws_{uuid.uuid4().hex[:12]}"
|
||||
self._workspaces[external_id] = image
|
||||
return WorkspaceInfo(external_id=external_id, status="ready", raw={"image": image})
|
||||
|
||||
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
|
||||
external_id = f"ses_{uuid.uuid4().hex[:12]}"
|
||||
self._sessions[external_id] = workspace_external_id
|
||||
self._files.setdefault(external_id, {})
|
||||
return SessionInfo(external_id=external_id, status="running")
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
*,
|
||||
session_external_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> CommandResult:
|
||||
limit = max_output_bytes or self.max_output_bytes
|
||||
started = time.perf_counter()
|
||||
self.commands.append(command)
|
||||
stdout = f"[mock] اجرا شد: {command}"
|
||||
truncated = len(stdout.encode()) > limit
|
||||
if truncated:
|
||||
stdout = stdout.encode()[:limit].decode(errors="ignore")
|
||||
return CommandResult(
|
||||
exit_code=0,
|
||||
stdout=stdout,
|
||||
stderr="",
|
||||
duration_ms=max(1, int((time.perf_counter() - started) * 1000)),
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
async def read_file(self, *, session_external_id: str, path: str) -> str:
|
||||
return self._files.get(session_external_id, {}).get(path, "")
|
||||
|
||||
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
|
||||
self._files.setdefault(session_external_id, {})[path] = content
|
||||
|
||||
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
|
||||
return SnapshotInfo(external_id=f"snap_{uuid.uuid4().hex[:12]}", name=name)
|
||||
|
||||
async def pause(self, *, session_external_id: str) -> None:
|
||||
return None
|
||||
|
||||
async def resume(self, *, session_external_id: str) -> SessionInfo:
|
||||
return SessionInfo(external_id=session_external_id, status="running")
|
||||
|
||||
async def destroy(self, *, session_external_id: str) -> None:
|
||||
self._sessions.pop(session_external_id, None)
|
||||
self._files.pop(session_external_id, None)
|
||||
|
||||
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
|
||||
return f"http://127.0.0.1:{port}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LocalMockPaymentAdapter:
|
||||
async def create_checkout(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
amount: float,
|
||||
currency: str,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
) -> CheckoutSession:
|
||||
external_id = f"mock_cs_{uuid.uuid4().hex[:12]}"
|
||||
return CheckoutSession(
|
||||
external_id=external_id,
|
||||
url=f"{success_url}?mock_checkout={external_id}",
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
async def refund(
|
||||
self, *, external_payment_id: str, amount: float | None = None
|
||||
) -> PaymentResult:
|
||||
return PaymentResult(ok=True, external_id=external_payment_id, message="بازگشت (mock).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage / vector / notification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryStorageAdapter:
|
||||
def __init__(self) -> None:
|
||||
self._objects: dict[str, bytes] = {}
|
||||
|
||||
async def put(self, *, key: str, data: bytes, content_type: str = "") -> StoredObject:
|
||||
self._objects[key] = data
|
||||
return StoredObject(key=key, size_bytes=len(data), url=f"memory://{key}")
|
||||
|
||||
async def get(self, *, key: str) -> bytes:
|
||||
return self._objects.get(key, b"")
|
||||
|
||||
async def delete(self, *, key: str) -> None:
|
||||
self._objects.pop(key, None)
|
||||
|
||||
async def signed_url(self, *, key: str, expires_seconds: int = 900) -> str:
|
||||
return f"memory://{key}?expires={expires_seconds}"
|
||||
|
||||
|
||||
class InMemoryVectorStoreAdapter:
|
||||
"""Keyword-overlap search. Good enough for MVP; swappable for pgvector."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._chunks: dict[str, tuple[str, str, str, dict]] = {}
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
chunks: Iterable[tuple[str, str, list[float]]],
|
||||
metadata: dict | None = None,
|
||||
) -> int:
|
||||
count = 0
|
||||
for chunk_id, content, _embedding in chunks:
|
||||
document_id = str((metadata or {}).get("document_id", ""))
|
||||
self._chunks[chunk_id] = (organization_id, document_id, content, metadata or {})
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def search(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
project_id: str | None = None,
|
||||
) -> list[VectorHit]:
|
||||
terms = {t for t in query.lower().split() if t}
|
||||
scored: list[VectorHit] = []
|
||||
for chunk_id, (org, document_id, content, meta) in self._chunks.items():
|
||||
if org != organization_id:
|
||||
continue
|
||||
if project_id and meta.get("project_id") not in (None, project_id):
|
||||
continue
|
||||
overlap = len(terms & set(content.lower().split()))
|
||||
if overlap:
|
||||
scored.append(
|
||||
VectorHit(
|
||||
chunk_id=chunk_id,
|
||||
document_id=document_id,
|
||||
content=content,
|
||||
score=float(overlap),
|
||||
metadata=meta,
|
||||
)
|
||||
)
|
||||
scored.sort(key=lambda h: h.score, reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
async def delete_document(self, *, document_id: str) -> None:
|
||||
for chunk_id, (_, doc_id, _, _) in list(self._chunks.items()):
|
||||
if doc_id == document_id:
|
||||
self._chunks.pop(chunk_id, None)
|
||||
|
||||
|
||||
class ConsoleNotificationAdapter:
|
||||
"""Records notifications instead of sending them."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[Notification] = []
|
||||
|
||||
async def send(self, *, notification: Notification) -> bool:
|
||||
self.sent.append(notification)
|
||||
return True
|
||||
|
||||
|
||||
class InMemoryAuditAdapter:
|
||||
"""In-memory AuditPort, used by unit tests that do not want a database."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Records whatever the caller hands over; the port is untyped here.
|
||||
self.events: list[Any] = []
|
||||
|
||||
async def record(self, *, event):
|
||||
self.events.append(event)
|
||||
return event
|
||||
|
||||
async def list_for_organization(
|
||||
self, *, organization_id: str, limit: int = 100, offset: int = 0
|
||||
):
|
||||
matching = [e for e in self.events if e.organization_id == organization_id]
|
||||
return matching[offset : offset + limit]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Real model adapter for any OpenAI-compatible endpoint.
|
||||
|
||||
Works with OpenAI, OpenRouter, vLLM, LiteLLM, Ollama's OpenAI shim, etc. The
|
||||
API key is read from the credential record (decrypted by the caller), never from
|
||||
the environment at request time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.ports import ChatMessage, ModelHealth, ModelResponse
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
from app.domain.ai_control.models import AiModel, ModelCredential
|
||||
|
||||
|
||||
class OpenAICompatibleModelAdapter:
|
||||
def __init__(self, *, base_url: str, default_api_key: str = "", timeout: float = 120.0) -> None:
|
||||
self.base_url = (base_url or "").rstrip("/")
|
||||
self.default_api_key = default_api_key
|
||||
self.timeout = timeout
|
||||
|
||||
def _resolve_key(self, credential: ModelCredential | None, decrypt) -> str:
|
||||
if credential is not None and credential.encrypted_api_key:
|
||||
try:
|
||||
return decrypt(credential.encrypted_api_key)
|
||||
except Exception: # noqa: BLE001 - fall through to the env default
|
||||
pass
|
||||
return self.default_api_key
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
model: AiModel,
|
||||
credential: ModelCredential | None,
|
||||
messages: list[ChatMessage],
|
||||
system: str = "",
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 2048,
|
||||
) -> ModelResponse:
|
||||
if not self.base_url:
|
||||
raise ProviderUnavailableError(
|
||||
"OPENAI_COMPATIBLE_BASE_URL تنظیم نشده است."
|
||||
)
|
||||
|
||||
payload_messages = ([{"role": "system", "content": system}] if system else []) + [
|
||||
{"role": m.role, "content": m.content} for m in messages
|
||||
]
|
||||
payload = {
|
||||
"model": model.internal_name,
|
||||
"messages": payload_messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = self.default_api_key
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/chat/completions", json=payload, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"ارائهدهندهٔ مدل خطا برگرداند ({exc.response.status_code})."
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderUnavailableError("اتصال به ارائهدهندهٔ مدل برقرار نشد.") from exc
|
||||
|
||||
choice = (data.get("choices") or [{}])[0]
|
||||
content = (choice.get("message") or {}).get("content", "") or ""
|
||||
usage = data.get("usage") or {}
|
||||
return ModelResponse(
|
||||
content=content,
|
||||
model_internal_name=data.get("model", model.internal_name),
|
||||
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
||||
output_tokens=int(usage.get("completion_tokens", 0) or 0),
|
||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||
raw={"id": data.get("id", "")},
|
||||
)
|
||||
|
||||
async def health(self) -> ModelHealth:
|
||||
if not self.base_url:
|
||||
return ModelHealth(ok=False, detail="base_url تنظیم نشده است.")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(f"{self.base_url}/models")
|
||||
return ModelHealth(ok=response.status_code < 500, detail=str(response.status_code))
|
||||
except httpx.HTTPError as exc:
|
||||
return ModelHealth(ok=False, detail=str(exc))
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Adapter registry.
|
||||
|
||||
One place decides which concrete adapter backs each port, driven purely by the
|
||||
``*_PROVIDER`` environment switches. Everything defaults to ``mock`` so a fresh
|
||||
clone runs with no credentials.
|
||||
|
||||
Adapters that hold process state (the mock Git forge, the mock port ledger) are
|
||||
cached as singletons so behaviour is consistent within a process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
from app.infrastructure.adapters import mock
|
||||
from app.infrastructure.adapters.openai_compatible import OpenAICompatibleModelAdapter
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_model_provider():
|
||||
provider = settings.ai_default_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.MockModelProviderAdapter()
|
||||
if provider == "openai_compatible":
|
||||
return OpenAICompatibleModelAdapter(
|
||||
base_url=settings.openai_compatible_base_url,
|
||||
default_api_key=settings.openai_compatible_api_key,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"AI_DEFAULT_PROVIDER='{provider}' پشتیبانی نمیشود. "
|
||||
"مقادیر مجاز: mock | openai_compatible"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_git_provider():
|
||||
provider = settings.git_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.MockGitAdapter()
|
||||
if provider == "gitea":
|
||||
from app.infrastructure.adapters.gitea import GiteaGitAdapter
|
||||
|
||||
return GiteaGitAdapter(
|
||||
base_url=settings.gitea_internal_url or settings.gitea_url,
|
||||
admin_user=settings.gitea_admin_user,
|
||||
admin_token=settings.gitea_admin_token,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"GIT_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | gitea"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_domain_router():
|
||||
provider = settings.domain_router.lower()
|
||||
if provider == "mock":
|
||||
return mock.MockDomainRouterAdapter(
|
||||
root_domain=settings.preview_root_domain,
|
||||
port_start=settings.preview_port_start,
|
||||
port_end=settings.preview_port_end,
|
||||
)
|
||||
if provider == "caddy":
|
||||
from app.infrastructure.adapters.caddy import CaddyDomainRouterAdapter
|
||||
|
||||
return CaddyDomainRouterAdapter(
|
||||
caddyfile_path=settings.caddyfile_path,
|
||||
root_domain=settings.preview_root_domain,
|
||||
port_start=settings.preview_port_start,
|
||||
port_end=settings.preview_port_end,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"DOMAIN_ROUTER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | caddy"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_sandbox_provider():
|
||||
provider = settings.sandbox_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.LocalMockSandboxAdapter(max_output_bytes=settings.sandbox_max_output_bytes)
|
||||
if provider in {"daytona", "e2b"}:
|
||||
from app.infrastructure.adapters.sandbox_remote import build_remote_sandbox_adapter
|
||||
|
||||
return build_remote_sandbox_adapter(
|
||||
provider,
|
||||
daytona_api_key=settings.daytona_api_key,
|
||||
daytona_api_url=settings.daytona_api_url,
|
||||
e2b_api_key=settings.e2b_api_key,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | daytona | e2b"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_payment_provider():
|
||||
provider = settings.payment_provider.lower()
|
||||
if provider == "mock":
|
||||
return mock.LocalMockPaymentAdapter()
|
||||
if provider == "stripe":
|
||||
from app.infrastructure.adapters.stripe_payments import StripePaymentAdapter
|
||||
|
||||
return StripePaymentAdapter(
|
||||
secret_key=settings.stripe_secret_key,
|
||||
webhook_secret=settings.stripe_webhook_secret,
|
||||
)
|
||||
raise ProviderUnavailableError(
|
||||
f"PAYMENT_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | stripe"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_storage():
|
||||
return mock.InMemoryStorageAdapter()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_vector_store():
|
||||
return mock.InMemoryVectorStoreAdapter()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_notifier():
|
||||
return mock.ConsoleNotificationAdapter()
|
||||
|
||||
|
||||
def reset_adapters() -> None:
|
||||
"""Drop cached singletons. Used by tests that need a clean slate."""
|
||||
for fn in (
|
||||
get_model_provider,
|
||||
get_git_provider,
|
||||
get_domain_router,
|
||||
get_sandbox_provider,
|
||||
get_payment_provider,
|
||||
get_storage,
|
||||
get_vector_store,
|
||||
get_notifier,
|
||||
):
|
||||
fn.cache_clear()
|
||||
@@ -0,0 +1,430 @@
|
||||
"""Remote sandbox adapters (Daytona / E2B) for :class:`SandboxProviderPort`.
|
||||
|
||||
This is the **only** place tenant code may execute. It never shells out on the
|
||||
platform host: every command is sent to a remote, disposable sandbox through the
|
||||
provider's official Python SDK.
|
||||
|
||||
Why the SDKs are optional extras
|
||||
--------------------------------
|
||||
``daytona`` and ``e2b`` are large, fast-moving dependencies. They are declared as
|
||||
the ``sandbox-remote`` extra so a default install stays small, and they are
|
||||
imported lazily inside :meth:`_load_sdk`. A missing extra produces an actionable
|
||||
``ProviderUnavailableError`` instead of an ``ImportError`` at startup.
|
||||
|
||||
Fail loudly, not silently
|
||||
-------------------------
|
||||
The two SDKs change their surface between minor releases. Rather than assume a
|
||||
method exists and fail halfway through a customer's build, :meth:`preflight`
|
||||
probes for every attribute this adapter relies on and refuses to construct the
|
||||
adapter if any is missing. A version drift therefore surfaces as a clear
|
||||
configuration error the moment ``SANDBOX_PROVIDER`` is switched on.
|
||||
|
||||
Known gap
|
||||
---------
|
||||
Provider-native snapshots (a resumable VM image) are **not** wired yet. Calling
|
||||
:meth:`snapshot` raises a clear error rather than pretending to have saved one.
|
||||
The mock provider implements snapshots fully, and the database already records
|
||||
them, so only the remote path is affected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.application.ports import CommandResult, SessionInfo, SnapshotInfo, WorkspaceInfo
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
|
||||
#: Never let a caller ask for an unbounded run.
|
||||
ABSOLUTE_MAX_TIMEOUT_SECONDS = 900
|
||||
#: Mirror of the platform output cap; enforced client-side as well because a
|
||||
#: remote SDK is under no obligation to truncate.
|
||||
ABSOLUTE_MAX_OUTPUT_BYTES = 2_000_000
|
||||
|
||||
|
||||
def _load_sdk(provider: str) -> Any:
|
||||
"""Import the provider SDK, or explain exactly how to install it."""
|
||||
try:
|
||||
if provider == "daytona":
|
||||
import daytona # noqa: PLC0415
|
||||
|
||||
return daytona
|
||||
if provider == "e2b":
|
||||
import e2b # noqa: PLC0415
|
||||
|
||||
return e2b
|
||||
except ImportError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"SDK «{provider}» نصب نیست. برای فعالسازی اجرا کنید: "
|
||||
f"pip install 'agy-web-api[sandbox-remote]'"
|
||||
) from exc
|
||||
raise ProviderUnavailableError(f"ارائهدهندهٔ sandbox «{provider}» پشتیبانی نمیشود.")
|
||||
|
||||
|
||||
def _require(obj: Any, attr: str, *, provider: str, where: str) -> Any:
|
||||
"""Return ``obj.attr`` or fail with a precise version-mismatch message."""
|
||||
if not hasattr(obj, attr):
|
||||
raise ProviderUnavailableError(
|
||||
f"SDK «{provider}» با این نسخه سازگار نیست: «{where}.{attr}» یافت نشد. "
|
||||
"نسخهٔ پشتیبانیشده را نصب کنید یا SANDBOX_PROVIDER=mock بگذارید."
|
||||
)
|
||||
return getattr(obj, attr)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> tuple[str, bool]:
|
||||
encoded = text.encode("utf-8", errors="replace")
|
||||
if len(encoded) <= limit:
|
||||
return text, False
|
||||
return encoded[:limit].decode("utf-8", errors="ignore"), True
|
||||
|
||||
|
||||
class _RemoteSandboxAdapter:
|
||||
"""Shared behaviour for both remote providers."""
|
||||
|
||||
provider_name = "remote"
|
||||
|
||||
def __init__(self, *, max_output_bytes: int = 200_000) -> None:
|
||||
self.max_output_bytes = min(max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
|
||||
self._client: Any = None
|
||||
self._sandboxes: dict[str, Any] = {}
|
||||
self._sessions: dict[str, str] = {}
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
async def _ensure_client(self) -> Any: # pragma: no cover - overridden
|
||||
raise NotImplementedError
|
||||
|
||||
async def create_workspace(
|
||||
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
||||
) -> WorkspaceInfo:
|
||||
client = await self._ensure_client()
|
||||
sandbox = await self._create_sandbox(client, image=image, template_id=template_id)
|
||||
external_id = str(
|
||||
_require(sandbox, "sandbox_id", provider=self.provider_name, where="sandbox")
|
||||
)
|
||||
self._sandboxes[external_id] = sandbox
|
||||
return WorkspaceInfo(
|
||||
external_id=external_id,
|
||||
status="ready",
|
||||
raw={"provider": self.provider_name, "image": image},
|
||||
)
|
||||
|
||||
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
|
||||
# Remote sandboxes are long-lived; a "session" is a handle onto one.
|
||||
external_id = f"ses_{workspace_external_id}"
|
||||
self._sessions[external_id] = workspace_external_id
|
||||
return SessionInfo(external_id=external_id, status="running")
|
||||
|
||||
async def _sandbox_for_session(self, session_external_id: str) -> Any:
|
||||
workspace_id = self._sessions.get(session_external_id, session_external_id)
|
||||
sandbox = self._sandboxes.get(workspace_id)
|
||||
if sandbox is None:
|
||||
raise ProviderUnavailableError("نشست sandbox یافت نشد یا منقضی شده است.")
|
||||
return sandbox
|
||||
|
||||
# -- execution -----------------------------------------------------------
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
*,
|
||||
session_external_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> CommandResult:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
limit = min(max_output_bytes or self.max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
|
||||
# Clamp here too: the port contract says the caller can never ask for
|
||||
# "no limit", and a bug upstream must not become an unbounded remote run.
|
||||
timeout = max(1, min(int(timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS))
|
||||
|
||||
started = time.perf_counter()
|
||||
raw = await self._exec(sandbox, command=command, timeout=timeout, env=env or {})
|
||||
duration_ms = max(1, int((time.perf_counter() - started) * 1000))
|
||||
|
||||
# Pull the three fields out defensively; a missing one is a version
|
||||
# mismatch and must fail with a precise message.
|
||||
exit_code = _require(
|
||||
raw, "exit_code", provider=self.provider_name, where="command result"
|
||||
)
|
||||
raw_stdout = _require(
|
||||
raw, "stdout", provider=self.provider_name, where="command result"
|
||||
)
|
||||
raw_stderr = _require(
|
||||
raw, "stderr", provider=self.provider_name, where="command result"
|
||||
)
|
||||
|
||||
stdout, cut_out = _truncate(str(raw_stdout or ""), limit)
|
||||
stderr, cut_err = _truncate(str(raw_stderr or ""), limit)
|
||||
|
||||
return CommandResult(
|
||||
exit_code=int(exit_code or 0),
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
duration_ms=duration_ms,
|
||||
truncated=cut_out or cut_err,
|
||||
)
|
||||
|
||||
# -- files ---------------------------------------------------------------
|
||||
|
||||
async def read_file(self, *, session_external_id: str, path: str) -> str:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
return await self._read(sandbox, path)
|
||||
|
||||
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
await self._write(sandbox, path, content)
|
||||
|
||||
# -- snapshots / preview -------------------------------------------------
|
||||
|
||||
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
|
||||
raise ProviderUnavailableError(
|
||||
"snapshot بومی ارائهدهندهٔ راهدور هنوز فعال نشده است. "
|
||||
"برای snapshot از SANDBOX_PROVIDER=mock استفاده کنید یا این قابلیت را "
|
||||
"برای ارائهدهندهٔ خود پیادهسازی کنید."
|
||||
)
|
||||
|
||||
async def pause(self, *, session_external_id: str) -> None:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
await self._pause(sandbox)
|
||||
|
||||
async def resume(self, *, session_external_id: str) -> SessionInfo:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
await self._resume(sandbox)
|
||||
return SessionInfo(external_id=session_external_id, status="running")
|
||||
|
||||
async def destroy(self, *, session_external_id: str) -> None:
|
||||
workspace_id = self._sessions.pop(session_external_id, session_external_id)
|
||||
sandbox = self._sandboxes.pop(workspace_id, None)
|
||||
if sandbox is not None:
|
||||
await self._destroy(sandbox)
|
||||
|
||||
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
return await self._preview_url(sandbox, port)
|
||||
|
||||
# -- provider hooks ------------------------------------------------------
|
||||
|
||||
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _read(self, sandbox: Any, path: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _pause(self, sandbox: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _resume(self, sandbox: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _destroy(self, sandbox: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DaytonaSandboxAdapter(_RemoteSandboxAdapter):
|
||||
"""Daytona Cloud sandboxes via the async SDK.
|
||||
|
||||
Targets the documented surface: ``AsyncDaytona(DaytonaConfig(...))``,
|
||||
``client.create()``, ``sandbox.process.exec(...)``, ``sandbox.fs``,
|
||||
``client.delete(sandbox)``.
|
||||
"""
|
||||
|
||||
provider_name = "daytona"
|
||||
|
||||
def __init__(
|
||||
self, *, api_key: str, api_url: str = "", max_output_bytes: int = 200_000
|
||||
) -> None:
|
||||
super().__init__(max_output_bytes=max_output_bytes)
|
||||
if not api_key:
|
||||
raise ProviderUnavailableError(
|
||||
"SANDBOX_PROVIDER=daytona نیازمند تنظیم DAYTONA_API_KEY است."
|
||||
)
|
||||
self._api_key = api_key
|
||||
self._api_url = api_url
|
||||
self._sdk = _load_sdk("daytona")
|
||||
self._preflight()
|
||||
|
||||
def _preflight(self) -> None:
|
||||
_require(self._sdk, "AsyncDaytona", provider="daytona", where="daytona")
|
||||
_require(self._sdk, "DaytonaConfig", provider="daytona", where="daytona")
|
||||
|
||||
async def _ensure_client(self) -> Any:
|
||||
if self._client is None:
|
||||
config_kwargs: dict[str, Any] = {"api_key": self._api_key}
|
||||
if self._api_url:
|
||||
config_kwargs["api_url"] = self._api_url
|
||||
config = self._sdk.DaytonaConfig(**config_kwargs)
|
||||
self._client = self._sdk.AsyncDaytona(config)
|
||||
return self._client
|
||||
|
||||
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||
# `create` accepts no arguments on the default image; a custom image is
|
||||
# passed positionally as a snapshot/image reference.
|
||||
try:
|
||||
return await client.create()
|
||||
except TypeError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
"ساخت sandbox در Daytona با این نسخهٔ SDK سازگار نیست: "
|
||||
f"{exc}. نسخهٔ پشتیبانیشدهٔ daytona را نصب کنید."
|
||||
) from exc
|
||||
|
||||
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||
process = _require(sandbox, "process", provider="daytona", where="sandbox")
|
||||
exec_fn = _require(process, "exec", provider="daytona", where="sandbox.process")
|
||||
response = await exec_fn(command, timeout=timeout)
|
||||
# Daytona reports the exit code separately from the combined output.
|
||||
result = str(
|
||||
_require(response, "result", provider="daytona", where="process response") or ""
|
||||
)
|
||||
return _DaytonaCommandOutput(
|
||||
exit_code=int(getattr(response, "exit_code", 0) or 0),
|
||||
stdout=result,
|
||||
stderr="",
|
||||
)
|
||||
|
||||
async def _read(self, sandbox: Any, path: str) -> str:
|
||||
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
|
||||
read_fn = _require(fs, "download_file", provider="daytona", where="sandbox.fs")
|
||||
content = await read_fn(path)
|
||||
if isinstance(content, bytes):
|
||||
return content.decode("utf-8", errors="replace")
|
||||
return str(content or "")
|
||||
|
||||
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
|
||||
write_fn = _require(fs, "upload_file", provider="daytona", where="sandbox.fs")
|
||||
await write_fn(content.encode("utf-8"), path)
|
||||
|
||||
async def _pause(self, sandbox: Any) -> None:
|
||||
stop_fn = _require(sandbox, "stop", provider="daytona", where="sandbox")
|
||||
await stop_fn()
|
||||
|
||||
async def _resume(self, sandbox: Any) -> None:
|
||||
start_fn = _require(sandbox, "start", provider="daytona", where="sandbox")
|
||||
await start_fn()
|
||||
|
||||
async def _destroy(self, sandbox: Any) -> None:
|
||||
client = await self._ensure_client()
|
||||
delete_fn = _require(client, "delete", provider="daytona", where="AsyncDaytona")
|
||||
await delete_fn(sandbox)
|
||||
|
||||
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||
link_fn = _require(sandbox, "get_preview_link", provider="daytona", where="sandbox")
|
||||
link = await link_fn(port)
|
||||
return str(getattr(link, "url", link))
|
||||
|
||||
|
||||
class E2BSandboxAdapter(_RemoteSandboxAdapter):
|
||||
"""E2B sandboxes via the async SDK.
|
||||
|
||||
Targets the documented surface: ``AsyncSandbox.create(...)``,
|
||||
``sandbox.commands.run(...)``, ``sandbox.files.read/write``,
|
||||
``sandbox.kill()``.
|
||||
"""
|
||||
|
||||
provider_name = "e2b"
|
||||
|
||||
def __init__(self, *, api_key: str, max_output_bytes: int = 200_000) -> None:
|
||||
super().__init__(max_output_bytes=max_output_bytes)
|
||||
if not api_key:
|
||||
raise ProviderUnavailableError(
|
||||
"SANDBOX_PROVIDER=e2b نیازمند تنظیم E2B_API_KEY است."
|
||||
)
|
||||
self._api_key = api_key
|
||||
self._sdk = _load_sdk("e2b")
|
||||
self._preflight()
|
||||
|
||||
def _preflight(self) -> None:
|
||||
_require(self._sdk, "AsyncSandbox", provider="e2b", where="e2b")
|
||||
|
||||
async def _ensure_client(self) -> Any:
|
||||
# E2B has no long-lived client object; the API key is passed per call.
|
||||
return self._sdk.AsyncSandbox
|
||||
|
||||
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||
create_fn = _require(client, "create", provider="e2b", where="AsyncSandbox")
|
||||
return await create_fn(api_key=self._api_key)
|
||||
|
||||
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||
commands = _require(sandbox, "commands", provider="e2b", where="sandbox")
|
||||
run_fn = _require(commands, "run", provider="e2b", where="sandbox.commands")
|
||||
result = await run_fn(command, timeout=timeout, envs=env or None)
|
||||
return _E2BCommandOutput(
|
||||
exit_code=int(getattr(result, "exit_code", 0) or 0),
|
||||
stdout=str(getattr(result, "stdout", "") or ""),
|
||||
stderr=str(getattr(result, "stderr", "") or ""),
|
||||
)
|
||||
|
||||
async def _read(self, sandbox: Any, path: str) -> str:
|
||||
files = _require(sandbox, "files", provider="e2b", where="sandbox")
|
||||
read_fn = _require(files, "read", provider="e2b", where="sandbox.files")
|
||||
content = await read_fn(path)
|
||||
if isinstance(content, bytes):
|
||||
return content.decode("utf-8", errors="replace")
|
||||
return str(content or "")
|
||||
|
||||
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||
files = _require(sandbox, "files", provider="e2b", where="sandbox")
|
||||
write_fn = _require(files, "write", provider="e2b", where="sandbox.files")
|
||||
await write_fn(path, content)
|
||||
|
||||
async def _pause(self, sandbox: Any) -> None:
|
||||
pause_fn = _require(sandbox, "pause", provider="e2b", where="sandbox")
|
||||
await pause_fn()
|
||||
|
||||
async def _resume(self, sandbox: Any) -> None:
|
||||
# E2B resumes by reconnecting to the sandbox id.
|
||||
connect_fn = _require(
|
||||
self._sdk.AsyncSandbox, "connect", provider="e2b", where="AsyncSandbox"
|
||||
)
|
||||
await connect_fn(sandbox.sandbox_id, api_key=self._api_key)
|
||||
|
||||
async def _destroy(self, sandbox: Any) -> None:
|
||||
kill_fn = _require(sandbox, "kill", provider="e2b", where="sandbox")
|
||||
await kill_fn()
|
||||
|
||||
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||
host_fn = _require(sandbox, "get_host", provider="e2b", where="sandbox")
|
||||
return f"https://{await host_fn(port)}"
|
||||
|
||||
|
||||
class _DaytonaCommandOutput:
|
||||
__slots__ = ("exit_code", "stdout", "stderr")
|
||||
|
||||
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
|
||||
self.exit_code = exit_code
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
class _E2BCommandOutput:
|
||||
__slots__ = ("exit_code", "stdout", "stderr")
|
||||
|
||||
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
|
||||
self.exit_code = exit_code
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def build_remote_sandbox_adapter(
|
||||
provider: str, *, daytona_api_key: str = "", daytona_api_url: str = "", e2b_api_key: str = ""
|
||||
):
|
||||
"""Factory used by the adapter registry."""
|
||||
normalised = (provider or "").strip().lower()
|
||||
if normalised == "daytona":
|
||||
return DaytonaSandboxAdapter(api_key=daytona_api_key, api_url=daytona_api_url)
|
||||
if normalised == "e2b":
|
||||
return E2BSandboxAdapter(api_key=e2b_api_key)
|
||||
raise ProviderUnavailableError(
|
||||
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | daytona | e2b"
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Stripe adapter for :class:`PaymentProviderPort`.
|
||||
|
||||
Uses Stripe's REST API directly over ``httpx`` rather than the official SDK: the
|
||||
two calls we need are stable, and it keeps the API process free of an extra
|
||||
dependency and its release cadence.
|
||||
|
||||
Responsibility boundary
|
||||
-----------------------
|
||||
The internal usage ledger is the **source of truth** for what a tenant owes.
|
||||
This adapter only moves money — it never decides a balance, and a successful
|
||||
checkout does not by itself credit a wallet. The application layer settles the
|
||||
payment and then writes the credit, so a webhook replayed twice cannot
|
||||
double-credit.
|
||||
|
||||
Amounts are converted to Stripe's minor units (cents) with ``Decimal`` rounding,
|
||||
never float multiplication, so ``19.99`` cannot become ``1998``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.ports import CheckoutSession, PaymentResult
|
||||
from app.core.errors import ProviderUnavailableError, ValidationError
|
||||
|
||||
STRIPE_API_BASE = "https://api.stripe.com/v1"
|
||||
DEFAULT_TIMEOUT_SECONDS = 20.0
|
||||
#: Stripe rejects signatures older than this; mirrored locally to fail early.
|
||||
WEBHOOK_TOLERANCE_SECONDS = 300
|
||||
#: Card networks require a minimum charge. Refuse early with a clear message
|
||||
#: rather than letting Stripe return a confusing error.
|
||||
MINIMUM_CHARGE_USD = Decimal("0.50")
|
||||
|
||||
|
||||
class StripePaymentAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
secret_key: str,
|
||||
webhook_secret: str = "",
|
||||
api_base: str = STRIPE_API_BASE,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
if not secret_key:
|
||||
raise ProviderUnavailableError(
|
||||
"PAYMENT_PROVIDER=stripe نیازمند تنظیم STRIPE_SECRET_KEY است."
|
||||
)
|
||||
self._secret_key = secret_key
|
||||
self._webhook_secret = webhook_secret
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
# -- port ----------------------------------------------------------------
|
||||
|
||||
async def create_checkout(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
amount: float,
|
||||
currency: str,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
) -> CheckoutSession:
|
||||
"""Create a one-off Checkout Session for a wallet top-up."""
|
||||
minor_units = self._to_minor_units(amount)
|
||||
currency_code = (currency or "usd").strip().lower()
|
||||
|
||||
form = {
|
||||
"mode": "payment",
|
||||
"success_url": success_url,
|
||||
"cancel_url": cancel_url,
|
||||
# Ties the Stripe object back to our tenant without exposing an id
|
||||
# the customer can edit meaningfully.
|
||||
"client_reference_id": organization_id,
|
||||
"metadata[organization_id]": organization_id,
|
||||
"line_items[0][quantity]": "1",
|
||||
"line_items[0][price_data][currency]": currency_code,
|
||||
"line_items[0][price_data][unit_amount]": str(minor_units),
|
||||
"line_items[0][price_data][product_data][name]": "AGY credit top-up",
|
||||
}
|
||||
|
||||
payload = await self._request("POST", "/checkout/sessions", data=form)
|
||||
url = payload.get("url")
|
||||
if not url:
|
||||
raise ProviderUnavailableError("Stripe آدرس پرداخت را برنگرداند.")
|
||||
|
||||
return CheckoutSession(
|
||||
external_id=payload.get("id", ""),
|
||||
url=url,
|
||||
# Report back in major units so the caller never sees minor units.
|
||||
amount=float(Decimal(payload.get("amount_total") or minor_units) / 100),
|
||||
currency=(payload.get("currency") or currency_code).upper(),
|
||||
)
|
||||
|
||||
async def refund(
|
||||
self, *, external_payment_id: str, amount: float | None = None
|
||||
) -> PaymentResult:
|
||||
if not external_payment_id:
|
||||
raise ValidationError("شناسهٔ پرداخت برای بازگشت وجه لازم است.")
|
||||
|
||||
form: dict[str, str] = {}
|
||||
# A Checkout Session is not refundable directly; its PaymentIntent is.
|
||||
# Accept either so a caller does not have to know Stripe's object graph.
|
||||
if external_payment_id.startswith("cs_"):
|
||||
session = await self._request(
|
||||
"GET", f"/checkout/sessions/{external_payment_id}", params={}
|
||||
)
|
||||
intent = session.get("payment_intent")
|
||||
if not intent:
|
||||
return PaymentResult(
|
||||
ok=False,
|
||||
external_id=external_payment_id,
|
||||
message="این پرداخت هنوز تکمیل نشده است.",
|
||||
)
|
||||
form["payment_intent"] = str(intent)
|
||||
else:
|
||||
form["payment_intent"] = external_payment_id
|
||||
|
||||
if amount is not None:
|
||||
form["amount"] = str(self._to_minor_units(amount))
|
||||
|
||||
payload = await self._request("POST", "/refunds", data=form)
|
||||
return PaymentResult(
|
||||
ok=payload.get("status") in {"succeeded", "pending"},
|
||||
external_id=payload.get("id", ""),
|
||||
message=f"وضعیت بازگشت وجه: {payload.get('status', 'unknown')}",
|
||||
)
|
||||
|
||||
# -- webhook -------------------------------------------------------------
|
||||
|
||||
def verify_webhook_signature(self, *, payload: bytes, signature_header: str) -> bool:
|
||||
"""Verify a ``Stripe-Signature`` header over the raw request body.
|
||||
|
||||
The body must be the *raw bytes* — re-serialising parsed JSON changes the
|
||||
byte sequence and the HMAC will never match.
|
||||
"""
|
||||
if not self._webhook_secret:
|
||||
return False
|
||||
|
||||
timestamp = ""
|
||||
signatures: list[str] = []
|
||||
for part in (signature_header or "").split(","):
|
||||
key, _, value = part.partition("=")
|
||||
if key.strip() == "t":
|
||||
timestamp = value.strip()
|
||||
elif key.strip() == "v1":
|
||||
signatures.append(value.strip())
|
||||
|
||||
if not timestamp or not signatures:
|
||||
return False
|
||||
try:
|
||||
age = abs(time.time() - int(timestamp))
|
||||
except ValueError:
|
||||
return False
|
||||
if age > WEBHOOK_TOLERANCE_SECONDS:
|
||||
# Replay protection.
|
||||
return False
|
||||
|
||||
signed_payload = f"{timestamp}.".encode() + payload
|
||||
expected = hmac.new(
|
||||
self._webhook_secret.encode(), signed_payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
# Constant-time compare against every offered signature.
|
||||
return any(hmac.compare_digest(expected, candidate) for candidate in signatures)
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _to_minor_units(amount: float) -> int:
|
||||
value = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if value <= 0:
|
||||
raise ValidationError("مبلغ باید بزرگتر از صفر باشد.")
|
||||
if value < MINIMUM_CHARGE_USD:
|
||||
raise ValidationError(f"حداقل مبلغ پرداخت {MINIMUM_CHARGE_USD} دلار است.")
|
||||
return int(value * 100)
|
||||
|
||||
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||
headers = {"Authorization": f"Bearer {self._secret_key}"}
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.api_base, timeout=self.timeout_seconds
|
||||
) as client:
|
||||
response = await client.request(method, path, headers=headers, **kwargs)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Stripe زمانبر شد.") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProviderUnavailableError("ارتباط با Stripe برقرار نشد.") from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
detail = ""
|
||||
try:
|
||||
detail = (response.json().get("error") or {}).get("message", "")
|
||||
except ValueError:
|
||||
detail = response.text[:200]
|
||||
raise ProviderUnavailableError(
|
||||
f"Stripe خطا برگرداند ({response.status_code}): {detail}"
|
||||
)
|
||||
return response.json()
|
||||
Reference in New Issue
Block a user