From mboxrd@z Thu Jan 1 00:00:00 1970 Received: from bombadil.infradead.org (bombadil.infradead.org [198.137.202.133]) (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) (No client certificate requested) by smtp.subspace.kernel.org (Postfix) with ESMTPS id A664930C62D for ; Wed, 27 Aug 2025 21:29:04 +0000 (UTC) Authentication-Results: smtp.subspace.kernel.org; arc=none smtp.client-ip=198.137.202.133 ARC-Seal:i=1; a=rsa-sha256; d=subspace.kernel.org; s=arc-20240116; t=1756330148; cv=none; b=OlXz+xVOE7Yw5k/9TfborPMlwMIR+89eBjJxQg7qumTtrXs8LMSPY4bsQQ7mIn1IxWulx3ZBGnUfp3ATGBDg2Tb2ikp9IXyTFVOHiF73aB1NF2GeJo3aBt+VK3L6gjzDUg+YdEPgMC8CbpxEwJ/QXuQLpK9QjIFjmG0+NUFDe0w= ARC-Message-Signature:i=1; a=rsa-sha256; d=subspace.kernel.org; s=arc-20240116; t=1756330148; c=relaxed/simple; bh=JWOpHt8MlPQsmrSIN/E6NuSopcHFo7StxwV/hrSiWkg=; h=From:To:Cc:Subject:Date:Message-ID:In-Reply-To:References: MIME-Version; b=u+gc9wAArg7mQb9mVeMoO3mmQ3qK6RWCNSYLXyiaXpyr/8qBmxOe0j9lO3b/o60sziTQ+uRpKSKxP9xfh2EwjpXaKAVtyNMB+XBtsUOjV5kGibjpFkfNtlCKOdg39nav7Zt/YPFrcBbzmbEVI5Ah2Zg2DVn0PATfN0Sd9NBRgTc= ARC-Authentication-Results:i=1; smtp.subspace.kernel.org; dmarc=fail (p=quarantine dis=none) header.from=kernel.org; spf=none smtp.mailfrom=infradead.org; dkim=pass (2048-bit key) header.d=infradead.org header.i=@infradead.org header.b=kRDD68Fe; arc=none smtp.client-ip=198.137.202.133 Authentication-Results: smtp.subspace.kernel.org; dmarc=fail (p=quarantine dis=none) header.from=kernel.org Authentication-Results: smtp.subspace.kernel.org; spf=none smtp.mailfrom=infradead.org Authentication-Results: smtp.subspace.kernel.org; dkim=pass (2048-bit key) header.d=infradead.org header.i=@infradead.org header.b="kRDD68Fe" DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=infradead.org; s=bombadil.20210309; h=Sender:Content-Transfer-Encoding: MIME-Version:References:In-Reply-To:Message-ID:Date:Subject:Cc:To:From: Reply-To:Content-Type:Content-ID:Content-Description; bh=0kP9RexqbgyrQQf8vU34C4pS84/an69DrjD/YKIh/eg=; b=kRDD68FeN8anIh+XdBQuBlpAso FGzNJfO7dKZ5pPpomy7+Hl43NsyA5RazZpt3k/A8vjUnwQcMev3Zlsre3onQI9598ww9bkDL0sdfK G0aR6rAmLRNpim8EJlNmczukp9VKMINaJ1NTEJ3ujonaWtuGj49w0iH77cOuye5Pyvq7mMsjAzZnx StUu33FuT7PJzddm6JDXUnVS+/gkDyYDGJRC26fW35uT10Os/l1p2EtPx94Ab2hbqzdnYpRfKPgvt rgsB3w/2tyvXPQQOmx0+94qEYjXB/EOsM0qNBUlx98j6LDCPmffJlH303Rsio+DGyY4qWXxRD1PfI hrjTwexw==; Received: from mcgrof by bombadil.infradead.org with local (Exim 4.98.2 #2 (Red Hat Linux)) id 1urNhY-0000000GsJU-1SbY; Wed, 27 Aug 2025 21:29:04 +0000 From: Luis Chamberlain To: Chuck Lever , Daniel Gomez , kdevops@lists.linux.dev Cc: Luis Chamberlain Subject: [PATCH v2 04/10] scripts: add Lambda Labs SSH key management utilities Date: Wed, 27 Aug 2025 14:28:55 -0700 Message-ID: <20250827212902.4021990-5-mcgrof@kernel.org> X-Mailer: git-send-email 2.49.0 In-Reply-To: <20250827212902.4021990-1-mcgrof@kernel.org> References: <20250827212902.4021990-1-mcgrof@kernel.org> Precedence: bulk X-Mailing-List: kdevops@lists.linux.dev List-Id: List-Subscribe: List-Unsubscribe: MIME-Version: 1.0 Content-Transfer-Encoding: 8bit Sender: Luis Chamberlain Add utilities for managing SSH keys with Lambda Labs cloud instances. This includes automatic key upload, verification, and SSH config management with per-directory isolation to support multiple kdevops instances. Key features: - Upload and manage SSH keys via API - Per-directory SSH key isolation using directory checksums - SSH config file management and updates - Key verification and listing utilities - Automatic key creation workflow support The per-directory isolation ensures each kdevops workspace uses unique SSH keys, preventing conflicts when running multiple instances. Generated-by: Claude AI Signed-off-by: Luis Chamberlain --- scripts/lambdalabs_ssh_key_name.py | 135 +++++++++ scripts/lambdalabs_ssh_keys.py | 357 ++++++++++++++++++++++++ scripts/ssh_config_file_name.py | 79 ++++++ scripts/test_ssh_keys.py | 97 +++++++ scripts/update_ssh_config_lambdalabs.py | 145 ++++++++++ scripts/upload_ssh_key_to_lambdalabs.py | 176 ++++++++++++ 6 files changed, 989 insertions(+) create mode 100755 scripts/lambdalabs_ssh_key_name.py create mode 100755 scripts/lambdalabs_ssh_keys.py create mode 100755 scripts/ssh_config_file_name.py create mode 100644 scripts/test_ssh_keys.py create mode 100755 scripts/update_ssh_config_lambdalabs.py create mode 100755 scripts/upload_ssh_key_to_lambdalabs.py diff --git a/scripts/lambdalabs_ssh_key_name.py b/scripts/lambdalabs_ssh_key_name.py new file mode 100755 index 0000000..131ac3a --- /dev/null +++ b/scripts/lambdalabs_ssh_key_name.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: copyleft-next-0.3.1 + +""" +Generate a unique SSH key name for Lambda Labs based on the current directory. +This ensures each kdevops instance uses its own SSH key for security. +""" + +import hashlib +import os +import sys + + +def get_directory_hash(path: str, length: int = 8) -> str: + """ + Generate a short hash of the directory path. + + Args: + path: Directory path to hash + length: Number of hex characters to use (default 8) + + Returns: + Hex string of specified length + """ + # Get the absolute path to ensure consistency + abs_path = os.path.abspath(path) + + # Create SHA256 hash of the path + hash_obj = hashlib.sha256(abs_path.encode("utf-8")) + + # Return first N characters of the hex digest + return hash_obj.hexdigest()[:length] + + +def get_project_name(path: str) -> str: + """ + Extract a meaningful project name from the path. + + Args: + path: Directory path + + Returns: + Project name derived from directory + """ + abs_path = os.path.abspath(path) + + # Get the last two directory components for context + # e.g., /home/user/projects/kdevops -> projects-kdevops + parts = abs_path.rstrip("/").split("/") + + if len(parts) >= 2: + # Use last two directories + project_parts = parts[-2:] + # Filter out generic names + filtered = [ + p + for p in project_parts + if p not in ["data", "home", "root", "usr", "var", "tmp"] + ] + if filtered: + return "-".join(filtered) + + # Fallback to just the last directory + return parts[-1] if parts else "kdevops" + + +def generate_ssh_key_name(prefix: str = "kdevops", include_project: bool = True) -> str: + """ + Generate a unique SSH key name for the current directory. + + Args: + prefix: Prefix for the key name (default "kdevops") + include_project: Include project name in the key (default True) + + Returns: + Unique SSH key name like "kdevops-lambda-kdevops-a1b2c3d4" + """ + cwd = os.getcwd() + dir_hash = get_directory_hash(cwd) + + parts = [prefix] + + if include_project: + project = get_project_name(cwd) + # Limit project name length and sanitize + project = project.replace("_", "-").replace(".", "-")[:20] + parts.append(project) + + parts.append(dir_hash) + + # Create the key name + key_name = "-".join(parts) + + # Ensure it's a valid name (alphanumeric and hyphens only) + key_name = "".join(c if c.isalnum() or c == "-" else "-" for c in key_name) + + # Remove multiple consecutive hyphens + while "--" in key_name: + key_name = key_name.replace("--", "-") + + # Trim to reasonable length (Lambda Labs might have limits) + if len(key_name) > 50: + # Keep prefix, partial project, and full hash + key_name = f"{prefix}-{dir_hash}" + + return key_name.strip("-") + + +def main(): + """Main entry point.""" + if len(sys.argv) > 1: + if sys.argv[1] == "--help" or sys.argv[1] == "-h": + print("Usage: lambdalabs_ssh_key_name.py [--simple]") + print() + print("Generate a unique SSH key name based on current directory.") + print() + print("Options:") + print(" --simple Generate simple name without project context") + print(" --help Show this help message") + print() + print("Examples:") + print(" Default: kdevops-lambda-kdevops-a1b2c3d4") + print(" Simple: kdevops-a1b2c3d4") + sys.exit(0) + elif sys.argv[1] == "--simple": + print(generate_ssh_key_name(include_project=False)) + else: + print(f"Unknown option: {sys.argv[1]}", file=sys.stderr) + sys.exit(1) + else: + print(generate_ssh_key_name()) + + +if __name__ == "__main__": + main() diff --git a/scripts/lambdalabs_ssh_keys.py b/scripts/lambdalabs_ssh_keys.py new file mode 100755 index 0000000..d4caede --- /dev/null +++ b/scripts/lambdalabs_ssh_keys.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: copyleft-next-0.3.1 + +""" +Lambda Labs SSH Key Management via API. +Provides functions to list, add, and delete SSH keys through the Lambda Labs API. +""" + +import json +import os +import sys +import urllib.request +import urllib.error +from typing import Dict, List, Optional, Tuple + +# Import our credentials module +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from lambdalabs_credentials import get_api_key as get_api_key_from_credentials + +LAMBDALABS_API_BASE = "https://cloud.lambdalabs.com/api/v1" + + +def get_api_key() -> Optional[str]: + """Get Lambda Labs API key from credentials file or environment variable.""" + return get_api_key_from_credentials() + + +def make_api_request( + endpoint: str, api_key: str, method: str = "GET", data: Optional[Dict] = None +) -> Optional[Dict]: + """Make a request to Lambda Labs API.""" + url = f"{LAMBDALABS_API_BASE}{endpoint}" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "User-Agent": "kdevops/1.0", + } + + try: + req_data = None + if data and method in ["POST", "PUT", "PATCH"]: + req_data = json.dumps(data).encode("utf-8") + + req = urllib.request.Request(url, headers=headers, data=req_data, method=method) + with urllib.request.urlopen(req) as response: + return json.loads(response.read().decode()) + except urllib.error.HTTPError as e: + print(f"HTTP Error {e.code}: {e.reason}", file=sys.stderr) + if e.code == 404: + print(f"Endpoint not found: {endpoint}", file=sys.stderr) + try: + error_body = e.read().decode() + print(f"Error details: {error_body}", file=sys.stderr) + except: + pass + return None + except Exception as e: + print(f"Error making API request: {e}", file=sys.stderr) + return None + + +def list_ssh_keys(api_key: str) -> Optional[List[Dict]]: + """ + List all SSH keys associated with the Lambda Labs account. + + Returns: + List of SSH key dictionaries with 'name', 'id', and 'public_key' fields + """ + response = make_api_request("/ssh-keys", api_key) + if response: + # The API returns {"data": [{name, id, public_key}, ...]} + if "data" in response: + return response["data"] + # Fallback for other response formats + elif isinstance(response, list): + return response + return None + + +def add_ssh_key(api_key: str, name: str, public_key: str) -> bool: + """ + Add a new SSH key to the Lambda Labs account. + + Args: + api_key: Lambda Labs API key + name: Name for the SSH key + public_key: The public key content + + Returns: + True if successful, False otherwise + """ + # Based on the API response structure, the endpoint is /ssh-keys + # and the format is likely {"name": name, "public_key": public_key} + endpoint = "/ssh-keys" + data = {"name": name, "public_key": public_key.strip()} + + print(f"Adding SSH key '{name}' via POST {endpoint}", file=sys.stderr) + response = make_api_request(endpoint, api_key, method="POST", data=data) + if response: + print(f"Successfully added SSH key '{name}'", file=sys.stderr) + return True + + # Try alternative format if the first one fails + data = {"name": name, "key": public_key.strip()} + print(f"Trying alternative format with 'key' field", file=sys.stderr) + response = make_api_request(endpoint, api_key, method="POST", data=data) + if response: + print(f"Successfully added SSH key '{name}'", file=sys.stderr) + return True + + return False + + +def delete_ssh_key(api_key: str, key_name_or_id: str) -> bool: + """ + Delete an SSH key from the Lambda Labs account. + + Args: + api_key: Lambda Labs API key + key_name_or_id: Name or ID of the SSH key to delete + + Returns: + True if successful, False otherwise + """ + # Check if input looks like an ID (32 character hex string) + is_id = len(key_name_or_id) == 32 and all( + c in "0123456789abcdef" for c in key_name_or_id.lower() + ) + + if not is_id: + # If we have a name, we need to find the ID + keys = list_ssh_keys(api_key) + if keys: + for key in keys: + if key.get("name") == key_name_or_id: + key_id = key.get("id") + if key_id: + print( + f"Found ID {key_id} for key '{key_name_or_id}'", + file=sys.stderr, + ) + key_name_or_id = key_id + break + else: + print(f"SSH key '{key_name_or_id}' not found", file=sys.stderr) + return False + + # Delete using the ID + endpoint = f"/ssh-keys/{key_name_or_id}" + print(f"Deleting SSH key via DELETE {endpoint}", file=sys.stderr) + response = make_api_request(endpoint, api_key, method="DELETE") + if response is not None: + print(f"Successfully deleted SSH key", file=sys.stderr) + return True + + return False + + +def read_public_key_file(filepath: str) -> Optional[str]: + """Read SSH public key from file.""" + expanded_path = os.path.expanduser(filepath) + if not os.path.exists(expanded_path): + print(f"SSH public key file not found: {expanded_path}", file=sys.stderr) + return None + + try: + with open(expanded_path, "r") as f: + return f.read().strip() + except Exception as e: + print(f"Error reading SSH public key: {e}", file=sys.stderr) + return None + + +def check_ssh_key_exists(api_key: str, key_name: str) -> bool: + """ + Check if an SSH key with the given name exists. + + Args: + api_key: Lambda Labs API key + key_name: Name of the SSH key to check + + Returns: + True if key exists, False otherwise + """ + keys = list_ssh_keys(api_key) + if not keys: + return False + + for key in keys: + # Try different possible field names + if key.get("name") == key_name or key.get("key_name") == key_name: + return True + + return False + + +def validate_ssh_setup( + api_key: str, expected_key_name: str = "kdevops-lambdalabs" +) -> Tuple[bool, str]: + """ + Validate that SSH keys are properly configured for Lambda Labs. + + Args: + api_key: Lambda Labs API key + expected_key_name: The SSH key name we expect to use + + Returns: + Tuple of (success, message) + """ + # First, try to list SSH keys + keys = list_ssh_keys(api_key) + + if keys is None: + # API doesn't support SSH key management + return ( + False, + "Lambda Labs API does not appear to support SSH key management.\n" + "You must manually add your SSH key through the Lambda Labs web console:\n" + "1. Go to https://cloud.lambdalabs.com/ssh-keys\n" + "2. Click 'Add SSH key'\n" + f"3. Name it '{expected_key_name}'\n" + "4. Paste your public key from ~/.ssh/kdevops_terraform.pub", + ) + + if not keys: + # No keys found + return ( + False, + "No SSH keys found in your Lambda Labs account.\n" + "Please add an SSH key through the web console or API before proceeding.", + ) + + # Check if expected key exists + key_names = [] + for key in keys: + name = key.get("name") or key.get("key_name") + if name: + key_names.append(name) + if name == expected_key_name: + return (True, f"SSH key '{expected_key_name}' found and ready to use.") + + # Key not found but other keys exist + key_list = "\n - ".join(key_names) + return ( + False, + f"SSH key '{expected_key_name}' not found in your Lambda Labs account.\n" + f"Available SSH keys:\n - {key_list}\n" + f"Either:\n" + f"1. Add a key named '{expected_key_name}' through the web console\n" + f"2. Or update terraform/lambdalabs/kconfigs/Kconfig.identity to use one of the existing keys", + ) + + +def main(): + """Main entry point for SSH key management.""" + if len(sys.argv) < 2: + print("Usage: lambdalabs_ssh_keys.py [args...]") + print("Commands:") + print(" list - List all SSH keys") + print(" check - Check if a specific key exists") + print(" add - Add a new SSH key") + print(" delete - Delete an SSH key") + print(" validate [key_name] - Validate SSH setup for kdevops") + sys.exit(1) + + command = sys.argv[1] + api_key = get_api_key() + + if not api_key: + print("Error: Lambda Labs API key not found", file=sys.stderr) + print("Please configure your API key:", file=sys.stderr) + print( + " python3 scripts/lambdalabs_credentials.py set 'your-api-key'", file=sys.stderr + ) + sys.exit(1) + + if command == "list": + keys = list_ssh_keys(api_key) + if keys is None: + print("Failed to list SSH keys - API may not support this feature") + sys.exit(1) + elif not keys: + print("No SSH keys found") + else: + print("SSH Keys:") + for key in keys: + if isinstance(key, dict): + name = key.get("name") or key.get("key_name") or "Unknown" + key_id = key.get("id", "") + fingerprint = key.get("fingerprint", "") + print(f" - Name: {name}") + if key_id and key_id != name: + print(f" ID: {key_id}") + if fingerprint: + print(f" Fingerprint: {fingerprint}") + # Show all fields for debugging + for k, v in key.items(): + if k not in ["name", "id", "fingerprint", "key_name"]: + print(f" {k}: {v}") + else: + # Key is just a string (name) + print(f" - {key}") + + elif command == "check": + if len(sys.argv) < 3: + print("Usage: lambdalabs_ssh_keys.py check ") + sys.exit(1) + key_name = sys.argv[2] + if check_ssh_key_exists(api_key, key_name): + print(f"SSH key '{key_name}' exists") + else: + print(f"SSH key '{key_name}' not found") + sys.exit(1) + + elif command == "add": + if len(sys.argv) < 4: + print("Usage: lambdalabs_ssh_keys.py add ") + sys.exit(1) + name = sys.argv[2] + key_file = sys.argv[3] + + public_key = read_public_key_file(key_file) + if not public_key: + sys.exit(1) + + if add_ssh_key(api_key, name, public_key): + print(f"Successfully added SSH key '{name}'") + else: + print(f"Failed to add SSH key '{name}'") + sys.exit(1) + + elif command == "delete": + if len(sys.argv) < 3: + print("Usage: lambdalabs_ssh_keys.py delete ") + sys.exit(1) + key_name = sys.argv[2] + + if delete_ssh_key(api_key, key_name): + print(f"Successfully deleted SSH key '{key_name}'") + else: + print(f"Failed to delete SSH key '{key_name}'") + sys.exit(1) + + elif command == "validate": + key_name = sys.argv[2] if len(sys.argv) > 2 else "kdevops-lambdalabs" + success, message = validate_ssh_setup(api_key, key_name) + print(message) + if not success: + sys.exit(1) + + else: + print(f"Unknown command: {command}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/ssh_config_file_name.py b/scripts/ssh_config_file_name.py new file mode 100755 index 0000000..9363548 --- /dev/null +++ b/scripts/ssh_config_file_name.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: copyleft-next-0.3.1 + +""" +Generate a unique SSH config file name based on the current directory. +This ensures each kdevops instance uses its own SSH config file. +""" + +import hashlib +import os +import sys + + +def get_directory_hash(path: str, length: int = 8) -> str: + """ + Generate a short hash of the directory path. + + Args: + path: Directory path to hash + length: Number of hex characters to use (default 8) + + Returns: + Hex string of specified length + """ + # Get the absolute path to ensure consistency + abs_path = os.path.abspath(path) + + # Create SHA256 hash of the path + hash_obj = hashlib.sha256(abs_path.encode("utf-8")) + + # Return first N characters of the hex digest + return hash_obj.hexdigest()[:length] + + +def generate_ssh_config_filename(base_path: str = "~/.ssh/config_kdevops") -> str: + """ + Generate a unique SSH config filename for the current directory. + + Args: + base_path: Base path for the SSH config file (default ~/.ssh/config_kdevops) + + Returns: + Unique SSH config filename like "~/.ssh/config_kdevops_a1b2c3d4" + """ + cwd = os.getcwd() + dir_hash = get_directory_hash(cwd) + + # Create the unique filename + config_file = f"{base_path}_{dir_hash}" + + return config_file + + +def main(): + """Main entry point.""" + if len(sys.argv) > 1: + if sys.argv[1] == "--help" or sys.argv[1] == "-h": + print("Usage: ssh_config_file_name.py [base_path]") + print() + print("Generate a unique SSH config filename based on current directory.") + print() + print("Options:") + print( + " base_path Base path for SSH config (default: ~/.ssh/config_kdevops)" + ) + print() + print("Examples:") + print(" Default: ~/.ssh/config_kdevops_a1b2c3d4") + print(" Custom: /tmp/ssh_config_a1b2c3d4") + sys.exit(0) + else: + # Use provided base path + print(generate_ssh_config_filename(sys.argv[1])) + else: + print(generate_ssh_config_filename()) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_ssh_keys.py b/scripts/test_ssh_keys.py new file mode 100644 index 0000000..608268a --- /dev/null +++ b/scripts/test_ssh_keys.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +import json +import os +import sys +import urllib.request +import urllib.error + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from lambdalabs_credentials import get_api_key + + +def list_ssh_keys(): + api_key = get_api_key() + if not api_key: + print("No API key found") + return None + + url = "https://cloud.lambdalabs.com/api/v1/ssh-keys" + headers = {"Authorization": f"Bearer {api_key}", "User-Agent": "kdevops/1.0"} + + print(f"Attempting to list SSH keys...") + print(f"URL: {url}") + print(f"API Key prefix: {api_key[:15]}...") + + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req) as response: + data = json.loads(response.read().decode()) + return data + except urllib.error.HTTPError as e: + print(f"HTTP Error {e.code}: {e.reason}") + try: + error_body = json.loads(e.read().decode()) + print(f"Error details: {json.dumps(error_body, indent=2)}") + except: + pass + return None + except Exception as e: + print(f"Error: {e}") + return None + + +def delete_ssh_key(key_name): + api_key = get_api_key() + if not api_key: + print("No API key found") + return False + + url = f"https://cloud.lambdalabs.com/api/v1/ssh-keys/{key_name}" + headers = {"Authorization": f"Bearer {api_key}", "User-Agent": "kdevops/1.0"} + + print(f"Attempting to delete SSH key: {key_name}") + + try: + req = urllib.request.Request(url, headers=headers, method="DELETE") + with urllib.request.urlopen(req) as response: + print(f"Successfully deleted key: {key_name}") + return True + except urllib.error.HTTPError as e: + print(f"HTTP Error {e.code}: {e.reason}") + try: + error_body = json.loads(e.read().decode()) + print(f"Error details: {json.dumps(error_body, indent=2)}") + except: + pass + return False + except Exception as e: + print(f"Error: {e}") + return False + + +if __name__ == "__main__": + # First try to list keys + keys_data = list_ssh_keys() + + if keys_data: + print("\nSSH Keys found:") + print(json.dumps(keys_data, indent=2)) + + # Extract key names + if "data" in keys_data: + keys = keys_data["data"] + else: + keys = keys_data if isinstance(keys_data, list) else [] + + if keys: + print("\nAttempting to delete all keys...") + for key in keys: + if isinstance(key, dict): + key_name = key.get("name") or key.get("id") + if key_name: + delete_ssh_key(key_name) + elif isinstance(key, str): + delete_ssh_key(key) + else: + print("\nCould not retrieve SSH keys") diff --git a/scripts/update_ssh_config_lambdalabs.py b/scripts/update_ssh_config_lambdalabs.py new file mode 100755 index 0000000..66a626f --- /dev/null +++ b/scripts/update_ssh_config_lambdalabs.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: copyleft-next-0.3.1 + +""" +Update SSH config for Lambda Labs instances. +Based on the existing SSH config update scripts. +""" + +import sys +import os +import re +import argparse + + +def update_ssh_config( + action, hostname, ip_address, username, ssh_config_path, ssh_key_path, tag +): + """Update or remove SSH config entries for Lambda Labs instances.""" + + # Normalize paths + ssh_config_path = ( + os.path.expanduser(ssh_config_path) if ssh_config_path else "~/.ssh/config" + ) + # For ssh_key_path, only expand and use if not None and if action is update + if action == "update" and ssh_key_path: + ssh_key_path = os.path.expanduser(ssh_key_path) + + # Ensure SSH config directory exists + ssh_config_dir = os.path.dirname(ssh_config_path) + if not os.path.exists(ssh_config_dir): + os.makedirs(ssh_config_dir, mode=0o700) + + # Read existing SSH config + if os.path.exists(ssh_config_path): + with open(ssh_config_path, "r") as f: + config_lines = f.readlines() + else: + config_lines = [] + + # Find and remove existing entry for this host + new_lines = [] + skip_block = False + for line in config_lines: + if line.strip().startswith(f"Host {hostname}"): + skip_block = True + elif skip_block and line.strip().startswith("Host "): + skip_block = False + + if not skip_block: + new_lines.append(line) + + # Add new entry if action is update + if action == "update": + if not ssh_key_path: + print(f"Error: SSH key path is required for update action") + return + + # Add Lambda Labs tag comment if not present + tag_comment = f"# {tag} instances\n" + if tag_comment not in new_lines: + new_lines.append(f"\n{tag_comment}") + + # Add host configuration + host_config = f""" +Host {hostname} + HostName {ip_address} + User {username} + IdentityFile {ssh_key_path} + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR +""" + new_lines.append(host_config) + + # Write updated config + with open(ssh_config_path, "w") as f: + f.writelines(new_lines) + + if action == "update": + print(f"Updated SSH config for {hostname} ({ip_address})") + else: + print(f"Removed SSH config for {hostname}") + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Update SSH config for Lambda Labs instances" + ) + parser.add_argument( + "action", choices=["update", "remove"], help="Action to perform" + ) + parser.add_argument("hostname", help="Hostname for the SSH config entry") + parser.add_argument( + "ip_address", nargs="?", help="IP address of the instance (required for update)" + ) + parser.add_argument( + "username", nargs="?", help="SSH username (required for update)" + ) + parser.add_argument( + "ssh_config_path", + nargs="?", + default="~/.ssh/config", + help="Path to SSH config file", + ) + parser.add_argument( + "ssh_key_path", + nargs="?", + default=None, + help="Path to SSH private key", + ) + parser.add_argument( + "tag", nargs="?", default="Lambda Labs", help="Tag for grouping instances" + ) + + args = parser.parse_args() + + if args.action == "update": + if not args.ip_address or not args.username: + print("Error: IP address and username are required for update action") + sys.exit(1) + update_ssh_config( + args.action, + args.hostname, + args.ip_address, + args.username, + args.ssh_config_path, + args.ssh_key_path, + args.tag, + ) + else: + # For remove action, we don't need all parameters + update_ssh_config( + args.action, + args.hostname, + None, + None, + args.ssh_config_path or "~/.ssh/config", + None, + args.tag, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/upload_ssh_key_to_lambdalabs.py b/scripts/upload_ssh_key_to_lambdalabs.py new file mode 100755 index 0000000..06a5f03 --- /dev/null +++ b/scripts/upload_ssh_key_to_lambdalabs.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: copyleft-next-0.3.1 + +""" +Upload SSH key to Lambda Labs via API. +This script helps upload your local SSH public key to Lambda Labs. +""" + +import json +import os +import sys +import urllib.request +import urllib.error +import urllib.parse +import lambdalabs_credentials + +LAMBDALABS_API_BASE = "https://cloud.lambdalabs.com/api/v1" + + +def get_api_key(): + """Get Lambda Labs API key from credentials file.""" + # Get API key from credentials + api_key = lambdalabs_credentials.get_api_key() + if not api_key: + print( + "Error: Lambda Labs API key not found in credentials file", file=sys.stderr + ) + print( + "Please configure it with: python3 scripts/lambdalabs_credentials.py set 'your-api-key'", + file=sys.stderr, + ) + sys.exit(1) + return api_key + + +def make_api_request(endpoint, api_key, method="GET", data=None): + """Make a request to Lambda Labs API.""" + url = f"{LAMBDALABS_API_BASE}{endpoint}" + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + + try: + if data: + data = json.dumps(data).encode("utf-8") + + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req) as response: + return json.loads(response.read().decode()) + except urllib.error.HTTPError as e: + error_body = e.read().decode() if e.read() else "No error body" + print(f"HTTP Error {e.code}: {e.reason}", file=sys.stderr) + print(f"Error details: {error_body}", file=sys.stderr) + return None + except Exception as e: + print(f"Error making API request: {e}", file=sys.stderr) + return None + + +def list_ssh_keys(api_key): + """List existing SSH keys.""" + response = make_api_request("/ssh-keys", api_key) + if response and "data" in response: + return response["data"] + return [] + + +def create_ssh_key(api_key, name, public_key): + """Create a new SSH key.""" + data = {"name": name, "public_key": public_key.strip()} + response = make_api_request("/ssh-keys", api_key, method="POST", data=data) + return response + + +def delete_ssh_key(api_key, key_id): + """Delete an SSH key by ID.""" + response = make_api_request(f"/ssh-keys/{key_id}", api_key, method="DELETE") + return response + + +def main(): + if len(sys.argv) < 2: + print("Usage: python3 upload_ssh_key_to_lambdalabs.py [args]") + print("Commands:") + print(" list - List all SSH keys") + print(" upload - Upload a new SSH key") + print(" delete - Delete an SSH key by ID") + print(" check - Check if key with name exists") + sys.exit(1) + + command = sys.argv[1] + api_key = get_api_key() + + if command == "list": + keys = list_ssh_keys(api_key) + if keys: + print("Existing SSH keys:") + for key in keys: + print(f" - ID: {key.get('id')}, Name: {key.get('name')}") + else: + print("No SSH keys found or unable to retrieve keys") + + elif command == "upload": + if len(sys.argv) < 4: + print( + "Error: upload requires and arguments", + file=sys.stderr, + ) + sys.exit(1) + + name = sys.argv[2] + key_file = sys.argv[3] + + # Check if key already exists + existing_keys = list_ssh_keys(api_key) + for key in existing_keys: + if key.get("name") == name: + print( + f"SSH key with name '{name}' already exists (ID: {key.get('id')})" + ) + print("Use 'delete' command first if you want to replace it") + sys.exit(1) + + # Read the public key + try: + with open(os.path.expanduser(key_file), "r") as f: + public_key = f.read().strip() + except FileNotFoundError: + print(f"Error: File {key_file} not found", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error reading file: {e}", file=sys.stderr) + sys.exit(1) + + # Upload the key + result = create_ssh_key(api_key, name, public_key) + if result: + print(f"Successfully uploaded SSH key '{name}'") + if "data" in result: + print(f"Key ID: {result['data'].get('id')}") + else: + print("Failed to upload SSH key") + sys.exit(1) + + elif command == "delete": + if len(sys.argv) < 3: + print("Error: delete requires argument", file=sys.stderr) + sys.exit(1) + + key_id = sys.argv[2] + result = delete_ssh_key(api_key, key_id) + if result is not None: + print(f"Successfully deleted SSH key with ID: {key_id}") + else: + print("Failed to delete SSH key") + sys.exit(1) + + elif command == "check": + if len(sys.argv) < 3: + print("Error: check requires argument", file=sys.stderr) + sys.exit(1) + + name = sys.argv[2] + existing_keys = list_ssh_keys(api_key) + for key in existing_keys: + if key.get("name") == name: + print(f"SSH key '{name}' exists (ID: {key.get('id')})") + sys.exit(0) + print(f"SSH key '{name}' does not exist") + sys.exit(1) + + else: + print(f"Unknown command: {command}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() -- 2.50.1