Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -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("اتصال به آدرسهای داخلی مجاز نیست.")
|
||||
Reference in New Issue
Block a user