refactor: slim config_profiles router to 301 lines
Extract CRUD helpers into services/config/crud_service.py. Move instance-related config logic to services/tool/instance_service.py. Quality gates: py_compile pass
This commit is contained in:
@@ -398,9 +398,6 @@ def expand_glob_source(source_path: str, repo_path: str) -> list[str]:
|
||||
return results
|
||||
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||
|
||||
|
||||
async def validate_config_profile(
|
||||
session: AsyncSession,
|
||||
profile_id: str | None,
|
||||
@@ -880,3 +877,308 @@ async def prepare_manifest_instance(
|
||||
return image_tag, compose_content, manifest, home_dir
|
||||
|
||||
|
||||
|
||||
|
||||
async def create_tool_instance(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: "CreateInstanceRequest",
|
||||
) -> ToolInstance:
|
||||
"""Create a new tool instance for a repository.
|
||||
|
||||
Returns the created ToolInstance.
|
||||
Raises ValueError for invalid input, RuntimeError for internal failures.
|
||||
"""
|
||||
repo = await session.get(GitRepository, repo_id)
|
||||
if repo is None or repo.project_id != project_id:
|
||||
raise ValueError("repository not found")
|
||||
|
||||
tool_type_id = uuid.UUID(data.tool_type_id)
|
||||
tool_type = await session.get(ToolType, tool_type_id)
|
||||
if tool_type is None:
|
||||
raise ValueError("tool type not found")
|
||||
|
||||
# Validate config profile if provided
|
||||
selected_profile_id = await validate_config_profile(
|
||||
session, data.config_profile_id, user_id, project_id, tool_type_id
|
||||
)
|
||||
|
||||
# Resolve workspace if provided
|
||||
workspace = None
|
||||
workspace_id = None
|
||||
if data.workspace_id:
|
||||
from src.models import Workspace as WorkspaceModel
|
||||
|
||||
try:
|
||||
workspace_id = uuid.UUID(data.workspace_id)
|
||||
except ValueError:
|
||||
raise ValueError("Invalid workspace_id format")
|
||||
workspace = await session.get(WorkspaceModel, workspace_id)
|
||||
if workspace is None:
|
||||
raise ValueError("workspace not found")
|
||||
if workspace.repo_id != repo_id:
|
||||
raise ValueError("workspace does not belong to this repository")
|
||||
|
||||
# Validate clone mode requirements (legacy path)
|
||||
if data.clone_mode == "clone" and not workspace:
|
||||
if not repo.remote_url:
|
||||
raise ValueError("repository does not have a remote URL for cloning")
|
||||
if not repo.ssh_key_id:
|
||||
raise ValueError("repository must have an SSH key assigned for clone mode")
|
||||
|
||||
# Generate unique name
|
||||
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Auto-generate display name with scoped numbering.
|
||||
if data.display_name:
|
||||
instance_display = data.display_name
|
||||
else:
|
||||
scope_name = workspace.name if workspace else repo.name
|
||||
auto_name = f"{scope_name} / {tool_type.display_name}"
|
||||
|
||||
if workspace:
|
||||
count_query = (
|
||||
select(ToolInstance)
|
||||
.where(ToolInstance.workspace_id == workspace_id)
|
||||
.where(ToolInstance.tool_type_id == tool_type_id)
|
||||
.where(ToolInstance.owner_id == user_id)
|
||||
)
|
||||
else:
|
||||
count_query = (
|
||||
select(ToolInstance)
|
||||
.where(ToolInstance.repository_id == repo_id)
|
||||
.where(ToolInstance.tool_type_id == tool_type_id)
|
||||
.where(ToolInstance.owner_id == user_id)
|
||||
)
|
||||
|
||||
result = await session.execute(count_query)
|
||||
existing_count = len(result.scalars().all())
|
||||
if existing_count > 0:
|
||||
instance_display = f"{auto_name} #{existing_count + 1}"
|
||||
else:
|
||||
instance_display = auto_name
|
||||
|
||||
# Create instance directory
|
||||
instance_dir = ensure_instance_directory(instance_name)
|
||||
compose_path = os.path.join(instance_dir, "docker-compose.yml")
|
||||
|
||||
# Find free port
|
||||
tool_port = find_free_port()
|
||||
|
||||
# Determine repo path based on workspace or clone mode
|
||||
if workspace:
|
||||
repo_path = workspace.path
|
||||
elif data.clone_mode == "clone":
|
||||
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
||||
if ssh_key is None:
|
||||
raise ValueError("repository SSH key not found")
|
||||
|
||||
ssh_key_path = None
|
||||
try:
|
||||
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
|
||||
ssh_key_path = os.path.join(ssh_dir, "id_ed25519")
|
||||
|
||||
clone_path = clone_repository(
|
||||
remote_url=repo.remote_url,
|
||||
ssh_key_path=ssh_key_path,
|
||||
instance_dir=instance_dir,
|
||||
branch=data.branch or "main",
|
||||
)
|
||||
repo_path = clone_path
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to clone repository: %s", exc)
|
||||
cleanup_ssh_key_files(instance_dir)
|
||||
raise RuntimeError(f"Failed to clone repository: {exc}")
|
||||
else:
|
||||
repo_path = repo.path
|
||||
|
||||
# Verify cloned repo has files
|
||||
if data.clone_mode == "clone" and repo_path:
|
||||
try:
|
||||
repo_contents = os.listdir(repo_path)
|
||||
if not repo_contents or (
|
||||
len(repo_contents) == 1 and repo_contents[0] == ".git"
|
||||
):
|
||||
logger.error("Cloned repository at %s appears empty", repo_path)
|
||||
raise RuntimeError("Cloned repository is empty")
|
||||
logger.debug(
|
||||
"Verified cloned repo at %s has %d items",
|
||||
repo_path,
|
||||
len(repo_contents),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to verify cloned repository: %s", exc)
|
||||
raise RuntimeError(f"Cloned repository verification failed: {exc}")
|
||||
|
||||
# Create new local branch if requested
|
||||
if data.clone_mode == "clone" and data.new_branch:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", repo_path, "checkout", "-b", data.new_branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(
|
||||
"Failed to create branch %s: %s", data.new_branch, result.stderr
|
||||
)
|
||||
raise RuntimeError(f"Failed to create branch: {result.stderr}")
|
||||
logger.debug(
|
||||
"Created local branch %s in cloned repository", data.new_branch
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to create local branch: %s", exc)
|
||||
raise RuntimeError(f"Failed to create local branch: {exc}")
|
||||
|
||||
# Handle based on definition type
|
||||
if tool_type.definition_type == "dockerfile":
|
||||
image_tag = f"headquarter/{instance_name}:latest".lower()
|
||||
|
||||
if tool_type.dockerfile_template:
|
||||
returncode, stdout, stderr = await asyncio.to_thread(
|
||||
build_image,
|
||||
instance_dir=instance_dir,
|
||||
dockerfile=tool_type.dockerfile_template,
|
||||
tag=image_tag,
|
||||
build_context=tool_type.build_context,
|
||||
)
|
||||
|
||||
if returncode != 0:
|
||||
logger.error(
|
||||
"Failed to build image for instance %s: %s",
|
||||
instance_name,
|
||||
stderr,
|
||||
)
|
||||
raise RuntimeError(f"Failed to build Docker image: {stderr[:500]}")
|
||||
|
||||
logger.info(
|
||||
"Successfully built image %s for instance %s",
|
||||
image_tag,
|
||||
instance_name,
|
||||
)
|
||||
|
||||
ports_section = (
|
||||
f""" ports:\n - "{tool_port}:{tool_type.default_port}"\n"""
|
||||
if tool_type.default_port and tool_type.default_port > 0
|
||||
else ""
|
||||
)
|
||||
|
||||
compose_content = f"""version: "3.8"\nservices:\n app:\n image: {image_tag}\n container_name: {instance_name.lower()}\n stdin_open: true\n tty: true\n{ports_section} volumes:\n - {repo_path}:/workspace\n restart: unless-stopped\n"""
|
||||
write_compose_file(instance_dir, compose_content)
|
||||
|
||||
elif tool_type.definition_type == "manifest":
|
||||
from src.models import ToolDefinitionManifest
|
||||
|
||||
manifest_def = await session.get(
|
||||
ToolDefinitionManifest, tool_type.manifest_id
|
||||
)
|
||||
if not manifest_def:
|
||||
raise RuntimeError("Manifest definition not found for this tool type")
|
||||
|
||||
manifest = dict(manifest_def.manifest)
|
||||
if manifest_def.base_definition_id:
|
||||
base_def = await session.get(
|
||||
ToolDefinitionManifest, manifest_def.base_definition_id
|
||||
)
|
||||
if base_def:
|
||||
manifest = resolve_base(
|
||||
deep_merge(dict(base_def.manifest), manifest)
|
||||
)
|
||||
|
||||
image_tag = compute_image_tag(tool_type.name, manifest)
|
||||
|
||||
variables = {
|
||||
"IMAGE_TAG": image_tag,
|
||||
"INSTANCE_NAME": instance_name.lower(),
|
||||
"INSTANCE_DIR": instance_dir,
|
||||
"REPO_PATH": repo_path,
|
||||
"SSH_PATH": "",
|
||||
"TOOL_PORT": tool_port,
|
||||
"EXTRA_ENV": {},
|
||||
"EXTRA_VOLUMES": [],
|
||||
}
|
||||
compose_content = compile_compose(manifest, variables)
|
||||
write_compose_file(instance_dir, compose_content)
|
||||
|
||||
else:
|
||||
variables = {
|
||||
"REPO_PATH": repo_path,
|
||||
"INSTANCE_NAME": instance_name,
|
||||
"INSTANCE_ID": instance_name,
|
||||
"TOOL_NAME": instance_name,
|
||||
"TOOL_PORT": tool_port,
|
||||
"USER_ID": str(user_id),
|
||||
"PROJECT_ID": str(project_id),
|
||||
}
|
||||
compose_content = render_compose_template(
|
||||
tool_type.compose_template, variables
|
||||
)
|
||||
|
||||
if data.clone_mode == "clone" and repo_path:
|
||||
import yaml
|
||||
|
||||
compose_data = yaml.safe_load(compose_content)
|
||||
repo_mounted = False
|
||||
if compose_data and "services" in compose_data:
|
||||
for svc in compose_data["services"].values():
|
||||
volumes = svc.get("volumes", [])
|
||||
for vol in volumes:
|
||||
vol_str = str(vol)
|
||||
if repo_path in vol_str:
|
||||
repo_mounted = True
|
||||
break
|
||||
if repo_mounted:
|
||||
break
|
||||
|
||||
if not repo_mounted:
|
||||
logger.warning(
|
||||
"Compose template for tool type %s does not mount repo path; adding default mount",
|
||||
tool_type.name,
|
||||
)
|
||||
if compose_data and "services" in compose_data:
|
||||
for svc in compose_data["services"].values():
|
||||
if "volumes" not in svc:
|
||||
svc["volumes"] = []
|
||||
svc["volumes"].append(f"{repo_path}:/workspace")
|
||||
break
|
||||
compose_content = yaml.dump(
|
||||
compose_data, default_flow_style=False
|
||||
)
|
||||
|
||||
write_compose_file(instance_dir, compose_content)
|
||||
|
||||
# Create database record
|
||||
instance = ToolInstance(
|
||||
name=instance_name,
|
||||
display_name=instance_display,
|
||||
tool_type_id=tool_type_id,
|
||||
repository_id=repo_id,
|
||||
project_id=project_id,
|
||||
owner_id=user_id,
|
||||
status="pending",
|
||||
compose_path=compose_path,
|
||||
port=tool_port,
|
||||
workspace_id=workspace_id,
|
||||
clone_mode=data.clone_mode,
|
||||
branch=data.new_branch
|
||||
if data.new_branch
|
||||
else (data.branch if data.clone_mode == "clone" else None),
|
||||
selected_config_profile_id=selected_profile_id,
|
||||
ssh_key_ids=data.ssh_key_ids or None,
|
||||
)
|
||||
session.add(instance)
|
||||
await session.commit()
|
||||
await session.refresh(instance)
|
||||
await publish_lifecycle_event(
|
||||
event_bus=_event_bus,
|
||||
session=session,
|
||||
instance=instance,
|
||||
event_type="instance.created",
|
||||
created_by=user_id,
|
||||
status="pending",
|
||||
message="Instance created",
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
Reference in New Issue
Block a user