mirror of
https://github.com/trailofbits/dropkit
synced 2026-06-21 14:11:54 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
"""dropkit - Manage DigitalOcean droplets for ToB engineers."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
"""
|
||||
Get version string.
|
||||
|
||||
Returns:
|
||||
- "dev" if running from git repository (development mode)
|
||||
- "0.1.0+git.<commit>" if installed (commit hash embedded at build time)
|
||||
- "0.1.0" fallback if version cannot be determined
|
||||
"""
|
||||
# Check if we're in a git repository (development mode)
|
||||
try:
|
||||
repo_root = Path(__file__).parent.parent
|
||||
if (repo_root / ".git").exists():
|
||||
return "dev"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Installed mode - check for embedded version file (created at build time)
|
||||
try:
|
||||
version_file = Path(__file__).parent / "_version.txt"
|
||||
if version_file.exists():
|
||||
commit = version_file.read_text().strip()
|
||||
if commit:
|
||||
return f"0.1.0+git.{commit}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: try to get commit from git (in case running from source)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short=7", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1,
|
||||
cwd=Path(__file__).parent,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
commit = result.stdout.strip()
|
||||
if commit:
|
||||
return f"0.1.0+git.{commit}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Final fallback to base version
|
||||
return "0.1.0"
|
||||
|
||||
|
||||
__version__ = _get_version()
|
||||
+954
@@ -0,0 +1,954 @@
|
||||
"""DigitalOcean API client using raw REST API calls.
|
||||
|
||||
API Reference
|
||||
=============
|
||||
|
||||
Base URL: https://api.digitalocean.com/v2
|
||||
Auth: Authorization: Bearer <token>
|
||||
|
||||
Key Endpoints
|
||||
-------------
|
||||
|
||||
Account & SSH Keys:
|
||||
GET /account Account info (includes email for username)
|
||||
GET /account/keys List SSH keys (paginated)
|
||||
GET /account/keys/{fingerprint} Get SSH key by fingerprint
|
||||
POST /account/keys Add new SSH key
|
||||
PUT /account/keys/{id} Update SSH key name
|
||||
DELETE /account/keys/{id} Delete SSH key
|
||||
|
||||
Droplets:
|
||||
POST /droplets Create droplet
|
||||
GET /droplets List droplets
|
||||
GET /droplets?tag_name=X Filter by tag
|
||||
GET /droplets/{id} Get droplet info
|
||||
DELETE /droplets/{id} Delete droplet
|
||||
POST /droplets/{id}/actions Perform action (resize, power_on, power_off, snapshot)
|
||||
|
||||
Metadata:
|
||||
GET /regions List regions (paginated)
|
||||
GET /sizes List droplet sizes (paginated)
|
||||
GET /images List images (paginated)
|
||||
|
||||
Actions:
|
||||
GET /actions/{id} Check action status
|
||||
|
||||
Projects:
|
||||
GET /projects List projects (paginated)
|
||||
GET /projects/{project_id} Get project by UUID
|
||||
GET /projects/default Get default project
|
||||
POST /projects/{project_id}/resources Assign resources (body: {"resources": ["do:droplet:123"]})
|
||||
|
||||
Snapshots:
|
||||
GET /snapshots List snapshots (filter: resource_type=droplet)
|
||||
GET /snapshots/{id} Get snapshot
|
||||
DELETE /snapshots/{id} Delete snapshot
|
||||
|
||||
Tags:
|
||||
POST /tags Create tag
|
||||
POST /tags/{name}/resources Tag a resource
|
||||
|
||||
Pagination
|
||||
----------
|
||||
Uses `page` and `per_page` query params (max 200/page).
|
||||
This module auto-handles pagination by following `links.pages.next` URLs.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class DigitalOceanAPIError(Exception):
|
||||
"""Exception raised for DigitalOcean API errors."""
|
||||
|
||||
def __init__(self, message: str, status_code: int | None = None):
|
||||
self.status_code = status_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class DigitalOceanAPI:
|
||||
"""Client for DigitalOcean REST API."""
|
||||
|
||||
def __init__(self, token: str):
|
||||
"""Initialize API client with authentication token."""
|
||||
self.token = token
|
||||
self.base_url = "https://api.digitalocean.com/v2"
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def get_account(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get account information.
|
||||
|
||||
Returns:
|
||||
Account object with details like email, droplet_limit, status, etc.
|
||||
"""
|
||||
response = self._request("GET", "/account")
|
||||
return response.get("account", {})
|
||||
|
||||
def get_username(self) -> str:
|
||||
"""
|
||||
Get username from DigitalOcean account email.
|
||||
|
||||
Fetches the account information and sanitizes the email address
|
||||
to create a valid Linux username.
|
||||
|
||||
Returns:
|
||||
Sanitized username from DigitalOcean account
|
||||
|
||||
Raises:
|
||||
DigitalOceanAPIError: If account email cannot be fetched or is empty
|
||||
"""
|
||||
account = self.get_account()
|
||||
email = account.get("email", "")
|
||||
|
||||
if not email:
|
||||
raise DigitalOceanAPIError("No email found in DigitalOcean account")
|
||||
|
||||
return self._sanitize_email_for_username(email)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_email_for_username(email: str) -> str:
|
||||
"""
|
||||
Sanitize email address to create a valid username.
|
||||
|
||||
Removes @trailofbits.com suffix and replaces special characters.
|
||||
|
||||
Args:
|
||||
email: Email address from DigitalOcean account
|
||||
|
||||
Returns:
|
||||
Sanitized username suitable for Linux user creation
|
||||
"""
|
||||
# Remove @trailofbits.com suffix (case insensitive)
|
||||
username = re.sub(r"@trailofbits\.com$", "", email, flags=re.IGNORECASE)
|
||||
|
||||
# If no @trailofbits.com, just take the part before @
|
||||
if "@" in username:
|
||||
username = username.split("@")[0]
|
||||
|
||||
# Replace dots, hyphens, and other special characters with underscores
|
||||
username = re.sub(r"[^a-z0-9_]", "_", username.lower())
|
||||
|
||||
# Remove leading/trailing underscores
|
||||
username = username.strip("_")
|
||||
|
||||
# Ensure it starts with a letter (Linux username requirement)
|
||||
if username and not username[0].isalpha():
|
||||
username = "u" + username
|
||||
|
||||
# Fallback if sanitization results in empty string
|
||||
if not username:
|
||||
username = "user"
|
||||
|
||||
return username
|
||||
|
||||
@staticmethod
|
||||
def _validate_positive_int(value: int, name: str) -> None:
|
||||
"""
|
||||
Validate that an integer ID is positive.
|
||||
|
||||
Args:
|
||||
value: The integer to validate
|
||||
name: Name of the parameter (for error message)
|
||||
|
||||
Raises:
|
||||
ValueError: If the value is not positive
|
||||
"""
|
||||
if value <= 0:
|
||||
raise ValueError(f"{name} must be a positive integer, got: {value}")
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
**kwargs,
|
||||
) -> dict[str, Any]:
|
||||
"""Make an API request."""
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
|
||||
try:
|
||||
response = self.session.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
if response.status_code == 204 and response.text == "":
|
||||
return {}
|
||||
|
||||
return response.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
error_msg = f"API request failed: {e}"
|
||||
if e.response is not None:
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
if "message" in error_data:
|
||||
error_msg = f"API error: {error_data['message']}"
|
||||
except ValueError:
|
||||
pass
|
||||
raise DigitalOceanAPIError(error_msg, e.response.status_code)
|
||||
raise DigitalOceanAPIError(error_msg) from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise DigitalOceanAPIError(f"Network error: {e}") from e
|
||||
|
||||
def _get_paginated(
|
||||
self,
|
||||
endpoint: str,
|
||||
key: str,
|
||||
per_page: int = 200,
|
||||
extra_params: dict[str, str] | None = None,
|
||||
max_pages: int = 1000,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetch all pages of a paginated API endpoint.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint to fetch
|
||||
key: Key in response containing the items (e.g., 'regions', 'sizes')
|
||||
per_page: Number of items per page (default 200, max 200)
|
||||
extra_params: Additional query parameters to include (safely encoded)
|
||||
max_pages: Maximum number of pages to fetch (default 1000, prevents DoS)
|
||||
|
||||
Returns:
|
||||
List of all items across all pages
|
||||
|
||||
Raises:
|
||||
DigitalOceanAPIError: If max_pages limit is reached
|
||||
"""
|
||||
all_items = []
|
||||
page = 1
|
||||
|
||||
while True:
|
||||
# Build params dict safely
|
||||
params: dict[str, str | int] = {"page": page, "per_page": per_page}
|
||||
if extra_params:
|
||||
params.update(extra_params)
|
||||
|
||||
response = self._request("GET", endpoint, params=params)
|
||||
|
||||
items = response.get(key, [])
|
||||
all_items.extend(items)
|
||||
|
||||
# Check if there are more pages
|
||||
links = response.get("links", {})
|
||||
pages = links.get("pages", {})
|
||||
|
||||
# If there's no "next" link, we're done
|
||||
if "next" not in pages:
|
||||
break
|
||||
|
||||
# Safety: prevent infinite pagination
|
||||
if page >= max_pages:
|
||||
raise DigitalOceanAPIError(
|
||||
f"Pagination limit reached: {max_pages} pages. "
|
||||
"This may indicate an API issue or misconfiguration."
|
||||
)
|
||||
|
||||
page += 1
|
||||
|
||||
return all_items
|
||||
|
||||
def get_regions(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetch all available regions (handles pagination).
|
||||
|
||||
Returns:
|
||||
List of region objects with slug, name, available, features, etc.
|
||||
"""
|
||||
return self._get_paginated("/regions", "regions")
|
||||
|
||||
def get_sizes(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetch all available droplet sizes (handles pagination).
|
||||
|
||||
Returns:
|
||||
List of size objects with slug, memory, vcpus, disk, price, etc.
|
||||
"""
|
||||
return self._get_paginated("/sizes", "sizes")
|
||||
|
||||
def get_available_regions(self) -> list[dict[str, Any]]:
|
||||
"""Get only available regions."""
|
||||
regions = self.get_regions()
|
||||
return [r for r in regions if r.get("available", False)]
|
||||
|
||||
def get_available_sizes(self) -> list[dict[str, Any]]:
|
||||
"""Get only available sizes."""
|
||||
sizes = self.get_sizes()
|
||||
return [s for s in sizes if s.get("available", False)]
|
||||
|
||||
def get_images(self, image_type: str = "distribution") -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetch all available images (handles pagination).
|
||||
|
||||
Args:
|
||||
image_type: Filter by image type ('distribution', 'application', or 'all')
|
||||
|
||||
Returns:
|
||||
List of image objects with slug, name, distribution, etc.
|
||||
"""
|
||||
if image_type == "all":
|
||||
return self._get_paginated("/images", "images")
|
||||
else:
|
||||
# Use extra_params to safely pass query parameters
|
||||
return self._get_paginated("/images", "images", extra_params={"type": image_type})
|
||||
|
||||
def get_available_images(self, image_type: str = "distribution") -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get only available distribution images.
|
||||
|
||||
Args:
|
||||
image_type: Filter by image type ('distribution', 'application', or 'all')
|
||||
|
||||
Returns:
|
||||
List of available image objects
|
||||
"""
|
||||
images = self.get_images(image_type)
|
||||
# Filter for public images that are available
|
||||
return [
|
||||
img for img in images if img.get("public", False) and img.get("status") == "available"
|
||||
]
|
||||
|
||||
def create_droplet(
|
||||
self,
|
||||
name: str,
|
||||
region: str,
|
||||
size: str,
|
||||
image: str,
|
||||
user_data: str,
|
||||
tags: list[str],
|
||||
ssh_keys: list[int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a new droplet.
|
||||
|
||||
Args:
|
||||
name: Droplet name
|
||||
region: Region slug (e.g., 'nyc3')
|
||||
size: Size slug (e.g., 's-2vcpu-4gb')
|
||||
image: Image slug (e.g., 'ubuntu-25-04-x64')
|
||||
user_data: Cloud-init user data
|
||||
tags: List of tags to apply
|
||||
ssh_keys: List of SSH key IDs for root access (optional)
|
||||
|
||||
Returns:
|
||||
Droplet object from API response
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"name": name,
|
||||
"region": region,
|
||||
"size": size,
|
||||
"image": image,
|
||||
"user_data": user_data,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
if ssh_keys:
|
||||
payload["ssh_keys"] = ssh_keys
|
||||
|
||||
response = self._request("POST", "/droplets", json=payload)
|
||||
return response.get("droplet", {})
|
||||
|
||||
def get_droplet(self, droplet_id: int) -> dict[str, Any]:
|
||||
"""
|
||||
Get droplet information by ID.
|
||||
|
||||
Args:
|
||||
droplet_id: Droplet ID
|
||||
|
||||
Returns:
|
||||
Droplet object
|
||||
|
||||
Raises:
|
||||
ValueError: If droplet_id is not positive
|
||||
"""
|
||||
self._validate_positive_int(droplet_id, "droplet_id")
|
||||
response = self._request("GET", f"/droplets/{droplet_id}")
|
||||
return response.get("droplet", {})
|
||||
|
||||
def list_droplets(self, tag_name: str | None = None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List all droplets, optionally filtered by tag.
|
||||
|
||||
Args:
|
||||
tag_name: Optional tag to filter by (e.g., 'owner:myname')
|
||||
|
||||
Returns:
|
||||
List of droplet objects
|
||||
"""
|
||||
if tag_name:
|
||||
# Use tag-based filtering with safe parameter encoding
|
||||
return self._get_paginated("/droplets", "droplets", extra_params={"tag_name": tag_name})
|
||||
else:
|
||||
# Get all droplets
|
||||
return self._get_paginated("/droplets", "droplets")
|
||||
|
||||
def wait_for_droplet_active(
|
||||
self,
|
||||
droplet_id: int,
|
||||
timeout: int = 300,
|
||||
poll_interval: int = 5,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Wait for droplet to become active.
|
||||
|
||||
Args:
|
||||
droplet_id: Droplet ID
|
||||
timeout: Maximum time to wait in seconds (default 300)
|
||||
poll_interval: Time between polls in seconds (default 5)
|
||||
|
||||
Returns:
|
||||
Final droplet object
|
||||
|
||||
Raises:
|
||||
ValueError: If droplet_id is not positive
|
||||
DigitalOceanAPIError: If timeout is reached or droplet enters error state
|
||||
"""
|
||||
import time
|
||||
|
||||
self._validate_positive_int(droplet_id, "droplet_id")
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
droplet = self.get_droplet(droplet_id)
|
||||
status = droplet.get("status", "")
|
||||
|
||||
if status == "active":
|
||||
return droplet
|
||||
elif status == "error":
|
||||
raise DigitalOceanAPIError(
|
||||
f"Droplet entered error state: {droplet.get('name', droplet_id)}"
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed > timeout:
|
||||
raise DigitalOceanAPIError(
|
||||
f"Timeout waiting for droplet to become active (waited {elapsed:.0f}s)"
|
||||
)
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
def list_ssh_keys(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List all SSH keys in the account.
|
||||
|
||||
Returns:
|
||||
List of SSH key objects with id, fingerprint, public_key, name
|
||||
"""
|
||||
return self._get_paginated("/account/keys", "ssh_keys")
|
||||
|
||||
def get_ssh_key_by_fingerprint(self, fingerprint: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get SSH key by fingerprint.
|
||||
|
||||
Args:
|
||||
fingerprint: SSH key fingerprint (MD5 format: aa:bb:cc:...)
|
||||
|
||||
Returns:
|
||||
SSH key object if found, None if not found
|
||||
"""
|
||||
try:
|
||||
response = self._request("GET", f"/account/keys/{fingerprint}")
|
||||
return response.get("ssh_key", {})
|
||||
except DigitalOceanAPIError as e:
|
||||
# 404 means key doesn't exist
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def add_ssh_key(self, name: str, public_key: str) -> dict[str, Any]:
|
||||
"""
|
||||
Add a new SSH key to the account.
|
||||
|
||||
Args:
|
||||
name: Name for the SSH key
|
||||
public_key: The full SSH public key content
|
||||
|
||||
Returns:
|
||||
SSH key object from API response
|
||||
"""
|
||||
payload = {
|
||||
"name": name,
|
||||
"public_key": public_key,
|
||||
}
|
||||
|
||||
response = self._request("POST", "/account/keys", json=payload)
|
||||
return response.get("ssh_key", {})
|
||||
|
||||
def update_ssh_key(self, key_id: int, name: str) -> dict[str, Any]:
|
||||
"""
|
||||
Update an SSH key name.
|
||||
|
||||
Args:
|
||||
key_id: The SSH key ID to update
|
||||
name: New name for the SSH key
|
||||
|
||||
Returns:
|
||||
Updated SSH key object from API response
|
||||
|
||||
Raises:
|
||||
ValueError: If key_id is not positive
|
||||
DigitalOceanAPIError: If update fails
|
||||
"""
|
||||
self._validate_positive_int(key_id, "key_id")
|
||||
payload = {"name": name}
|
||||
response = self._request("PUT", f"/account/keys/{key_id}", json=payload)
|
||||
return response.get("ssh_key", {})
|
||||
|
||||
def delete_ssh_key(self, key_id: int) -> None:
|
||||
"""
|
||||
Delete an SSH key from the account.
|
||||
|
||||
Args:
|
||||
key_id: The SSH key ID to delete
|
||||
|
||||
Raises:
|
||||
ValueError: If key_id is not positive
|
||||
DigitalOceanAPIError: If deletion fails
|
||||
"""
|
||||
self._validate_positive_int(key_id, "key_id")
|
||||
self._request("DELETE", f"/account/keys/{key_id}")
|
||||
|
||||
def delete_droplet(self, droplet_id: int) -> None:
|
||||
"""
|
||||
Delete a droplet by ID.
|
||||
|
||||
Args:
|
||||
droplet_id: Droplet ID to delete
|
||||
|
||||
Raises:
|
||||
ValueError: If droplet_id is not positive
|
||||
DigitalOceanAPIError: If deletion fails
|
||||
"""
|
||||
self._validate_positive_int(droplet_id, "droplet_id")
|
||||
self._request("DELETE", f"/droplets/{droplet_id}")
|
||||
|
||||
def rename_droplet(self, droplet_id: int, new_name: str) -> dict[str, Any]:
|
||||
"""
|
||||
Rename a droplet.
|
||||
|
||||
Args:
|
||||
droplet_id: Droplet ID
|
||||
new_name: New name for the droplet
|
||||
|
||||
Returns:
|
||||
Action object with id, status, etc.
|
||||
|
||||
Raises:
|
||||
ValueError: If droplet_id is not positive
|
||||
DigitalOceanAPIError: If rename fails
|
||||
"""
|
||||
self._validate_positive_int(droplet_id, "droplet_id")
|
||||
payload = {"type": "rename", "name": new_name}
|
||||
response = self._request("POST", f"/droplets/{droplet_id}/actions", json=payload)
|
||||
return response.get("action", {})
|
||||
|
||||
def resize_droplet(self, droplet_id: int, size: str, disk: bool = True) -> dict[str, Any]:
|
||||
"""
|
||||
Resize a droplet (requires power off, causes downtime).
|
||||
|
||||
Args:
|
||||
droplet_id: Droplet ID
|
||||
size: New size slug (e.g., 's-4vcpu-8gb')
|
||||
disk: Whether to resize disk (permanent, cannot be undone). Default: True
|
||||
|
||||
Returns:
|
||||
Action object with id, status, etc.
|
||||
|
||||
Raises:
|
||||
ValueError: If droplet_id is not positive
|
||||
DigitalOceanAPIError: If resize fails
|
||||
"""
|
||||
self._validate_positive_int(droplet_id, "droplet_id")
|
||||
payload = {
|
||||
"type": "resize",
|
||||
"size": size,
|
||||
"disk": disk,
|
||||
}
|
||||
|
||||
response = self._request("POST", f"/droplets/{droplet_id}/actions", json=payload)
|
||||
return response.get("action", {})
|
||||
|
||||
def power_on_droplet(self, droplet_id: int) -> dict[str, Any]:
|
||||
"""
|
||||
Power on a droplet.
|
||||
|
||||
Args:
|
||||
droplet_id: Droplet ID
|
||||
|
||||
Returns:
|
||||
Action object with id, status, etc.
|
||||
|
||||
Raises:
|
||||
ValueError: If droplet_id is not positive
|
||||
DigitalOceanAPIError: If power on fails
|
||||
"""
|
||||
self._validate_positive_int(droplet_id, "droplet_id")
|
||||
payload = {"type": "power_on"}
|
||||
response = self._request("POST", f"/droplets/{droplet_id}/actions", json=payload)
|
||||
return response.get("action", {})
|
||||
|
||||
def power_off_droplet(self, droplet_id: int) -> dict[str, Any]:
|
||||
"""
|
||||
Power off a droplet.
|
||||
|
||||
Args:
|
||||
droplet_id: Droplet ID
|
||||
|
||||
Returns:
|
||||
Action object with id, status, etc.
|
||||
|
||||
Raises:
|
||||
ValueError: If droplet_id is not positive
|
||||
DigitalOceanAPIError: If power off fails
|
||||
"""
|
||||
self._validate_positive_int(droplet_id, "droplet_id")
|
||||
payload = {"type": "power_off"}
|
||||
response = self._request("POST", f"/droplets/{droplet_id}/actions", json=payload)
|
||||
return response.get("action", {})
|
||||
|
||||
def get_action(self, action_id: int) -> dict[str, Any]:
|
||||
"""
|
||||
Get action status by ID.
|
||||
|
||||
Args:
|
||||
action_id: Action ID
|
||||
|
||||
Returns:
|
||||
Action object with id, status, type, etc.
|
||||
|
||||
Raises:
|
||||
ValueError: If action_id is not positive
|
||||
DigitalOceanAPIError: If request fails
|
||||
"""
|
||||
self._validate_positive_int(action_id, "action_id")
|
||||
response = self._request("GET", f"/actions/{action_id}")
|
||||
return response.get("action", {})
|
||||
|
||||
def list_droplet_actions(self, droplet_id: int) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List all actions for a droplet.
|
||||
|
||||
Args:
|
||||
droplet_id: Droplet ID
|
||||
|
||||
Returns:
|
||||
List of action objects (most recent first)
|
||||
|
||||
Raises:
|
||||
ValueError: If droplet_id is not positive
|
||||
"""
|
||||
self._validate_positive_int(droplet_id, "droplet_id")
|
||||
return self._get_paginated(f"/droplets/{droplet_id}/actions", "actions")
|
||||
|
||||
def wait_for_action_complete(
|
||||
self,
|
||||
action_id: int,
|
||||
timeout: int = 300,
|
||||
poll_interval: int = 5,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Wait for action to complete.
|
||||
|
||||
Args:
|
||||
action_id: Action ID
|
||||
timeout: Maximum time to wait in seconds (default 300)
|
||||
poll_interval: Time between polls in seconds (default 5)
|
||||
|
||||
Returns:
|
||||
Final action object
|
||||
|
||||
Raises:
|
||||
ValueError: If action_id is not positive
|
||||
DigitalOceanAPIError: If timeout is reached or action enters error state
|
||||
"""
|
||||
import time
|
||||
|
||||
self._validate_positive_int(action_id, "action_id")
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
action = self.get_action(action_id)
|
||||
status = action.get("status", "")
|
||||
|
||||
if status == "completed":
|
||||
return action
|
||||
elif status == "errored":
|
||||
raise DigitalOceanAPIError(
|
||||
f"Action failed: {action.get('type', 'unknown')} (ID: {action_id})"
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed > timeout:
|
||||
raise DigitalOceanAPIError(
|
||||
f"Timeout waiting for action to complete (waited {elapsed:.0f}s)"
|
||||
)
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
def list_projects(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List all projects in the account.
|
||||
|
||||
Returns:
|
||||
List of project objects with id, name, description, purpose, etc.
|
||||
"""
|
||||
return self._get_paginated("/projects", "projects")
|
||||
|
||||
def get_project(self, project_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get a specific project by ID.
|
||||
|
||||
Args:
|
||||
project_id: Project UUID
|
||||
|
||||
Returns:
|
||||
Project object if found, None if not found (404)
|
||||
|
||||
Raises:
|
||||
DigitalOceanAPIError: If request fails (non-404 errors)
|
||||
"""
|
||||
try:
|
||||
response = self._request("GET", f"/projects/{project_id}")
|
||||
return response.get("project", {})
|
||||
except DigitalOceanAPIError as e:
|
||||
# 404 means project doesn't exist
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def get_default_project(self) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get the default project for the account.
|
||||
|
||||
Returns:
|
||||
Project object if default project exists, None if not found
|
||||
|
||||
Raises:
|
||||
DigitalOceanAPIError: If request fails (non-404 errors)
|
||||
"""
|
||||
try:
|
||||
response = self._request("GET", "/projects/default")
|
||||
return response.get("project", {})
|
||||
except DigitalOceanAPIError as e:
|
||||
# 404 means no default project
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def assign_resources_to_project(
|
||||
self, project_id: str, resource_urns: list[str]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Assign resources to a project.
|
||||
|
||||
Args:
|
||||
project_id: Project UUID
|
||||
resource_urns: List of resource URNs (e.g., ["do:droplet:12345"])
|
||||
|
||||
Returns:
|
||||
Response with assigned resources
|
||||
|
||||
Raises:
|
||||
DigitalOceanAPIError: If assignment fails
|
||||
"""
|
||||
payload = {"resources": resource_urns}
|
||||
response = self._request("POST", f"/projects/{project_id}/resources", json=payload)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def get_droplet_urn(droplet_id: int) -> str:
|
||||
"""
|
||||
Get the URN (Uniform Resource Name) for a droplet.
|
||||
|
||||
Args:
|
||||
droplet_id: Droplet ID
|
||||
|
||||
Returns:
|
||||
URN string in format "do:droplet:{id}"
|
||||
"""
|
||||
return f"do:droplet:{droplet_id}"
|
||||
|
||||
# Snapshot methods
|
||||
|
||||
def create_snapshot(self, droplet_id: int, name: str) -> dict[str, Any]:
|
||||
"""
|
||||
Create a snapshot of a droplet.
|
||||
|
||||
Args:
|
||||
droplet_id: Droplet ID to snapshot
|
||||
name: Name for the snapshot
|
||||
|
||||
Returns:
|
||||
Action object with id, status, etc.
|
||||
|
||||
Raises:
|
||||
ValueError: If droplet_id is not positive
|
||||
DigitalOceanAPIError: If snapshot creation fails
|
||||
"""
|
||||
self._validate_positive_int(droplet_id, "droplet_id")
|
||||
payload = {
|
||||
"type": "snapshot",
|
||||
"name": name,
|
||||
}
|
||||
response = self._request("POST", f"/droplets/{droplet_id}/actions", json=payload)
|
||||
return response.get("action", {})
|
||||
|
||||
def list_snapshots(self, tag: str | None = None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List snapshots, optionally filtered by tag.
|
||||
|
||||
Args:
|
||||
tag: Optional tag to filter by (e.g., 'owner:myname')
|
||||
|
||||
Returns:
|
||||
List of snapshot objects
|
||||
"""
|
||||
extra_params: dict[str, str] = {"resource_type": "droplet"}
|
||||
if tag:
|
||||
extra_params["tag_name"] = tag
|
||||
return self._get_paginated("/snapshots", "snapshots", extra_params=extra_params)
|
||||
|
||||
def get_snapshot(self, snapshot_id: int) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get a snapshot by ID.
|
||||
|
||||
Args:
|
||||
snapshot_id: Snapshot ID
|
||||
|
||||
Returns:
|
||||
Snapshot object if found, None if not found (404)
|
||||
|
||||
Raises:
|
||||
ValueError: If snapshot_id is not positive
|
||||
DigitalOceanAPIError: If request fails (non-404 errors)
|
||||
"""
|
||||
self._validate_positive_int(snapshot_id, "snapshot_id")
|
||||
try:
|
||||
response = self._request("GET", f"/snapshots/{snapshot_id}")
|
||||
return response.get("snapshot", {})
|
||||
except DigitalOceanAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def get_snapshot_by_name(self, name: str, tag: str | None = None) -> dict[str, Any] | None:
|
||||
"""
|
||||
Find a snapshot by exact name.
|
||||
|
||||
Args:
|
||||
name: Exact snapshot name to search for
|
||||
tag: Optional tag to filter by
|
||||
|
||||
Returns:
|
||||
Snapshot object if found, None if not found
|
||||
"""
|
||||
snapshots = self.list_snapshots(tag=tag)
|
||||
for snapshot in snapshots:
|
||||
if snapshot.get("name") == name:
|
||||
return snapshot
|
||||
return None
|
||||
|
||||
def delete_snapshot(self, snapshot_id: int) -> None:
|
||||
"""
|
||||
Delete a snapshot by ID.
|
||||
|
||||
Args:
|
||||
snapshot_id: Snapshot ID to delete
|
||||
|
||||
Raises:
|
||||
ValueError: If snapshot_id is not positive
|
||||
DigitalOceanAPIError: If deletion fails
|
||||
"""
|
||||
self._validate_positive_int(snapshot_id, "snapshot_id")
|
||||
self._request("DELETE", f"/snapshots/{snapshot_id}")
|
||||
|
||||
def tag_resource(self, tag_name: str, resource_id: str, resource_type: str) -> None:
|
||||
"""
|
||||
Add a tag to a resource.
|
||||
|
||||
Args:
|
||||
tag_name: Tag name to apply
|
||||
resource_id: Resource ID (string for snapshots/images)
|
||||
resource_type: Resource type ('image' for snapshots, 'droplet', etc.)
|
||||
|
||||
Raises:
|
||||
DigitalOceanAPIError: If tagging fails
|
||||
"""
|
||||
payload = {
|
||||
"resources": [
|
||||
{
|
||||
"resource_id": resource_id,
|
||||
"resource_type": resource_type,
|
||||
}
|
||||
]
|
||||
}
|
||||
self._request("POST", f"/tags/{tag_name}/resources", json=payload)
|
||||
|
||||
def create_tag(self, tag_name: str) -> dict[str, Any]:
|
||||
"""
|
||||
Create a tag if it doesn't exist.
|
||||
|
||||
Args:
|
||||
tag_name: Tag name to create
|
||||
|
||||
Returns:
|
||||
Tag object from API response
|
||||
|
||||
Raises:
|
||||
DigitalOceanAPIError: If creation fails (ignores 422 for existing tags)
|
||||
"""
|
||||
payload = {"name": tag_name}
|
||||
try:
|
||||
response = self._request("POST", "/tags", json=payload)
|
||||
return response.get("tag", {})
|
||||
except DigitalOceanAPIError as e:
|
||||
# 422 means tag already exists, which is fine
|
||||
if e.status_code == 422:
|
||||
return {"name": tag_name}
|
||||
raise
|
||||
|
||||
def create_droplet_from_snapshot(
|
||||
self,
|
||||
name: str,
|
||||
region: str,
|
||||
size: str,
|
||||
snapshot_id: int,
|
||||
tags: list[str],
|
||||
ssh_keys: list[int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a new droplet from a snapshot image.
|
||||
|
||||
Args:
|
||||
name: Droplet name
|
||||
region: Region slug (e.g., 'nyc3')
|
||||
size: Size slug (e.g., 's-2vcpu-4gb')
|
||||
snapshot_id: Snapshot ID to restore from
|
||||
tags: List of tags to apply
|
||||
ssh_keys: List of SSH key IDs for root access (optional)
|
||||
|
||||
Returns:
|
||||
Droplet object from API response
|
||||
|
||||
Raises:
|
||||
ValueError: If snapshot_id is not positive
|
||||
DigitalOceanAPIError: If droplet creation fails
|
||||
"""
|
||||
self._validate_positive_int(snapshot_id, "snapshot_id")
|
||||
payload: dict[str, Any] = {
|
||||
"name": name,
|
||||
"region": region,
|
||||
"size": size,
|
||||
"image": snapshot_id, # Snapshot ID as the image
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
if ssh_keys:
|
||||
payload["ssh_keys"] = ssh_keys
|
||||
|
||||
response = self._request("POST", "/droplets", json=payload)
|
||||
return response.get("droplet", {})
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Cloud-init template rendering."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from dropkit.config import Config
|
||||
|
||||
|
||||
def render_cloud_init(
|
||||
template_path: str,
|
||||
username: str,
|
||||
full_name: str,
|
||||
email: str,
|
||||
ssh_keys: list[str],
|
||||
tailscale_enabled: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Render cloud-init template with user data.
|
||||
|
||||
Args:
|
||||
template_path: Path to the cloud-init template file
|
||||
username: Username to create in the droplet
|
||||
full_name: Full name extracted from email (for git user.name)
|
||||
email: Email address from DigitalOcean account (for git user.email)
|
||||
ssh_keys: List of SSH public key file paths
|
||||
tailscale_enabled: Whether to install Tailscale VPN (default: True)
|
||||
|
||||
Returns:
|
||||
Rendered cloud-init configuration as string
|
||||
"""
|
||||
# Read template
|
||||
template_file = Path(template_path).expanduser()
|
||||
if not template_file.exists():
|
||||
raise FileNotFoundError(f"Cloud-init template not found: {template_path}")
|
||||
|
||||
with open(template_file) as f:
|
||||
template_content = f.read()
|
||||
|
||||
# Validate and read SSH key contents
|
||||
ssh_key_contents = []
|
||||
for key_path in ssh_keys:
|
||||
# Validate it's a public key
|
||||
Config.validate_ssh_public_key(key_path)
|
||||
# Read the content
|
||||
content = Config.read_ssh_key_content(key_path)
|
||||
ssh_key_contents.append(content)
|
||||
|
||||
# Render template with Jinja2
|
||||
template = Template(template_content)
|
||||
rendered = template.render(
|
||||
username=username,
|
||||
full_name=full_name,
|
||||
email=email,
|
||||
ssh_keys=ssh_key_contents,
|
||||
tailscale_enabled=tailscale_enabled,
|
||||
)
|
||||
|
||||
return rendered
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Configuration management for dropkit."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class DigitalOceanConfig(BaseModel):
|
||||
"""DigitalOcean API configuration."""
|
||||
|
||||
token: str = Field(..., min_length=1, description="DigitalOcean API token")
|
||||
api_base: str = Field(default="https://api.digitalocean.com/v2", description="API base URL")
|
||||
|
||||
@field_validator("token")
|
||||
@classmethod
|
||||
def token_not_empty(cls, v: str) -> str:
|
||||
"""Validate token is not empty or whitespace."""
|
||||
if not v or not v.strip():
|
||||
raise ValueError("Token cannot be empty")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class DefaultsConfig(BaseModel):
|
||||
"""Default settings for droplet creation."""
|
||||
|
||||
region: str = Field(..., min_length=1, description="Default region slug")
|
||||
size: str = Field(..., min_length=1, description="Default droplet size slug")
|
||||
image: str = Field(..., min_length=1, description="Default image slug")
|
||||
extra_tags: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Extra tags (in addition to mandatory owner:<username> and firewall tags)",
|
||||
)
|
||||
project_id: str | None = Field(
|
||||
default=None,
|
||||
description="Default project ID (UUID) for new droplets",
|
||||
)
|
||||
|
||||
|
||||
class CloudInitConfig(BaseModel):
|
||||
"""Cloud-init configuration."""
|
||||
|
||||
template_path: str | None = Field(
|
||||
default=None, description="Path to cloud-init template (None = use package default)"
|
||||
)
|
||||
ssh_keys: list[str] = Field(..., min_length=1, description="SSH public key paths")
|
||||
ssh_key_ids: list[int] = Field(
|
||||
..., min_length=1, description="DigitalOcean SSH key IDs for root access"
|
||||
)
|
||||
|
||||
@field_validator("ssh_keys")
|
||||
@classmethod
|
||||
def ssh_keys_not_empty(cls, v: list[str]) -> list[str]:
|
||||
"""Validate at least one SSH key is provided."""
|
||||
if not v:
|
||||
raise ValueError("At least one SSH key must be configured")
|
||||
return v
|
||||
|
||||
@field_validator("ssh_key_ids")
|
||||
@classmethod
|
||||
def ssh_key_ids_not_empty(cls, v: list[int]) -> list[int]:
|
||||
"""Validate at least one SSH key ID is provided."""
|
||||
if not v:
|
||||
raise ValueError("At least one SSH key ID must be configured")
|
||||
return v
|
||||
|
||||
|
||||
class SSHConfig(BaseModel):
|
||||
"""SSH configuration."""
|
||||
|
||||
config_path: str = Field(..., description="Path to SSH config file")
|
||||
auto_update: bool = Field(default=True, description="Auto-update SSH config")
|
||||
identity_file: str = Field(..., description="SSH identity file path")
|
||||
|
||||
|
||||
class TailscaleConfig(BaseModel):
|
||||
"""Tailscale VPN configuration."""
|
||||
|
||||
enabled: bool = Field(default=True, description="Enable Tailscale by default for new droplets")
|
||||
lock_down_firewall: bool = Field(
|
||||
default=True, description="Reset UFW to only allow traffic on tailscale0 interface"
|
||||
)
|
||||
auth_timeout: int = Field(
|
||||
default=300, ge=30, description="Timeout in seconds for Tailscale authentication (min: 30)"
|
||||
)
|
||||
|
||||
|
||||
class DropkitConfig(BaseModel):
|
||||
"""Main dropkit configuration."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
digitalocean: DigitalOceanConfig
|
||||
defaults: DefaultsConfig
|
||||
cloudinit: CloudInitConfig
|
||||
ssh: SSHConfig
|
||||
tailscale: TailscaleConfig = Field(default_factory=TailscaleConfig)
|
||||
|
||||
|
||||
class Config:
|
||||
"""Manages dropkit configuration with Pydantic validation."""
|
||||
|
||||
CONFIG_DIR = Path.home() / ".config" / "dropkit"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
||||
CLOUD_INIT_FILE = CONFIG_DIR / "cloud-init.yaml"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize config manager."""
|
||||
self._config: DropkitConfig | None = None
|
||||
|
||||
@property
|
||||
def config(self) -> DropkitConfig:
|
||||
"""Get the validated configuration."""
|
||||
if self._config is None:
|
||||
raise ValueError("Configuration not loaded. Call load() first.")
|
||||
return self._config
|
||||
|
||||
@classmethod
|
||||
def exists(cls) -> bool:
|
||||
"""Check if config file exists."""
|
||||
return cls.CONFIG_FILE.exists()
|
||||
|
||||
@classmethod
|
||||
def get_config_dir(cls) -> Path:
|
||||
"""Get the config directory path."""
|
||||
return cls.CONFIG_DIR
|
||||
|
||||
@staticmethod
|
||||
def get_default_template_path() -> Path:
|
||||
"""Get the default cloud-init template path from package."""
|
||||
return Path(__file__).parent / "templates" / "default-cloud-init.yaml"
|
||||
|
||||
@classmethod
|
||||
def ensure_config_dir(cls) -> None:
|
||||
"""Create config directory if it doesn't exist."""
|
||||
cls.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Set restrictive permissions on config directory
|
||||
cls.CONFIG_DIR.chmod(0o700)
|
||||
|
||||
def load(self) -> None:
|
||||
"""Load and validate configuration from file."""
|
||||
if not self.CONFIG_FILE.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Config file not found at {self.CONFIG_FILE}. Run 'dropkit init' first."
|
||||
)
|
||||
|
||||
with open(self.CONFIG_FILE) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Validate with Pydantic
|
||||
self._config = DropkitConfig(**data)
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save configuration to file."""
|
||||
if self._config is None:
|
||||
raise ValueError("No configuration to save")
|
||||
|
||||
self.ensure_config_dir()
|
||||
|
||||
# Convert Pydantic model to dict for YAML serialization
|
||||
data = self._config.model_dump(mode="python")
|
||||
|
||||
with open(self.CONFIG_FILE, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Set restrictive permissions on config file (contains API token)
|
||||
self.CONFIG_FILE.chmod(0o600)
|
||||
|
||||
@staticmethod
|
||||
def get_system_username() -> str:
|
||||
"""
|
||||
Get current system username from environment.
|
||||
|
||||
This is used for tagging droplets (owner:<username>) to track
|
||||
who created them.
|
||||
|
||||
Returns:
|
||||
Current system username from $USER environment variable
|
||||
"""
|
||||
return os.environ.get("USER", "user")
|
||||
|
||||
@staticmethod
|
||||
def detect_ssh_keys() -> list[str]:
|
||||
"""Auto-detect all SSH public keys (*.pub) in ~/.ssh/."""
|
||||
ssh_dir = Path.home() / ".ssh"
|
||||
if not ssh_dir.exists():
|
||||
return []
|
||||
|
||||
# Find all .pub files in the SSH directory
|
||||
found_keys = [str(key_path) for key_path in ssh_dir.glob("*.pub")]
|
||||
|
||||
# Sort by modification time (most recently modified first)
|
||||
found_keys.sort(key=lambda p: Path(p).stat().st_mtime, reverse=True)
|
||||
|
||||
return found_keys
|
||||
|
||||
@staticmethod
|
||||
def validate_ssh_public_key(key_path: str) -> None:
|
||||
"""
|
||||
Validate that a file is a valid SSH public key.
|
||||
|
||||
Args:
|
||||
key_path: Path to the SSH key file
|
||||
|
||||
Raises:
|
||||
ValueError: If the file is not a valid SSH public key
|
||||
FileNotFoundError: If the file doesn't exist
|
||||
"""
|
||||
path = Path(key_path).expanduser()
|
||||
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"SSH key not found: {key_path}")
|
||||
|
||||
# Check if filename ends with .pub
|
||||
if not path.name.endswith(".pub"):
|
||||
raise ValueError(
|
||||
f"SSH key file must be a public key (*.pub): {key_path}\n"
|
||||
f"Private keys should NEVER be uploaded to DigitalOcean."
|
||||
)
|
||||
|
||||
# Read and validate content
|
||||
try:
|
||||
with open(path) as f:
|
||||
content = f.read().strip()
|
||||
except Exception as e:
|
||||
raise ValueError(f"Cannot read SSH key file: {e}")
|
||||
|
||||
if not content:
|
||||
raise ValueError(f"SSH key file is empty: {key_path}")
|
||||
|
||||
# Valid public key prefixes
|
||||
valid_prefixes = (
|
||||
"ssh-rsa ",
|
||||
"ssh-ed25519 ",
|
||||
"ecdsa-sha2-nistp256 ",
|
||||
"ecdsa-sha2-nistp384 ",
|
||||
"ecdsa-sha2-nistp521 ",
|
||||
"sk-ssh-ed25519@openssh.com ",
|
||||
"sk-ecdsa-sha2-nistp256@openssh.com ",
|
||||
)
|
||||
|
||||
if not content.startswith(valid_prefixes):
|
||||
raise ValueError(
|
||||
f"File does not appear to be a valid SSH public key: {key_path}\n"
|
||||
f"Public keys should start with: {', '.join(p.strip() for p in valid_prefixes)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def read_ssh_key_content(key_path: str) -> str:
|
||||
"""Read SSH public key content."""
|
||||
path = Path(key_path).expanduser()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"SSH key not found: {key_path}")
|
||||
|
||||
with open(path) as f:
|
||||
return f.read().strip()
|
||||
|
||||
@staticmethod
|
||||
def compute_ssh_key_fingerprint(public_key: str) -> str:
|
||||
"""
|
||||
Compute MD5 fingerprint of an SSH public key.
|
||||
|
||||
Args:
|
||||
public_key: SSH public key content
|
||||
|
||||
Returns:
|
||||
MD5 fingerprint in format: aa:bb:cc:dd:...
|
||||
|
||||
Raises:
|
||||
ValueError: If key format is invalid
|
||||
"""
|
||||
try:
|
||||
# Validate and load SSH public key using cryptography library
|
||||
serialization.load_ssh_public_key(public_key.encode())
|
||||
|
||||
# Parse SSH public key format: "ssh-rsa AAAAB3... comment"
|
||||
# Extract the base64 key material (second field)
|
||||
parts = public_key.strip().split()
|
||||
if len(parts) < 2:
|
||||
raise ValueError("Invalid SSH public key format")
|
||||
|
||||
# Decode the base64 key material and compute MD5
|
||||
key_data = base64.b64decode(parts[1])
|
||||
fingerprint = hashlib.md5(key_data).hexdigest()
|
||||
|
||||
# Format as colon-separated hex pairs
|
||||
return ":".join(fingerprint[i : i + 2] for i in range(0, len(fingerprint), 2))
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to compute SSH key fingerprint: {e}")
|
||||
|
||||
def create_default_config(
|
||||
self,
|
||||
token: str,
|
||||
username: str, # noqa: ARG002 - kept for API compatibility, username derived from DO API
|
||||
region: str = "nyc3",
|
||||
size: str = "s-2vcpu-4gb",
|
||||
image: str = "ubuntu-25-04-x64",
|
||||
ssh_keys: list[str] | None = None,
|
||||
ssh_key_ids: list[int] | None = None,
|
||||
extra_tags: list[str] | None = None,
|
||||
project_id: str | None = None,
|
||||
) -> None:
|
||||
"""Create a default configuration with validation.
|
||||
|
||||
Note: The mandatory tags owner:<username> and firewall are NOT stored in config.
|
||||
They are always added at runtime when creating droplets.
|
||||
"""
|
||||
if ssh_keys is None:
|
||||
ssh_keys = self.detect_ssh_keys()
|
||||
|
||||
if not ssh_keys:
|
||||
raise ValueError("No SSH keys found. Please specify SSH key paths.")
|
||||
|
||||
if ssh_key_ids is None:
|
||||
raise ValueError("SSH key IDs must be provided from DigitalOcean.")
|
||||
|
||||
# Store only extra tags (mandatory tags added at runtime)
|
||||
if extra_tags is None:
|
||||
extra_tags = []
|
||||
|
||||
# Create validated Pydantic model
|
||||
self._config = DropkitConfig(
|
||||
digitalocean=DigitalOceanConfig(
|
||||
token=token,
|
||||
api_base="https://api.digitalocean.com/v2",
|
||||
),
|
||||
defaults=DefaultsConfig(
|
||||
region=region,
|
||||
size=size,
|
||||
image=image,
|
||||
extra_tags=extra_tags,
|
||||
project_id=project_id,
|
||||
),
|
||||
cloudinit=CloudInitConfig(
|
||||
ssh_keys=ssh_keys,
|
||||
ssh_key_ids=ssh_key_ids,
|
||||
),
|
||||
ssh=SSHConfig(
|
||||
config_path=str(Path.home() / ".ssh" / "config"),
|
||||
auto_update=True,
|
||||
identity_file=ssh_keys[0].replace(".pub", "") if ssh_keys else "~/.ssh/id_ed25519",
|
||||
),
|
||||
)
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
"""File-based locking to prevent concurrent dropkit write operations."""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager, suppress
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
# Lock file location
|
||||
LOCK_FILE = Path("/tmp/dropkit.lock")
|
||||
|
||||
# Default timeout for acquiring lock (seconds)
|
||||
DEFAULT_TIMEOUT = 30.0
|
||||
|
||||
# Polling interval when waiting for lock (seconds)
|
||||
POLL_INTERVAL = 0.5
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
class LockError(Exception):
|
||||
"""Raised when lock acquisition fails."""
|
||||
|
||||
|
||||
class LockInfo:
|
||||
"""Information about the current lock holder."""
|
||||
|
||||
def __init__(self, pid: int, command: str):
|
||||
self.pid = pid
|
||||
self.command = command
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, lock_file: Path) -> "LockInfo | None":
|
||||
"""Read lock info from file, returns None if unreadable."""
|
||||
try:
|
||||
if lock_file.exists():
|
||||
content = lock_file.read_text().strip()
|
||||
if content:
|
||||
data = json.loads(content)
|
||||
return cls(pid=data.get("pid", 0), command=data.get("command", "unknown"))
|
||||
except (json.JSONDecodeError, OSError, KeyError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Serialize lock info to JSON."""
|
||||
return json.dumps({"pid": self.pid, "command": self.command})
|
||||
|
||||
def is_process_alive(self) -> bool:
|
||||
"""Check if the lock holder process is still running."""
|
||||
try:
|
||||
os.kill(self.pid, 0) # Signal 0 checks existence without killing
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _write_lock_info(lock_fd: int, command: str) -> None:
|
||||
"""Write lock holder information to the lock file."""
|
||||
info = LockInfo(pid=os.getpid(), command=command)
|
||||
content = info.to_json().encode()
|
||||
os.ftruncate(lock_fd, 0)
|
||||
os.lseek(lock_fd, 0, os.SEEK_SET)
|
||||
os.write(lock_fd, content)
|
||||
|
||||
|
||||
def _clear_lock_info(lock_fd: int) -> None:
|
||||
"""Clear lock holder information (on release)."""
|
||||
with suppress(OSError):
|
||||
os.ftruncate(lock_fd, 0)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def operation_lock(
|
||||
command: str,
|
||||
timeout: float | None = None,
|
||||
) -> Generator[None, None, None]:
|
||||
"""
|
||||
Context manager that acquires an exclusive lock for dropkit operations.
|
||||
|
||||
Uses fcntl.flock() which automatically releases the lock when:
|
||||
- The context manager exits normally
|
||||
- An exception is raised
|
||||
- The process crashes or is killed
|
||||
|
||||
Args:
|
||||
command: Name of the command acquiring the lock (for debugging)
|
||||
timeout: Maximum seconds to wait for lock (default: 30)
|
||||
|
||||
Raises:
|
||||
LockError: If lock cannot be acquired within timeout
|
||||
|
||||
Example:
|
||||
with operation_lock("create"):
|
||||
# Exclusive operation here
|
||||
pass
|
||||
"""
|
||||
# Resolve default timeout at runtime (allows patching in tests)
|
||||
if timeout is None:
|
||||
timeout = DEFAULT_TIMEOUT
|
||||
|
||||
# Ensure parent directory exists (should always exist for /tmp)
|
||||
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Open file for reading and writing (creates if doesn't exist)
|
||||
lock_fd = os.open(str(LOCK_FILE), os.O_RDWR | os.O_CREAT, 0o600)
|
||||
|
||||
try:
|
||||
start_time = time.monotonic()
|
||||
acquired = False
|
||||
|
||||
while time.monotonic() - start_time < timeout:
|
||||
try:
|
||||
# Try non-blocking exclusive lock
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
acquired = True
|
||||
break
|
||||
except OSError:
|
||||
# Lock is held by another process, wait and retry
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
if not acquired:
|
||||
# Timeout - read lock info for error message
|
||||
lock_info = LockInfo.from_file(LOCK_FILE)
|
||||
if lock_info and lock_info.is_process_alive():
|
||||
raise LockError(
|
||||
f"Another dropkit operation is in progress.\n"
|
||||
f" Command: {lock_info.command}\n"
|
||||
f" PID: {lock_info.pid}\n"
|
||||
f"Wait for it to complete or check if it's stuck."
|
||||
)
|
||||
else:
|
||||
# Stale lock - try one more time (blocking briefly)
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
acquired = True
|
||||
except OSError:
|
||||
raise LockError(
|
||||
f"Could not acquire lock after {timeout} seconds.\nLock file: {LOCK_FILE}"
|
||||
)
|
||||
|
||||
# Write lock holder info
|
||||
_write_lock_info(lock_fd, command)
|
||||
|
||||
yield # Execute the protected code
|
||||
|
||||
finally:
|
||||
# Clear lock info and release lock
|
||||
_clear_lock_info(lock_fd)
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
os.close(lock_fd)
|
||||
|
||||
|
||||
def requires_lock(command_name: str):
|
||||
"""
|
||||
Decorator that wraps a function with operation_lock and handles errors.
|
||||
|
||||
Args:
|
||||
command_name: Name of the command (for lock info)
|
||||
|
||||
Example:
|
||||
@app.command()
|
||||
@requires_lock("create")
|
||||
def create(...):
|
||||
pass
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
with operation_lock(command_name):
|
||||
return func(*args, **kwargs)
|
||||
except LockError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
+4154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,303 @@
|
||||
"""SSH config file management."""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _backup_config(config_file: Path) -> None:
|
||||
"""
|
||||
Create a backup of the SSH config file.
|
||||
|
||||
Args:
|
||||
config_file: Path to the SSH config file
|
||||
"""
|
||||
if not config_file.exists():
|
||||
return
|
||||
|
||||
backup_file = config_file.parent / f"{config_file.name}.bak"
|
||||
|
||||
# Copy file
|
||||
shutil.copy2(config_file, backup_file)
|
||||
|
||||
# Ensure backup has same permissions as original
|
||||
original_mode = config_file.stat().st_mode
|
||||
backup_file.chmod(original_mode)
|
||||
|
||||
|
||||
def add_ssh_host(
|
||||
config_path: str,
|
||||
host_name: str,
|
||||
hostname: str,
|
||||
user: str,
|
||||
identity_file: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Add or update an SSH host entry in the SSH config file.
|
||||
|
||||
Args:
|
||||
config_path: Path to SSH config file (e.g., ~/.ssh/config)
|
||||
host_name: Host alias to use (e.g., 'my-droplet')
|
||||
hostname: IP address or hostname
|
||||
user: SSH username
|
||||
identity_file: Path to SSH private key (optional)
|
||||
"""
|
||||
config_file = Path(config_path).expanduser()
|
||||
|
||||
# Create SSH directory if it doesn't exist
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
|
||||
# Backup existing config before modifying
|
||||
_backup_config(config_file)
|
||||
|
||||
# Read existing config
|
||||
if config_file.exists():
|
||||
with open(config_file) as f:
|
||||
existing_content = f.read()
|
||||
else:
|
||||
existing_content = ""
|
||||
|
||||
# Check if host already exists
|
||||
if f"Host {host_name}" in existing_content:
|
||||
# Host exists, update it
|
||||
lines = existing_content.split("\n")
|
||||
new_lines = []
|
||||
skip_until_next_host = False
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("Host "):
|
||||
if line == f"Host {host_name}":
|
||||
# Found our host, skip this block
|
||||
skip_until_next_host = True
|
||||
else:
|
||||
# Different host
|
||||
skip_until_next_host = False
|
||||
new_lines.append(line)
|
||||
elif skip_until_next_host:
|
||||
# Skip lines in the old host block
|
||||
continue
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
existing_content = "\n".join(new_lines).rstrip() + "\n\n"
|
||||
|
||||
# Create new host entry
|
||||
host_entry = f"Host {host_name}\n"
|
||||
host_entry += f" HostName {hostname}\n"
|
||||
host_entry += " ForwardAgent yes\n"
|
||||
host_entry += f" User {user}\n"
|
||||
|
||||
if identity_file:
|
||||
host_entry += f" IdentityFile {identity_file}\n"
|
||||
|
||||
# Ensure existing content ends with newline before appending
|
||||
if existing_content and not existing_content.endswith("\n"):
|
||||
existing_content += "\n"
|
||||
|
||||
# Append new entry
|
||||
new_content = existing_content + host_entry + "\n"
|
||||
|
||||
# Write back to file
|
||||
with open(config_file, "w") as f:
|
||||
f.write(new_content)
|
||||
|
||||
# Set restrictive permissions
|
||||
config_file.chmod(0o600)
|
||||
|
||||
|
||||
def get_ssh_host_ip(config_path: str, host_name: str) -> str | None:
|
||||
"""
|
||||
Get the HostName (IP address) for an SSH host entry.
|
||||
|
||||
Args:
|
||||
config_path: Path to SSH config file
|
||||
host_name: Host alias to look up
|
||||
|
||||
Returns:
|
||||
IP address/hostname if found, None otherwise
|
||||
"""
|
||||
config_file = Path(config_path).expanduser()
|
||||
|
||||
if not config_file.exists():
|
||||
return None
|
||||
|
||||
with open(config_file) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
in_target_host = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
# Check for Host directive
|
||||
if stripped.startswith("Host "):
|
||||
# Extract host name (handle multiple hosts on same line)
|
||||
host_part = stripped[5:].strip()
|
||||
hosts = host_part.split()
|
||||
in_target_host = host_name in hosts
|
||||
elif in_target_host and stripped.startswith("HostName "):
|
||||
# Found HostName in target host block
|
||||
return stripped[9:].strip()
|
||||
elif in_target_host and stripped and not stripped.startswith((" ", "\t", "#")):
|
||||
# Left the host block (non-indented, non-comment line)
|
||||
# This handles case where there's no HostName
|
||||
if not line.startswith((" ", "\t")):
|
||||
in_target_host = False
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def host_exists(config_path: str, host_name: str) -> bool:
|
||||
"""
|
||||
Check if an SSH host entry exists in the SSH config file.
|
||||
|
||||
Args:
|
||||
config_path: Path to SSH config file
|
||||
host_name: Host alias to check
|
||||
|
||||
Returns:
|
||||
True if host exists, False otherwise
|
||||
"""
|
||||
config_file = Path(config_path).expanduser()
|
||||
|
||||
if not config_file.exists():
|
||||
return False
|
||||
|
||||
with open(config_file) as f:
|
||||
content = f.read()
|
||||
|
||||
return f"Host {host_name}" in content
|
||||
|
||||
|
||||
def remove_ssh_host(config_path: str, host_name: str) -> bool:
|
||||
"""
|
||||
Remove an SSH host entry from the SSH config file.
|
||||
|
||||
Args:
|
||||
config_path: Path to SSH config file
|
||||
host_name: Host alias to remove
|
||||
|
||||
Returns:
|
||||
True if host was found and removed, False otherwise
|
||||
"""
|
||||
config_file = Path(config_path).expanduser()
|
||||
|
||||
if not config_file.exists():
|
||||
return False
|
||||
|
||||
# Backup existing config before modifying
|
||||
_backup_config(config_file)
|
||||
|
||||
# Read existing config
|
||||
with open(config_file) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find and remove the host block
|
||||
new_lines = []
|
||||
skip_until_next_host = False
|
||||
found = False
|
||||
|
||||
for line in lines:
|
||||
if line.strip().startswith("Host "):
|
||||
if line.strip() == f"Host {host_name}":
|
||||
# Found our host, skip this block
|
||||
skip_until_next_host = True
|
||||
found = True
|
||||
else:
|
||||
# Different host
|
||||
skip_until_next_host = False
|
||||
new_lines.append(line)
|
||||
elif skip_until_next_host:
|
||||
# Skip lines in the host block (indented lines)
|
||||
if line.strip() and not line.startswith((" ", "\t")):
|
||||
# This is not an indented line, so we're past the block
|
||||
skip_until_next_host = False
|
||||
new_lines.append(line)
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
if found:
|
||||
# Write back to file
|
||||
with open(config_file, "w") as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def remove_known_hosts_entry(known_hosts_path: str, hostnames: list[str]) -> int:
|
||||
"""
|
||||
Remove entries for specified hostnames from known_hosts file.
|
||||
|
||||
Args:
|
||||
known_hosts_path: Path to known_hosts file (e.g., ~/.ssh/known_hosts)
|
||||
hostnames: List of hostnames/IPs to remove
|
||||
|
||||
Returns:
|
||||
Number of entries removed
|
||||
"""
|
||||
known_hosts_file = Path(known_hosts_path).expanduser()
|
||||
|
||||
if not known_hosts_file.exists():
|
||||
return 0
|
||||
|
||||
# Backup existing known_hosts before modifying
|
||||
_backup_config(known_hosts_file)
|
||||
|
||||
# Read existing known_hosts
|
||||
with open(known_hosts_file) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Normalize hostnames for matching (lowercase)
|
||||
hostnames_lower = {h.lower() for h in hostnames}
|
||||
|
||||
# Filter out matching entries
|
||||
new_lines = []
|
||||
removed_count = 0
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
# Skip empty lines and comments, preserve them
|
||||
if not stripped or stripped.startswith("#"):
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
# Skip hashed entries (can't match them)
|
||||
if stripped.startswith("|1|"):
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
# Parse the first field (comma-separated hostnames)
|
||||
# Format: hostname[,hostname2,...] keytype key [comment]
|
||||
parts = stripped.split(None, 1)
|
||||
if not parts:
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
host_field = parts[0]
|
||||
entry_hosts = host_field.split(",")
|
||||
|
||||
# Check if any of our hostnames match any entry host
|
||||
should_remove = False
|
||||
for entry_host in entry_hosts:
|
||||
# Handle bracketed entries like [hostname]:port
|
||||
check_host = entry_host.lower()
|
||||
if check_host.startswith("["):
|
||||
# Extract hostname from [hostname]:port
|
||||
bracket_end = check_host.find("]")
|
||||
if bracket_end > 0:
|
||||
check_host = check_host[1:bracket_end]
|
||||
|
||||
if check_host in hostnames_lower:
|
||||
should_remove = True
|
||||
break
|
||||
|
||||
if should_remove:
|
||||
removed_count += 1
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
# Only write if we removed something
|
||||
if removed_count > 0:
|
||||
with open(known_hosts_file, "w") as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
return removed_count
|
||||
@@ -0,0 +1,135 @@
|
||||
#cloud-config
|
||||
|
||||
# Update and upgrade system packages
|
||||
package_update: true
|
||||
package_upgrade: true
|
||||
disable_root_opts: no-port-forwarding,no-agent-forwarding,no-X11-forwarding
|
||||
|
||||
apt:
|
||||
sources:
|
||||
docker:
|
||||
keyid: 9DC858229FC7DD38854AE2D88D81803C0EBFCD88
|
||||
keyserver: https://download.docker.com/linux/ubuntu/gpg
|
||||
source: deb [arch=amd64 signed-by=$KEY_FILE] https://download.docker.com/linux/ubuntu $RELEASE stable
|
||||
|
||||
# Install required packages
|
||||
packages:
|
||||
- ufw
|
||||
- zsh
|
||||
- git
|
||||
- curl
|
||||
- wget
|
||||
- build-essential
|
||||
- fontconfig
|
||||
- neovim
|
||||
- apt-transport-https
|
||||
- ca-certificates
|
||||
- gnupg
|
||||
- docker-ce
|
||||
- docker-ce-cli
|
||||
- containerd.io
|
||||
- docker-buildx-plugin
|
||||
- docker-compose-plugin
|
||||
|
||||
users:
|
||||
- name: {{ username }}
|
||||
gecos: "{{ username }} user"
|
||||
shell: /bin/bash
|
||||
groups: sudo,admin,wheel,docker
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||
ssh_authorized_keys:{% for key in ssh_keys %}
|
||||
- {{ key }}
|
||||
{% endfor %}
|
||||
# Write custom zshrc file
|
||||
write_files:
|
||||
- path: /home/{{ username }}/.zshrc
|
||||
owner: {{ username }}:{{ username }}
|
||||
permissions: '0644'
|
||||
defer: true
|
||||
content: |
|
||||
# Enable Powerlevel10k instant prompt. Should stay close to the top of ~/.zshrc.
|
||||
# Initialization code that may require console input (password prompts, [y/n]
|
||||
# confirmations, etc.) must go above this block; everything else may go below.
|
||||
if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then
|
||||
source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh"
|
||||
fi
|
||||
|
||||
DEFAULT_USER="{{ username }}"
|
||||
|
||||
# Zprezto + prompt configuration
|
||||
source "${ZDOTDIR:-$HOME}/.zprezto/init.zsh"
|
||||
|
||||
autoload -Uz promptinit
|
||||
promptinit
|
||||
prompt powerlevel10k
|
||||
|
||||
# You may need to manually set your language environment
|
||||
export LANG=en_US.UTF-8
|
||||
export LC_ALL=en_US.UTF-8
|
||||
|
||||
# Preferred editor for local and remote sessions
|
||||
export EDITOR='nvim'
|
||||
export LESS='-R'
|
||||
|
||||
# Disable open-interpreter telemetry
|
||||
export DISABLE_TELEMETRY=true
|
||||
|
||||
alias vim="nvim"
|
||||
|
||||
export PATH="$PATH:/home/{{ username }}/.local/bin"
|
||||
|
||||
# To customize prompt, run `p10k configure` or edit ~/.p10k.zsh.
|
||||
[[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh
|
||||
|
||||
# Run commands after boot
|
||||
runcmd:
|
||||
# Configure UFW firewall
|
||||
- ufw default deny incoming
|
||||
- ufw default allow outgoing
|
||||
- ufw allow OpenSSH
|
||||
- ufw limit ssh
|
||||
- ufw --force enable
|
||||
|
||||
{% if tailscale_enabled %}
|
||||
# Install Tailscale via official script (daemon only, authentication handled by dropkit)
|
||||
- curl -fsSL https://tailscale.com/install.sh | sh
|
||||
{% endif %}
|
||||
|
||||
# Wait for user to be fully created
|
||||
- |
|
||||
timeout=30
|
||||
count=0
|
||||
while ! id {{ username }} >/dev/null 2>&1; do
|
||||
if [ $count -ge $timeout ]; then
|
||||
echo "Timeout waiting for user {{ username }} to be created"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "User {{ username }} is ready"
|
||||
|
||||
# Ensure home directory exists and has correct permissions
|
||||
- mkdir -p /home/{{ username }}
|
||||
- chown {{ username }}:{{ username }} /home/{{ username }}
|
||||
|
||||
# Configure git globally for the user
|
||||
- su {{ username }} -c "git config --global user.name '{{ full_name }}'"
|
||||
- su {{ username }} -c "git config --global user.email '{{ email }}'"
|
||||
|
||||
# Install zprezto for user
|
||||
- su {{ username }} -c "zsh -c 'git clone --recursive https://github.com/sorin-ionescu/prezto.git /home/{{ username }}/.zprezto'"
|
||||
|
||||
# Create Zsh configuration using setopt EXTENDED_GLOB and loop through runcoms
|
||||
- |
|
||||
su {{ username }} -c "zsh -c '
|
||||
setopt EXTENDED_GLOB
|
||||
for rcfile in /home/{{ username }}/.zprezto/runcoms/^README.md(.N); do
|
||||
ln -s "\$rcfile" "/home/{{ username }}/.\${rcfile:t}"
|
||||
done
|
||||
'"
|
||||
|
||||
# Set Zsh as default shell and ensure ownership
|
||||
- chsh -s /usr/bin/zsh {{ username }}
|
||||
- chown -R {{ username }}:{{ username }} /home/{{ username }}
|
||||
- reboot
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
"""UI utilities for dropkit - display functions and prompts."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def display_regions(regions: list[dict[str, Any]]) -> None:
|
||||
"""Display available regions in a table, sorted alphabetically by slug."""
|
||||
table = Table(title="Available Regions", show_header=True)
|
||||
table.add_column("Slug", style="cyan", no_wrap=True)
|
||||
table.add_column("Name", style="white")
|
||||
table.add_column("Features", style="dim")
|
||||
|
||||
# Sort regions alphabetically by slug
|
||||
sorted_regions = sorted(regions, key=lambda r: r.get("slug", ""))
|
||||
|
||||
for region in sorted_regions:
|
||||
slug = region.get("slug", "")
|
||||
name = region.get("name", "")
|
||||
features = ", ".join(region.get("features", [])[:3]) # Show first 3 features
|
||||
if len(region.get("features", [])) > 3:
|
||||
features += "..."
|
||||
|
||||
table.add_row(slug, name, features)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def display_sizes(sizes: list[dict[str, Any]]) -> None:
|
||||
"""Display available droplet sizes in a table, sorted by price."""
|
||||
table = Table(title="Available Droplet Sizes", show_header=True)
|
||||
table.add_column("Slug", style="cyan", no_wrap=True)
|
||||
table.add_column("Memory", style="white", justify="right")
|
||||
table.add_column("vCPUs", style="white", justify="right")
|
||||
table.add_column("Disk", style="white", justify="right")
|
||||
table.add_column("Transfer", style="white", justify="right")
|
||||
table.add_column("Price/mo", style="green", justify="right")
|
||||
|
||||
# Sort sizes by price (monthly) ascending
|
||||
sorted_sizes = sorted(sizes, key=lambda s: s.get("price_monthly", 0))
|
||||
|
||||
for size in sorted_sizes:
|
||||
slug = size.get("slug", "")
|
||||
memory = f"{size.get('memory', 0)} MB"
|
||||
vcpus = str(size.get("vcpus", 0))
|
||||
disk = f"{size.get('disk', 0)} GB"
|
||||
transfer = f"{size.get('transfer', 0)} TB"
|
||||
price = f"${size.get('price_monthly', 0):.2f}"
|
||||
|
||||
table.add_row(slug, memory, vcpus, disk, transfer, price)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def display_images(images: list[dict[str, Any]]) -> None:
|
||||
"""Display available images in a table, sorted by distribution and name."""
|
||||
table = Table(title="Available Images", show_header=True)
|
||||
table.add_column("Slug", style="cyan", no_wrap=True)
|
||||
table.add_column("Name", style="white")
|
||||
table.add_column("Distribution", style="dim")
|
||||
|
||||
# Sort images by distribution, then by name
|
||||
sorted_images = sorted(
|
||||
images, key=lambda img: (img.get("distribution", ""), img.get("name", ""))
|
||||
)
|
||||
|
||||
for image in sorted_images:
|
||||
slug = image.get("slug", "")
|
||||
name = image.get("name", "")
|
||||
distribution = image.get("distribution", "")
|
||||
|
||||
# Only show images with slugs (not snapshots)
|
||||
if slug:
|
||||
table.add_row(slug, name, distribution)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def display_projects(projects: list[dict[str, Any]]) -> None:
|
||||
"""Display available projects in a table, sorted alphabetically by name."""
|
||||
table = Table(title="Available Projects", show_header=True)
|
||||
table.add_column("ID", style="dim", no_wrap=True)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Purpose", style="white")
|
||||
table.add_column("Description", style="dim")
|
||||
|
||||
# Sort projects alphabetically by name
|
||||
sorted_projects = sorted(projects, key=lambda p: p.get("name", "").lower())
|
||||
|
||||
for project in sorted_projects:
|
||||
project_id = project.get("id", "")
|
||||
name = project.get("name", "")
|
||||
purpose = project.get("purpose", "")
|
||||
description = project.get("description", "")
|
||||
|
||||
# Truncate description if too long
|
||||
if len(description) > 50:
|
||||
description = description[:47] + "..."
|
||||
|
||||
table.add_row(project_id, name, purpose, description)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def prompt_with_help(
|
||||
prompt_text: str,
|
||||
default: str,
|
||||
display_func: Callable[[list[dict[str, Any]]], None] | None = None,
|
||||
data: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Prompt user for input with optional help via '?'.
|
||||
|
||||
Args:
|
||||
prompt_text: The prompt to display (without the default/? part)
|
||||
default: Default value
|
||||
display_func: Function to call when user enters '?'
|
||||
data: Data to pass to display_func
|
||||
|
||||
Returns:
|
||||
User's input value
|
||||
"""
|
||||
while True:
|
||||
value = Prompt.ask(
|
||||
f"[cyan]{prompt_text} (? for help)[/cyan]",
|
||||
default=default,
|
||||
)
|
||||
|
||||
if value == "?":
|
||||
if display_func and data is not None:
|
||||
console.print()
|
||||
display_func(data)
|
||||
console.print()
|
||||
else:
|
||||
console.print("[yellow]No help available[/yellow]")
|
||||
else:
|
||||
return value
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Version checking functionality for dropkit."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from dropkit import __version__
|
||||
from dropkit.config import Config
|
||||
|
||||
|
||||
def get_last_check_file() -> Path:
|
||||
"""Get the path to the last version check file."""
|
||||
return Config.get_config_dir() / ".last_version_check"
|
||||
|
||||
|
||||
def should_check_version() -> bool:
|
||||
"""
|
||||
Check if we should check for a new version.
|
||||
|
||||
Only checks once per day to avoid slowing down commands.
|
||||
"""
|
||||
check_file = get_last_check_file()
|
||||
|
||||
if not check_file.exists():
|
||||
return True
|
||||
|
||||
try:
|
||||
with open(check_file) as f:
|
||||
data = json.load(f)
|
||||
last_check = data.get("timestamp", 0)
|
||||
# Check if more than 24 hours have passed
|
||||
return (time.time() - last_check) > 86400 # 24 hours in seconds
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return True
|
||||
|
||||
|
||||
def update_last_check_time() -> None:
|
||||
"""Update the last version check timestamp."""
|
||||
check_file = get_last_check_file()
|
||||
check_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = {"timestamp": time.time(), "current_version": __version__}
|
||||
|
||||
try:
|
||||
with open(check_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
except OSError:
|
||||
# Silently fail if we can't write the file
|
||||
pass
|
||||
|
||||
|
||||
def get_latest_git_commit() -> str | None:
|
||||
"""
|
||||
Get the latest git commit hash from the main branch.
|
||||
|
||||
Returns:
|
||||
Latest commit hash (short, 7 chars) or None if unable to fetch
|
||||
"""
|
||||
try:
|
||||
# Get the latest commit from main branch
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"ls-remote",
|
||||
"https://github.com/trailofbits/dropkit.git",
|
||||
"HEAD",
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
# Parse the output to get the commit hash
|
||||
# Format: "commit_hash\tHEAD"
|
||||
lines = result.stdout.strip().split("\n")
|
||||
if not lines or not lines[0]:
|
||||
return None
|
||||
|
||||
# Extract commit hash (first column)
|
||||
commit_hash = lines[0].split("\t")[0]
|
||||
# Return short hash (first 7 chars)
|
||||
return commit_hash[:7] if commit_hash else None
|
||||
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_commit_from_version(version: str) -> str | None:
|
||||
"""
|
||||
Extract commit hash from version string.
|
||||
|
||||
Version format: "0.1.0+git.a1b2c3d" or similar
|
||||
|
||||
Args:
|
||||
version: Version string potentially containing a commit hash
|
||||
|
||||
Returns:
|
||||
Commit hash (short) or None if not found
|
||||
"""
|
||||
# Version format from hatchling-vcs: "0.1.0+git.a1b2c3d" or "0.1.0.dev123+a1b2c3d"
|
||||
if "+git." in version:
|
||||
# Format: "0.1.0+git.a1b2c3d"
|
||||
parts = version.split("+git.")
|
||||
if len(parts) == 2:
|
||||
return parts[1][:7] # Return first 7 chars
|
||||
elif "+" in version:
|
||||
# Format: "0.1.0+a1b2c3d" or similar
|
||||
parts = version.split("+")
|
||||
if len(parts) == 2:
|
||||
commit = parts[1]
|
||||
# Extract hash if it contains other info
|
||||
if "." in commit:
|
||||
commit = commit.split(".")[-1]
|
||||
return commit[:7]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def commits_differ(current_version: str, latest_commit: str) -> bool:
|
||||
"""
|
||||
Check if current version's commit differs from latest commit.
|
||||
|
||||
Args:
|
||||
current_version: Current version string (e.g., "0.1.0+git.a1b2c3d")
|
||||
latest_commit: Latest commit hash from remote
|
||||
|
||||
Returns:
|
||||
True if commits differ (update available), False otherwise
|
||||
"""
|
||||
current_commit = extract_commit_from_version(current_version)
|
||||
|
||||
if not current_commit:
|
||||
# Can't determine current commit, don't show update
|
||||
return False
|
||||
|
||||
# Compare commit hashes (case-insensitive)
|
||||
return current_commit.lower() != latest_commit.lower()
|
||||
|
||||
|
||||
def check_for_updates() -> None:
|
||||
"""
|
||||
Check for updates and display a message if a new version is available.
|
||||
|
||||
This function:
|
||||
- Skips check in development mode (version == "dev")
|
||||
- Only runs once per day for installed versions
|
||||
- Fetches the latest git commit from main branch
|
||||
- Compares with current version's commit
|
||||
- Shows a non-blocking message if commits differ
|
||||
"""
|
||||
# Skip check in development mode
|
||||
if __version__ == "dev" or "dev" in __version__.lower():
|
||||
return
|
||||
|
||||
# Only check once per day
|
||||
if not should_check_version():
|
||||
return
|
||||
|
||||
# Update the check time regardless of success
|
||||
update_last_check_time()
|
||||
|
||||
# Try to get latest commit
|
||||
latest_commit = get_latest_git_commit()
|
||||
|
||||
if not latest_commit:
|
||||
# Silently fail if we can't check
|
||||
return
|
||||
|
||||
# Compare commits
|
||||
if commits_differ(__version__, latest_commit):
|
||||
# Import here to avoid circular dependency
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
current_commit = extract_commit_from_version(__version__)
|
||||
console.print(
|
||||
f"\n[yellow]New version available:[/yellow] [cyan]{latest_commit}[/cyan] "
|
||||
f"[dim](current: {current_commit or __version__})[/dim]"
|
||||
)
|
||||
console.print(
|
||||
"[yellow]Run[/yellow] [cyan]uv tool upgrade dropkit[/cyan] [yellow]to update[/yellow]\n"
|
||||
)
|
||||
Reference in New Issue
Block a user