feat: add ssh_key_id to config profiles for container key mounting
- Add ssh_key_id column to ConfigProfile model and migration - Update config profile API to accept/return ssh_key_id - Include ssh_key_id in ResolvedProfile and resolver logic - Mount selected SSH key into container home dir at start_instance - Frontend config profile form with SSH key selector dropdown - Git mount URL validation defaults to profile's SSH key Quality gates: pytest (231 passed, 6 pre-existing), tsc --noEmit clean
This commit is contained in:
@@ -188,6 +188,9 @@ class ConfigProfileCreate(BaseModel):
|
||||
git_mounts: list[GitMountItem] = Field(
|
||||
default_factory=list, description="Git repository mounts"
|
||||
)
|
||||
ssh_key_id: str | None = Field(
|
||||
default=None, description="Optional SSH key ID to mount into containers"
|
||||
)
|
||||
is_default: bool = Field(
|
||||
default=False, description="Whether this is the default profile for its scope"
|
||||
)
|
||||
@@ -249,6 +252,9 @@ class ConfigProfileUpdate(BaseModel):
|
||||
git_mounts: list[GitMountItem] | None = Field(
|
||||
default=None, description="Git repository mounts"
|
||||
)
|
||||
ssh_key_id: str | None = Field(
|
||||
default=None, description="Optional SSH key ID to mount into containers"
|
||||
)
|
||||
is_default: bool | None = Field(
|
||||
default=None, description="Whether this is the default profile"
|
||||
)
|
||||
@@ -376,6 +382,7 @@ def _profile_to_response(
|
||||
"mounts": profile.mounts or [],
|
||||
"git_mounts": profile.git_mounts or [],
|
||||
"files": profile.files or {},
|
||||
"ssh_key_id": str(profile.ssh_key_id) if profile.ssh_key_id else None,
|
||||
"is_default": profile.is_default,
|
||||
"includes": [
|
||||
{
|
||||
@@ -842,7 +849,9 @@ async def resolve_default_profile(
|
||||
|
||||
class ValidateGitUrlRequest(BaseModel):
|
||||
url: str = Field(description="Git remote URL to validate")
|
||||
ssh_key_id: str | None = Field(default=None, description="Optional SSH key ID for private repos")
|
||||
ssh_key_id: str | None = Field(
|
||||
default=None, description="Optional SSH key ID for private repos"
|
||||
)
|
||||
|
||||
|
||||
class ValidateGitUrlResponse(BaseModel):
|
||||
@@ -953,11 +962,18 @@ async def validate_git_url(
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
if "could not resolve" in stderr.lower() or "unable to access" in stderr.lower():
|
||||
if (
|
||||
"could not resolve" in stderr.lower()
|
||||
or "unable to access" in stderr.lower()
|
||||
):
|
||||
error_msg = "Could not reach repository. Check the URL and network access."
|
||||
error_code = "UNREACHABLE"
|
||||
elif "authentication" in stderr.lower() or "permission denied" in stderr.lower():
|
||||
error_msg = "Authentication failed. Provide an SSH key for private repositories."
|
||||
elif (
|
||||
"authentication" in stderr.lower() or "permission denied" in stderr.lower()
|
||||
):
|
||||
error_msg = (
|
||||
"Authentication failed. Provide an SSH key for private repositories."
|
||||
)
|
||||
error_code = "AUTH_FAILED"
|
||||
else:
|
||||
error_msg = f"Repository not accessible: {stderr[:200]}"
|
||||
@@ -979,7 +995,7 @@ async def validate_git_url(
|
||||
ref = parts[1]
|
||||
# refs/heads/branch-name
|
||||
if ref.startswith("refs/heads/"):
|
||||
branch_name = ref[len("refs/heads/"):]
|
||||
branch_name = ref[len("refs/heads/") :]
|
||||
branches.append(branch_name)
|
||||
if branch_name in ("main", "master"):
|
||||
default_branch = branch_name
|
||||
|
||||
@@ -1355,6 +1355,40 @@ async def start_instance(
|
||||
working_directory = profile_hints["working_directory"]
|
||||
if profile_hints.get("port_override"):
|
||||
port_override = profile_hints["port_override"]
|
||||
# Mount SSH key from config profile into container home dir
|
||||
if resolved.ssh_key_id is not None:
|
||||
ssh_key = await session.get(SSHKey, resolved.ssh_key_id)
|
||||
if ssh_key:
|
||||
try:
|
||||
ssh_dir = prepare_ssh_key_files(
|
||||
instance_dir, ssh_key, subdir="mounts/ssh/.ssh"
|
||||
)
|
||||
ssh_target = os.path.join(home_dir, ".ssh")
|
||||
extra_volumes.append(
|
||||
{
|
||||
"source": ssh_dir,
|
||||
"target": ssh_target,
|
||||
"type": "ro",
|
||||
}
|
||||
)
|
||||
logger.debug(
|
||||
"Mounted SSH key %s for instance %s to %s",
|
||||
ssh_key.name,
|
||||
instance.id,
|
||||
ssh_target,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to prepare SSH key for instance %s: %s",
|
||||
instance.id,
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"SSH key %s not found for config profile %s",
|
||||
resolved.ssh_key_id,
|
||||
resolved.profile_name,
|
||||
)
|
||||
logger.debug(
|
||||
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
|
||||
resolved.profile_name,
|
||||
|
||||
@@ -9,6 +9,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
|
||||
@@ -42,11 +43,15 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
git_mounts: Mapped[list] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
||||
ssh_key_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("ssh_keys.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship()
|
||||
project: Mapped["Project | None"] = relationship()
|
||||
tool_type: Mapped["ToolType | None"] = relationship()
|
||||
ssh_key: Mapped["SSHKey | None"] = relationship()
|
||||
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
|
||||
"ConfigProfileInclude",
|
||||
foreign_keys="ConfigProfileInclude.profile_id",
|
||||
|
||||
@@ -51,6 +51,7 @@ class ResolvedProfile:
|
||||
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
||||
git_mounts: list[dict[str, Any]] = field(default_factory=list)
|
||||
files: dict[str, str] = field(default_factory=dict)
|
||||
ssh_key_id: uuid.UUID | None = None
|
||||
env_overrides: dict[str, str] = field(default_factory=dict)
|
||||
hint_overrides: dict[str, str] = field(default_factory=dict)
|
||||
file_overrides: dict[str, str] = field(default_factory=dict)
|
||||
@@ -318,6 +319,9 @@ async def _resolve_profile_recursive(
|
||||
result.git_mounts = _merge_git_mounts(
|
||||
result.git_mounts, included.git_mounts, included.profile_name
|
||||
)
|
||||
# Later included profile's SSH key wins
|
||||
if included.ssh_key_id is not None:
|
||||
result.ssh_key_id = included.ssh_key_id
|
||||
|
||||
# Apply the profile's own settings (selected profile overrides includes)
|
||||
result.env_vars = _merge_env_vars(
|
||||
@@ -349,6 +353,9 @@ async def _resolve_profile_recursive(
|
||||
profile.git_mounts or [],
|
||||
profile.name,
|
||||
)
|
||||
# Own SSH key overrides any inherited one
|
||||
if profile.ssh_key_id is not None:
|
||||
result.ssh_key_id = profile.ssh_key_id
|
||||
return result
|
||||
|
||||
|
||||
@@ -564,4 +571,5 @@ def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
||||
},
|
||||
"git_mounts": resolved.git_mounts,
|
||||
"included_profiles": resolved.included_profiles,
|
||||
"ssh_key_id": str(resolved.ssh_key_id) if resolved.ssh_key_id else None,
|
||||
}
|
||||
|
||||
@@ -19,17 +19,18 @@ def _get_fernet() -> Fernet:
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def prepare_ssh_key_files(instance_dir: str, ssh_key) -> str:
|
||||
def prepare_ssh_key_files(instance_dir: str, ssh_key, subdir: str = ".ssh") -> str:
|
||||
"""Decrypt and write SSH key files to instance directory for container mounting.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
ssh_key: SSHKey model instance with encrypted private key
|
||||
subdir: Subdirectory within instance_dir to write to (default: ".ssh")
|
||||
|
||||
Returns:
|
||||
Path to the .ssh directory
|
||||
"""
|
||||
ssh_dir = Path(instance_dir) / ".ssh"
|
||||
ssh_dir = Path(instance_dir) / subdir
|
||||
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Decrypt private key
|
||||
|
||||
Reference in New Issue
Block a user