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:
@@ -0,0 +1,32 @@
|
|||||||
|
"""add_ssh_key_id_to_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 069d3da4dc9b
|
||||||
|
Revises: 2026_05_29_add_notifications_table
|
||||||
|
Create Date: 2026-05-29 12:30:16.580532
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "069d3da4dc9b"
|
||||||
|
down_revision = "2026_05_29_add_notifications_table"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column(
|
||||||
|
"ssh_key_id",
|
||||||
|
sa.Uuid(),
|
||||||
|
sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("config_profiles", "ssh_key_id")
|
||||||
@@ -188,6 +188,9 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -249,6 +252,9 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -376,6 +382,7 @@ 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": [
|
||||||
{
|
{
|
||||||
@@ -842,7 +849,9 @@ async def resolve_default_profile(
|
|||||||
|
|
||||||
class ValidateGitUrlRequest(BaseModel):
|
class ValidateGitUrlRequest(BaseModel):
|
||||||
url: str = Field(description="Git remote URL to validate")
|
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):
|
class ValidateGitUrlResponse(BaseModel):
|
||||||
@@ -953,11 +962,18 @@ async def validate_git_url(
|
|||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
stderr = result.stderr.strip()
|
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_msg = "Could not reach repository. Check the URL and network access."
|
||||||
error_code = "UNREACHABLE"
|
error_code = "UNREACHABLE"
|
||||||
elif "authentication" in stderr.lower() or "permission denied" in stderr.lower():
|
elif (
|
||||||
error_msg = "Authentication failed. Provide an SSH key for private repositories."
|
"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"
|
error_code = "AUTH_FAILED"
|
||||||
else:
|
else:
|
||||||
error_msg = f"Repository not accessible: {stderr[:200]}"
|
error_msg = f"Repository not accessible: {stderr[:200]}"
|
||||||
@@ -979,7 +995,7 @@ async def validate_git_url(
|
|||||||
ref = parts[1]
|
ref = parts[1]
|
||||||
# refs/heads/branch-name
|
# refs/heads/branch-name
|
||||||
if ref.startswith("refs/heads/"):
|
if ref.startswith("refs/heads/"):
|
||||||
branch_name = ref[len("refs/heads/"):]
|
branch_name = ref[len("refs/heads/") :]
|
||||||
branches.append(branch_name)
|
branches.append(branch_name)
|
||||||
if branch_name in ("main", "master"):
|
if branch_name in ("main", "master"):
|
||||||
default_branch = branch_name
|
default_branch = branch_name
|
||||||
|
|||||||
@@ -1355,6 +1355,40 @@ 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,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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
|
||||||
|
|
||||||
@@ -42,11 +43,15 @@ 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",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ 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)
|
||||||
@@ -318,6 +319,9 @@ 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(
|
||||||
@@ -349,6 +353,9 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -564,4 +571,5 @@ 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,17 +19,18 @@ def _get_fernet() -> Fernet:
|
|||||||
return Fernet(key)
|
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.
|
"""Decrypt and write SSH key files to instance directory for container mounting.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_dir: Path to instance directory
|
instance_dir: Path to instance directory
|
||||||
ssh_key: SSHKey model instance with encrypted private key
|
ssh_key: SSHKey model instance with encrypted private key
|
||||||
|
subdir: Subdirectory within instance_dir to write to (default: ".ssh")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to the .ssh directory
|
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)
|
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Decrypt private key
|
# Decrypt private key
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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;
|
||||||
@@ -52,6 +53,7 @@ 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>;
|
||||||
@@ -78,6 +80,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +94,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 {
|
||||||
@@ -33,7 +34,7 @@ function normalizeMounts(mounts: GitMount[]): GitMount[] {
|
|||||||
return mounts.map(normalizeMount);
|
return mounts.map(normalizeMount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEditorProps) => {
|
||||||
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
|
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
|
||||||
normalizeMounts(mounts),
|
normalizeMounts(mounts),
|
||||||
);
|
);
|
||||||
@@ -92,6 +93,7 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
|||||||
mount={mount}
|
mount={mount}
|
||||||
onSave={(updated) => handleUpdate(index, updated)}
|
onSave={(updated) => handleUpdate(index, updated)}
|
||||||
onCancel={() => setEditingIndex(null)}
|
onCancel={() => setEditingIndex(null)}
|
||||||
|
defaultSshKeyId={defaultSshKeyId}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -183,6 +185,7 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
|||||||
}}
|
}}
|
||||||
onSave={handleAdd}
|
onSave={handleAdd}
|
||||||
onCancel={() => setIsAdding(false)}
|
onCancel={() => setIsAdding(false)}
|
||||||
|
defaultSshKeyId={defaultSshKeyId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -203,6 +206,7 @@ interface GitMountFormProps {
|
|||||||
mount: GitMount;
|
mount: GitMount;
|
||||||
onSave: (mount: GitMount) => void;
|
onSave: (mount: GitMount) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
defaultSshKeyId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ValidationState =
|
type ValidationState =
|
||||||
@@ -212,7 +216,7 @@ 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 }: GitMountFormProps) => {
|
const GitMountForm = ({ mount, onSave, onCancel, defaultSshKeyId }: 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[]>(
|
||||||
@@ -221,7 +225,9 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
|||||||
: [{ source_path: ".", target_path: "" }],
|
: [{ source_path: ".", target_path: "" }],
|
||||||
);
|
);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [validation, setValidation] = useState<ValidationState>({ status: "idle" });
|
const [validation, setValidation] = useState<ValidationState>({
|
||||||
|
status: "idle",
|
||||||
|
});
|
||||||
|
|
||||||
const isUrlValidated =
|
const isUrlValidated =
|
||||||
validation.status === "valid" ||
|
validation.status === "valid" ||
|
||||||
@@ -239,7 +245,10 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await validateGitUrl(remoteUrl.trim());
|
const result = await validateGitUrl(
|
||||||
|
remoteUrl.trim(),
|
||||||
|
defaultSshKeyId,
|
||||||
|
);
|
||||||
if (result.valid && result.branches) {
|
if (result.valid && result.branches) {
|
||||||
setValidation({
|
setValidation({
|
||||||
status: "valid",
|
status: "valid",
|
||||||
@@ -351,7 +360,10 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
<div className="form-row" style={{ gap: "0.5rem", alignItems: "flex-start" }}>
|
<div
|
||||||
|
className="form-row"
|
||||||
|
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||||
|
>
|
||||||
<div style={{ flex: 2 }}>
|
<div style={{ flex: 2 }}>
|
||||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||||
Repository URL
|
Repository URL
|
||||||
@@ -393,16 +405,19 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
|||||||
)}
|
)}
|
||||||
{validation.status === "valid" && (
|
{validation.status === "valid" && (
|
||||||
<span className="validation-status valid">
|
<span className="validation-status valid">
|
||||||
Repository is accessible ({(validation as Extract<ValidationState, { status: "valid" }>).branches.length} branches)
|
Repository is accessible (
|
||||||
|
{
|
||||||
|
(validation as Extract<ValidationState, { status: "valid" }>)
|
||||||
|
.branches.length
|
||||||
|
}{" "}
|
||||||
|
branches)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{validation.status === "suggestion" && (
|
{validation.status === "suggestion" && (
|
||||||
<div className="url-suggestion">
|
<div className="url-suggestion">
|
||||||
<span>{validation.message}</span>
|
<span>{validation.message}</span>
|
||||||
<div className="suggestion-actions">
|
<div className="suggestion-actions">
|
||||||
<code className="suggested-url">
|
<code className="suggested-url">{validation.suggestedUrl}</code>
|
||||||
{validation.suggestedUrl}
|
|
||||||
</code>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
@@ -429,13 +444,13 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
|||||||
onChange={(e) => setBranch(e.target.value)}
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
>
|
>
|
||||||
{(validation as Extract<ValidationState, { status: "valid" }>).branches.map(
|
{(
|
||||||
(b) => (
|
validation as Extract<ValidationState, { status: "valid" }>
|
||||||
<option key={b} value={b}>
|
).branches.map((b) => (
|
||||||
{b}
|
<option key={b} value={b}>
|
||||||
</option>
|
{b}
|
||||||
),
|
</option>
|
||||||
)}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
@@ -450,7 +465,12 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ opacity: isUrlValidated ? 1 : 0.5, pointerEvents: isUrlValidated ? "auto" : "none" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: isUrlValidated ? 1 : 0.5,
|
||||||
|
pointerEvents: isUrlValidated ? "auto" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||||
Mappings
|
Mappings
|
||||||
</label>
|
</label>
|
||||||
@@ -461,7 +481,8 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
|||||||
Source paths within the repo and where to mount them in the container.
|
Source paths within the repo and where to mount them in the container.
|
||||||
{!isUrlValidated && (
|
{!isUrlValidated && (
|
||||||
<span style={{ color: "var(--warning)" }}>
|
<span style={{ color: "var(--warning)" }}>
|
||||||
{" "}Validate the URL first.
|
{" "}
|
||||||
|
Validate the URL first.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type CreateConfigProfileRequest,
|
type CreateConfigProfileRequest,
|
||||||
type ResolvedProfile,
|
type ResolvedProfile,
|
||||||
} from "../api/config_profiles";
|
} from "../api/config_profiles";
|
||||||
|
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
@@ -33,6 +34,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
|
||||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
@@ -50,6 +52,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
mounts: [],
|
mounts: [],
|
||||||
git_mounts: [],
|
git_mounts: [],
|
||||||
files: {},
|
files: {},
|
||||||
|
ssh_key_id: undefined,
|
||||||
is_default: false,
|
is_default: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,11 +64,13 @@ export const ConfigProfilesPage = () => {
|
|||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
try {
|
try {
|
||||||
const [profs, projs, types] = await Promise.all([
|
const [profs, projs, types, keys] = await Promise.all([
|
||||||
listConfigProfiles(),
|
listConfigProfiles(),
|
||||||
listProjects(),
|
listProjects(),
|
||||||
listToolTypes(),
|
listToolTypes(),
|
||||||
|
listSSHKeys(),
|
||||||
]);
|
]);
|
||||||
|
setSshKeys(keys);
|
||||||
setProfiles(profs || []);
|
setProfiles(profs || []);
|
||||||
setProjects(projs || []);
|
setProjects(projs || []);
|
||||||
setToolTypes(types || []);
|
setToolTypes(types || []);
|
||||||
@@ -89,6 +94,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
mounts: [],
|
mounts: [],
|
||||||
git_mounts: [],
|
git_mounts: [],
|
||||||
files: {},
|
files: {},
|
||||||
|
ssh_key_id: undefined,
|
||||||
is_default: false,
|
is_default: false,
|
||||||
});
|
});
|
||||||
setIncludedProfileIds([]);
|
setIncludedProfileIds([]);
|
||||||
@@ -108,6 +114,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
mounts: profile.mounts,
|
mounts: profile.mounts,
|
||||||
git_mounts: profile.git_mounts || [],
|
git_mounts: profile.git_mounts || [],
|
||||||
files: profile.files,
|
files: profile.files,
|
||||||
|
ssh_key_id: profile.ssh_key_id || undefined,
|
||||||
is_default: profile.is_default,
|
is_default: profile.is_default,
|
||||||
});
|
});
|
||||||
setIncludedProfileIds(
|
setIncludedProfileIds(
|
||||||
@@ -467,6 +474,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
{ label: "Description", value: selectedProfile.description || "-" },
|
{ label: "Description", value: selectedProfile.description || "-" },
|
||||||
{ label: "Scope", value: getScopeLabel(selectedProfile) },
|
{ label: "Scope", value: getScopeLabel(selectedProfile) },
|
||||||
{ label: "Default", value: selectedProfile.is_default ? "Yes" : "No" },
|
{ label: "Default", value: selectedProfile.is_default ? "Yes" : "No" },
|
||||||
|
{ label: "SSH Key", value: sshKeys.find(k => k.id === selectedProfile.ssh_key_id)?.name || "-" },
|
||||||
{ label: "Environment Variables", value: Object.keys(selectedProfile.env_vars).length > 0 ? Object.entries(selectedProfile.env_vars).map(([k, v]) => `${k}=${v}`).join(", ") : "-" },
|
{ label: "Environment Variables", value: Object.keys(selectedProfile.env_vars).length > 0 ? Object.entries(selectedProfile.env_vars).map(([k, v]) => `${k}=${v}`).join(", ") : "-" },
|
||||||
{ label: "Mounts", value: selectedProfile.mounts.length > 0 ? selectedProfile.mounts.map((m) => `${m.target} (${m.mode})`).join(", ") : "-" },
|
{ label: "Mounts", value: selectedProfile.mounts.length > 0 ? selectedProfile.mounts.map((m) => `${m.target} (${m.mode})`).join(", ") : "-" },
|
||||||
{ label: "Includes", value: selectedProfile.includes.length > 0 ? `${selectedProfile.includes.length} profile(s)` : "-" },
|
{ label: "Includes", value: selectedProfile.includes.length > 0 ? `${selectedProfile.includes.length} profile(s)` : "-" },
|
||||||
@@ -563,6 +571,27 @@ export const ConfigProfilesPage = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>SSH Key</label>
|
||||||
|
<select
|
||||||
|
value={formData.ssh_key_id || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateFormField("ssh_key_id", e.target.value || undefined)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{sshKeys.map((key) => (
|
||||||
|
<option key={key.id} value={key.id}>
|
||||||
|
{key.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="hint">
|
||||||
|
Mounts this SSH key into the container at {"~/.ssh"} when a
|
||||||
|
tool instance is started with this profile.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Environment Variables */}
|
{/* Environment Variables */}
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Environment Variables</label>
|
<label>Environment Variables</label>
|
||||||
@@ -1248,6 +1277,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
<GitMountEditor
|
<GitMountEditor
|
||||||
mounts={formData.git_mounts || []}
|
mounts={formData.git_mounts || []}
|
||||||
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
||||||
|
defaultSshKeyId={formData.ssh_key_id || undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: ssh-key-mounting
|
||||||
|
status: implementing
|
||||||
|
priority: high
|
||||||
|
created_at: 2026-05-28
|
||||||
|
updated_at: 2026-05-28
|
||||||
|
labels:
|
||||||
|
- feature
|
||||||
|
- ssh
|
||||||
|
- config-profiles
|
||||||
|
stories:
|
||||||
|
- title: Select SSH key in config profile
|
||||||
|
description: |
|
||||||
|
Add ssh_key_id to ConfigProfile so users can select an SSH key
|
||||||
|
to mount into container home directory (~/.ssh) when starting
|
||||||
|
a tool instance with that profile.
|
||||||
|
acceptance_criteria:
|
||||||
|
- ConfigProfile model has nullable ssh_key_id column
|
||||||
|
- Config profile API accepts/returns ssh_key_id
|
||||||
|
- ResolvedProfile includes ssh_key_id
|
||||||
|
- start_instance mounts SSH key to {home_dir}/.ssh after applying profile
|
||||||
|
- Frontend config profile form has SSH key selector dropdown
|
||||||
|
- Git mount URL validation defaults to profile's SSH key
|
||||||
|
tests:
|
||||||
|
- unit: test_config_profile_resolver.py (resolver includes ssh_key_id)
|
||||||
|
- unit: test_tool_instances_legacy.py (ssh key mount integration)
|
||||||
|
estimated_effort: small
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Exploration: SSH Key Mounting in Config Profiles
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
- SSH keys are stored in `ssh_keys` table, user-scoped
|
||||||
|
- Keys are attached to `GitRepository` via `ssh_key_id`
|
||||||
|
- On clone-mode instance start, the repo's key is mounted to `/root/.ssh`
|
||||||
|
- `prepare_ssh_key_files` writes to `instance_dir/.ssh`
|
||||||
|
- Only works for clone mode; always mounts to `/root/.ssh`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
1. Keys are tied to repositories, not selectable per-instance or per-profile
|
||||||
|
2. Always mounted to `/root/.ssh`, not the container user's home dir
|
||||||
|
3. Only clone-mode instances get SSH keys; mount-mode instances can't use SSH
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
Add `ssh_key_id` to ConfigProfile. When a profile with an SSH key is applied:
|
||||||
|
1. Fetch the SSH key
|
||||||
|
2. Stage decrypted files to `instance_dir/mounts/ssh/.ssh`
|
||||||
|
3. Add volume mount to compose: `instance_dir/mounts/ssh/.ssh` → `{home_dir}/.ssh`
|
||||||
|
4. This works for all instance types (manifest, legacy, clone, mount)
|
||||||
|
|
||||||
|
## Files to Change
|
||||||
|
- `apps/api/src/models/config_profile.py` — add `ssh_key_id` column
|
||||||
|
- `apps/api/alembic/versions/` — migration
|
||||||
|
- `apps/api/src/services/config_profile_resolver.py` — resolve + apply
|
||||||
|
- `apps/api/src/services/ssh_keys.py` — allow custom output subdir
|
||||||
|
- `apps/api/src/api/config_profiles.py` — CRUD + validation
|
||||||
|
- `apps/web/src/api/config_profiles.ts` — type + API
|
||||||
|
- `apps/web/src/pages/config-profiles.tsx` — SSH key selector UI
|
||||||
Reference in New Issue
Block a user