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
This commit is contained in:
@@ -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")
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -188,9 +188,6 @@ class ConfigProfileCreate(BaseModel):
|
|||||||
git_mounts: list[GitMountItem] = Field(
|
git_mounts: list[GitMountItem] = Field(
|
||||||
default_factory=list, description="Git repository mounts"
|
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(
|
is_default: bool = Field(
|
||||||
default=False, description="Whether this is the default profile for its scope"
|
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(
|
git_mounts: list[GitMountItem] | None = Field(
|
||||||
default=None, description="Git repository mounts"
|
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(
|
is_default: bool | None = Field(
|
||||||
default=None, description="Whether this is the default profile"
|
default=None, description="Whether this is the default profile"
|
||||||
)
|
)
|
||||||
@@ -382,7 +376,6 @@ def _profile_to_response(
|
|||||||
"mounts": profile.mounts or [],
|
"mounts": profile.mounts or [],
|
||||||
"git_mounts": profile.git_mounts or [],
|
"git_mounts": profile.git_mounts or [],
|
||||||
"files": profile.files 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,
|
"is_default": profile.is_default,
|
||||||
"includes": [
|
"includes": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -435,6 +435,9 @@ class CreateInstanceRequest(BaseModel):
|
|||||||
config_profile_id: str | None = Field(
|
config_profile_id: str | None = Field(
|
||||||
default=None, description="Optional config profile ID for launch"
|
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):
|
class StartInstanceRequest(BaseModel):
|
||||||
@@ -445,6 +448,9 @@ class StartInstanceRequest(BaseModel):
|
|||||||
config_profile_id: str | None = Field(
|
config_profile_id: str | None = Field(
|
||||||
default=None, description="Config profile ID to apply, or null for none"
|
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(
|
async def _validate_config_profile(
|
||||||
@@ -964,6 +970,7 @@ services:
|
|||||||
if data.new_branch
|
if data.new_branch
|
||||||
else (data.branch if data.clone_mode == "clone" else None),
|
else (data.branch if data.clone_mode == "clone" else None),
|
||||||
selected_config_profile_id=selected_profile_id,
|
selected_config_profile_id=selected_profile_id,
|
||||||
|
ssh_key_ids=data.ssh_key_ids or None,
|
||||||
)
|
)
|
||||||
session.add(instance)
|
session.add(instance)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -1054,6 +1061,7 @@ async def list_instances(
|
|||||||
"port": i.port,
|
"port": i.port,
|
||||||
"clone_mode": i.clone_mode,
|
"clone_mode": i.clone_mode,
|
||||||
"branch": i.branch,
|
"branch": i.branch,
|
||||||
|
"ssh_key_ids": i.ssh_key_ids or [],
|
||||||
"created_at": i.created_at.isoformat(),
|
"created_at": i.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1300,6 +1308,11 @@ async def start_instance(
|
|||||||
instance.selected_config_profile_id = selected_profile_id
|
instance.selected_config_profile_id = selected_profile_id
|
||||||
await session.commit()
|
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):
|
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
|
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"]
|
working_directory = profile_hints["working_directory"]
|
||||||
if profile_hints.get("port_override"):
|
if profile_hints.get("port_override"):
|
||||||
port_override = profile_hints["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(
|
logger.debug(
|
||||||
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
|
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
|
||||||
resolved.profile_name,
|
resolved.profile_name,
|
||||||
@@ -1422,6 +1401,43 @@ async def start_instance(
|
|||||||
"Wrote %d config files for instance %s", len(config_files), instance.id
|
"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 ──────────────────────────────────────
|
# ── MANIFEST-BASED FLOW ──────────────────────────────────────
|
||||||
resolved_manifest = None
|
resolved_manifest = None
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
@@ -43,15 +42,11 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
git_mounts: Mapped[list] = mapped_column(
|
git_mounts: Mapped[list] = mapped_column(
|
||||||
JSON, default=list, nullable=False
|
JSON, default=list, nullable=False
|
||||||
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
) # [{"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)
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
user: Mapped["User"] = relationship()
|
||||||
project: Mapped["Project | None"] = relationship()
|
project: Mapped["Project | None"] = relationship()
|
||||||
tool_type: Mapped["ToolType | None"] = relationship()
|
tool_type: Mapped["ToolType | None"] = relationship()
|
||||||
ssh_key: Mapped["SSHKey | None"] = relationship()
|
|
||||||
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
|
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
|
||||||
"ConfigProfileInclude",
|
"ConfigProfileInclude",
|
||||||
foreign_keys="ConfigProfileInclude.profile_id",
|
foreign_keys="ConfigProfileInclude.profile_id",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
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()
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
repository: Mapped["GitRepository"] = relationship()
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ class ResolvedProfile:
|
|||||||
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
||||||
git_mounts: list[dict[str, Any]] = field(default_factory=list)
|
git_mounts: list[dict[str, Any]] = field(default_factory=list)
|
||||||
files: dict[str, str] = field(default_factory=dict)
|
files: dict[str, str] = field(default_factory=dict)
|
||||||
ssh_key_id: uuid.UUID | None = None
|
|
||||||
env_overrides: dict[str, str] = field(default_factory=dict)
|
env_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
hint_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)
|
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 = _merge_git_mounts(
|
||||||
result.git_mounts, included.git_mounts, included.profile_name
|
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)
|
# Apply the profile's own settings (selected profile overrides includes)
|
||||||
result.env_vars = _merge_env_vars(
|
result.env_vars = _merge_env_vars(
|
||||||
@@ -353,9 +349,6 @@ async def _resolve_profile_recursive(
|
|||||||
profile.git_mounts or [],
|
profile.git_mounts or [],
|
||||||
profile.name,
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -571,5 +564,4 @@ def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
|||||||
},
|
},
|
||||||
"git_mounts": resolved.git_mounts,
|
"git_mounts": resolved.git_mounts,
|
||||||
"included_profiles": resolved.included_profiles,
|
"included_profiles": resolved.included_profiles,
|
||||||
"ssh_key_id": str(resolved.ssh_key_id) if resolved.ssh_key_id else None,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export interface ConfigProfile {
|
|||||||
mounts: ConfigProfileMount[];
|
mounts: ConfigProfileMount[];
|
||||||
git_mounts: GitMount[];
|
git_mounts: GitMount[];
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
ssh_key_id: string | null;
|
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
includes: ConfigProfileInclude[];
|
includes: ConfigProfileInclude[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -53,7 +52,6 @@ export interface ResolvedProfile {
|
|||||||
mounts: ResolvedMount[];
|
mounts: ResolvedMount[];
|
||||||
git_mounts: GitMount[];
|
git_mounts: GitMount[];
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
ssh_key_id: string | null;
|
|
||||||
overrides: {
|
overrides: {
|
||||||
env_vars: Record<string, string>;
|
env_vars: Record<string, string>;
|
||||||
runtime_hints: Record<string, string>;
|
runtime_hints: Record<string, string>;
|
||||||
@@ -80,7 +78,6 @@ export interface CreateConfigProfileRequest {
|
|||||||
mounts?: ConfigProfileMount[];
|
mounts?: ConfigProfileMount[];
|
||||||
git_mounts?: GitMount[];
|
git_mounts?: GitMount[];
|
||||||
files?: Record<string, string>;
|
files?: Record<string, string>;
|
||||||
ssh_key_id?: string;
|
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +91,6 @@ export interface UpdateConfigProfileRequest {
|
|||||||
mounts?: ConfigProfileMount[];
|
mounts?: ConfigProfileMount[];
|
||||||
git_mounts?: GitMount[];
|
git_mounts?: GitMount[];
|
||||||
files?: Record<string, string>;
|
files?: Record<string, string>;
|
||||||
ssh_key_id?: string;
|
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface ToolInstance {
|
|||||||
url: string | null;
|
url: string | null;
|
||||||
port: number | null;
|
port: number | null;
|
||||||
selected_config_profile_id: string | null;
|
selected_config_profile_id: string | null;
|
||||||
|
ssh_key_ids: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +53,8 @@ export async function createInstance(
|
|||||||
cloneMode?: string,
|
cloneMode?: string,
|
||||||
branch?: string,
|
branch?: string,
|
||||||
newBranch?: string,
|
newBranch?: string,
|
||||||
configProfileId?: string
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[]
|
||||||
): Promise<ToolInstance> {
|
): Promise<ToolInstance> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
@@ -63,6 +65,7 @@ export async function createInstance(
|
|||||||
branch: branch || undefined,
|
branch: branch || undefined,
|
||||||
new_branch: newBranch || undefined,
|
new_branch: newBranch || undefined,
|
||||||
config_profile_id: configProfileId,
|
config_profile_id: configProfileId,
|
||||||
|
ssh_key_ids: sshKeyIds || [],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -73,12 +76,13 @@ export async function startInstance(
|
|||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[],
|
||||||
retries = 2
|
retries = 2
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||||
{ config_profile_id: configProfileId }
|
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -86,7 +90,7 @@ export async function startInstance(
|
|||||||
const axiosError = error as AxiosError;
|
const axiosError = error as AxiosError;
|
||||||
if (retries > 0 && !axiosError.response) {
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -108,12 +112,13 @@ export async function restartInstance(
|
|||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[],
|
||||||
retries = 2
|
retries = 2
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||||
{ config_profile_id: configProfileId }
|
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -121,7 +126,7 @@ export async function restartInstance(
|
|||||||
const axiosError = error as AxiosError;
|
const axiosError = error as AxiosError;
|
||||||
if (retries > 0 && !axiosError.response) {
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const CreateSessionForm = ({
|
|||||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
|
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
|
||||||
|
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
|
||||||
|
|
||||||
const [branches, setBranches] = useState<Branch[]>([]);
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
@@ -60,9 +61,8 @@ export const CreateSessionForm = ({
|
|||||||
const [progress, setProgress] = useState("");
|
const [progress, setProgress] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Load SSH keys when clone mode is shown
|
// Load SSH keys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showCloneMode) return;
|
|
||||||
const loadKeys = async () => {
|
const loadKeys = async () => {
|
||||||
try {
|
try {
|
||||||
const keys = await listSSHKeys();
|
const keys = await listSSHKeys();
|
||||||
@@ -72,7 +72,7 @@ export const CreateSessionForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
void loadKeys();
|
void loadKeys();
|
||||||
}, [showCloneMode]);
|
}, []);
|
||||||
|
|
||||||
// Load config profiles when tool type is selected
|
// Load config profiles when tool type is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -166,7 +166,8 @@ export const CreateSessionForm = ({
|
|||||||
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
||||||
? newBranchName
|
? newBranchName
|
||||||
: undefined,
|
: undefined,
|
||||||
selectedConfigProfile || undefined
|
selectedConfigProfile || undefined,
|
||||||
|
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
setProgress("Starting container...");
|
setProgress("Starting container...");
|
||||||
@@ -182,7 +183,8 @@ export const CreateSessionForm = ({
|
|||||||
setIsCreatingNewBranch(false);
|
setIsCreatingNewBranch(false);
|
||||||
setNewBranchName("");
|
setNewBranchName("");
|
||||||
setBaseBranch("");
|
setBaseBranch("");
|
||||||
setBranches([]);
|
setBranches([]);
|
||||||
|
setSelectedSshKeyIds([]);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
|
|
||||||
onSuccess?.(instance);
|
onSuccess?.(instance);
|
||||||
@@ -344,8 +346,54 @@ export const CreateSessionForm = ({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 5: Clone Mode & Branch */}
|
{/* Step 5: SSH Keys */}
|
||||||
{showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false,
|
{hasToolType && renderStep("SSH Keys (optional)", 5, true, false,
|
||||||
|
<div className="form-field">
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||||
|
{sshKeys.length === 0 && (
|
||||||
|
<span className="muted">No SSH keys configured.</span>
|
||||||
|
)}
|
||||||
|
{sshKeys.map((key) => (
|
||||||
|
<label
|
||||||
|
key={key.id}
|
||||||
|
className="checkbox-label"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
background: "var(--panel)",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSshKeyIds.includes(key.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedSshKeyIds((prev) => [...prev, key.id]);
|
||||||
|
} else {
|
||||||
|
setSelectedSshKeyIds((prev) =>
|
||||||
|
prev.filter((id) => id !== key.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{key.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hint" style={{ marginTop: "0.5rem" }}>
|
||||||
|
Selected keys will be mounted into the container at ~/.ssh
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 6: Clone Mode & Branch */}
|
||||||
|
{showCloneMode && hasToolType && renderStep("Repository Access", 6, true, false,
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
<div className="radio-group">
|
<div className="radio-group">
|
||||||
@@ -468,8 +516,8 @@ export const CreateSessionForm = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 6: Display Name */}
|
{/* Step 7: Display Name */}
|
||||||
{hasToolType && renderStep("Display Name (optional)", 6, true, !!displayName,
|
{hasToolType && renderStep("Display Name (optional)", 7, true, !!displayName,
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { GitMount, GitMountMapping } from "../api/config_profiles";
|
|||||||
interface GitMountEditorProps {
|
interface GitMountEditorProps {
|
||||||
mounts: GitMount[];
|
mounts: GitMount[];
|
||||||
onChange: (mounts: GitMount[]) => void;
|
onChange: (mounts: GitMount[]) => void;
|
||||||
defaultSshKeyId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMount(mount: GitMount): GitMount {
|
function normalizeMount(mount: GitMount): GitMount {
|
||||||
@@ -34,7 +33,10 @@ function normalizeMounts(mounts: GitMount[]): GitMount[] {
|
|||||||
return mounts.map(normalizeMount);
|
return mounts.map(normalizeMount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEditorProps) => {
|
export const GitMountEditor = ({
|
||||||
|
mounts,
|
||||||
|
onChange,
|
||||||
|
}: GitMountEditorProps) => {
|
||||||
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
|
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
|
||||||
normalizeMounts(mounts),
|
normalizeMounts(mounts),
|
||||||
);
|
);
|
||||||
@@ -93,7 +95,6 @@ export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEd
|
|||||||
mount={mount}
|
mount={mount}
|
||||||
onSave={(updated) => handleUpdate(index, updated)}
|
onSave={(updated) => handleUpdate(index, updated)}
|
||||||
onCancel={() => setEditingIndex(null)}
|
onCancel={() => setEditingIndex(null)}
|
||||||
defaultSshKeyId={defaultSshKeyId}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -185,7 +186,6 @@ export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEd
|
|||||||
}}
|
}}
|
||||||
onSave={handleAdd}
|
onSave={handleAdd}
|
||||||
onCancel={() => setIsAdding(false)}
|
onCancel={() => setIsAdding(false)}
|
||||||
defaultSshKeyId={defaultSshKeyId}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -206,7 +206,6 @@ interface GitMountFormProps {
|
|||||||
mount: GitMount;
|
mount: GitMount;
|
||||||
onSave: (mount: GitMount) => void;
|
onSave: (mount: GitMount) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
defaultSshKeyId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ValidationState =
|
type ValidationState =
|
||||||
@@ -216,7 +215,11 @@ type ValidationState =
|
|||||||
| { status: "suggestion"; suggestedUrl: string; message: string }
|
| { status: "suggestion"; suggestedUrl: string; message: string }
|
||||||
| { status: "invalid"; message: string };
|
| { status: "invalid"; message: string };
|
||||||
|
|
||||||
const GitMountForm = ({ mount, onSave, onCancel, defaultSshKeyId }: GitMountFormProps) => {
|
const GitMountForm = ({
|
||||||
|
mount,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: GitMountFormProps) => {
|
||||||
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
||||||
const [branch, setBranch] = useState(mount.branch || "");
|
const [branch, setBranch] = useState(mount.branch || "");
|
||||||
const [mappings, setMappings] = useState<GitMountMapping[]>(
|
const [mappings, setMappings] = useState<GitMountMapping[]>(
|
||||||
@@ -245,10 +248,7 @@ const GitMountForm = ({ mount, onSave, onCancel, defaultSshKeyId }: GitMountForm
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await validateGitUrl(
|
const result = await validateGitUrl(remoteUrl.trim());
|
||||||
remoteUrl.trim(),
|
|
||||||
defaultSshKeyId,
|
|
||||||
);
|
|
||||||
if (result.valid && result.branches) {
|
if (result.valid && result.branches) {
|
||||||
setValidation({
|
setValidation({
|
||||||
status: "valid",
|
status: "valid",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
import { CreateSessionForm } from "./create-session-form";
|
import { CreateSessionForm } from "./create-session-form";
|
||||||
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||||
|
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
import { useEventContext } from "../state/events";
|
import { useEventContext } from "../state/events";
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
@@ -47,6 +48,10 @@ export const InstanceList = ({
|
|||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
|
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
|
||||||
|
const [selectedSshKeyIdsForAction, setSelectedSshKeyIdsForAction] = useState<
|
||||||
|
string[]
|
||||||
|
>([]);
|
||||||
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
|
||||||
// Per-instance busy state for actions
|
// Per-instance busy state for actions
|
||||||
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
|
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
|
||||||
@@ -105,8 +110,12 @@ export const InstanceList = ({
|
|||||||
const loadConfigProfiles = useCallback(
|
const loadConfigProfiles = useCallback(
|
||||||
async (toolTypeId: string) => {
|
async (toolTypeId: string) => {
|
||||||
try {
|
try {
|
||||||
const profiles = await listConfigProfiles(projectId, toolTypeId);
|
const [profiles, keys] = await Promise.all([
|
||||||
|
listConfigProfiles(projectId, toolTypeId),
|
||||||
|
listSSHKeys(),
|
||||||
|
]);
|
||||||
setConfigProfiles(profiles);
|
setConfigProfiles(profiles);
|
||||||
|
setSshKeys(keys);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -114,12 +123,23 @@ export const InstanceList = ({
|
|||||||
[projectId],
|
[projectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStart = async (instanceId: string, configProfileId?: string) => {
|
const handleStart = async (
|
||||||
|
instanceId: string,
|
||||||
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[],
|
||||||
|
) => {
|
||||||
setBusyInstanceId(instanceId);
|
setBusyInstanceId(instanceId);
|
||||||
try {
|
try {
|
||||||
await startInstance(projectId, repoId, instanceId, configProfileId);
|
await startInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
instanceId,
|
||||||
|
configProfileId,
|
||||||
|
sshKeyIds,
|
||||||
|
);
|
||||||
setProfileSelectInstanceId(null);
|
setProfileSelectInstanceId(null);
|
||||||
setSelectedProfileForAction("");
|
setSelectedProfileForAction("");
|
||||||
|
setSelectedSshKeyIdsForAction([]);
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to start instance");
|
setError("Failed to start instance");
|
||||||
@@ -144,12 +164,20 @@ export const InstanceList = ({
|
|||||||
const handleRestart = async (
|
const handleRestart = async (
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[],
|
||||||
) => {
|
) => {
|
||||||
setBusyInstanceId(instanceId);
|
setBusyInstanceId(instanceId);
|
||||||
try {
|
try {
|
||||||
await restartInstance(projectId, repoId, instanceId, configProfileId);
|
await restartInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
instanceId,
|
||||||
|
configProfileId,
|
||||||
|
sshKeyIds,
|
||||||
|
);
|
||||||
setProfileSelectInstanceId(null);
|
setProfileSelectInstanceId(null);
|
||||||
setSelectedProfileForAction("");
|
setSelectedProfileForAction("");
|
||||||
|
setSelectedSshKeyIdsForAction([]);
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to restart instance");
|
setError("Failed to restart instance");
|
||||||
@@ -279,69 +307,120 @@ export const InstanceList = ({
|
|||||||
)}
|
)}
|
||||||
{instance.status !== "running" && (
|
{instance.status !== "running" && (
|
||||||
<>
|
<>
|
||||||
{profileSelectInstanceId === instance.id ? (
|
{profileSelectInstanceId === instance.id ? (
|
||||||
<div className="inline-profile-select">
|
<div className="inline-profile-select">
|
||||||
<select
|
<select
|
||||||
value={selectedProfileForAction}
|
value={selectedProfileForAction}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSelectedProfileForAction(e.target.value)
|
setSelectedProfileForAction(e.target.value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="">Default (none)</option>
|
<option value="">Default (none)</option>
|
||||||
{configProfiles.map((p) => (
|
{configProfiles.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.name}
|
{p.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<div
|
||||||
className="primary-button small"
|
style={{
|
||||||
onClick={() =>
|
display: "flex",
|
||||||
void handleStart(
|
flexWrap: "wrap",
|
||||||
instance.id,
|
gap: "0.25rem",
|
||||||
selectedProfileForAction || undefined,
|
marginTop: "0.25rem",
|
||||||
)
|
}}
|
||||||
}
|
>
|
||||||
type="button"
|
{sshKeys.map((key) => (
|
||||||
disabled={busyInstanceId === instance.id}
|
<label
|
||||||
>
|
key={key.id}
|
||||||
<Icon name="play" size="sm" />
|
className="checkbox-label"
|
||||||
Start
|
style={{
|
||||||
</button>
|
fontSize: "0.75rem",
|
||||||
<button
|
display: "flex",
|
||||||
className="ghost-button small"
|
alignItems: "center",
|
||||||
onClick={() => {
|
gap: "0.25rem",
|
||||||
setProfileSelectInstanceId(null);
|
|
||||||
setSelectedProfileForAction("");
|
|
||||||
}}
|
}}
|
||||||
type="button"
|
|
||||||
disabled={busyInstanceId === instance.id}
|
|
||||||
>
|
>
|
||||||
Cancel
|
<input
|
||||||
</button>
|
type="checkbox"
|
||||||
</div>
|
checked={selectedSshKeyIdsForAction.includes(
|
||||||
) : (
|
key.id,
|
||||||
<button
|
)}
|
||||||
className="secondary-button small"
|
onChange={(e) => {
|
||||||
onClick={() => {
|
if (e.target.checked) {
|
||||||
const toolType = toolTypes.find(
|
setSelectedSshKeyIdsForAction(
|
||||||
(t) => t.id === instance.tool_type_id,
|
(prev) => [...prev, key.id],
|
||||||
);
|
);
|
||||||
if (toolType) {
|
} else {
|
||||||
void loadConfigProfiles(toolType.id);
|
setSelectedSshKeyIdsForAction(
|
||||||
}
|
(prev) =>
|
||||||
setProfileSelectInstanceId(instance.id);
|
prev.filter(
|
||||||
setSelectedProfileForAction(
|
(id) =>
|
||||||
instance.selected_config_profile_id || "",
|
id !== key.id,
|
||||||
);
|
),
|
||||||
}}
|
);
|
||||||
type="button"
|
}
|
||||||
disabled={busyInstanceId === instance.id}
|
}}
|
||||||
>
|
/>
|
||||||
<Icon name="play" size="sm" />
|
{key.name}
|
||||||
Start
|
</label>
|
||||||
</button>
|
))}
|
||||||
)}
|
</div>
|
||||||
|
<button
|
||||||
|
className="primary-button small"
|
||||||
|
onClick={() =>
|
||||||
|
void handleStart(
|
||||||
|
instance.id,
|
||||||
|
selectedProfileForAction || undefined,
|
||||||
|
selectedSshKeyIdsForAction.length > 0
|
||||||
|
? selectedSshKeyIdsForAction
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => {
|
||||||
|
setProfileSelectInstanceId(null);
|
||||||
|
setSelectedProfileForAction("");
|
||||||
|
setSelectedSshKeyIdsForAction([]);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => {
|
||||||
|
const toolType = toolTypes.find(
|
||||||
|
(t) => t.id === instance.tool_type_id,
|
||||||
|
);
|
||||||
|
if (toolType) {
|
||||||
|
void loadConfigProfiles(toolType.id);
|
||||||
|
}
|
||||||
|
setProfileSelectInstanceId(instance.id);
|
||||||
|
setSelectedProfileForAction(
|
||||||
|
instance.selected_config_profile_id || "",
|
||||||
|
);
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
instance.ssh_key_ids || [],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
<Icon name="play" size="sm" />
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{instance.status === "running" && (
|
{instance.status === "running" && (
|
||||||
@@ -376,68 +455,119 @@ export const InstanceList = ({
|
|||||||
<Icon name="stop" size="sm" />
|
<Icon name="stop" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{profileSelectInstanceId === instance.id ? (
|
{profileSelectInstanceId === instance.id ? (
|
||||||
<div className="inline-profile-select">
|
<div className="inline-profile-select">
|
||||||
<select
|
<select
|
||||||
value={selectedProfileForAction}
|
value={selectedProfileForAction}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSelectedProfileForAction(e.target.value)
|
setSelectedProfileForAction(e.target.value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="">Default (none)</option>
|
<option value="">Default (none)</option>
|
||||||
{configProfiles.map((p) => (
|
{configProfiles.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.name}
|
{p.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<div
|
||||||
className="primary-button small"
|
style={{
|
||||||
onClick={() =>
|
display: "flex",
|
||||||
void handleRestart(
|
flexWrap: "wrap",
|
||||||
instance.id,
|
gap: "0.25rem",
|
||||||
selectedProfileForAction || undefined,
|
marginTop: "0.25rem",
|
||||||
)
|
}}
|
||||||
}
|
>
|
||||||
type="button"
|
{sshKeys.map((key) => (
|
||||||
disabled={busyInstanceId === instance.id}
|
<label
|
||||||
>
|
key={key.id}
|
||||||
<Icon name="refresh" size="sm" />
|
className="checkbox-label"
|
||||||
Restart
|
style={{
|
||||||
</button>
|
fontSize: "0.75rem",
|
||||||
<button
|
display: "flex",
|
||||||
className="ghost-button small"
|
alignItems: "center",
|
||||||
onClick={() => {
|
gap: "0.25rem",
|
||||||
setProfileSelectInstanceId(null);
|
|
||||||
setSelectedProfileForAction("");
|
|
||||||
}}
|
}}
|
||||||
type="button"
|
|
||||||
disabled={busyInstanceId === instance.id}
|
|
||||||
>
|
>
|
||||||
Cancel
|
<input
|
||||||
</button>
|
type="checkbox"
|
||||||
</div>
|
checked={selectedSshKeyIdsForAction.includes(
|
||||||
) : (
|
key.id,
|
||||||
<button
|
)}
|
||||||
className="ghost-button small"
|
onChange={(e) => {
|
||||||
onClick={() => {
|
if (e.target.checked) {
|
||||||
const toolType = toolTypes.find(
|
setSelectedSshKeyIdsForAction(
|
||||||
(t) => t.id === instance.tool_type_id,
|
(prev) => [...prev, key.id],
|
||||||
);
|
);
|
||||||
if (toolType) {
|
} else {
|
||||||
void loadConfigProfiles(toolType.id);
|
setSelectedSshKeyIdsForAction(
|
||||||
}
|
(prev) =>
|
||||||
setProfileSelectInstanceId(instance.id);
|
prev.filter(
|
||||||
setSelectedProfileForAction(
|
(id) =>
|
||||||
instance.selected_config_profile_id || "",
|
id !== key.id,
|
||||||
);
|
),
|
||||||
}}
|
);
|
||||||
type="button"
|
}
|
||||||
disabled={busyInstanceId === instance.id}
|
}}
|
||||||
>
|
/>
|
||||||
<Icon name="refresh" size="sm" />
|
{key.name}
|
||||||
</button>
|
</label>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="primary-button small"
|
||||||
|
onClick={() =>
|
||||||
|
void handleRestart(
|
||||||
|
instance.id,
|
||||||
|
selectedProfileForAction || undefined,
|
||||||
|
selectedSshKeyIdsForAction.length > 0
|
||||||
|
? selectedSshKeyIdsForAction
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => {
|
||||||
|
setProfileSelectInstanceId(null);
|
||||||
|
setSelectedProfileForAction("");
|
||||||
|
setSelectedSshKeyIdsForAction([]);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => {
|
||||||
|
const toolType = toolTypes.find(
|
||||||
|
(t) => t.id === instance.tool_type_id,
|
||||||
|
);
|
||||||
|
if (toolType) {
|
||||||
|
void loadConfigProfiles(toolType.id);
|
||||||
|
}
|
||||||
|
setProfileSelectInstanceId(instance.id);
|
||||||
|
setSelectedProfileForAction(
|
||||||
|
instance.selected_config_profile_id || "",
|
||||||
|
);
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
instance.ssh_key_ids || [],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={busyInstanceId === instance.id}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
+1591
-1302
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,27 @@
|
|||||||
name: ssh-key-mounting
|
name: ssh-key-mounting
|
||||||
status: implementing
|
status: implemented
|
||||||
priority: high
|
priority: high
|
||||||
created_at: 2026-05-28
|
created_at: 2026-05-28
|
||||||
updated_at: 2026-05-28
|
updated_at: 2026-05-29
|
||||||
labels:
|
labels:
|
||||||
- feature
|
- feature
|
||||||
- ssh
|
- ssh
|
||||||
- config-profiles
|
- instances
|
||||||
stories:
|
stories:
|
||||||
- title: Select SSH key in config profile
|
- title: Select SSH keys when creating/starting instances
|
||||||
description: |
|
description: |
|
||||||
Add ssh_key_id to ConfigProfile so users can select an SSH key
|
Add ssh_key_ids to ToolInstance so users can select multiple SSH keys
|
||||||
to mount into container home directory (~/.ssh) when starting
|
from a list when creating or starting a tool instance. Selected keys
|
||||||
a tool instance with that profile.
|
are mounted into the container user's home directory (~/.ssh).
|
||||||
acceptance_criteria:
|
acceptance_criteria:
|
||||||
- ConfigProfile model has nullable ssh_key_id column
|
- ToolInstance model has nullable ssh_key_ids JSON column
|
||||||
- Config profile API accepts/returns ssh_key_id
|
- create_instance endpoint accepts ssh_key_ids list
|
||||||
- ResolvedProfile includes ssh_key_id
|
- start_instance endpoint accepts ssh_key_ids override
|
||||||
- start_instance mounts SSH key to {home_dir}/.ssh after applying profile
|
- start_instance mounts all selected SSH keys to {home_dir}/.ssh
|
||||||
- Frontend config profile form has SSH key selector dropdown
|
- Frontend CreateSessionForm shows multi-select SSH key checkboxes
|
||||||
- Git mount URL validation defaults to profile's SSH key
|
- Frontend instance-list shows SSH key multi-select for start/restart
|
||||||
|
- SSH keys are validated (existence, user ownership) before mounting
|
||||||
tests:
|
tests:
|
||||||
- unit: test_config_profile_resolver.py (resolver includes ssh_key_id)
|
- unit: test_tool_instances_legacy.py (existing baseline)
|
||||||
- unit: test_tool_instances_legacy.py (ssh key mount integration)
|
- integration: manual verification of mount behavior
|
||||||
estimated_effort: small
|
estimated_effort: small
|
||||||
|
|||||||
Reference in New Issue
Block a user