نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,454 @@
|
||||
"""Sandbox Gateway use cases.
|
||||
|
||||
The security posture is the whole point of this module:
|
||||
|
||||
* **No host execution path.** The only way to run anything is through
|
||||
``SandboxProviderPort``. There is no fallback that shells out locally.
|
||||
* **Limits come from policy, never from the caller.** A request cannot ask for a
|
||||
longer timeout, more memory, or a bigger output cap than its scope allows.
|
||||
* **Egress is deny-by-default.** The stored ``network_policy`` defaults to
|
||||
``deny_all``; nothing here opens it implicitly.
|
||||
* **Every command is metered.** CPU seconds are written to the usage ledger, so
|
||||
sandbox time is billable and auditable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from datetime import datetime
|
||||
|
||||
from app.application.ports import CommandResult, SandboxProviderPort, UsageRecord
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ConflictError, NotFoundError, ProviderUnavailableError, ValidationError
|
||||
from app.domain.ai_control.models import RateUnit
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.sandbox.models import (
|
||||
CommandRun,
|
||||
NetworkPolicy,
|
||||
PreviewUrl,
|
||||
SandboxProviderKind,
|
||||
SandboxResourceLimit,
|
||||
SandboxSession,
|
||||
SessionStatus,
|
||||
Snapshot,
|
||||
Workspace,
|
||||
WorkspaceStatus,
|
||||
)
|
||||
from app.infrastructure.repositories.ai_control import RateCardRepository
|
||||
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||
from app.infrastructure.repositories.projects import ProjectRepository
|
||||
from app.infrastructure.repositories.sandbox import (
|
||||
CommandRunRepository,
|
||||
PreviewUrlRepository,
|
||||
ResourceLimitRepository,
|
||||
SandboxSessionRepository,
|
||||
SandboxTemplateRepository,
|
||||
SnapshotRepository,
|
||||
WorkspaceRepository,
|
||||
)
|
||||
|
||||
#: Hard ceiling, independent of policy. A policy may be lower, never higher.
|
||||
ABSOLUTE_MAX_TIMEOUT_SECONDS = 900
|
||||
ABSOLUTE_MAX_OUTPUT_BYTES = 2_000_000
|
||||
MAX_COMMAND_LENGTH = 4_000
|
||||
|
||||
|
||||
class SandboxService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
templates: SandboxTemplateRepository,
|
||||
workspaces: WorkspaceRepository,
|
||||
sessions: SandboxSessionRepository,
|
||||
commands: CommandRunRepository,
|
||||
snapshots: SnapshotRepository,
|
||||
previews: PreviewUrlRepository,
|
||||
limits: ResourceLimitRepository,
|
||||
rate_cards: RateCardRepository,
|
||||
ledger: UsageLedgerRepository,
|
||||
projects: ProjectRepository,
|
||||
provider: SandboxProviderPort,
|
||||
) -> None:
|
||||
self.templates = templates
|
||||
self.workspaces = workspaces
|
||||
self.sessions = sessions
|
||||
self.commands = commands
|
||||
self.snapshots = snapshots
|
||||
self.previews = previews
|
||||
self.limits = limits
|
||||
self.rate_cards = rate_cards
|
||||
self.ledger = ledger
|
||||
self.projects = projects
|
||||
self.provider = provider
|
||||
|
||||
# -- limits --------------------------------------------------------------
|
||||
|
||||
async def effective_limits(
|
||||
self, *, organization_id: str, project_id: str | None = None
|
||||
) -> SandboxResourceLimit:
|
||||
"""Resolve the strictest applicable policy.
|
||||
|
||||
Project scope wins over organization scope; the absolute ceiling always
|
||||
wins over both.
|
||||
"""
|
||||
if project_id:
|
||||
scoped = await self.limits.get_for_scope("project", project_id)
|
||||
if scoped is not None:
|
||||
return self._clamp(scoped)
|
||||
|
||||
org_scoped = await self.limits.get_for_scope("organization", organization_id)
|
||||
if org_scoped is not None:
|
||||
return self._clamp(org_scoped)
|
||||
|
||||
return self._clamp(
|
||||
SandboxResourceLimit(
|
||||
scope="platform",
|
||||
scope_id=None,
|
||||
timeout_seconds=settings.sandbox_command_timeout_seconds,
|
||||
max_output_bytes=settings.sandbox_max_output_bytes,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clamp(limit: SandboxResourceLimit) -> SandboxResourceLimit:
|
||||
limit.timeout_seconds = max(
|
||||
1, min(int(limit.timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS)
|
||||
)
|
||||
limit.max_output_bytes = max(
|
||||
1024, min(int(limit.max_output_bytes), ABSOLUTE_MAX_OUTPUT_BYTES)
|
||||
)
|
||||
limit.memory_mb = max(128, int(limit.memory_mb))
|
||||
limit.disk_mb = max(256, int(limit.disk_mb))
|
||||
return limit
|
||||
|
||||
# -- templates -----------------------------------------------------------
|
||||
|
||||
async def list_templates(self):
|
||||
return await self.templates.list()
|
||||
|
||||
# -- workspaces ----------------------------------------------------------
|
||||
|
||||
async def create_workspace(
|
||||
self, *, organization_id: str, project_id: str, template_id: str
|
||||
) -> Workspace:
|
||||
template = await self.templates.get(template_id)
|
||||
if template is None:
|
||||
raise NotFoundError("قالب سندباکس یافت نشد.")
|
||||
|
||||
provider_kind = str(template.provider) or SandboxProviderKind.MOCK
|
||||
try:
|
||||
info = await self.provider.create_workspace(
|
||||
organization_id=organization_id,
|
||||
project_id=project_id,
|
||||
template_id=template_id,
|
||||
image=template.image,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - surface as a provider outage
|
||||
raise ProviderUnavailableError(
|
||||
"ساخت محیط اجرای ایزوله ناموفق بود."
|
||||
) from exc
|
||||
|
||||
workspace = await self.workspaces.create(
|
||||
Workspace(
|
||||
organization_id=organization_id,
|
||||
project_id=project_id,
|
||||
template_id=template_id,
|
||||
provider=provider_kind,
|
||||
external_workspace_id=info.external_id,
|
||||
status=WorkspaceStatus.READY,
|
||||
)
|
||||
)
|
||||
return workspace
|
||||
|
||||
async def list_workspaces(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
project_id: str,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[Workspace]:
|
||||
"""Workspaces for a project the caller's tenant actually owns."""
|
||||
project = await self.projects.get(project_id)
|
||||
# 404 rather than an empty list, so a foreign project id is
|
||||
# indistinguishable from a non-existent one.
|
||||
if project is None or project.organization_id != organization_id:
|
||||
raise NotFoundError("پروژه یافت نشد.")
|
||||
return await self.workspaces.list_for_project(
|
||||
project_id, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
async def get_workspace_or_404(
|
||||
self, *, organization_id: str, workspace_id: str
|
||||
) -> Workspace:
|
||||
workspace = await self.workspaces.get_for_org(organization_id, workspace_id)
|
||||
if workspace is None:
|
||||
raise NotFoundError("محیط اجرا یافت نشد.")
|
||||
return workspace
|
||||
|
||||
# -- sessions ------------------------------------------------------------
|
||||
|
||||
async def start_session(
|
||||
self, *, organization_id: str, workspace_id: str
|
||||
) -> SandboxSession:
|
||||
workspace = await self.get_workspace_or_404(
|
||||
organization_id=organization_id, workspace_id=workspace_id
|
||||
)
|
||||
if workspace.status == WorkspaceStatus.DESTROYED:
|
||||
raise ConflictError("این محیط اجرا از بین رفته است.")
|
||||
if not workspace.external_workspace_id:
|
||||
raise ProviderUnavailableError("محیط اجرا در ارائهدهنده ثبت نشده است.")
|
||||
|
||||
info = await self.provider.start_session(
|
||||
workspace_external_id=workspace.external_workspace_id
|
||||
)
|
||||
session = await self.sessions.create(
|
||||
SandboxSession(
|
||||
workspace_id=workspace.id,
|
||||
external_session_id=info.external_id,
|
||||
status=SessionStatus.RUNNING,
|
||||
)
|
||||
)
|
||||
await self.workspaces.set_status(workspace.id, WorkspaceStatus.READY)
|
||||
return session
|
||||
|
||||
async def get_session_or_404(
|
||||
self, *, organization_id: str, session_id: str
|
||||
) -> SandboxSession:
|
||||
session = await self.sessions.get_for_org(organization_id, session_id)
|
||||
if session is None:
|
||||
raise NotFoundError("نشست اجرا یافت نشد.")
|
||||
return session
|
||||
|
||||
async def stop_session(self, *, organization_id: str, session_id: str) -> SandboxSession:
|
||||
session = await self.get_session_or_404(
|
||||
organization_id=organization_id, session_id=session_id
|
||||
)
|
||||
if not session.external_session_id:
|
||||
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||
await self.provider.destroy(session_external_id=session.external_session_id)
|
||||
await self.sessions.set_status(session.id, SessionStatus.STOPPED, stopped=True)
|
||||
refreshed = await self.sessions.get(session.id)
|
||||
return refreshed or session
|
||||
|
||||
# -- commands ------------------------------------------------------------
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
session_id: str,
|
||||
command: str,
|
||||
user_id: str | None = None,
|
||||
) -> tuple[CommandRun, CommandResult]:
|
||||
session = await self.get_session_or_404(
|
||||
organization_id=organization_id, session_id=session_id
|
||||
)
|
||||
if not session.external_session_id:
|
||||
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||
if session.status != SessionStatus.RUNNING:
|
||||
raise ConflictError("نشست در وضعیت اجرا نیست.")
|
||||
|
||||
cleaned = command.strip()
|
||||
if not cleaned:
|
||||
raise ValidationError("دستور نمیتواند خالی باشد.")
|
||||
if len(cleaned) > MAX_COMMAND_LENGTH:
|
||||
raise ValidationError("دستور بیش از حد طولانی است.")
|
||||
|
||||
workspace = await self.workspaces.get_for_org(
|
||||
organization_id, session.workspace_id
|
||||
)
|
||||
limits = await self.effective_limits(
|
||||
organization_id=organization_id,
|
||||
project_id=workspace.project_id if workspace else None,
|
||||
)
|
||||
|
||||
result = await self.provider.run_command(
|
||||
session_external_id=session.external_session_id,
|
||||
command=cleaned,
|
||||
# Timeout and output cap are decided here, not by the caller.
|
||||
timeout_seconds=limits.timeout_seconds,
|
||||
max_output_bytes=limits.max_output_bytes,
|
||||
env={},
|
||||
)
|
||||
|
||||
run = await self.commands.create(
|
||||
CommandRun(
|
||||
session_id=session.id,
|
||||
command=cleaned,
|
||||
exit_code=result.exit_code,
|
||||
stdout_ref=result.stdout[: limits.max_output_bytes],
|
||||
stderr_ref=result.stderr[: limits.max_output_bytes],
|
||||
duration_ms=result.duration_ms,
|
||||
truncated=result.truncated,
|
||||
)
|
||||
)
|
||||
await self.sessions.set_status(session.id, SessionStatus.RUNNING)
|
||||
|
||||
# CPU seconds are billed. 1 second of wall time ~= 1 CPU second at MVP.
|
||||
cpu_seconds = max(0.0, result.duration_ms / 1000.0)
|
||||
if cpu_seconds > 0:
|
||||
card = await self.rate_cards.get_by_unit(RateUnit.SANDBOX_CPU_SECOND)
|
||||
unit_price = card.customer_price if card else 0.0
|
||||
internal_cost = card.internal_cost_estimate if card else 0.0
|
||||
await self.ledger.record(
|
||||
usage=UsageRecord(
|
||||
organization_id=organization_id,
|
||||
project_id=workspace.project_id if workspace else None,
|
||||
user_id=user_id,
|
||||
unit=RateUnit.SANDBOX_CPU_SECOND,
|
||||
quantity=cpu_seconds,
|
||||
unit_price=unit_price,
|
||||
internal_cost_estimate=cpu_seconds * internal_cost,
|
||||
total_price=cpu_seconds * unit_price,
|
||||
metadata_json={"session_id": session.id, "exit_code": result.exit_code},
|
||||
)
|
||||
)
|
||||
|
||||
return run, result
|
||||
|
||||
async def list_commands(self, *, session_id: str) -> list[CommandRun]:
|
||||
return await self.commands.list_for_session(session_id)
|
||||
|
||||
# -- files ---------------------------------------------------------------
|
||||
|
||||
async def read_file(
|
||||
self, *, organization_id: str, session_id: str, path: str
|
||||
) -> str:
|
||||
session = await self.get_session_or_404(
|
||||
organization_id=organization_id, session_id=session_id
|
||||
)
|
||||
if not session.external_session_id:
|
||||
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||
return await self.provider.read_file(
|
||||
session_external_id=session.external_session_id, path=path
|
||||
)
|
||||
|
||||
async def write_file(
|
||||
self, *, organization_id: str, session_id: str, path: str, content: str
|
||||
) -> None:
|
||||
session = await self.get_session_or_404(
|
||||
organization_id=organization_id, session_id=session_id
|
||||
)
|
||||
if not session.external_session_id:
|
||||
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||
await self.provider.write_file(
|
||||
session_external_id=session.external_session_id, path=path, content=content
|
||||
)
|
||||
|
||||
# -- snapshots & previews ------------------------------------------------
|
||||
|
||||
async def snapshot(
|
||||
self, *, organization_id: str, session_id: str, name: str
|
||||
) -> Snapshot:
|
||||
session = await self.get_session_or_404(
|
||||
organization_id=organization_id, session_id=session_id
|
||||
)
|
||||
if not session.external_session_id:
|
||||
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||
info = await self.provider.snapshot(
|
||||
session_external_id=session.external_session_id, name=name
|
||||
)
|
||||
return await self.snapshots.create(
|
||||
Snapshot(
|
||||
workspace_id=session.workspace_id, name=name,
|
||||
external_snapshot_id=info.external_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def get_preview_url(
|
||||
self, *, organization_id: str, session_id: str, port: int
|
||||
) -> PreviewUrl:
|
||||
session = await self.get_session_or_404(
|
||||
organization_id=organization_id, session_id=session_id
|
||||
)
|
||||
if not session.external_session_id:
|
||||
raise ProviderUnavailableError("نشست در ارائهدهنده ثبت نشده است.")
|
||||
url = await self.provider.get_preview_url(
|
||||
session_external_id=session.external_session_id, port=port
|
||||
)
|
||||
return await self.previews.create(
|
||||
PreviewUrl(workspace_id=session.workspace_id, port=port, url=url)
|
||||
)
|
||||
|
||||
# -- policy administration ----------------------------------------------
|
||||
|
||||
async def set_limits(
|
||||
self,
|
||||
*,
|
||||
scope: str,
|
||||
scope_id: str | None,
|
||||
cpu: float,
|
||||
memory_mb: int,
|
||||
disk_mb: int,
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int,
|
||||
network_policy: NetworkPolicy,
|
||||
) -> SandboxResourceLimit:
|
||||
if scope not in ("organization", "project", "platform"):
|
||||
raise ValidationError("دامنهٔ محدودیت نامعتبر است.")
|
||||
return await self.limits.upsert(
|
||||
self._clamp(
|
||||
SandboxResourceLimit(
|
||||
scope=scope, scope_id=scope_id, cpu=cpu, memory_mb=memory_mb,
|
||||
disk_mb=disk_mb, timeout_seconds=timeout_seconds,
|
||||
max_output_bytes=max_output_bytes, network_policy=network_policy,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def destroy_workspace(
|
||||
self, *, organization_id: str, workspace_id: str
|
||||
) -> Workspace:
|
||||
workspace = await self.get_workspace_or_404(
|
||||
organization_id=organization_id, workspace_id=workspace_id
|
||||
)
|
||||
for session in await self.sessions.list_for_workspace(workspace.id):
|
||||
if session.external_session_id:
|
||||
# Teardown is best-effort: a remote sandbox that is already gone
|
||||
# must not block us from marking the local row stopped.
|
||||
with contextlib.suppress(Exception):
|
||||
await self.provider.destroy(
|
||||
session_external_id=session.external_session_id
|
||||
)
|
||||
await self.sessions.set_status(
|
||||
session.id, SessionStatus.STOPPED, stopped=True
|
||||
)
|
||||
await self.workspaces.set_status(workspace.id, WorkspaceStatus.DESTROYED)
|
||||
refreshed = await self.workspaces.get_for_org(organization_id, workspace_id)
|
||||
return refreshed or workspace
|
||||
|
||||
@staticmethod
|
||||
def now():
|
||||
return utcnow()
|
||||
|
||||
# -- platform maintenance ------------------------------------------------
|
||||
|
||||
async def reap_idle_sessions(
|
||||
self, *, idle_before: datetime, limit: int = 200
|
||||
) -> list[SandboxSession]:
|
||||
"""Stop sandbox sessions untouched since ``idle_before``.
|
||||
|
||||
**Deliberately not tenant-scoped.** This is the gateway's own housekeeping
|
||||
and spans every organization, so it is never reachable from a
|
||||
tenant-scoped route — only from the sandbox-gateway worker. Callers are
|
||||
responsible for the audit trail.
|
||||
|
||||
A provider that has already forgotten the session must not keep it
|
||||
"active" in our own record forever, so the remote teardown is
|
||||
best-effort and the local row is always marked stopped.
|
||||
"""
|
||||
stopped: list[SandboxSession] = []
|
||||
stale = await self.sessions.list_stale_active(
|
||||
idle_before=idle_before, limit=limit
|
||||
)
|
||||
for session in stale:
|
||||
if session.external_session_id:
|
||||
with contextlib.suppress(Exception):
|
||||
await self.provider.destroy(
|
||||
session_external_id=session.external_session_id
|
||||
)
|
||||
await self.sessions.set_status(session.id, SessionStatus.STOPPED, stopped=True)
|
||||
refreshed = await self.sessions.get(session.id)
|
||||
stopped.append(refreshed or session)
|
||||
return stopped
|
||||
Reference in New Issue
Block a user