From 004541e7d1ec8e2197eb8820aba9f73d434b6e82 Mon Sep 17 00:00:00 2001 From: redikultsevsilver Date: Tue, 23 Jun 2026 15:01:39 +0300 Subject: [PATCH] PROD-1420 adding whmcs module for chainstack saas v1 --- .gitignore | 6 + README.md | 70 +++++- composer.json | 11 + modules/servers/chainstack/README.md | 127 ++++++++++ modules/servers/chainstack/chainstack.php | 230 ++++++++++++++++++ modules/servers/chainstack/hooks.php | 63 +++++ .../chainstack/lib/ChainstackClient.php | 175 +++++++++++++ modules/servers/chainstack/lib/Helpers.php | 154 ++++++++++++ .../servers/chainstack/lib/Provisioner.php | 213 ++++++++++++++++ .../chainstack/scripts/list_networks.php | 41 ++++ .../scripts/setup_network_option.php | 182 ++++++++++++++ .../chainstack/templates/clientarea.tpl | 73 ++++++ phpunit.xml | 12 + tests/HelpersTest.php | 50 ++++ tests/ProvisionerTest.php | 129 ++++++++++ tests/bootstrap.php | 123 ++++++++++ tests/e2e_harness.php | 79 ++++++ 17 files changed, 1737 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 modules/servers/chainstack/README.md create mode 100644 modules/servers/chainstack/chainstack.php create mode 100644 modules/servers/chainstack/hooks.php create mode 100644 modules/servers/chainstack/lib/ChainstackClient.php create mode 100644 modules/servers/chainstack/lib/Helpers.php create mode 100644 modules/servers/chainstack/lib/Provisioner.php create mode 100644 modules/servers/chainstack/scripts/list_networks.php create mode 100644 modules/servers/chainstack/scripts/setup_network_option.php create mode 100644 modules/servers/chainstack/templates/clientarea.tpl create mode 100644 phpunit.xml create mode 100644 tests/HelpersTest.php create mode 100644 tests/ProvisionerTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/e2e_harness.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d32767b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +composer.lock +.DS_Store +*.log +.idea/ +.vscode/ diff --git a/README.md b/README.md index 67b8a15..cc9cc47 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,70 @@ # whmcs-chainstack -WHMCS provisioning module for Chainstack — provision and manage RPC/WSS node endpoints from WHMCS. + +A WHMCS provisioning module for [Chainstack](https://chainstack.com). It provisions and manages +Chainstack RPC/WSS node endpoints directly from WHMCS: ordering a service creates a node, the +endpoints show up in the client area, and suspend/terminate tear the resources down. + +**One WHMCS service = one Chainstack node**, deployed into a per-service project inside a single +operator-owned Chainstack organization, authenticated with one admin API key. + +## Requirements + +- WHMCS 8.x, PHP 8.1+ (tested on 8.3). PHP extensions: `curl`, `json`, `openssl`. +- A Chainstack organization on a plan that permits the Platform API and node creation. +- A Chainstack admin **API key** (Console → Settings → API keys). + +## Install + +Copy the module into your WHMCS installation: + +```bash +cp -r modules/servers/chainstack /modules/servers/ +``` + +Then in WHMCS admin: + +1. **Setup → Products/Services → Servers → Add New Server** + - Hostname `api.chainstack.com`, Type **Chainstack**, **Password** = your API key. Test Connection. +2. **Create a product**, Module = **Chainstack**, and either: + - set **Default network** to a network slug (one product per network), or + - attach a **Network** Configurable Option dropdown (one product, customer picks the network). + +Full configuration, lifecycle behavior, and the network-dropdown helper are documented in +[`modules/servers/chainstack/README.md`](modules/servers/chainstack/README.md). + +## Networks + +The deployable network is resolved **live** against `GET /v2/deployment-options/` at provision +time, and the cloud is derived automatically — so every network Chainstack offers is supported +without code changes. List the available slugs: + +```bash +CHAINSTACK_API_KEY=xxx php modules/servers/chainstack/scripts/list_networks.php +``` + +## Lifecycle + +| Action | Effect | +|---|---| +| Create | Creates a project + node; endpoints appear in the client area (global nodes deploy synchronously). | +| Suspend | Deletes the node + project (stops usage/cost). | +| Unsuspend | Re-provisions — note the endpoint **URL changes** (new auth key). | +| Terminate | Deletes the node + project. | + +## Tests + +```bash +composer install +vendor/bin/phpunit +``` + +A manual end-to-end harness (creates and deletes one real node + project) is at +[`tests/e2e_harness.php`](tests/e2e_harness.php). + +## Repository layout + +``` +modules/servers/chainstack/ the module (install this into WHMCS) + scripts/ operator helpers (list networks, sync dropdown) +tests/ PHPUnit suite + manual e2e harness +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d3b54d0 --- /dev/null +++ b/composer.json @@ -0,0 +1,11 @@ +{ + "name": "chainstack/whmcs-provisioning", + "description": "Chainstack WHMCS provisioning module (tests)", + "license": "proprietary", + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5 || ^11.0" + } +} diff --git a/modules/servers/chainstack/README.md b/modules/servers/chainstack/README.md new file mode 100644 index 0000000..7a95a92 --- /dev/null +++ b/modules/servers/chainstack/README.md @@ -0,0 +1,127 @@ +# Chainstack WHMCS Provisioning Module + +Provisions Chainstack RPC/WSS endpoints (nodes) for WHMCS customers. **One WHMCS service = one +Chainstack node**, deployed into a per-service project inside a single operator-owned Chainstack +organization. Authenticates with one admin API key. + +## Requirements + +- WHMCS 8.x (tested with PHP 8.3). PHP extensions: `curl`, `json`, `openssl`. +- A Chainstack organization on a plan that permits the Platform API and node creation. +- An admin **API key** (Chainstack console → Settings → API keys). + +## Install + +1. Copy this directory to `/modules/servers/chainstack/`. +2. Ensure files are owned by the web user and readable (dirs 755, files 644). + +## Configure + +### 1. Add the server +Setup → Products/Services → **Servers** → Add New Server: +- **Name:** Chainstack +- **Hostname:** `api.chainstack.com` +- **Type:** Chainstack +- **Password:** *your Chainstack admin API key* (stored encrypted by WHMCS) +- Save → **Test Connection** (calls `GET /v1/organization/`). + +### 2. Create one product per network +Each product maps to a fixed network. For each chain you want to sell: +- Create a product (e.g. "Ethereum Mainnet Node"). +- Module Settings → Module Name = **Chainstack**. +- **Default network** = the network slug (e.g. `ethereum-mainnet`). + Run `scripts/list_networks.php` for the full list of valid slugs: + ``` + CHAINSTACK_API_KEY=xxx php scripts/list_networks.php + ``` + +### 3. (Alternative) One product, customer picks the network from a dropdown +The same module also supports a single "Chainstack Node" product where the customer selects the +network at order time. Resolution precedence: **Configurable Option `Network` › product `Default +network`**, so both styles coexist. + +Run the sync helper to create/refresh the `Network` Configurable Option group (dropdown of all +network slugs, priced free) directly from the live API. **Run it as your WHMCS web/PHP user** +(not root) so WHMCS bootstrap doesn't create root-owned cache files: + +```bash +# from the WHMCS root, using the PHP binary your WHMCS runs on: +php modules/servers/chainstack/scripts/setup_network_option.php +``` +- API key is auto-detected from the configured Chainstack server (or pass `CHAINSTACK_API_KEY`). +- Pass `PRODUCT_ID=` to auto-link the group to a product, or attach it manually via + Products/Services → edit → Configurable Options. +- Idempotent: re-run anytime to pick up new networks (existing values and any prices you set are + left untouched). Networks removed upstream are reported, not deleted. + +The Configurable Option **must be named `Network`** (the module reads `configoptions['Network']`). +Each value is written as `slug|Friendly Name` (e.g. `ethereum-sepolia-testnet|Ethereum Sepolia +Testnet`) — WHMCS shows the friendly name to customers and passes the **slug** to the module. The +helper generates the friendly names automatically (with nicer casing for BNB Smart Chain, PoS, +zkEVM, opBNB, etc.). + +## Lifecycle behavior + +| WHMCS action | Effect | +|---|---| +| **Create** | Creates a project + one node; stores their IDs on the service. Endpoints appear in the client area (global nodes deploy synchronously). | +| **Suspend** | **Deletes the node + project** (stops all usage/cost). | +| **Unsuspend** | **Re-provisions** a fresh project + node. ⚠️ The endpoint **URL changes** (new auth key) — the previous URL does not return. | +| **Terminate** | Deletes the node + project. | +| **Client buttons** | `Refresh Status`. | +| **Admin buttons** | `Create Endpoint` (recovery), `Refresh Status`. | + +**Important:** because suspend tears down and unsuspend recreates, a suspend/unsuspend cycle gives +the customer a **new endpoint URL**. Communicate this to customers if you rely on automatic +suspension (e.g. overdue invoices). + +## Errors + +API errors are surfaced with friendly text where mapped — e.g. hitting the org's node limit shows +*"Your Chainstack account has reached its node limit. Please contact your administrator…"*. All +calls are logged via WHMCS Module Log (Utilities → Logs → Module Log); the API key is redacted. + +## Blockchain icons + +Per-protocol icons come from Chainstack's CDN (`https://static.chainstack.dev/.svg`, +the same source the console uses). They appear: +- next to each endpoint inside the service's client-area panel, and +- as the product-details **header icon** (swapped in via a `ClientAreaFooterOutput` hook — no theme + files are modified). Unknown protocols fall back to a small inline generic SVG. + +The header swap reads the protocol stored at provision time, so it shows only for services +provisioned by the current module version. + +## Reliability & pricing + +- **Re-run safe.** Module commands are idempotent: WHMCS re-running a failed `Create` will not + create duplicate projects/nodes, and a node-create failure rolls back a project created in the + same call. (No custom HTTP retry — WHMCS's command re-run is the retry mechanism.) +- **Synchronous deploys.** Scope is global/elastic nodes, which return `status=running` with + endpoints immediately; the client area fetches status live. There is no background sync cron. +- **Pricing.** The sync helper creates network options priced **free (0.00)**. The operator + (WHMCS account owner) sets whatever prices they want per product / configurable option. + +## Files + +``` +chainstack.php WHMCS module functions (incl. AdminServicesTabFields) +hooks.php ClientAreaFooterOutput: product-details blockchain icon swap +lib/ChainstackClient.php HTTP client (Bearer auth, typed errors) +lib/Provisioner.php lifecycle orchestration + live network resolution +lib/Helpers.php server config, per-service state, naming, friendly errors +templates/clientarea.tpl endpoint display +scripts/list_networks.php list deployable network slugs (run with CHAINSTACK_API_KEY) +scripts/setup_network_option.php create/sync the "Network" Configurable Option dropdown +``` + +State is stored on the service via `serviceProperties`: `chainstack_project_id`, +`chainstack_node_ids`, `chainstack_status`, `chainstack_protocol`. + +## Tests + +PHPUnit suite under `tests/` (run from the package root): +``` +composer install +vendor/bin/phpunit +``` diff --git a/modules/servers/chainstack/chainstack.php b/modules/servers/chainstack/chainstack.php new file mode 100644 index 0000000..34c9191 --- /dev/null +++ b/modules/servers/chainstack/chainstack.php @@ -0,0 +1,230 @@ + 'Chainstack', + 'APIVersion' => '1.1', + 'RequiresServer' => true, + 'DefaultNonSSLPort' => '', + 'DefaultSSLPort' => '443', + 'ServiceSingleSignOn' => false, + 'AdminSingleSignOn' => false, + ]; +} + +/** + * Product config options. The network slug is resolved live at provision time and the cloud is + * derived from it. "Default network" is used unless a "Network" configurable option is set. + */ +function chainstack_ConfigOptions() +{ + return [ + 'Default network' => [ + 'Type' => 'text', + 'Size' => '30', + 'Description' => 'Network slug to deploy, e.g. ethereum-mainnet or ' + . 'ethereum-sepolia-testnet. Run scripts/list_networks.php for the full list.', + ], + 'Node name template' => [ + 'Type' => 'text', + 'Size' => '40', + 'Description' => 'Optional. Supports {serviceid} and {domain}. Default: node-svc-{serviceid}.', + ], + ]; +} + +/** Verify API reachability with the configured key. */ +function chainstack_TestConnection(array $params) +{ + try { + ChainstackClient::fromParams($params)->getOrganization(); + return ['success' => true, 'error' => '']; + } catch (\Throwable $e) { + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), $e->getMessage(), $e->getTraceAsString()); + return ['success' => false, 'error' => $e->getMessage()]; + } +} + +/** Create the project and node. Endpoints are shown by the client area. */ +function chainstack_CreateAccount(array $params) +{ + try { + $node = Provisioner::fromParams($params)->provision(); + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), $node); + return 'success'; + } catch (\Throwable $e) { + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), $e->getMessage(), $e->getTraceAsString()); + return Helpers::friendlyError($e); + } +} + +/** Delete the node(s) and project. */ +function chainstack_TerminateAccount(array $params) +{ + try { + Provisioner::fromParams($params)->terminate(); + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), 'terminated'); + return 'success'; + } catch (\Throwable $e) { + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), $e->getMessage(), $e->getTraceAsString()); + return Helpers::friendlyError($e); + } +} + +/** + * Suspend tears down the resources (no native node pause). Unsuspend re-provisions, so the + * endpoint URL changes across a suspend/unsuspend cycle. + */ +function chainstack_SuspendAccount(array $params) +{ + try { + Provisioner::fromParams($params)->terminate(); + Helpers::setStatus($params, 'suspended'); + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), 'suspended'); + return 'success'; + } catch (\Throwable $e) { + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), $e->getMessage(), $e->getTraceAsString()); + return Helpers::friendlyError($e); + } +} + +/** Re-provision fresh resources (new endpoint URL). */ +function chainstack_UnsuspendAccount(array $params) +{ + try { + Provisioner::fromParams($params)->provision(); + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), 'unsuspended'); + return 'success'; + } catch (\Throwable $e) { + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), $e->getMessage(), $e->getTraceAsString()); + return Helpers::friendlyError($e); + } +} + +/** Client area: show the service's endpoints with per-protocol icons. */ +function chainstack_ClientArea(array $params) +{ + try { + $nodes = Provisioner::fromParams($params)->fetchNodes(); + // Fallback icon for protocols the CDN doesn't have. + $genericSvg = '' + . ''; + return [ + 'templatefile' => 'clientarea', + 'vars' => [ + 'nodes' => $nodes, + 'serviceStatus' => $params['status'] ?? '', + 'consoleUrl' => 'https://console.chainstack.com', + 'iconBase' => 'https://static.chainstack.dev', + 'fallbackIcon' => 'data:image/svg+xml,' . rawurlencode($genericSvg), + ], + ]; + } catch (\Throwable $e) { + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), $e->getMessage()); + return [ + 'templatefile' => 'clientarea', + 'vars' => ['nodes' => [], 'error' => $e->getMessage()], + ]; + } +} + +/** Admin service tab: show the provisioned resources to staff. */ +function chainstack_AdminServicesTabFields(array $params) +{ + try { + $projectId = Helpers::getProjectId($params); + $nodes = Provisioner::fromParams($params)->fetchNodes(); + + $fields = []; + $fields['Chainstack Project'] = htmlspecialchars($projectId !== '' ? $projectId : '—'); + if (!$nodes) { + $fields['Nodes'] = 'None'; + } + foreach ($nodes as $i => $n) { + $label = 'Node ' . ($i + 1) + . ($n['protocol'] !== '' ? ' (' . htmlspecialchars($n['protocol']) . ')' : ''); + $value = '' . htmlspecialchars($n['id']) . ' — ' + . htmlspecialchars($n['status']); + if (!empty($n['https'])) { + $value .= '
' . htmlspecialchars($n['https']) . ''; + } + $fields[$label] = $value; + } + return $fields; + } catch (\Throwable $e) { + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), $e->getMessage()); + return ['Chainstack' => 'Error: ' . htmlspecialchars($e->getMessage())]; + } +} + +/** Client-area buttons (one endpoint per service, so no create here). */ +function chainstack_ClientAreaCustomButtonArray() +{ + return [ + 'Refresh Status' => 'refreshStatus', + ]; +} + +/** Admin-area buttons. "Create Endpoint" is an admin recovery action. */ +function chainstack_AdminCustomButtonArray() +{ + return [ + 'Create Endpoint' => 'createEndpoint', + 'Refresh Status' => 'refreshStatus', + ]; +} + +/** Create an additional endpoint in the existing project. */ +function chainstack_createEndpoint(array $params) +{ + try { + $node = Provisioner::fromParams($params)->addEndpoint(); + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), $node); + return 'success'; + } catch (\Throwable $e) { + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), $e->getMessage()); + return Helpers::friendlyError($e); + } +} + +/** Refresh the stored node status from the API. */ +function chainstack_refreshStatus(array $params) +{ + try { + $nodes = Provisioner::fromParams($params)->fetchNodes(); + Helpers::setStatus($params, $nodes ? $nodes[0]['status'] : 'unknown'); + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), $nodes); + return 'success'; + } catch (\Throwable $e) { + logModuleCall('chainstack', __FUNCTION__, chainstack_redact($params), $e->getMessage()); + return Helpers::friendlyError($e); + } +} + +/** Redact the API key before logging. */ +function chainstack_redact(array $params) +{ + if (isset($params['serverpassword'])) { + $params['serverpassword'] = '***REDACTED***'; + } + return $params; +} diff --git a/modules/servers/chainstack/hooks.php b/modules/servers/chainstack/hooks.php new file mode 100644 index 0000000..d72844e --- /dev/null +++ b/modules/servers/chainstack/hooks.php @@ -0,0 +1,63 @@ +/hooks.php. + */ + +if (!defined('WHMCS')) { + die('This file cannot be accessed directly'); +} + +require_once __DIR__ . '/lib/ChainstackClient.php'; +require_once __DIR__ . '/lib/Helpers.php'; +require_once __DIR__ . '/lib/Provisioner.php'; + +use WHMCS\Database\Capsule; +use WHMCS\Module\Server\Chainstack\Helpers; + +/** + * On the product-details page, replace the theme's generic `.product-icon` with the protocol + * icon from Chainstack's CDN. Injected via footer output so no theme files are modified. + */ +add_hook('ClientAreaFooterOutput', 1, function ($vars) { + if (($_REQUEST['action'] ?? '') !== 'productdetails') { + return ''; + } + $serviceId = (int) ($_REQUEST['id'] ?? 0); + if ($serviceId <= 0) { + return ''; + } + + try { + $servertype = Capsule::table('tblhosting') + ->join('tblproducts', 'tblhosting.packageid', '=', 'tblproducts.id') + ->where('tblhosting.id', $serviceId) + ->value('tblproducts.servertype'); + if ($servertype !== 'chainstack') { + return ''; + } + $service = \WHMCS\Service\Service::find($serviceId); + $protocol = $service ? (string) $service->serviceProperties->get(Helpers::KEY_PROTOCOL) : ''; + } catch (\Throwable $e) { + return ''; + } + if ($protocol === '') { + return ''; + } + + $genericSvg = '' + . ''; + $u = json_encode('https://static.chainstack.dev/' . $protocol . '.svg'); + $f = json_encode('data:image/svg+xml,' . rawurlencode($genericSvg)); + $a = json_encode($protocol); + + return ""; +}); diff --git a/modules/servers/chainstack/lib/ChainstackClient.php b/modules/servers/chainstack/lib/ChainstackClient.php new file mode 100644 index 0000000..51261df --- /dev/null +++ b/modules/servers/chainstack/lib/ChainstackClient.php @@ -0,0 +1,175 @@ +`. + */ +class ChainstackClient +{ + /** @var string */ + private $baseUrl; + + /** @var string */ + private $apiKey; + + /** @var int */ + private $timeout; + + public function __construct($baseUrl, $apiKey, $timeout = 30) + { + $this->baseUrl = rtrim($baseUrl, '/'); + $this->apiKey = $apiKey; + $this->timeout = (int) $timeout; + } + + /** Build a client from a WHMCS $params array (reads base URL + API key from the server config). */ + public static function fromParams(array $params) + { + return new self(Helpers::baseUrl($params), Helpers::apiKey($params)); + } + + /** List deployable networks: {blockchain, cloud, region, protocol, network}. */ + public function getDeploymentOptions() + { + return $this->request('GET', '/v2/deployment-options/'); + } + + /** Return the API key's organization (used by TestConnection). */ + public function getOrganization() + { + return $this->request('GET', '/v1/organization/'); + } + + /** Create a project. Body: {name, description, type}. */ + public function createProject(array $body) + { + return $this->request('POST', '/v1/projects/', $body); + } + + public function deleteProject($id) + { + return $this->request('DELETE', '/v1/projects/' . rawurlencode($id) . '/'); + } + + /** Create a node. Body: {name, blockchain, cloud, project}. */ + public function createNode(array $body) + { + return $this->request('POST', '/v2/nodes/', $body); + } + + public function getNode($id) + { + return $this->request('GET', '/v2/nodes/' . rawurlencode($id) . '/'); + } + + public function listNodes(array $query = []) + { + $qs = $query ? ('?' . http_build_query($query)) : ''; + return $this->request('GET', '/v2/nodes/' . $qs); + } + + public function deleteNode($id) + { + return $this->request('DELETE', '/v2/nodes/' . rawurlencode($id) . '/'); + } + + /** + * @return array Decoded JSON (empty array for 204 No Content). + * @throws ChainstackApiException on transport error or HTTP >= 400. + */ + private function request($method, $path, array $body = null) + { + $ch = curl_init($this->baseUrl . $path); + + $headers = [ + 'Authorization: Bearer ' . $this->apiKey, + 'Accept: application/json', + ]; + + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + + if ($body !== null) { + $payload = json_encode($body); + if ($payload === false) { + curl_close($ch); + throw new ChainstackApiException('Failed to encode request body: ' . json_last_error_msg()); + } + $headers[] = 'Content-Type: application/json'; + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + } + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + + $raw = curl_exec($ch); + if ($raw === false) { + $err = curl_error($ch); + curl_close($ch); + throw new ChainstackApiException('HTTP transport error: ' . $err); + } + + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $decoded = []; + if ($raw !== '') { + $decoded = json_decode($raw, true); + // A success response that isn't valid JSON is an error worth surfacing clearly. + if (json_last_error() !== JSON_ERROR_NONE && $status < 400) { + throw new ChainstackApiException( + 'Invalid JSON response (HTTP ' . $status . '): ' . json_last_error_msg(), + $status, + $raw + ); + } + } + + if ($status >= 400) { + $code = null; + $message = null; + if (is_array($decoded)) { + if (isset($decoded['error']) && is_array($decoded['error'])) { + // {"error": {"code": "...", "message": "..."}} + $code = $decoded['error']['code'] ?? null; + $message = $decoded['error']['message'] ?? null; + } elseif (isset($decoded['detail'])) { + $message = is_array($decoded['detail']) ? json_encode($decoded['detail']) : $decoded['detail']; + } else { + $message = json_encode($decoded); + } + } + if ($message === null || $message === '') { + $message = 'Chainstack API returned HTTP ' . $status; + } + throw new ChainstackApiException($message, $status, $raw, $code); + } + + return is_array($decoded) ? $decoded : []; + } +} + +/** API exception carrying the HTTP status, raw body, and error code. */ +class ChainstackApiException extends \Exception +{ + /** @var int */ + public $httpStatus; + + /** @var string|null */ + public $rawBody; + + /** @var string|null e.g. "quota_exceeded" */ + public $errorCode; + + public function __construct($message, $httpStatus = 0, $rawBody = null, $errorCode = null) + { + parent::__construct($message); + $this->httpStatus = $httpStatus; + $this->rawBody = $rawBody; + $this->errorCode = $errorCode; + } +} diff --git a/modules/servers/chainstack/lib/Helpers.php b/modules/servers/chainstack/lib/Helpers.php new file mode 100644 index 0000000..799b746 --- /dev/null +++ b/modules/servers/chainstack/lib/Helpers.php @@ -0,0 +1,154 @@ +errorCode) { + case 'quota_exceeded': + return 'Your Chainstack account has reached its node limit. ' + . 'Please contact your administrator to increase the quota.'; + } + } + return $e->getMessage(); + } + + // ----- Naming ------------------------------------------------------------------ + + /** Deterministic project name for a service, e.g. "whmcs-svc-1024". */ + public static function projectName(array $params) + { + return 'whmcs-svc-' . (int) ($params['serviceid'] ?? 0); + } + + /** Node name from the optional template (config option #2), sanitized. */ + public static function nodeName(array $params) + { + $base = trim((string) ($params['configoption2'] ?? '')); + if ($base === '') { + $base = 'node-svc-' . (int) ($params['serviceid'] ?? 0); + } else { + $base = str_replace( + ['{serviceid}', '{domain}'], + [(int) ($params['serviceid'] ?? 0), (string) ($params['domain'] ?? '')], + $base + ); + } + $base = preg_replace('/[^A-Za-z0-9\-_]/', '-', $base); + return substr($base, 0, 64); + } + + // ----- serviceProperties bridge ------------------------------------------------ + + private static function propGet(array $params, $key) + { + if (!isset($params['model']) || !is_object($params['model'])) { + return ''; + } + // Let read failures propagate — a silent '' could trigger duplicate provisioning. + return (string) $params['model']->serviceProperties->get($key); + } + + private static function propSet(array $params, $key, $value) + { + if (!isset($params['model']) || !is_object($params['model'])) { + return; + } + // Let write failures propagate — a silently dropped id would orphan a real resource. + $params['model']->serviceProperties->save([$key => $value]); + } +} diff --git a/modules/servers/chainstack/lib/Provisioner.php b/modules/servers/chainstack/lib/Provisioner.php new file mode 100644 index 0000000..f8a55f5 --- /dev/null +++ b/modules/servers/chainstack/lib/Provisioner.php @@ -0,0 +1,213 @@ +client = $client; + $this->params = $params; + } + + public static function fromParams(array $params) + { + return new self(ChainstackClient::fromParams($params), $params); + } + + /** Create the project (if missing) and one node. Returns the created node. */ + public function provision() + { + // Idempotent: if a node already exists for this service (e.g. a Create re-run), do nothing. + $existingNodes = Helpers::getNodeIds($this->params); + if (!empty($existingNodes)) { + return ['id' => $existingNodes[0], 'status' => 'running', 'already_provisioned' => true]; + } + + $projectId = Helpers::getProjectId($this->params); + $createdProjectThisCall = false; + if (!$projectId) { + $project = $this->client->createProject([ + 'name' => Helpers::projectName($this->params), + 'description' => 'Provisioned via WHMCS service #' . (int) $this->params['serviceid'], + 'type' => 'public', // the only project type the API accepts + ]); + $projectId = $project['id']; + Helpers::setProjectId($this->params, $projectId); + $createdProjectThisCall = true; + } + + // Roll back a project we created this call if node creation fails (avoids orphans). + try { + [$blockchain, $cloud] = $this->resolveDeployment(); + $node = $this->client->createNode([ + 'name' => Helpers::nodeName($this->params), + 'blockchain' => $blockchain, + 'cloud' => $cloud, + 'project' => $projectId, + ]); + } catch (\Throwable $e) { + if ($createdProjectThisCall) { + try { + $this->client->deleteProject($projectId); + } catch (\Throwable $ignore) { + // best effort; surface the original error + } + Helpers::setProjectId($this->params, ''); + } + throw $e; + } + + Helpers::appendNodeId($this->params, $node['id']); + Helpers::setStatus($this->params, $node['status'] ?? 'pending'); + Helpers::setProtocol($this->params, $node['protocol'] ?? ''); + + return $node; + } + + /** Create an additional node in the existing project. */ + public function addEndpoint() + { + $projectId = Helpers::getProjectId($this->params); + if (!$projectId) { + throw new ChainstackApiException('No Chainstack project associated with this service.'); + } + [$blockchain, $cloud] = $this->resolveDeployment(); + $node = $this->client->createNode([ + 'name' => Helpers::nodeName($this->params) . '-' . substr(uniqid(), -4), + 'blockchain' => $blockchain, + 'cloud' => $cloud, + 'project' => $projectId, + ]); + Helpers::appendNodeId($this->params, $node['id']); + return $node; + } + + /** Delete a single node. Tolerates an already-deleted (404) node and still clears local state. */ + public function deleteEndpoint($nodeId) + { + try { + $this->client->deleteNode($nodeId); + } catch (ChainstackApiException $e) { + if ($e->httpStatus !== 404) { + throw $e; + } + } + Helpers::removeNodeId($this->params, $nodeId); + } + + /** Delete the node(s), then the project, then clear stored state. */ + public function terminate() + { + foreach (Helpers::getNodeIds($this->params) as $nodeId) { + try { + $this->client->deleteNode($nodeId); + } catch (ChainstackApiException $e) { + if ($e->httpStatus !== 404) { // 404 => already gone + throw $e; + } + } + } + + $projectId = Helpers::getProjectId($this->params); + if ($projectId) { + try { + $this->client->deleteProject($projectId); + } catch (ChainstackApiException $e) { + if ($e->httpStatus !== 404) { + throw $e; + } + } + } + + Helpers::clearState($this->params); + } + + /** Fetch current node view-models for display. */ + public function fetchNodes() + { + $out = []; + foreach (Helpers::getNodeIds($this->params) as $nodeId) { + try { + $n = $this->client->getNode($nodeId); + } catch (ChainstackApiException $e) { + if ($e->httpStatus === 404) { + continue; + } + throw $e; + } + $details = isset($n['details']) && is_array($n['details']) ? $n['details'] : []; + $out[] = [ + 'id' => $n['id'] ?? $nodeId, + 'name' => $n['name'] ?? '', + 'protocol' => $n['protocol'] ?? '', // drives the displayed icon + 'status' => $n['status'] ?? 'unknown', + 'https' => $details['https_endpoint'] ?? null, + 'wss' => $details['wss_endpoint'] ?? null, + 'beacon' => $details['beacon_endpoint'] ?? null, + 'namespaces' => $details['api_namespaces'] ?? [], + ]; + } + return $out; + } + + /** + * Resolve {blockchain, cloud} for the selected network. The cloud is derived, not entered: + * the network slug is looked up live against the deployment options. + * + * @return array{0:string,1:string} [blockchain_id, cloud_id] + */ + private function resolveDeployment() + { + $slug = $this->selectedNetworkSlug(); + if ($slug === '') { + throw new ChainstackApiException('No network configured for this product.'); + } + + $options = $this->client->getDeploymentOptions(); + $list = isset($options['options']) && is_array($options['options']) ? $options['options'] : []; + foreach ($list as $opt) { + if (isset($opt['network']) && strcasecmp((string) $opt['network'], $slug) === 0) { + return [$opt['blockchain'], $opt['cloud']]; + } + } + throw new ChainstackApiException("Network '{$slug}' is not available for deployment."); + } + + /** Selected network slug: the "Network" configurable option if set, else "Default network". */ + private function selectedNetworkSlug() + { + $configoptions = $this->params['configoptions'] ?? []; + if (is_array($configoptions)) { + foreach (['Network', 'network'] as $key) { + if (!empty($configoptions[$key])) { + return $this->normalizeSlug($configoptions[$key]); + } + } + } + return $this->normalizeSlug($this->params['configoption1'] ?? ''); + } + + /** Trim and keep the value before any "value|Label" pipe. */ + private function normalizeSlug($value) + { + $value = trim((string) $value); + $pipe = strpos($value, '|'); + if ($pipe !== false) { + $value = trim(substr($value, 0, $pipe)); + } + return $value; + } +} diff --git a/modules/servers/chainstack/scripts/list_networks.php b/modules/servers/chainstack/scripts/list_networks.php new file mode 100644 index 0000000..2a48c23 --- /dev/null +++ b/modules/servers/chainstack/scripts/list_networks.php @@ -0,0 +1,41 @@ + chainstack/). +require dirname(__DIR__) . '/lib/ChainstackClient.php'; + +use WHMCS\Module\Server\Chainstack\ChainstackClient; + +$key = getenv('CHAINSTACK_API_KEY'); +if (!$key) { fwrite(STDERR, "ERROR: set CHAINSTACK_API_KEY\n"); exit(1); } + +$client = new ChainstackClient('https://api.chainstack.com', $key); +$resp = $client->getDeploymentOptions(); +$opts = isset($resp['options']) && is_array($resp['options']) ? $resp['options'] : []; + +usort($opts, function ($a, $b) { + return strcmp((string) ($a['network'] ?? ''), (string) ($b['network'] ?? '')); +}); + +printf("%-34s %-16s %-8s %-8s %s\n", 'NETWORK (slug)', 'BLOCKCHAIN', 'CLOUD', 'REGION', 'PROVIDER'); +printf("%s\n", str_repeat('-', 90)); +foreach ($opts as $o) { + printf( + "%-34s %-16s %-8s %-8s %s\n", + $o['network'] ?? '?', + $o['blockchain'] ?? '?', + $o['cloud'] ?? '?', + $o['region'] ?? '?', + $o['provider'] ?? '?' + ); +} +echo count($opts) . " networks available\n"; diff --git a/modules/servers/chainstack/scripts/setup_network_option.php b/modules/servers/chainstack/scripts/setup_network_option.php new file mode 100644 index 0000000..b8bc637 --- /dev/null +++ b/modules/servers/chainstack/scripts/setup_network_option.php @@ -0,0 +1,182 @@ +/modules/servers/chainstack/scripts/, so the WHMCS root is four +// directories up. Override with WHMCS_ROOT if your layout differs. +$whmcsRoot = rtrim(getenv('WHMCS_ROOT') ?: dirname(__DIR__, 4), '/'); +require $whmcsRoot . '/init.php'; + +// WHMCS may already have loaded the module's classes during bootstrap; guard against re-declaring. +if (!class_exists('WHMCS\\Module\\Server\\Chainstack\\ChainstackClient')) { + require_once dirname(__DIR__) . '/lib/ChainstackClient.php'; +} + +use WHMCS\Database\Capsule; +use WHMCS\Module\Server\Chainstack\ChainstackClient; + +const GROUP_NAME = 'Chainstack Networks'; +const OPTION_NAME = 'Network'; // module reads $params['configoptions']['Network'] + +function out($m) { echo $m . "\n"; } + +/** + * Human-friendly label for a network slug, e.g. ethereum-sepolia-testnet -> "Ethereum Sepolia + * Testnet", bsc-mainnet -> "BNB Smart Chain Mainnet". Per-token dictionary + title-case fallback. + */ +function labelFor($slug) +{ + static $tokens = [ + 'bsc' => 'BNB Smart Chain', 'pos' => 'PoS', 'zkevm' => 'zkEVM', 'zksync' => 'zkSync', + 'opbnb' => 'opBNB', 'ton' => 'TON', 'evm' => 'EVM', + 'mainnet' => 'Mainnet', 'testnet' => 'Testnet', 'sepolia' => 'Sepolia', + 'devnet' => 'Devnet', 'signet' => 'Signet', 'saigon' => 'Saigon', 'nile' => 'Nile', + 'amoy' => 'Amoy', 'chiado' => 'Chiado', 'hoodi' => 'Hoodi', + ]; + $parts = []; + foreach (explode('-', $slug) as $t) { + $parts[] = $tokens[$t] ?? ucfirst($t); + } + return implode(' ', $parts); +} + +// --- resolve API key (env, else decrypt the chainstack server's stored key) --- +$apiKey = getenv('CHAINSTACK_API_KEY') ?: ''; +if ($apiKey === '') { + $server = Capsule::table('tblservers')->where('type', 'chainstack')->where('disabled', 0)->first(); + if ($server) { + $dec = localAPI('DecryptPassword', ['password2' => $server->password]); + $apiKey = $dec['password'] ?? ''; + } +} +if ($apiKey === '') { + fwrite(STDERR, "ERROR: no API key (set CHAINSTACK_API_KEY or configure a chainstack server)\n"); + exit(1); +} + +// --- fetch network slugs from the live API --- +$client = new ChainstackClient('https://api.chainstack.com', $apiKey); +$resp = $client->getDeploymentOptions(); +$opts = isset($resp['options']) && is_array($resp['options']) ? $resp['options'] : []; +$slugs = []; +foreach ($opts as $o) { + if (!empty($o['network'])) { $slugs[(string) $o['network']] = true; } +} +$slugs = array_keys($slugs); +sort($slugs); +out(count($slugs) . ' networks from API'); + +// --- upsert group --- +$gid = Capsule::table('tblproductconfiggroups')->where('name', GROUP_NAME)->value('id'); +if (!$gid) { + $gid = Capsule::table('tblproductconfiggroups')->insertGetId([ + 'name' => GROUP_NAME, + 'description' => 'Chainstack network selection (auto-synced from deployment-options).', + ]); + out("created group #$gid"); +} else { + out("group #$gid exists"); +} + +// --- upsert option (dropdown) --- +$configid = Capsule::table('tblproductconfigoptions') + ->where('gid', $gid)->where('optionname', OPTION_NAME)->value('id'); +if (!$configid) { + $configid = Capsule::table('tblproductconfigoptions')->insertGetId([ + 'gid' => $gid, + 'optionname' => OPTION_NAME, + 'optiontype' => 1, // 1 = dropdown + 'qtyminimum' => 0, + 'qtymaximum' => 0, + 'order' => 0, + 'hidden' => 0, + ]); + out("created option '" . OPTION_NAME . "' #$configid"); +} else { + out("option #$configid exists"); +} + +// --- currencies --- +$currencies = Capsule::table('tblcurrencies')->pluck('id')->all(); + +// --- upsert sub-options (one per slug) + free pricing --- +// Map existing sub-options by their SLUG (the part before any "|Friendly Name"), so re-runs +// match and migrate values rather than duplicating them. +$existing = []; +foreach (Capsule::table('tblproductconfigoptionssub')->where('configid', $configid)->get(['id', 'optionname']) as $row) { + $existing[trim(explode('|', $row->optionname, 2)[0])] = $row->id; +} + +$added = 0; $updated = 0; $sort = 0; +foreach ($slugs as $slug) { + $sort++; + // WHMCS shows the friendly name; passes the value (slug) before the pipe to the module. + $value = $slug . '|' . labelFor($slug); + if (isset($existing[$slug])) { + $subid = $existing[$slug]; + Capsule::table('tblproductconfigoptionssub')->where('id', $subid) + ->update(['optionname' => $value, 'hidden' => 0, 'sortorder' => $sort]); + $updated++; + } else { + $subid = Capsule::table('tblproductconfigoptionssub')->insertGetId([ + 'configid' => $configid, 'optionname' => $value, 'sortorder' => $sort, 'hidden' => 0, + ]); + $added++; + } + // Free pricing per currency (only create if missing — never overwrite operator-set prices). + foreach ($currencies as $cur) { + $has = Capsule::table('tblpricing')->where('type', 'configoptions') + ->where('currency', $cur)->where('relid', $subid)->exists(); + if (!$has) { + Capsule::table('tblpricing')->insert([ + 'type' => 'configoptions', 'currency' => $cur, 'relid' => $subid, + 'msetupfee' => 0, 'qsetupfee' => 0, 'ssetupfee' => 0, + 'asetupfee' => 0, 'bsetupfee' => 0, 'tsetupfee' => 0, + 'monthly' => 0, 'quarterly' => 0, 'semiannually' => 0, + 'annually' => 0, 'biennially' => 0, 'triennially' => 0, + ]); + } + } +} +out("sub-options: +$added added, $updated updated"); + +// --- report stale (present in WHMCS but no longer offered by the API) --- +$stale = array_diff(array_keys($existing), $slugs); +if ($stale) { + out('NOTE: ' . count($stale) . ' option(s) no longer offered by the API (left untouched; hide ' + . 'manually if unwanted): ' . implode(', ', $stale)); +} + +// --- optional: link the group to a product --- +$pid = (int) getenv('PRODUCT_ID'); +if ($pid > 0) { + $linked = Capsule::table('tblproductconfiglinks')->where('gid', $gid)->where('pid', $pid)->exists(); + if (!$linked) { + Capsule::table('tblproductconfiglinks')->insert(['gid' => $gid, 'pid' => $pid]); + out("linked group #$gid to product #$pid"); + } else { + out("group already linked to product #$pid"); + } +} else { + out("To use it: attach the '" . GROUP_NAME . "' configurable option group to your 'Chainstack " + . "Node' product (Products/Services > edit > Configurable Options), or re-run with PRODUCT_ID=."); +} + +out('Done.'); diff --git a/modules/servers/chainstack/templates/clientarea.tpl b/modules/servers/chainstack/templates/clientarea.tpl new file mode 100644 index 0000000..9a46600 --- /dev/null +++ b/modules/servers/chainstack/templates/clientarea.tpl @@ -0,0 +1,73 @@ +{* + Chainstack endpoints — client area view. + Vars: $nodes, $serviceStatus, $iconBase, $fallbackIcon, optional $error. +*} +
+ {if $error} +
{$error|escape}
+ {/if} + + {if $serviceStatus eq 'Suspended'} +
+ This service is suspended, so its endpoint has been removed. When the service is + reactivated a new endpoint will be provisioned (the URL will differ + from the previous one). +
+ {elseif $nodes|count eq 0} +
+ No endpoints yet. Your node may still be deploying — check back shortly or use + Refresh Status. +
+ {else} + {foreach $nodes as $node} +
+
+ {if $node.protocol}{$node.protocol|escape}{/if} + {$node.name|default:$node.id|escape} + {assign var="st" value=$node.status|lower} + {if $st eq 'running' or $st eq 'active'} + {$node.status|escape} + {elseif $st eq 'error' or $st eq 'failed'} + {$node.status|escape} + {else} + {$node.status|escape} + {/if} +
+
+ {if $node.https} +
+ + +
+ {/if} + {if $node.wss} +
+ + +
+ {/if} + {if $node.beacon} +
+ + +
+ {/if} + {if $node.namespaces} +

API namespaces: {', '|implode:$node.namespaces|escape}

+ {/if} + {if $st eq 'error' or $st eq 'failed'} +

This node failed to deploy. Please contact support.

+ {elseif $st neq 'running' and $st neq 'active'} +

This node is being provisioned; endpoints appear once it is running.

+ {/if} +
+
+ {/foreach} + {/if} +
diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e11f4a9 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,12 @@ + + + + + tests + + + diff --git a/tests/HelpersTest.php b/tests/HelpersTest.php new file mode 100644 index 0000000..6ba7933 --- /dev/null +++ b/tests/HelpersTest.php @@ -0,0 +1,50 @@ +assertStringContainsString('node limit', Helpers::friendlyError($e)); + } + + public function testFriendlyErrorFallsBackToRawMessage(): void + { + $e = new ChainstackApiException('some other error', 500, null, null); + $this->assertSame('some other error', Helpers::friendlyError($e)); + } + + public function testProjectNameIsDeterministic(): void + { + $this->assertSame('whmcs-svc-7', Helpers::projectName(['serviceid' => 7])); + } + + public function testNodeNameDefault(): void + { + $this->assertSame('node-svc-42', Helpers::nodeName(['serviceid' => 42])); + } + + public function testNodeNameTemplateIsSanitized(): void + { + $name = Helpers::nodeName([ + 'serviceid' => 42, + 'configoption2' => '{serviceid}-{domain}', + 'domain' => 'a.b', + ]); + $this->assertSame('42-a-b', $name); // dots sanitized to hyphens + } + + public function testBaseUrlDefaultsToPublicApi(): void + { + $this->assertSame('https://api.chainstack.com', Helpers::baseUrl([])); + } + + public function testBaseUrlAcceptsBareHost(): void + { + $this->assertSame('https://api.chainstack.com', Helpers::baseUrl(['serverhostname' => 'api.chainstack.com'])); + } +} diff --git a/tests/ProvisionerTest.php b/tests/ProvisionerTest.php new file mode 100644 index 0000000..eb7a67c --- /dev/null +++ b/tests/ProvisionerTest.php @@ -0,0 +1,129 @@ +deploymentOptions = ['options' => [ + ['network' => 'ethereum-mainnet', 'blockchain' => 'BC-ETH', 'cloud' => 'CC-G1'], + ['network' => 'solana-mainnet', 'blockchain' => 'BC-SOL', 'cloud' => 'CC-G1'], + ]]; + return $c; + } + + private function params(array $overrides = []): array + { + return array_merge([ + 'serviceid' => 1001, + 'model' => new FakeServiceModel(), + 'configoption1' => 'ethereum-mainnet', // Default network + 'configoption2' => '', + 'configoptions' => [], + ], $overrides); + } + + public function testProvisionCreatesProjectAndNodeWithDerivedCloud(): void + { + $c = $this->client(); + $p = $this->params(); + $node = (new Provisioner($c, $p))->provision(); + + $this->assertSame('ND-TEST-1', $node['id']); + $create = $c->firstCall('createNode'); + $this->assertSame('BC-ETH', $create['blockchain']); + $this->assertSame('CC-G1', $create['cloud']); // derived, never entered + $this->assertSame('PR-TEST-1', $create['project']); + + $props = $p['model']->serviceProperties; + $this->assertSame('PR-TEST-1', $props->get('chainstack_project_id')); + $this->assertStringContainsString('ND-TEST-1', $props->get('chainstack_node_ids')); + $this->assertSame('ethereum', $props->get('chainstack_protocol')); + } + + public function testProvisionIsIdempotentOnRerun(): void + { + $c = $this->client(); + $p = $this->params(); + $p['model']->serviceProperties->save(['chainstack_node_ids' => json_encode(['ND-EXISTING'])]); + + $node = (new Provisioner($c, $p))->provision(); + + $this->assertTrue($node['already_provisioned'] ?? false); + $this->assertNull($c->firstCall('createNode'), 'must not create a second node on re-run'); + $this->assertNull($c->firstCall('createProject')); + } + + public function testResolvesViaConfigurableOptionWithFriendlyPipe(): void + { + $c = $this->client(); + $p = $this->params([ + 'configoption1' => '', + 'configoptions' => ['Network' => 'solana-mainnet|Solana Mainnet'], // pipe form + ]); + + (new Provisioner($c, $p))->provision(); + + $create = $c->firstCall('createNode'); + $this->assertSame('BC-SOL', $create['blockchain']); // pipe stripped, slug resolved + } + + public function testUnknownNetworkThrows(): void + { + $c = $this->client(); + $p = $this->params(['configoption1' => 'does-not-exist']); + $this->expectException(ChainstackApiException::class); + (new Provisioner($c, $p))->provision(); + } + + public function testNodeCreateFailureRollsBackProject(): void + { + $c = $this->client(); + $c->failNodeCreate = new ChainstackApiException('limit reached', 403, null, 'quota_exceeded'); + $p = $this->params(); + + try { + (new Provisioner($c, $p))->provision(); + $this->fail('expected ChainstackApiException'); + } catch (ChainstackApiException $e) { + $this->assertSame('quota_exceeded', $e->errorCode); + } + + $this->assertNotNull($c->firstCall('deleteProject'), 'orphaned project must be rolled back'); + $this->assertSame('', $p['model']->serviceProperties->get('chainstack_project_id')); + } + + public function testTerminateDeletesNodesThenProjectAndClearsState(): void + { + $c = $this->client(); + $p = $this->params(); + $p['model']->serviceProperties->save([ + 'chainstack_project_id' => 'PR-X', + 'chainstack_node_ids' => json_encode(['ND-A', 'ND-B']), + ]); + + (new Provisioner($c, $p))->terminate(); + + $this->assertCount(2, $c->callsNamed('deleteNode')); + $this->assertNotNull($c->firstCall('deleteProject')); + $this->assertSame('', $p['model']->serviceProperties->get('chainstack_project_id')); + $this->assertSame('', $p['model']->serviceProperties->get('chainstack_node_ids')); + } + + public function testFetchNodesExposesProtocolAndEndpoints(): void + { + $c = $this->client(); + $p = $this->params(); + $p['model']->serviceProperties->save(['chainstack_node_ids' => json_encode(['ND-A'])]); + + $nodes = (new Provisioner($c, $p))->fetchNodes(); + + $this->assertCount(1, $nodes); + $this->assertSame('ethereum', $nodes[0]['protocol']); + $this->assertSame('https://e.example/k', $nodes[0]['https']); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..73d0aab --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,123 @@ +data[$key] ?? ''; } + public function save(array $kv) { foreach ($kv as $k => $v) { $this->data[$k] = $v; } } +} + +class FakeServiceModel +{ + public FakeServiceProperties $serviceProperties; + public function __construct() { $this->serviceProperties = new FakeServiceProperties(); } +} + +/** + * ChainstackClient test double: records calls, returns canned data, no HTTP. + */ +class FakeChainstackClient extends ChainstackClient +{ + /** @var array */ + public array $calls = []; + public array $deploymentOptions = ['options' => []]; + public ?ChainstackApiException $failNodeCreate = null; + + public function __construct() { parent::__construct('https://api.test', 'key'); } + + public function getDeploymentOptions() + { + $this->calls[] = ['name' => 'getDeploymentOptions', 'arg' => null]; + return $this->deploymentOptions; + } + + public function createProject(array $body) + { + $this->calls[] = ['name' => 'createProject', 'arg' => $body]; + return array_merge(['id' => 'PR-TEST-1'], $body); + } + + public function deleteProject($id) + { + $this->calls[] = ['name' => 'deleteProject', 'arg' => $id]; + return []; + } + + public function createNode(array $body) + { + $this->calls[] = ['name' => 'createNode', 'arg' => $body]; + if ($this->failNodeCreate) { + throw $this->failNodeCreate; + } + return [ + 'id' => 'ND-TEST-1', 'name' => 'test-node', 'protocol' => 'ethereum', 'status' => 'running', + 'details' => [ + 'https_endpoint' => 'https://e.example/k', + 'wss_endpoint' => 'wss://e.example/k', + 'api_namespaces' => ['eth'], + ], + ]; + } + + public function getNode($id) + { + $this->calls[] = ['name' => 'getNode', 'arg' => $id]; + return [ + 'id' => $id, 'name' => 'test-node', 'protocol' => 'ethereum', 'status' => 'running', + 'details' => ['https_endpoint' => 'https://e.example/k'], + ]; + } + + public function deleteNode($id) + { + $this->calls[] = ['name' => 'deleteNode', 'arg' => $id]; + return []; + } + + public function listNodes(array $query = []) + { + $this->calls[] = ['name' => 'listNodes', 'arg' => $query]; + return []; + } + + /** First recorded arg for a call name, or null. */ + public function firstCall(string $name) + { + foreach ($this->calls as $c) { + if ($c['name'] === $name) { + return $c['arg']; + } + } + return null; + } + + /** All recorded args for a call name. */ + public function callsNamed(string $name): array + { + return array_values(array_map( + fn ($c) => $c['arg'], + array_filter($this->calls, fn ($c) => $c['name'] === $name) + )); + } +} diff --git a/tests/e2e_harness.php b/tests/e2e_harness.php new file mode 100644 index 0000000..805cbdf --- /dev/null +++ b/tests/e2e_harness.php @@ -0,0 +1,79 @@ + blockchain + DERIVED cloud), createProject(type=public), + * createNode, ClientArea fetch, and TerminateAccount cleanup. + * + * Usage (run with your WHMCS PHP binary, from the repo root): + * CHAINSTACK_API_KEY=xxx [NET=ethereum-sepolia-testnet] php tests/e2e_harness.php + * + * Creates and then deletes one real node + project. Use a testnet slug to avoid mainnet cost. + */ + +define('WHMCS', true); +if (!function_exists('logModuleCall')) { function logModuleCall() {} } + +// Resolve the module entry point relative to this file (tests/ -> repo root). +require dirname(__DIR__) . '/modules/servers/chainstack/chainstack.php'; + +// --- minimal in-memory stand-in for WHMCS service model serviceProperties --- +class FakeServiceProperties +{ + private $data = []; + public function get($key) { return $this->data[$key] ?? ''; } + public function save(array $kv) { foreach ($kv as $k => $v) { $this->data[$k] = $v; } } +} +class FakeServiceModel +{ + public $serviceProperties; + public function __construct() { $this->serviceProperties = new FakeServiceProperties(); } +} + +$key = getenv('CHAINSTACK_API_KEY'); +if (!$key) { fwrite(STDERR, "ERROR: set CHAINSTACK_API_KEY\n"); exit(1); } +$net = getenv('NET') ?: 'ethereum-sepolia-testnet'; + +$model = new FakeServiceModel(); +$params = [ + 'serviceid' => 999001, + 'model' => $model, + 'serverhostname' => 'api.chainstack.com', + 'serversecure' => true, + 'serverpassword' => $key, + 'domain' => 'harness.test', + 'configoption1' => $net, // "Default network" (slug) -> resolved live via deployment-options + 'configoption2' => '', // node name template (blank -> default) + 'configoptions' => [], // no customer Configurable Option in this harness +]; + +$line = str_repeat('-', 60); +echo "Network slug: $net\n$line\n"; + +try { + echo "CreateAccount -> " . chainstack_CreateAccount($params) . "\n"; + echo " stored project = " . $model->serviceProperties->get('chainstack_project_id') . "\n"; + echo " stored nodes = " . $model->serviceProperties->get('chainstack_node_ids') . "\n$line\n"; + + $ca = chainstack_ClientArea($params); + $urlBefore = $ca['vars']['nodes'][0]['https'] ?? null; + echo "ClientArea https (before) = $urlBefore\n$line\n"; + + echo "SuspendAccount -> " . chainstack_SuspendAccount($params) . "\n"; + $caSusp = chainstack_ClientArea($params); + echo " nodes after suspend = " . count($caSusp['vars']['nodes'] ?? []) . " (expect 0)\n"; + echo " project after suspend = '" . $model->serviceProperties->get('chainstack_project_id') . "'\n$line\n"; + + echo "UnsuspendAccount -> " . chainstack_UnsuspendAccount($params) . "\n"; + $caUns = chainstack_ClientArea($params); + $urlAfter = $caUns['vars']['nodes'][0]['https'] ?? null; + echo " ClientArea https (after) = $urlAfter\n"; + echo " URL changed across suspend/unsuspend? " . ($urlBefore !== $urlAfter ? 'YES (expected)' : 'no') . "\n$line\n"; +} finally { + // Always attempt teardown so a failure mid-run can't leave billable resources behind. + echo "TerminateAccount -> " . chainstack_TerminateAccount($params) . "\n"; + echo " project after terminate = '" . $model->serviceProperties->get('chainstack_project_id') . "'\n"; + echo " nodes after terminate = '" . $model->serviceProperties->get('chainstack_node_ids') . "'\n$line\n"; +} +echo "Done. (If terminate succeeded, no resources remain.)\n";