From e9364fa70ff4188710570850e3b960271ed6385d Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 13:30:53 +0200 Subject: [PATCH] feat: instance-level SSH key selection for container mounting - Revert mistaken ssh_key_id from ConfigProfile (model, API, resolver, frontend) - Add ssh_key_ids JSON column to tool_instances via migration - Update create_instance to accept and store ssh_key_ids - Update start_instance to mount selected SSH keys to {home_dir}/.ssh - Update list_instances to return ssh_key_ids - Frontend CreateSessionForm: multi-select SSH key checkboxes - Frontend instance-list: SSH key selector for start/restart actions - Maintain separate SSH key dirs per key to avoid conflicts Quality gates: pytest (231 passed, 6 pre-existing), tsc --noEmit clean --- ...05_29_add_ssh_key_ids_to_tool_instances.py | 27 + ...29_drop_ssh_key_id_from_config_profiles.py | 32 + apps/api/src/api/config_profiles.py | 7 - apps/api/src/api/tool_instances.py | 84 +- apps/api/src/models/config_profile.py | 5 - apps/api/src/models/tool_instance.py | 1 + .../src/services/config_profile_resolver.py | 8 - apps/web/src/api/config_profiles.ts | 4 - apps/web/src/api/sessions.ts | 15 +- .../src/components/create-session-form.tsx | 66 +- apps/web/src/components/git-mount-editor.tsx | 20 +- apps/web/src/components/instance-list.tsx | 380 ++- apps/web/src/pages/config-profiles.tsx | 2893 +++++++++-------- .../changes/ssh-key-mounting/.openspec.yaml | 31 +- 14 files changed, 2049 insertions(+), 1524 deletions(-) create mode 100644 apps/api/alembic/versions/2026_05_29_add_ssh_key_ids_to_tool_instances.py create mode 100644 apps/api/alembic/versions/2026_05_29_drop_ssh_key_id_from_config_profiles.py diff --git a/apps/api/alembic/versions/2026_05_29_add_ssh_key_ids_to_tool_instances.py b/apps/api/alembic/versions/2026_05_29_add_ssh_key_ids_to_tool_instances.py new file mode 100644 index 0000000..84cb8ba --- /dev/null +++ b/apps/api/alembic/versions/2026_05_29_add_ssh_key_ids_to_tool_instances.py @@ -0,0 +1,27 @@ +"""add_ssh_key_ids_to_tool_instances + +Revision ID: 2026_05_29_add_ssh_key_ids_to_tool_instances +Revises: 2026_05_29_drop_ssh_key_id_from_config_profiles +Create Date: 2026-05-29 12:46:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "2026_05_29_add_ssh_key_ids_to_tool_instances" +down_revision = "2026_05_29_drop_ssh_key_id_from_config_profiles" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "tool_instances", + sa.Column("ssh_key_ids", sa.JSON(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("tool_instances", "ssh_key_ids") diff --git a/apps/api/alembic/versions/2026_05_29_drop_ssh_key_id_from_config_profiles.py b/apps/api/alembic/versions/2026_05_29_drop_ssh_key_id_from_config_profiles.py new file mode 100644 index 0000000..163512b --- /dev/null +++ b/apps/api/alembic/versions/2026_05_29_drop_ssh_key_id_from_config_profiles.py @@ -0,0 +1,32 @@ +"""drop_ssh_key_id_from_config_profiles + +Revision ID: 2026_05_29_drop_ssh_key_id_from_config_profiles +Revises: 069d3da4dc9b +Create Date: 2026-05-29 12:45:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "2026_05_29_drop_ssh_key_id_from_config_profiles" +down_revision = "069d3da4dc9b" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("config_profiles", "ssh_key_id") + + +def downgrade() -> None: + op.add_column( + "config_profiles", + sa.Column( + "ssh_key_id", + sa.Uuid(), + sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"), + nullable=True, + ), + ) diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py index f971d66..36dbbda 100644 --- a/apps/api/src/api/config_profiles.py +++ b/apps/api/src/api/config_profiles.py @@ -188,9 +188,6 @@ 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" ) @@ -252,9 +249,6 @@ 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" ) @@ -382,7 +376,6 @@ 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": [ { diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 88b672e..73888bf 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -435,6 +435,9 @@ class CreateInstanceRequest(BaseModel): config_profile_id: str | None = Field( default=None, description="Optional config profile ID for launch" ) + ssh_key_ids: list[str] = Field( + default_factory=list, description="SSH key IDs to mount into container ~/.ssh" + ) class StartInstanceRequest(BaseModel): @@ -445,6 +448,9 @@ class StartInstanceRequest(BaseModel): config_profile_id: str | None = Field( default=None, description="Config profile ID to apply, or null for none" ) + ssh_key_ids: list[str] = Field( + default_factory=list, description="SSH key IDs to mount into container ~/.ssh" + ) async def _validate_config_profile( @@ -964,6 +970,7 @@ services: 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() @@ -1054,6 +1061,7 @@ async def list_instances( "port": i.port, "clone_mode": i.clone_mode, "branch": i.branch, + "ssh_key_ids": i.ssh_key_ids or [], "created_at": i.created_at.isoformat(), } ) @@ -1300,6 +1308,11 @@ async def start_instance( instance.selected_config_profile_id = selected_profile_id await session.commit() + # Store SSH key selection if provided + if data and data.ssh_key_ids is not None: + instance.ssh_key_ids = data.ssh_key_ids or None + await session.commit() + if not instance.compose_path or not os.path.exists(instance.compose_path): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found" @@ -1355,40 +1368,6 @@ 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, @@ -1422,6 +1401,43 @@ async def start_instance( "Wrote %d config files for instance %s", len(config_files), instance.id ) + # Mount selected SSH keys into container home dir + if instance.ssh_key_ids: + for key_id in instance.ssh_key_ids: + ssh_key = await session.get(SSHKey, uuid.UUID(key_id)) + if ssh_key and ssh_key.user_id == user_id: + try: + ssh_dir = prepare_ssh_key_files( + instance_dir, ssh_key, subdir=f"mounts/ssh/{key_id}/.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 %s for instance %s: %s", + key_id, + instance.id, + exc, + ) + else: + logger.warning( + "SSH key %s not found or not authorized for user %s", + key_id, + user_id, + ) + # ── MANIFEST-BASED FLOW ────────────────────────────────────── resolved_manifest = None diff --git a/apps/api/src/models/config_profile.py b/apps/api/src/models/config_profile.py index aee8cd8..04eae6f 100644 --- a/apps/api/src/models/config_profile.py +++ b/apps/api/src/models/config_profile.py @@ -9,7 +9,6 @@ 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 @@ -43,15 +42,11 @@ 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", diff --git a/apps/api/src/models/tool_instance.py b/apps/api/src/models/tool_instance.py index e1a1a8b..2bb3299 100644 --- a/apps/api/src/models/tool_instance.py +++ b/apps/api/src/models/tool_instance.py @@ -59,6 +59,7 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column( UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True ) + ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) tool_type: Mapped["ToolType"] = relationship() repository: Mapped["GitRepository"] = relationship() diff --git a/apps/api/src/services/config_profile_resolver.py b/apps/api/src/services/config_profile_resolver.py index 633a484..cdb9e5c 100644 --- a/apps/api/src/services/config_profile_resolver.py +++ b/apps/api/src/services/config_profile_resolver.py @@ -51,7 +51,6 @@ 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) @@ -319,9 +318,6 @@ 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( @@ -353,9 +349,6 @@ 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 @@ -571,5 +564,4 @@ 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, } diff --git a/apps/web/src/api/config_profiles.ts b/apps/web/src/api/config_profiles.ts index c8c65de..c8959b2 100644 --- a/apps/web/src/api/config_profiles.ts +++ b/apps/web/src/api/config_profiles.ts @@ -12,7 +12,6 @@ export interface ConfigProfile { mounts: ConfigProfileMount[]; git_mounts: GitMount[]; files: Record; - ssh_key_id: string | null; is_default: boolean; includes: ConfigProfileInclude[]; created_at: string; @@ -53,7 +52,6 @@ export interface ResolvedProfile { mounts: ResolvedMount[]; git_mounts: GitMount[]; files: Record; - ssh_key_id: string | null; overrides: { env_vars: Record; runtime_hints: Record; @@ -80,7 +78,6 @@ export interface CreateConfigProfileRequest { mounts?: ConfigProfileMount[]; git_mounts?: GitMount[]; files?: Record; - ssh_key_id?: string; is_default?: boolean; } @@ -94,7 +91,6 @@ export interface UpdateConfigProfileRequest { mounts?: ConfigProfileMount[]; git_mounts?: GitMount[]; files?: Record; - ssh_key_id?: string; is_default?: boolean; } diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index 23cd54a..7bede1c 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -12,6 +12,7 @@ export interface ToolInstance { url: string | null; port: number | null; selected_config_profile_id: string | null; + ssh_key_ids: string[]; created_at: string; } @@ -52,7 +53,8 @@ export async function createInstance( cloneMode?: string, branch?: string, newBranch?: string, - configProfileId?: string + configProfileId?: string, + sshKeyIds?: string[] ): Promise { const response = await apiClient.post( `/projects/${projectId}/repositories/${repoId}/instances`, @@ -63,6 +65,7 @@ export async function createInstance( branch: branch || undefined, new_branch: newBranch || undefined, config_profile_id: configProfileId, + ssh_key_ids: sshKeyIds || [], } ); return response.data; @@ -73,12 +76,13 @@ export async function startInstance( repoId: string, instanceId: string, configProfileId?: string, + sshKeyIds?: string[], retries = 2 ): Promise<{ status: string; url?: string }> { try { const response = await apiClient.post( `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`, - { config_profile_id: configProfileId } + { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] } ); return response.data; } catch (error) { @@ -86,7 +90,7 @@ export async function startInstance( const axiosError = error as AxiosError; if (retries > 0 && !axiosError.response) { await new Promise((r) => setTimeout(r, 1500)); - return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1); + return startInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1); } throw error; } @@ -108,12 +112,13 @@ export async function restartInstance( repoId: string, instanceId: string, configProfileId?: string, + sshKeyIds?: string[], retries = 2 ): Promise<{ status: string; url?: string }> { try { const response = await apiClient.post( `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`, - { config_profile_id: configProfileId } + { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] } ); return response.data; } catch (error) { @@ -121,7 +126,7 @@ export async function restartInstance( const axiosError = error as AxiosError; if (retries > 0 && !axiosError.response) { await new Promise((r) => setTimeout(r, 1500)); - return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1); + return restartInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1); } throw error; } diff --git a/apps/web/src/components/create-session-form.tsx b/apps/web/src/components/create-session-form.tsx index 4305e2a..18e7689 100644 --- a/apps/web/src/components/create-session-form.tsx +++ b/apps/web/src/components/create-session-form.tsx @@ -49,6 +49,7 @@ export const CreateSessionForm = ({ const [sshKeys, setSshKeys] = useState([]); const [configProfiles, setConfigProfiles] = useState([]); const [selectedConfigProfile, setSelectedConfigProfile] = useState(""); + const [selectedSshKeyIds, setSelectedSshKeyIds] = useState([]); const [branches, setBranches] = useState([]); const [isLoadingBranches, setIsLoadingBranches] = useState(false); @@ -60,9 +61,8 @@ export const CreateSessionForm = ({ const [progress, setProgress] = useState(""); const [error, setError] = useState(null); - // Load SSH keys when clone mode is shown + // Load SSH keys useEffect(() => { - if (!showCloneMode) return; const loadKeys = async () => { try { const keys = await listSSHKeys(); @@ -72,7 +72,7 @@ export const CreateSessionForm = ({ } }; void loadKeys(); - }, [showCloneMode]); + }, []); // Load config profiles when tool type is selected useEffect(() => { @@ -166,7 +166,8 @@ export const CreateSessionForm = ({ showCloneMode && cloneMode === "clone" && isCreatingNewBranch ? newBranchName : undefined, - selectedConfigProfile || undefined + selectedConfigProfile || undefined, + selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined ); setProgress("Starting container..."); @@ -182,7 +183,8 @@ export const CreateSessionForm = ({ setIsCreatingNewBranch(false); setNewBranchName(""); setBaseBranch(""); - setBranches([]); + setBranches([]); + setSelectedSshKeyIds([]); setStatus("idle"); onSuccess?.(instance); @@ -344,8 +346,54 @@ export const CreateSessionForm = ({ )} - {/* Step 5: Clone Mode & Branch */} - {showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false, + {/* Step 5: SSH Keys */} + {hasToolType && renderStep("SSH Keys (optional)", 5, true, false, +
+
+ {sshKeys.length === 0 && ( + No SSH keys configured. + )} + {sshKeys.map((key) => ( + + ))} +
+
+ Selected keys will be mounted into the container at ~/.ssh +
+
+ )} + + {/* Step 6: Clone Mode & Branch */} + {showCloneMode && hasToolType && renderStep("Repository Access", 6, true, false,
+ + + + ) : ( + + )} )} {instance.status === "running" && ( @@ -376,68 +455,119 @@ export const InstanceList = ({ )} - {profileSelectInstanceId === instance.id ? ( -
- - - -
- ) : ( - - )} + { + if (e.target.checked) { + setSelectedSshKeyIdsForAction( + (prev) => [...prev, key.id], + ); + } else { + setSelectedSshKeyIdsForAction( + (prev) => + prev.filter( + (id) => + id !== key.id, + ), + ); + } + }} + /> + {key.name} + + ))} + + + + + ) : ( + + )} )}