Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
mjalmoudy94
2026-10-14 12:08:23 +03:30
13 changed files with 3485 additions and 71 deletions
@@ -0,0 +1,596 @@
"""Organization creation wizard — from zero to a running app in one transaction.
The wizard is the *only* path that creates a project. It guarantees the 1-org-1-app
rule by checking before insertion and by relying on the DB unique constraint.
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from urllib.parse import urlsplit
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.delivery_hooks import DeliveryHooks
from app.application.ports.deploy_target import DeployCheck, DeployTargetSpec, DeployTargetTester
from app.application.ports.git_provider import GitProviderPort
from app.application.ports.repo_workspace import RemoteCheck, RemoteCredentials, RepoWorkspacePort
from app.application.ports.workforce_bootstrap import WorkforceBootstrapPort
from app.core.crypto import encrypt
from app.core.errors import ConflictError, NotFoundError, PermissionDeniedError, ValidationError
from app.domain.base import ensure_unique_slug, new_id, slugify, utcnow
from app.domain.identity.models import (
Membership,
MembershipStatus,
Organization,
Role,
User,
)
from app.domain.platform.rules import is_blocked_address
from app.domain.projects.catalog import (
ProfileDefaults,
defaults_for,
)
from app.domain.projects.models import (
ProjectExternalGit,
ProjectGitCredential,
ProjectGitRepo,
)
from app.infrastructure.db.models import CreditWalletRow, ProjectRow
from app.infrastructure.db.models_org import OrgSetupDraftRow
from app.infrastructure.repositories.app_profile import EDITABLE_FIELDS
from app.infrastructure.repositories.identity import MembershipRepository, OrganizationRepository
from app.infrastructure.repositories.projects import (
DeployTargetRepository,
ProjectExternalGitRepository,
ProjectGitCredentialRepository,
ProjectGitRepoRepository,
ProjectRepository,
)
logger = logging.getLogger("agy.org.wizard")
#: A minimal allowed scheme set for user-supplied git remotes.
_GIT_SCHEMES = {"https", "ssh"}
@dataclass(slots=True, kw_only=True)
class WizardPayload:
"""Validated data the wizard submits on ``complete``."""
org_name: str
slug: str
app_name: str
app_type: str
language: str
framework: str
# Optional overrides on top of catalog defaults
runtime_version: str | None = None
package_manager: str | None = None
setup_command: str | None = None
build_command: str | None = None
test_command: str | None = None
start_command: str | None = None
app_port: int | None = None
healthcheck_path: str = "/"
git_source: str = "platform"
external_repo_url: str | None = None
external_git_auth_type: str = "token"
external_git_username: str | None = None
external_git_secret: str | None = None
# Optional deploy target
deploy_target_kind: str | None = None
deploy_target_host: str | None = None
deploy_target_port: int = 22
deploy_target_username: str | None = None
deploy_target_auth_type: str = "password"
deploy_target_secret: str | None = None
deploy_target_remote_path: str = "/"
class OrgSetupService:
"""Backend for the org-creation wizard and app-profile management."""
def __init__(
self,
session: AsyncSession,
*,
repo_workspace: RepoWorkspacePort | None = None,
deploy_tester: DeployTargetTester | None = None,
git_provider: GitProviderPort | None = None,
workforce_bootstrap: WorkforceBootstrapPort | None = None,
delivery_hooks: DeliveryHooks | None = None,
) -> None:
self.session = session
self.orgs = OrganizationRepository(session)
self.memberships = MembershipRepository(session)
self.projects = ProjectRepository(session)
self.external_git = ProjectExternalGitRepository(session)
self.git_repos = ProjectGitRepoRepository(session)
self.git_credentials = ProjectGitCredentialRepository(session)
self.deploys = DeployTargetRepository(session)
self.repo_workspace = repo_workspace
self.deploy_tester = deploy_tester
self.git_provider = git_provider
self.workforce_bootstrap = workforce_bootstrap
self.delivery_hooks = delivery_hooks
# ------------------------------------------------------------------
# Catalog
# ------------------------------------------------------------------
@staticmethod
def catalog() -> dict:
from app.domain.projects.catalog import catalog_payload
return catalog_payload()
# ------------------------------------------------------------------
# Draft
# ------------------------------------------------------------------
async def get_draft(self, user_id: str) -> dict:
row = await self.session.scalar(
select(OrgSetupDraftRow).where(OrgSetupDraftRow.user_id == user_id)
)
if row is None:
return {"step": 1, "data": {}}
return {"step": row.step, "data": json.loads(row.data_json)}
async def save_draft(self, user_id: str, step: int, data: dict) -> None:
row = await self.session.scalar(
select(OrgSetupDraftRow).where(OrgSetupDraftRow.user_id == user_id)
)
if row is None:
row = OrgSetupDraftRow(
user_id=user_id, step=step, data_json=json.dumps(data)
)
self.session.add(row)
else:
row.step = step
row.data_json = json.dumps(data)
await self.session.flush()
# ------------------------------------------------------------------
# Git remote validation
# ------------------------------------------------------------------
async def check_git(
self,
*,
url: str,
auth_type: str = "none",
username: str | None = None,
secret: str | None = None,
) -> RemoteCheck:
_validate_git_url(url)
if self.repo_workspace is None:
raise ValidationError("git workspace در دسترس نیست.")
return await self.repo_workspace.test_remote(
url=url,
credentials=RemoteCredentials(
auth_type=auth_type, username=username, secret=secret
),
)
# ------------------------------------------------------------------
# Deploy-target validation
# ------------------------------------------------------------------
async def check_deploy(self, *, target: DeployTargetSpec) -> DeployCheck:
if self.deploy_tester is None:
raise ValidationError("تستر مقصد دیپلوی در دسترس نیست.")
return await self.deploy_tester.test_connection(target=target)
# ------------------------------------------------------------------
# Wizard completion
# ------------------------------------------------------------------
async def complete(self, user: User, payload: WizardPayload) -> dict:
if not user.can_create_organization:
raise PermissionDeniedError(
"ساخت سازمان فقط برای مالک سامانه و مدیران امکان‌پذیر است."
)
# -- slug uniqueness -------------------------------------------------
base_slug = slugify(payload.slug or payload.org_name)
slug = ensure_unique_slug(base_slug, await self.orgs.list_all_slugs())
# -- defaults from catalog -------------------------------------------
catalog_defaults = defaults_for(
app_type=payload.app_type,
language=payload.language,
framework=payload.framework,
)
# -- create org + owner membership + wallet --------------------------
org = await self.orgs.create(
Organization(name=payload.org_name.strip(), slug=slug, owner_user_id=user.id)
)
await self.memberships.add(
Membership(
organization_id=org.id,
user_id=user.id,
role=Role.OWNER,
status=MembershipStatus.ACTIVE,
)
)
self.session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
# -- create the single app -------------------------------------------
project_row = await self._create_single_app(org.id, user.id, payload, catalog_defaults)
# -- git setup -------------------------------------------------------
if payload.git_source == "platform":
await self._setup_platform_git(project_row.id, slug)
elif payload.git_source == "external":
await self._setup_external_git(
project_row.id,
url=payload.external_repo_url or "",
auth_type=payload.external_git_auth_type,
username=payload.external_git_username,
secret=payload.external_git_secret,
)
else:
raise ValidationError("منبع git نامعتبر است.")
# -- optional deploy target ------------------------------------------
deploy_target_id: str | None = None
if payload.deploy_target_kind:
deploy_target_id = await self._setup_deploy_target(org.id, project_row.id, payload)
await self.session.flush()
# -- commit so hooks see a consistent world --------------------------
await self.session.commit()
# -- post-commit hooks -----------------------------------------------
await self._call_hooks(organization_id=org.id, project_id=project_row.id)
return {
"organization": {
"id": org.id,
"name": org.name,
"slug": org.slug,
},
"project": {
"id": project_row.id,
"name": project_row.name,
"slug": project_row.slug,
},
"deploy_target_id": deploy_target_id,
}
async def _create_single_app(
self,
org_id: str,
user_id: str,
payload: WizardPayload,
defaults: ProfileDefaults,
) -> ProjectRow:
"""Create the one project for this org, raising 409 if one exists."""
existing = await self.session.scalar(
select(ProjectRow).where(ProjectRow.organization_id == org_id)
)
if existing is not None:
raise ConflictError("این سازمان قبلاً یک اپلیکیشن دارد.")
row = ProjectRow(
id=new_id(),
organization_id=org_id,
name=payload.app_name.strip(),
slug=payload.slug.strip(),
created_by_user_id=user_id,
app_type=payload.app_type,
language=payload.language,
framework=payload.framework,
runtime_version=(payload.runtime_version or defaults.runtime_version),
package_manager=(payload.package_manager or defaults.package_manager),
setup_command=(payload.setup_command or defaults.setup_command),
build_command=(
payload.build_command
if payload.build_command is not None
else defaults.build_command
),
test_command=(
payload.test_command
if payload.test_command is not None
else defaults.test_command
),
start_command=(payload.start_command or defaults.start_command),
app_port=(payload.app_port if payload.app_port is not None else defaults.app_port),
healthcheck_path=payload.healthcheck_path or defaults.healthcheck_path,
git_source=payload.git_source,
external_repo_url=payload.external_repo_url,
default_branch="main",
wizard_completed_at=utcnow(),
created_at=utcnow(),
)
self.session.add(row)
await self.session.flush()
return row
async def _setup_platform_git(self, project_id: str, repo_name: str) -> None:
if self.git_provider is None:
raise ValidationError("git provider در دسترس نیست.")
repo_info = await self.git_provider.ensure_project_repo(
project_id=project_id, repo_name=repo_name
)
creds = await self.git_provider.ensure_credentials(project_id=project_id)
await self.git_repos.upsert(
ProjectGitRepo(
project_id=project_id,
provider="gitea",
owner=repo_info.owner,
repo_name=repo_info.repo_name,
clone_url=repo_info.clone_url,
web_url=repo_info.web_url,
default_branch=repo_info.default_branch or "main",
)
)
await self.git_credentials.upsert(
ProjectGitCredential(
project_id=project_id,
username=creds.username,
encrypted_password=encrypt(creds.password),
)
)
async def _setup_external_git(
self,
project_id: str,
*,
url: str,
auth_type: str,
username: str | None,
secret: str | None,
) -> None:
await self.external_git.upsert(
ProjectExternalGit(
project_id=project_id,
auth_type=auth_type,
username=username,
secret_encrypted=encrypt(secret) if secret else None,
)
)
async def _setup_deploy_target(
self, org_id: str, project_id: str, payload: WizardPayload
) -> str:
secret = payload.deploy_target_secret
values = {
"organization_id": org_id,
"project_id": project_id,
"kind": payload.deploy_target_kind,
"host": payload.deploy_target_host or "",
"port": payload.deploy_target_port,
"username": payload.deploy_target_username or "",
"auth_type": payload.deploy_target_auth_type or "password",
"secret_encrypted": encrypt(secret) if secret else None,
"remote_path": payload.deploy_target_remote_path or "/",
"is_active": True,
}
row = await self.deploys.upsert(values=values)
return str(row.id)
async def _call_hooks(self, *, organization_id: str, project_id: str) -> None:
if self.workforce_bootstrap:
try:
await self.workforce_bootstrap.bootstrap_team(
organization_id=organization_id, project_id=project_id
)
except Exception as exc: # noqa: BLE001
logger.warning("Workforce bootstrap failed for %s: %s", organization_id, exc)
if self.delivery_hooks:
try:
await self.delivery_hooks.on_app_created(
organization_id=organization_id, project_id=project_id
)
except Exception as exc: # noqa: BLE001
logger.warning(
"Delivery hook on_app_created failed for %s: %s",
organization_id,
exc,
)
# ------------------------------------------------------------------
# App profile editing (post-wizard)
# ------------------------------------------------------------------
async def get_profile(self, *, organization_id: str, project_id: str) -> dict:
from app.infrastructure.repositories.app_profile import AppProfileRepository
profile = await AppProfileRepository(self.session).get(
organization_id=organization_id, project_id=project_id
)
if profile is None:
raise NotFoundError("پروفایل اپلیکیشن یافت نشد.")
return {
"project_id": profile.project_id,
"app_type": profile.app_type,
"language": profile.language,
"framework": profile.framework,
"runtime_version": profile.runtime_version,
"package_manager": profile.package_manager,
"setup_command": profile.setup_command,
"build_command": profile.build_command,
"test_command": profile.test_command,
"start_command": profile.start_command,
"app_port": profile.app_port,
"healthcheck_path": profile.healthcheck_path,
"git_source": profile.git_source,
"external_repo_url": profile.external_repo_url,
"default_branch": profile.default_branch,
"wizard_completed": profile.wizard_completed,
}
async def update_profile(
self, *, organization_id: str, project_id: str, values: dict
) -> dict:
from app.infrastructure.repositories.app_profile import AppProfileRepository
repo = AppProfileRepository(self.session)
unknown = set(values) - EDITABLE_FIELDS
if unknown:
raise ValidationError(f"فیلدهای غیرقابل ویرایش: {sorted(unknown)}")
profile = await repo.update_profile(
organization_id=organization_id, project_id=project_id, values=values
)
if profile is None:
raise NotFoundError("پروفایل اپلیکیشن یافت نشد.")
return {
"project_id": profile.project_id,
"app_type": profile.app_type,
"language": profile.language,
"framework": profile.framework,
"runtime_version": profile.runtime_version,
"package_manager": profile.package_manager,
"setup_command": profile.setup_command,
"build_command": profile.build_command,
"test_command": profile.test_command,
"start_command": profile.start_command,
"app_port": profile.app_port,
"healthcheck_path": profile.healthcheck_path,
"git_source": profile.git_source,
"external_repo_url": profile.external_repo_url,
"default_branch": profile.default_branch,
"wizard_completed": profile.wizard_completed,
}
async def update_deploy_target(
self,
*,
organization_id: str,
project_id: str,
kind: str,
host: str,
port: int,
username: str,
auth_type: str = "password",
secret: str | None = None,
remote_path: str = "/",
) -> dict:
# Verify project belongs to org
row = await self.session.scalar(
select(ProjectRow).where(
ProjectRow.id == project_id,
ProjectRow.organization_id == organization_id,
)
)
if row is None:
raise NotFoundError("پروژه یافت نشد.")
values = {
"organization_id": organization_id,
"project_id": project_id,
"kind": kind,
"host": host or "",
"port": port,
"username": username or "",
"auth_type": auth_type or "password",
"secret_encrypted": encrypt(secret) if secret else None,
"remote_path": remote_path or "/",
"is_active": True,
}
target = await self.deploys.upsert(values=values)
return {
"id": target.id,
"kind": target.kind,
"host": target.host,
"port": target.port,
"username": target.username,
"auth_type": target.auth_type,
"remote_path": target.remote_path,
"has_secret": bool(target.secret_encrypted),
"last_test_ok": target.last_test_ok,
"last_test_message": target.last_test_message,
}
async def update_git_credentials(
self,
*,
organization_id: str,
project_id: str,
auth_type: str,
username: str | None = None,
secret: str | None = None,
) -> dict:
row = await self.session.scalar(
select(ProjectRow).where(
ProjectRow.id == project_id,
ProjectRow.organization_id == organization_id,
)
)
if row is None:
raise NotFoundError("پروژه یافت نشد.")
git_source = (row.git_source or "platform").lower()
if git_source == "platform":
await self.git_credentials.upsert(
ProjectGitCredential(
project_id=project_id,
username=username or "",
encrypted_password=encrypt(secret) if secret else "",
)
)
return {"git_source": "platform", "has_secret": bool(secret)}
# external
await self.external_git.upsert(
ProjectExternalGit(
project_id=project_id,
auth_type=auth_type,
username=username,
secret_encrypted=encrypt(secret) if secret else None,
)
)
return {"git_source": "external", "has_secret": bool(secret)}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _validate_git_url(url: str) -> None:
"""Lightweight format check before we hand the URL to the git adapter.
Rejects: wrong scheme, embedded credentials, obvious localhost/private IPs.
The DNS-resolved SSRF check lives in ``RepoWorkspacePort.test_remote``.
"""
clean = (url or "").strip()
if not clean:
raise ValidationError("آدرس git نمی‌تواند خالی باشد.")
# Normalise scp-style ``git@host:path`` so urlsplit can parse it.
working = clean
if "://" not in working and re.match(r"^[^/@\s]+@[^/:\s]+:", working):
working = "ssh://" + working.replace(":", "/", 1)
parts = urlsplit(working)
scheme = (parts.scheme or "").lower()
if scheme not in _GIT_SCHEMES:
raise ValidationError("آدرس git باید با https:// یا ssh:// یا git@ شروع شود.")
if parts.password is not None:
raise ValidationError("آدرس git نباید شامل نام کاربری و رمز باشد.")
host = parts.hostname or ""
if not host:
raise ValidationError("میزبان آدرس git مشخص نیست.")
lowered = host.lower().strip(".")
if lowered in {"localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback"}:
raise ValidationError("اتصال به میزبان محلی مجاز نیست.")
# Reject blocked IP literals without DNS.
try:
import ipaddress
ipaddress.ip_address(lowered.strip("[]"))
except ValueError:
pass
else:
if is_blocked_address(lowered.strip("[]")):
raise ValidationError("اتصال به آدرس‌های داخلی مجاز نیست.")