Initial commit

This commit is contained in:
Dan Guido
2026-01-29 21:50:14 -05:00
commit bc36073036
38 changed files with 12434 additions and 0 deletions
+54
View File
@@ -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
View File
@@ -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", {})
+59
View File
@@ -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
+347
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+303
View File
@@ -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
+135
View File
@@ -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
View File
@@ -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
+187
View File
@@ -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"
)