Files
Riccardo Schirone 212306c568 Update default Ubuntu image to 25.10 and extract slug constants (#48)
* Update default Ubuntu image from 25.04 to 25.10

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract default region/size/image slugs into constants

Replace duplicated hardcoded strings across config.py, main.py, and
tests with DEFAULT_REGION, DEFAULT_SIZE, and DEFAULT_IMAGE constants
defined in config.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:01:13 +01:00

966 lines
30 KiB
Python

"""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)
PROTECTED_TAGS = {"owner", "firewall"}
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 Linux username.
Extracts the local part (before @) and replaces special characters
with underscores. Ensures the result is a valid Linux username.
Args:
email: Email address from DigitalOcean account
Returns:
Sanitized username suitable for Linux user creation
"""
# Extract local part (before @)
username = email.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-10-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 untag_resource(self, tag_name: str, resource_id: str, resource_type: str) -> None:
"""
Remove a tag from a resource.
Args:
tag_name: Tag name to remove
resource_id: Resource ID (string for snapshots/images)
resource_type: Resource type ('image' for snapshots, 'droplet', etc.)
Raises:
ValueError: If tag is protected (owner or firewall)
DigitalOceanAPIError: If untagging fails
"""
if tag_name.split(":")[0] in PROTECTED_TAGS:
raise ValueError(f"Cannot remove protected tag: {tag_name}")
payload = {"resources": [{"resource_id": resource_id, "resource_type": resource_type}]}
self._request("DELETE", 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", {})