Skip to content

feat: add DigitalOcean API service - #51

Merged
loadinglucian merged 4 commits into
mainfrom
feat/digitalocean-support
Oct 25, 2025
Merged

feat: add DigitalOcean API service#51
loadinglucian merged 4 commits into
mainfrom
feat/digitalocean-support

Conversation

@loadinglucian

@loadinglucian loadinglucian commented Oct 25, 2025

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features
    • DigitalOcean integration: create, delete, monitor droplets; retrieve public IPv4 and wait for readiness.
    • SSH key management: upload and remove SSH keys from your account.
    • Account queries: list available regions, sizes, images, and per-region VPC options.
    • Token-based authentication with verification and simple in-memory caching for repeated operations.

@coderabbitai

coderabbitai Bot commented Oct 25, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds four new DigitalOcean service classes (Account, Droplet, Key, and a facade) and a composer dependency; implements API client wiring, token management, resource listing/management, SSH key upload/delete, droplet lifecycle helpers, and a small in-memory cache.

Changes

Cohort / File(s) Summary
Account service
app/Services/DigitalOcean/DigitalOceanAccountService.php
New service exposing account-scoped listings: available regions, sizes (memory MB→GB), public Ubuntu/Debian images, per-region VPCs, and user SSH keys; requires injected API Client; errors wrapped as RuntimeException.
Droplet service
app/Services/DigitalOcean/DigitalOceanDropletService.php
New droplet lifecycle service: createDroplet, getDropletStatus, waitForDropletReady (poll + timeout), getDropletIp (public IPv4), and destroyDroplet (404 treated as success); uses injected API Client and translates errors to RuntimeException.
Key service
app/Services/DigitalOcean/DigitalOceanKeyService.php
New SSH key manager: constructor requires FilesystemService, setAPI for API client, uploadKey validates and uploads local public key returning key id, deleteKey deletes key (404 silent); errors wrapped in RuntimeException.
Facade / wiring
app/Services/DigitalOceanService.php
New facade wiring DigitalOceanAccountService, DigitalOceanKeyService, and DigitalOceanDropletService; manages token (setToken/initialize), lazy Client initialization and authentication check, sets API client on subservices, and provides simple in-memory cache methods.
Composer / deps
composer.json
Adds runtime dependency toin0u/digitalocean-v2:^5.0 and enables php-http/discovery in composer config.

Sequence Diagram(s)

sequenceDiagram
    participant U as User
    participant Facade as DigitalOceanService
    participant Account as AccountService
    participant Droplet as DropletService
    participant Key as KeyService
    participant API as DigitalOcean API

    U->>Facade: initialize(token)
    Facade->>Facade: store token, initializeAPI()
    Facade->>API: GET /account (verify)
    API-->>Facade: account info
    Facade->>Account: setAPI(client)
    Facade->>Droplet: setAPI(client)
    Facade->>Key: setAPI(client)

    U->>Droplet: createDroplet(params)
    Droplet->>API: POST /droplets
    API-->>Droplet: droplet data
    Droplet-->>U: {id, name, status}

    U->>Droplet: waitForDropletReady(id)
    loop poll every interval
        Droplet->>API: GET /droplets/{id}
        API-->>Droplet: status
    end
    Droplet-->>U: active / timeout

    U->>Key: uploadKey(path, name)
    Key->>API: POST /account/keys
    API-->>Key: key created (id)
    Key-->>U: id
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Areas to focus:
    • Polling and timeout logic in DigitalOceanDropletService::waitForDropletReady
    • SSH public key validation and file handling in DigitalOceanKeyService::uploadKey
    • Memory size formatting and filtering in DigitalOceanAccountService
    • 404-as-success semantics in delete/destroy methods
    • Token lifecycle, lazy Client init and error handling in DigitalOceanService

Possibly related PRs

  • bigpixelrocket/deployer-php#22 — Introduces the FilesystemService dependency that DigitalOceanKeyService uses; code-level dependency linkage.

Poem

🐇 I hopped through tokens, keys, and regions bright,
I spun a droplet up beneath the night,
I tucked a key, I watched it shine,
Cached a tiny secret, set things fine,
Carrots for CI — deploy takes flight! 🥕

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title "feat: add DigitalOcean API service" directly and accurately reflects the main objective of the changeset. The PR introduces a comprehensive suite of DigitalOcean API integration services including DigitalOceanAccountService, DigitalOceanDropletService, DigitalOceanKeyService, and a DigitalOceanService facade, along with the required dependency. The title is concise, uses clear semantic versioning convention, and is specific enough for a developer scanning history to immediately understand that this PR adds DigitalOcean API service functionality.
Docstring Coverage ✅ Passed Docstring coverage is 92.86% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/digitalocean-support

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (9)
app/Services/DigitalOcean/DigitalOceanKeyService.php (2)

88-105: Use HTTP status code/type for 404 detection and accept ID or fingerprint.

Parsing the exception message is brittle. The DO API allows deleting by ID or fingerprint; reflect that with a union type and check status code first.

Apply:

-    public function deleteKey(int $keyId): void
+    public function deleteKey(int|string $keyIdOrFingerprint): void
     {
         $client = $this->getAPI();

         try {
             $keyApi = $client->key();
-            $keyApi->remove((string) $keyId);
+            $keyApi->remove($keyIdOrFingerprint);
         } catch (\Throwable $e) {
-            // Check if 404 (already deleted) - silently succeed
-            $message = strtolower($e->getMessage());
-            if (str_contains($message, '404') || str_contains($message, 'not found')) {
-                return;
-            }
+            $code = (int) $e->getCode();
+            if ($code === 404) {
+                return; // already gone
+            }
+            // Fallback: tolerate common message patterns from underlying client
+            $message = strtolower((string) $e->getMessage());
+            if (str_contains($message, '404') || str_contains($message, 'not found')) {
+                return;
+            }

             // Other errors - throw
             throw new \RuntimeException("Failed to delete SSH key: {$e->getMessage()}", 0, $e);
         }
     }

Confirm your DigitalOcean client surfaces HTTP status via Exception::getCode() in this project version.


45-52: Guard against directories and empty key content.

Minor hardening for local FS reads.

Apply:

-        // Read public key content
-        $publicKey = $this->fs->readFile($publicKeyPath);
+        // Read public key content
+        if ($this->fs->isDirectory($publicKeyPath)) {
+            throw new \RuntimeException("Path is a directory, expected a .pub file: {$publicKeyPath}");
+        }
+        $publicKey = $this->fs->readFile($publicKeyPath);
         $publicKey = trim($publicKey);
+        if ($publicKey == '') {
+            throw new \RuntimeException("SSH public key file is empty: {$publicKeyPath}");
+        }
app/Services/DigitalOceanService.php (1)

125-131: Construct Client via container/factory for testability.

Avoid new Client(); inject a factory or use $container->build(Client::class) per guidelines. This simplifies mocking and decouples HTTP stack choice.

Example:

-        $this->api = new Client();
-        $this->api->authenticate($this->apiToken);
+        $this->api = ($this->clientFactory)(); // \Closure returning Client
+        $this->api->authenticate($this->apiToken);

And in constructor:

-    public function __construct(
+    public function __construct(
         public readonly DigitalOceanAccountService $account,
         public readonly DigitalOceanKeyService $key,
         public readonly DigitalOceanDropletService $droplet,
+        private readonly \Closure $clientFactory, // fn(): Client
     ) {
     }

I can supply a tiny DigitalOceanClientFactory service if you prefer a class over a closure. Based on coding guidelines.

app/Services/DigitalOcean/DigitalOceanDropletService.php (3)

157-166: Handle Droplet networks shape {v4:[], v6:[]} and property names.

The API returns networks as an object with v4/v6 arrays; some clients expose ip_address vs ipAddress. Current loop assumes a flat list and ipAddress, which may fail.

Refactor defensively:

-            // Find public IPv4 network
-            foreach ($droplet->networks as $network) {
-                if ($network->type === 'public' && $network->version === 4) {
-                    return $network->ipAddress;
-                }
-            }
+            // Find public IPv4 network (supports {v4:[], v6:[]} or flat arrays)
+            $items = [];
+            $networks = $droplet->networks;
+            if (is_object($networks)) {
+                $items = array_merge($networks->v4 ?? [], $networks->v6 ?? []);
+            } elseif (is_array($networks)) {
+                $items = $networks;
+            }
+            foreach ($items as $n) {
+                $type = $n->type ?? null;
+                $ver  = (int) ($n->version ?? 4);
+                $ip   = $n->ipAddress ?? ($n->ip_address ?? null);
+                if ($type === 'public' && $ver === 4 && is_string($ip) && $ip !== '') {
+                    return $ip;
+                }
+            }

DO docs show networks: { v4: [], v6: [] }. (docs.digitalocean.com)


60-62: Prefer null for default VPC over false.

vpc_uuid is optional; omitting it (or null) uses the region’s default VPC. private_networking is deprecated; false may be misinterpreted by client versions.

Apply:

-            // Prepare VPC parameter (API expects string|bool, false for default)
-            $vpcParam = $vpcUuid ?? false;
+            // Prepare VPC parameter (API expects string|null; null => default VPC)
+            $vpcParam = $vpcUuid; // null uses default VPC

Reference (Terraform docs mirror API semantics). (docs.digitalocean.com)


118-140: Consider adding a max attempts guard and jitter for long waits.

The tight loop with sleep() can block for minutes. Optional: cap attempts and add small jitter for friendlier cancellation.

Apply:

-        while (true) {
+        $attempt = 0;
+        while (true) {
             $status = $this->getDropletStatus($dropletId);
@@
-            sleep($pollIntervalSeconds);
+            $attempt++;
+            usleep(($pollIntervalSeconds * 1000000) + random_int(0, 200000)); // + up to 200ms jitter
app/Services/DigitalOcean/DigitalOceanAccountService.php (3)

65-86: Large accounts may require paging.

If you expect many sizes, regions, or images, consider ResultPager to fetch all pages consistently across endpoints.

Example (sizes):

$pager = new \DigitalOceanV2\ResultPager($client);
$sizes = $pager->fetchAll($client->size(), 'getAll');

This approach is shown in the official client examples. (packagist.org)


73-79: Round RAM GB for display.

Avoid long decimals from MB→GB conversion.

Apply:

-                    $memory = $size->memory / 1024; // Convert MB to GB
+                    $memory = number_format($size->memory / 1024, 1); // MB → GB (1 decimal)

138-146: Clarify the 'default' VPC sentinel.

getUserVpcs() returns 'default' => 'Use default VPC', while droplet creation treats null (or omitted) as default. Ensure the caller maps 'default' to null consistently, or return an explicit null sentinel out-of-band.

Option: return a separate data structure with a default boolean or document the mapping in the method PHPDoc. Reference for default behavior: Terraform docs mirror API semantics. (docs.digitalocean.com)

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2eeb18c and 5186155.

📒 Files selected for processing (4)
  • app/Services/DigitalOcean/DigitalOceanAccountService.php (1 hunks)
  • app/Services/DigitalOcean/DigitalOceanDropletService.php (1 hunks)
  • app/Services/DigitalOcean/DigitalOceanKeyService.php (1 hunks)
  • app/Services/DigitalOceanService.php (1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.php

📄 CodeRabbit inference engine (.cursor/rules/00-main.mdc)

**/*.php: Eliminate single-use methods: inline if a method is called only once
Cache computed values: initialize expensive calculations in the constructor
Avoid method call overhead: prefer direct property access when appropriate

**/*.php: Adhere to PSR-12, enable strict_types, and leverage PHP 8.x features (union types, match, attributes, readonly)
Use explicit return types, including generic-like annotations where applicable (e.g., Collection<int, User>)
Prefer Symfony components (e.g., Filesystem, Process) over native PHP functions for testability
Always import classes via use statements; only use root FQDNs for core exceptions (e.g., \InvalidArgumentException, \RuntimeException). Do not use inline FQDNs for non-root namespaces
Create objects via $container->build(ClassName::class) everywhere except DTOs, value objects, and pure data structures
Use minimalist DocBlocks documenting description, parameters, and return types for classes and functions
Follow the specified comment structure with section headers/subheaders and spacing; remove obsolete comments with removed code

Files:

  • app/Services/DigitalOcean/DigitalOceanKeyService.php
  • app/Services/DigitalOcean/DigitalOceanDropletService.php
  • app/Services/DigitalOcean/DigitalOceanAccountService.php
  • app/Services/DigitalOceanService.php
**/*Service.php

📄 CodeRabbit inference engine (.cursor/rules/01-architecture.mdc)

**/*Service.php: Services must perform no console I/O and should accept/return plain PHP types
Services are dependency-injected via constructor and encapsulate business logic, external APIs, and file operations
Stateful services should use lazy loading and explicit initialization methods (e.g., load(), initialize()) and document requirements

Files:

  • app/Services/DigitalOcean/DigitalOceanKeyService.php
  • app/Services/DigitalOcean/DigitalOceanDropletService.php
  • app/Services/DigitalOcean/DigitalOceanAccountService.php
  • app/Services/DigitalOceanService.php
**/*{Command,Service}.php

📄 CodeRabbit inference engine (.cursor/rules/01-architecture.mdc)

All dependencies should be expressed in constructor signatures; avoid circular dependencies

Files:

  • app/Services/DigitalOcean/DigitalOceanKeyService.php
  • app/Services/DigitalOcean/DigitalOceanDropletService.php
  • app/Services/DigitalOcean/DigitalOceanAccountService.php
  • app/Services/DigitalOceanService.php
🧬 Code graph analysis (4)
app/Services/DigitalOcean/DigitalOceanKeyService.php (4)
app/Services/FilesystemService.php (3)
  • FilesystemService (27-106)
  • exists (41-44)
  • readFile (51-54)
app/Services/DigitalOceanService.php (1)
  • __construct (26-31)
app/Services/DigitalOcean/DigitalOceanAccountService.php (2)
  • setAPI (24-27)
  • getAPI (181-188)
app/Services/DigitalOcean/DigitalOceanDropletService.php (2)
  • setAPI (22-25)
  • getAPI (203-210)
app/Services/DigitalOcean/DigitalOceanDropletService.php (2)
app/Services/DigitalOcean/DigitalOceanAccountService.php (2)
  • setAPI (24-27)
  • getAPI (181-188)
app/Services/DigitalOcean/DigitalOceanKeyService.php (2)
  • setAPI (27-30)
  • getAPI (112-119)
app/Services/DigitalOcean/DigitalOceanAccountService.php (2)
app/Services/DigitalOcean/DigitalOceanDropletService.php (2)
  • setAPI (22-25)
  • getAPI (203-210)
app/Services/DigitalOcean/DigitalOceanKeyService.php (2)
  • setAPI (27-30)
  • getAPI (112-119)
app/Services/DigitalOceanService.php (3)
app/Services/DigitalOcean/DigitalOceanAccountService.php (2)
  • DigitalOceanAccountService (17-189)
  • setAPI (24-27)
app/Services/DigitalOcean/DigitalOceanDropletService.php (2)
  • DigitalOceanDropletService (15-211)
  • setAPI (22-25)
app/Services/DigitalOcean/DigitalOceanKeyService.php (3)
  • DigitalOceanKeyService (15-120)
  • __construct (19-22)
  • setAPI (27-30)
🔇 Additional comments (1)
app/Services/DigitalOceanService.php (1)

59-64: Auth check endpoint choice is valid.

Using $client->account()->getUserInformation() is a lightweight token validation call. LGTM.

This endpoint exists in DigitalOceanPHP/Client examples. (packagist.org)

Comment thread app/Services/DigitalOcean/DigitalOceanKeyService.php
Comment thread app/Services/DigitalOceanService.php
Comment thread app/Services/DigitalOceanService.php Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
app/Services/DigitalOceanService.php (1)

132-136: Improve error message specificity.

The generic error message could be more helpful by mentioning the specific methods callers should use.

Apply this diff:

         if ($this->token === null || $this->token === '') {
             throw new \RuntimeException(
-                'DigitalOcean API token not set. '.
-                'Set API token before making API requests.'
+                'DigitalOcean API token not set. Call setToken() or initialize() before making API requests.'
             );
         }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0bb70ed and f9f9c48.

📒 Files selected for processing (1)
  • app/Services/DigitalOceanService.php (1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.php

📄 CodeRabbit inference engine (.cursor/rules/00-main.mdc)

**/*.php: Eliminate single-use methods: inline if a method is called only once
Cache computed values: initialize expensive calculations in the constructor
Avoid method call overhead: prefer direct property access when appropriate

**/*.php: Adhere to PSR-12, enable strict_types, and leverage PHP 8.x features (union types, match, attributes, readonly)
Use explicit return types, including generic-like annotations where applicable (e.g., Collection<int, User>)
Prefer Symfony components (e.g., Filesystem, Process) over native PHP functions for testability
Always import classes via use statements; only use root FQDNs for core exceptions (e.g., \InvalidArgumentException, \RuntimeException). Do not use inline FQDNs for non-root namespaces
Create objects via $container->build(ClassName::class) everywhere except DTOs, value objects, and pure data structures
Use minimalist DocBlocks documenting description, parameters, and return types for classes and functions
Follow the specified comment structure with section headers/subheaders and spacing; remove obsolete comments with removed code

Files:

  • app/Services/DigitalOceanService.php
**/*Service.php

📄 CodeRabbit inference engine (.cursor/rules/01-architecture.mdc)

**/*Service.php: Services must perform no console I/O and should accept/return plain PHP types
Services are dependency-injected via constructor and encapsulate business logic, external APIs, and file operations
Stateful services should use lazy loading and explicit initialization methods (e.g., load(), initialize()) and document requirements

Files:

  • app/Services/DigitalOceanService.php
**/*{Command,Service}.php

📄 CodeRabbit inference engine (.cursor/rules/01-architecture.mdc)

All dependencies should be expressed in constructor signatures; avoid circular dependencies

Files:

  • app/Services/DigitalOceanService.php
🧬 Code graph analysis (1)
app/Services/DigitalOceanService.php (3)
app/Services/DigitalOcean/DigitalOceanAccountService.php (2)
  • DigitalOceanAccountService (17-189)
  • setAPI (24-27)
app/Services/DigitalOcean/DigitalOceanDropletService.php (2)
  • DigitalOceanDropletService (15-211)
  • setAPI (22-25)
app/Services/DigitalOcean/DigitalOceanKeyService.php (3)
  • DigitalOceanKeyService (15-121)
  • __construct (19-22)
  • setAPI (27-30)
🔇 Additional comments (5)
app/Services/DigitalOceanService.php (5)

1-31: LGTM! Clean setup with proper DI and PHP 8.x features.

The file header, imports, and constructor follow PSR-12 and leverage readonly properties correctly. The dependency injection pattern for the three sub-services is well-structured.


44-49: Good explicit initialization pattern for stateful service.

The public initialize() method provides the clear entry point required by the coding guidelines for stateful services. The sequence of setting token, wiring the API client, and verifying authentication is well-structured.


56-62: LGTM! Proper token reset behavior.

Resetting the API client when the token changes ensures that subsequent API calls will use the new credentials.


69-79: LGTM! Lightweight authentication verification.

Using the account endpoint for token validation is appropriate, and the exception chaining preserves the underlying error context.


88-115: LGTM! Simple in-memory cache utilities.

The cache methods provide a clean API for managing cached data. While simple, they offer good encapsulation for the internal cache array.

@loadinglucian
loadinglucian merged commit bda2b44 into main Oct 25, 2025
4 of 5 checks passed
@loadinglucian
loadinglucian deleted the feat/digitalocean-support branch October 25, 2025 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant