Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev

This commit is contained in:
Fusion
2026-05-22 23:23:36 +02:00
3 changed files with 89 additions and 14 deletions
+65 -7
View File
@@ -35,6 +35,7 @@ from src.utils.git_control import (
)
from src.utils.git_history import get_commit_detail, get_commit_history
from src.utils.git_url_parser import parse_git_url
from src.services.ssh_keys import _get_fernet
router = APIRouter(prefix="/projects", tags=["git-repositories"])
@@ -95,41 +96,97 @@ def _build_provider_clone_url(owner: str, repo: str) -> str:
return f"git@git.commumedia.org:{owner}/{repo}.git"
def _preflight_remote_repository(remote_url: str) -> None:
def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
"""Prepare environment variables for git commands with SSH authentication.
Returns a dict of extra env vars, or None if no SSH key provided.
The caller is responsible for cleaning up the temporary key file.
"""
if ssh_key is None:
return None
import tempfile
# Decrypt private key
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
# Write to temp file with restricted permissions
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, private_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
# Return env vars and the key path for cleanup
env = {
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
}
return env, key_path
def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
"""Verify a remote repository is reachable before cloning."""
env = None
key_path = None
if ssh_key is not None:
ssh_result = _prepare_ssh_env(ssh_key)
if ssh_result:
env, key_path = ssh_result
try:
result = subprocess.run(
["git", "ls-remote", remote_url],
capture_output=True,
text=True,
timeout=60,
env={**os.environ, **env} if env else None,
)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0:
logger.error("Preflight check failed for %s: stderr=%s", remote_url, result.stderr)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="repository not found or inaccessible",
detail=f"repository not found or inaccessible: {result.stderr}",
)
def _clone_working_repository(remote_url: str, repo_path: str) -> None:
def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
env = None
key_path = None
if ssh_key is not None:
ssh_result = _prepare_ssh_env(ssh_key)
if ssh_result:
env, key_path = ssh_result
try:
result = subprocess.run(
["git", "clone", remote_url, repo_path],
capture_output=True,
text=True,
timeout=300,
env={**os.environ, **env} if env else None,
)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0:
logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to clone repository: {result.stderr}",
@@ -352,11 +409,9 @@ async def create_repository(
if parse_result["base_url"]:
remote_url = parse_result["base_url"]
if remote_url:
_preflight_remote_repository(remote_url)
# Validate SSH key if provided
ssh_key_id = None
ssh_key = None
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
@@ -369,13 +424,16 @@ async def create_repository(
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
if remote_url:
_preflight_remote_repository(remote_url, ssh_key)
repo_path = _get_repo_path(user_id, project_id, data.name)
# Ensure parent directory exists
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
if remote_url:
_clone_working_repository(remote_url, repo_path)
_clone_working_repository(remote_url, repo_path, ssh_key)
else:
_init_working_repository(repo_path)
+23 -6
View File
@@ -1,3 +1,4 @@
import re
import uuid
from datetime import datetime
@@ -7,6 +8,11 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
def _sanitize_template_vars(template: str) -> str:
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_type import ToolType
from src.models.user import User
@@ -65,8 +71,12 @@ class ToolTypeCreate(BaseModel):
if v is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
# Replace template variables with dummy values before YAML validation
# to avoid YAML parsing errors with {{VAR}} syntax
sanitized = _sanitize_template_vars(v)
try:
parsed = yaml.safe_load(v)
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
@@ -145,7 +155,8 @@ class ToolTypeCreate(BaseModel):
# Validate that default_port is exposed in compose template (only if requires_port)
if self.requires_port and self.definition_type == "compose" and self.compose_template:
try:
parsed = yaml.safe_load(self.compose_template)
sanitized = _sanitize_template_vars(self.compose_template)
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError:
return self
@@ -215,8 +226,11 @@ class ToolTypeUpdate(BaseModel):
if definition_type and definition_type != "compose":
return v
# Replace template variables with dummy values before YAML validation
sanitized = _sanitize_template_vars(v)
try:
parsed = yaml.safe_load(v)
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
@@ -426,7 +440,8 @@ async def update_tool_type(
template = update_data.get("compose_template", tool_type.compose_template)
if template:
try:
parsed = yaml.safe_load(template)
sanitized = _sanitize_template_vars(template)
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError:
parsed = None
@@ -517,7 +532,8 @@ async def validate_tool_type_template(
errors.append("Compose template is required")
else:
try:
parsed = yaml.safe_load(data.compose_template)
sanitized = _sanitize_template_vars(data.compose_template)
parsed = yaml.safe_load(sanitized)
if not isinstance(parsed, dict):
errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed:
@@ -574,7 +590,8 @@ async def validate_tool_type(
errors.append("Compose template is empty")
else:
try:
parsed = yaml.safe_load(tool_type.compose_template)
sanitized = _sanitize_template_vars(tool_type.compose_template)
parsed = yaml.safe_load(sanitized)
if not isinstance(parsed, dict):
errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed: