Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7825f6be80 | ||
|
|
1e0a1308bf | ||
|
|
8caf8c0c01 | ||
|
|
589442e59c | ||
|
|
c90cbd7399 | ||
|
|
bb12cae954 | ||
|
|
5249d6bc19 | ||
|
|
3ea21dfb60 | ||
|
|
fecbf701b7 | ||
|
|
02e059274b | ||
|
|
e9772ed29f | ||
|
|
a3c4154fb2 | ||
|
|
cece1f5ae0 | ||
|
|
f4d6b06203 | ||
|
|
1f09671fee | ||
|
|
6ae3ab55a9 | ||
|
|
0c913110cc |
21
.github/workflows/publish-release.yml
vendored
21
.github/workflows/publish-release.yml
vendored
@@ -166,3 +166,24 @@ jobs:
|
|||||||
body_path: /tmp/release-notes.md
|
body_path: /tmp/release-notes.md
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
make_latest: 'true'
|
||||||
|
|
||||||
|
# Belt-and-suspenders: action-gh-release@v2 has a long-standing
|
||||||
|
# intermittent bug where it creates the release as a draft and silently
|
||||||
|
# fails to flip the draft→published step, even though it reports success.
|
||||||
|
# When that happens the install script + README snippets resolve "latest"
|
||||||
|
# to whatever was last properly published, so users would get an old
|
||||||
|
# version. We explicitly flip to published + latest here as a safety net;
|
||||||
|
# if the action already did it correctly, this is a no-op.
|
||||||
|
#
|
||||||
|
# Security note: TAG and REPO are sourced from earlier `env:` blocks (not
|
||||||
|
# interpolated inline into the run command), matching the same pattern
|
||||||
|
# used elsewhere in this workflow.
|
||||||
|
- name: Force-publish release
|
||||||
|
if: steps.existing.outputs.skip != 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
gh release edit "$TAG" --repo "$REPO" --draft=false --latest
|
||||||
|
|||||||
57
CHANGELOG.md
57
CHANGELOG.md
@@ -2,6 +2,63 @@
|
|||||||
|
|
||||||
All notable changes to the VirtFusion Direct Provisioning Module for WHMCS.
|
All notable changes to the VirtFusion Direct Provisioning Module for WHMCS.
|
||||||
|
|
||||||
|
## [1.4.4] - 2026-04-25
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **`install.sh`: "TMP: unbound variable" error at script exit, plus exit code 1 even on successful installs.** The cleanup `trap 'rm -rf "$TMP"' EXIT` referenced a `local TMP` from inside `cmd_sync()`. The EXIT trap doesn't fire until the *shell* exits — by which time the function-scoped local is out of scope — and `set -u` then exploded the trap body, masking the real exit code with `1`. Fix: drop `local` so `TMP` persists at script scope until cleanup runs, and switch the trap body to `${TMP:-}` so any future regression that tightens TMP's scope still survives the trap. Cosmetic in practice (the install/upgrade work itself completed before the trap ran), but the non-zero exit broke automated wrappers and cron-driven invocations that check `$?`.
|
||||||
|
|
||||||
|
## [1.4.3] - 2026-04-25
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **`install.sh` helper script with `install` / `upgrade` / `check` subcommands.** Single-file POSIX bash script that handles both first-time installation and upgrades, auto-detects the WHMCS web user from the parent directory's ownership and applies it to new files via rsync `--chown`, optionally syncs the PowerDNS reverse-DNS addon (`--with-addon`), accepts a pinned version (`--version v1.4.1`, default: latest published release), preserves any custom `config/ConfigOptionMapping.php` across the rsync `--delete`, and writes a `.installed-version` marker so the `check` subcommand can report installed-vs-latest without making changes. Pipeable via curl or wget. Exit codes for `check` (0=current, 1=outdated, 2=not installed) make it usable as a cron-driven update monitor. Closes the long-standing pitfall where rsyncing as root left files owned by `root:root` and the web server couldn't read them — the classic "module installed but invisible in WHMCS" symptom.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Release workflow now force-publishes new releases to non-draft and marks them `--latest`.** `softprops/action-gh-release@v2` has a long-standing intermittent bug where it creates a release as a draft and silently fails to flip it to published, despite reporting success. v1.4.0, v1.4.1, and v1.4.2 all shipped as drafts because of this — meaning the GitHub `releases/latest` API returned v1.3.0, the install snippets and the new `install.sh` would all download v1.3.0, and users would never get the storage-type-code fix even after running the documented upgrade. Added a `make_latest: 'true'` input to the action and a follow-up `gh release edit --draft=false --latest` step that runs unconditionally as a safety net. v1.4.0/1/2 were manually re-published as a one-off cleanup.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- README install/upgrade sections rewritten to feature the `install.sh` script as the primary path (with both `curl` and `wget` examples), with the manual rsync recipe preserved in collapsible `<details>` blocks for users who prefer not to pipe scripts to bash. The manual recipe also gained a `stat -c '%U:%G'` ownership probe and `--chown="$OWNER"` flag, fixing the same root-owned-file pitfall the script handles automatically.
|
||||||
|
|
||||||
|
## [1.4.2] - 2026-04-25
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **Install/upgrade snippets now pull tagged releases instead of cloning `main`.** The previous `git clone` flow always pulled HEAD, which could include in-flight commits between releases — the same trap the v1.4.1 storage-type-code bug fell into for anyone who installed during the v1.4.0 release window. The new snippets default to the latest published release (queried live from the GitHub API at install time) and accept a `VERSION=vX.Y.Z` override for pinned installs and rollbacks. Pure POSIX — only requires `curl`, `sed`, `tar`, and `rsync`, all standard on any WHMCS host. The `archive/refs/tags/<TAG>.tar.gz` endpoint is public and cacheable, so only the version lookup hits the GitHub API (well under the 60/hr unauthenticated rate limit).
|
||||||
|
|
||||||
|
## [1.4.1] - 2026-04-25
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Critical: stock control returned qty=0 fleet-wide for packages with a `primaryStorageProfile`.** `StockControl::capForStorage()` was comparing the package's `primaryStorageProfile` against `otherStorage[].id`, but the VirtFusion API exposes that field as a **storage type code** (mirrors `server_packages.storage_type`) — a filter that should match `otherStorage[].storageType`. Pool ids are unique per hypervisor (e.g. 23/28/30 for the same logical mountpoint on three nodes) and almost never collide with the type-code domain (0=local, 4=mountpoint, etc.), so the check returned 0 for every hypervisor and silently zeroed inventory for any product that opted into stock control with a non-default storage profile. Symptoms: every stock-controlled VPS product showed qty=0 in WHMCS despite abundant memory/CPU/IPv4 capacity; only workarounds were disabling stock control or removing `primaryStorageProfile` from the package, both of which defeat the gating. Fix: match `pool.storageType` instead of `pool.id`; walk all pools that match (a hypervisor may carry multiple pools of the same type) and pick the one that fits the most VMs; treat a disabled pool as skip-and-continue rather than a hard zero, so an enabled peer of the same type still contributes. Also renamed the internal `$profileId` parameter to `$storageTypeId` so future readers don't fall into the same naming trap. Verified on a 3-hypervisor cluster: qty went from 0/0/0/0/0/0/0/0 to 66/32/15/7/3/1/32/15 across the VPS-1 through VPS-32 products with no other config change.
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-04-24
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Dynamic VPS stock control driven by live hypervisor capacity.** Opt-in per product via WHMCS's native `tblproducts.stockcontrol` toggle; when enabled, the module overwrites `tblproducts.qty` with the real number of VPSes the panel can still provision and WHMCS handles the "Out of Stock" badge, Add-to-Cart gating, and checkout refusal natively — no template work required. qty is derived by combining two authoritative sources:
|
||||||
|
- `GET /packages/{packageId}` for the per-VPS resource footprint (`memory`, `cpuCores`, `primaryStorage`, `primaryStorageProfile`, `enabled`)
|
||||||
|
- `GET /compute/hypervisors/groups/{id}/resources` for live per-hypervisor free/allocated data
|
||||||
|
|
||||||
|
Algorithm sums `min(memory, cpu, storage)` across eligible hypervisors (enabled AND commissioned AND !prohibit) for every group the product can be placed in (default `configoption1` plus every numeric value of a `Location` configurable option), capped by the group-level IPv4 pool taken as `max()` within a group to avoid double-counting. Storage matching is strict against `package.primaryStorageProfile`; hypervisors without the named pool contribute 0. Confirmed-missing conditions (HTTP 404 on `/packages/{id}`, `package.enabled=false`) force qty=0; transient failures leave `qty` UNTOUCHED to avoid false out-of-stock during API blips.
|
||||||
|
|
||||||
|
- **Event-driven stock recalculation hooks:**
|
||||||
|
- `AfterModuleCreate` — refreshes qty after every VirtFusion provision (capacity just decreased). Bursts of parallel provisions coalesce via a 30 s shared rate-limit.
|
||||||
|
- `AfterModuleTerminate` — refreshes qty after every VirtFusion termination (capacity just increased). Shares the 30 s rate-limit with create.
|
||||||
|
- `AfterCronJob` — every-2-hour safety net that catches capacity changes made directly in the VirtFusion panel without going through WHMCS. Interval tunable via `STOCK_CRON_INTERVAL_SECONDS` in `hooks.php`.
|
||||||
|
- `ClientAreaPageCart` — opportunistic per-product refresh during the order flow, rate-limited to once per product per 60 s.
|
||||||
|
|
||||||
|
- **Order auto-accept after successful provision.** `AfterModuleCreate` calls WHMCS `AcceptOrder` (with `autosetup=false` so there's no double-provision) when the parent order is still in Pending status. Closes the gap for installs that rely on pending-order workflows for non-VF products but want VirtFusion provisions to auto-advance. Idempotent — already-accepted orders are skipped.
|
||||||
|
|
||||||
|
- **Admin-triggered full recalculation.** New `admin.php?action=stockRecalculate` action (POST + same-origin required) runs `StockControl::recalculateAll()` on demand and returns a JSON `{productId: qty}` map; the module log gets a compact summary (`{total, updated, zeroed, skipped}`) so it stays readable on stores with hundreds of products.
|
||||||
|
|
||||||
|
- **Per-product safety buffer.** New `stockSafetyBufferPct` config option (configoption7, default 10) reserves X% of each resource's `max` during stock calculation. Applied only to capped resources (unlimited resources with `max=0` skip the buffer). Admins can override per product in the module settings; blank falls back to 10% so existing products get sensible headroom without any config change.
|
||||||
|
|
||||||
|
- **Test Connection now probes `/compute/hypervisors/groups`.** A VirtFusion API token scoped only to `/servers` would pass the existing `/connect` check but silently break nightly stock updates. The admin's Test Connection button now surfaces missing `/compute` read scope at config time with a specific error rather than as unexplained nightly silence.
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
- New cache keys: `pkg:{packageId}` (10 min TTL, package definitions rarely change) and `grpres:{groupId}` (120 s TTL, resources change minute-to-minute under load). Confirmed 404 responses are cached for 60 s so an admin re-creating a deleted package/group takes effect quickly.
|
||||||
|
|
||||||
|
### Safety Properties
|
||||||
|
- `Module::fetchPackage()` and `Module::fetchGroupResources()` return a tri-state `array | false | null`: `false` means "VirtFusion confirmed this doesn't exist → OOS is correct", `null` means "we can't tell right now → don't touch existing qty". Without this distinction the module would either zero out inventory during transient API blips, or show inventory for deleted packages.
|
||||||
|
- `\Throwable` catches on every stock-path entry point (not just `\Exception`) so a `TypeError` from a malformed API response can't escape the tri-state contract.
|
||||||
|
- Stock-control is gated by `tblproducts.stockcontrol=1` — products that opt out are never touched, even by the safety-net cron.
|
||||||
|
|
||||||
## [1.3.0] - 2026-04-17
|
## [1.3.0] - 2026-04-17
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
34
CLAUDE.md
34
CLAUDE.md
@@ -69,6 +69,7 @@ The `publish-release.yml` workflow creates a GitHub/Gitea release with auto-gene
|
|||||||
| `PowerDns\IpUtil` | Pure helpers: `ptrNameForIp` (v4/v6 nibble reversal), `expandIpv6`, `extractIps` (all interfaces), `findZoneAndPtrName` (standard + RFC 2317 classless), `parseClasslessZone`. |
|
| `PowerDns\IpUtil` | Pure helpers: `ptrNameForIp` (v4/v6 nibble reversal), `expandIpv6`, `extractIps` (all interfaces), `findZoneAndPtrName` (standard + RFC 2317 classless), `parseClasslessZone`. |
|
||||||
| `PowerDns\Resolver` | Forward-DNS verification via `dns_get_record()` with up-to-5-hop CNAME following. Cached per (hostname, ip) pair. |
|
| `PowerDns\Resolver` | Forward-DNS verification via `dns_get_record()` with up-to-5-hop CNAME following. Cached per (hostname, ip) pair. |
|
||||||
| `PowerDns\PtrManager` | Orchestrator: `syncServer`, `deleteForServer`, `listPtrs`, `setPtr`, `reconcile`, `reconcileAll`. Per-request zone cache. 10s per-IP write rate limit. Enforces FCrDNS before writes. |
|
| `PowerDns\PtrManager` | Orchestrator: `syncServer`, `deleteForServer`, `listPtrs`, `setPtr`, `reconcile`, `reconcileAll`. Per-request zone cache. 10s per-IP write rate limit. Enforces FCrDNS before writes. |
|
||||||
|
| `StockControl` | Orchestrator for dynamic inventory. `recalculateForProduct()` and `recalculateAll()` compute per-product qty from live `/packages/{id}` + `/compute/hypervisors/groups/{id}/resources` data and write to `tblproducts.qty`. Fail-safe: null return = qty untouched. |
|
||||||
|
|
||||||
### Class Hierarchy
|
### Class Hierarchy
|
||||||
|
|
||||||
@@ -134,6 +135,38 @@ Opt-in integration via the companion `VirtFusionDns` addon module. Loose-coupled
|
|||||||
|
|
||||||
Custom option names can be mapped in `config/ConfigOptionMapping.php` (copy from `-example.php`). Default mapping keys: `packageId`, `hypervisorId`, `ipv4`, `storage`, `memory`, `traffic`, `cpuCores`, `networkSpeedInbound`, `networkSpeedOutbound`, `networkProfile`, `storageProfile`.
|
Custom option names can be mapped in `config/ConfigOptionMapping.php` (copy from `-example.php`). Default mapping keys: `packageId`, `hypervisorId`, `ipv4`, `storage`, `memory`, `traffic`, `cpuCores`, `networkSpeedInbound`, `networkSpeedOutbound`, `networkProfile`, `storageProfile`.
|
||||||
|
|
||||||
|
### Inventory / Stock Control
|
||||||
|
|
||||||
|
Opt-in per product via WHMCS's native stock-control toggle (`tblproducts.stockcontrol=1`). When enabled, the module overwrites `tblproducts.qty` with the real number of VPSes that can still be provisioned — WHMCS then handles the "Out of Stock" badge, Add-to-Cart gating, and checkout refusal natively. No templates or JS required.
|
||||||
|
|
||||||
|
**Data sources (authoritative):**
|
||||||
|
- `GET /packages/{id}` — per-VPS resource footprint (`memory`, `cpuCores`, `primaryStorage`, `primaryStorageProfile`, `enabled`)
|
||||||
|
- `GET /compute/hypervisors/groups/{id}/resources` — live free/allocated per hypervisor with per-metric quotas, storage pools (filtered by `pool.storageType` against the package's `primaryStorageProfile` *type code* — see Safety properties), and a group-level IPv4 pool
|
||||||
|
|
||||||
|
**Algorithm:** for every group the product can be placed in (default `configoption1` plus every numeric value of the `Location` configurable option), sum `min(memory, cpu, storage)` across eligible hypervisors (enabled AND commissioned AND !prohibit) and cap by the group-level IPv4 pool (`max` across hypervisors, not summed — IPv4 is a single group-wide pool). Sum across groups → qty.
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- `AfterModuleCreate` — post-provision refresh; bursts rate-limited to one recalc per 30 s via `stockrefresh:event` cache key.
|
||||||
|
- `AfterModuleTerminate` — post-termination refresh; shares the same 30 s rate-limit key.
|
||||||
|
- `AfterCronJob` — every-2-hour safety net (captures out-of-band VirtFusion panel changes). Tunable via `STOCK_CRON_INTERVAL_SECONDS` constant in `hooks.php`.
|
||||||
|
- `ClientAreaPageCart` — opportunistic per-product refresh on cart/order pages with a 60 s rate-limit key (`stockrefresh:{pid}`). The `grpres:{id}` cache (120 s TTL) naturally coalesces bursts.
|
||||||
|
- `admin.php?action=stockRecalculate` — admin-triggered full recalc (POST + same-origin required); returns JSON `{productId: qty}` map.
|
||||||
|
|
||||||
|
**Order auto-accept:** `AfterModuleCreate` additionally calls WHMCS `AcceptOrder` with `autosetup=false` when the service's parent order is still Pending. Closes the loop for installs that rely on pending-order workflows for non-VF products but want VF provisions to auto-advance.
|
||||||
|
|
||||||
|
**Caching:** `pkg:{id}` 600 s (package definitions rarely change), `grpres:{id}` 120 s (resources change under load). Confirmed 404s cached 60 s so re-creating a deleted package/group takes effect quickly.
|
||||||
|
|
||||||
|
**Safety properties:**
|
||||||
|
- Transient API failures (null from `fetchPackage` / `fetchGroupResources`) leave `qty` UNTOUCHED — never silently takes the catalogue offline.
|
||||||
|
- Confirmed-missing conditions (HTTP 404 on package, `package.enabled=false`) return qty=0 — the product genuinely cannot be provisioned.
|
||||||
|
- IPv4 cap is max-within-group (not summed across hypervisors) to avoid double-counting the shared pool.
|
||||||
|
- Storage matching uses the package's `primaryStorageProfile` as a **storage type code** (it mirrors VirtFusion's `server_packages.storage_type` column — a *filter*, not a pool id). The hypervisor must expose at least one `otherStorage[]` pool whose `storageType` equals that code; if multiple match (e.g. several mountpoint pools on the same hypervisor) the one that fits the most VMs wins. A disabled pool is skipped, not fatal — an enabled peer of the same type still contributes. Hypervisors with no pool of the matching type contribute 0. Falls back to `localStorage` only when the package has no profile set (`primaryStorageProfile <= 0`).
|
||||||
|
- Stock control is gated by `tblproducts.stockcontrol=1` per product — the module never touches qty on products that opt out.
|
||||||
|
|
||||||
|
**Per-product setting:** `stockSafetyBufferPct` (configoption7, default 10). Reserves X% of each resource's `max` before computing fits; ignored for unlimited resources (`max=0`) and for IPv4 (no per-hypervisor `max` in the response). Admins can override per product in the module settings; blank falls back to 10%.
|
||||||
|
|
||||||
|
**API scope required:** the VirtFusion API token must have read access to both `/packages` and `/compute/hypervisors/groups`. The Test Connection button probes the compute endpoint and shows a clear error if scope is missing.
|
||||||
|
|
||||||
## Security Patterns
|
## Security Patterns
|
||||||
|
|
||||||
- All PHP files start with `if (!defined("WHMCS")) die()` to prevent direct access (except entry points using `init.php`)
|
- All PHP files start with `if (!defined("WHMCS")) die()` to prevent direct access (except entry points using `init.php`)
|
||||||
@@ -173,6 +206,7 @@ Custom option names can be mapped in `config/ConfigOptionMapping.php` (copy from
|
|||||||
| configoption4 | Self-Service Mode | 0=Disabled, 1=Hourly, 2=Resource Packs, 3=Both | 0 |
|
| configoption4 | Self-Service Mode | 0=Disabled, 1=Hourly, 2=Resource Packs, 3=Both | 0 |
|
||||||
| configoption5 | Auto Top-Off Threshold | Credit balance below which auto top-off triggers | 0 |
|
| configoption5 | Auto Top-Off Threshold | Credit balance below which auto top-off triggers | 0 |
|
||||||
| configoption6 | Auto Top-Off Amount | Credit amount to add on auto top-off | 100 |
|
| configoption6 | Auto Top-Off Amount | Credit amount to add on auto top-off | 100 |
|
||||||
|
| configoption7 | Stock Safety Buffer (%) | Headroom reserved per resource during stock calculation (0-100). Only effective with WHMCS stock control enabled. Blank falls back to the default. | 10 |
|
||||||
|
|
||||||
## WHMCS Compatibility
|
## WHMCS Compatibility
|
||||||
|
|
||||||
|
|||||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
support@ezscale.cloud.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
137
README.md
137
README.md
@@ -20,6 +20,7 @@ A comprehensive WHMCS provisioning module for [VirtFusion](https://virtfusion.co
|
|||||||
- [Module Configuration Options](#module-configuration-options)
|
- [Module Configuration Options](#module-configuration-options)
|
||||||
- [Configurable Options (Dynamic Pricing)](#configurable-options-dynamic-pricing)
|
- [Configurable Options (Dynamic Pricing)](#configurable-options-dynamic-pricing)
|
||||||
- [Custom Option Name Mapping](#custom-option-name-mapping)
|
- [Custom Option Name Mapping](#custom-option-name-mapping)
|
||||||
|
- [Stock Control (Dynamic Inventory)](#stock-control-dynamic-inventory)
|
||||||
- [Reverse DNS Addon (PowerDNS)](#reverse-dns-addon-powerdns)
|
- [Reverse DNS Addon (PowerDNS)](#reverse-dns-addon-powerdns)
|
||||||
- [Client Area Features](#client-area-features)
|
- [Client Area Features](#client-area-features)
|
||||||
- [Admin Area Features](#admin-area-features)
|
- [Admin Area Features](#admin-area-features)
|
||||||
@@ -86,6 +87,15 @@ You also need a VirtFusion API token with the following permissions:
|
|||||||
- Checkout validation ensuring OS selection before order placement
|
- Checkout validation ensuring OS selection before order placement
|
||||||
- **Resource sliders** - Configurable option dropdowns are replaced with interactive range sliders
|
- **Resource sliders** - Configurable option dropdowns are replaced with interactive range sliders
|
||||||
- Compatible with all WHMCS order form templates
|
- Compatible with all WHMCS order form templates
|
||||||
|
- **Order auto-accept after provision** — when a paid order's VirtFusion service provisions successfully, the module calls WHMCS `AcceptOrder` (with `autosetup=false` so there's no double-provision) to flip the order from Pending → Active automatically. Idempotent; already-accepted orders are untouched.
|
||||||
|
|
||||||
|
### Stock Control (Dynamic Inventory)
|
||||||
|
- **Out-of-stock badges driven by real hypervisor capacity** — opt-in per product via WHMCS's native Stock Control toggle. When enabled, the module keeps `tblproducts.qty` synced to the number of VPSes the panel can still actually provision, and WHMCS renders the "Out of Stock" badge, disables Add-to-Cart, and refuses checkout natively. No templates or JavaScript required.
|
||||||
|
- **Live-capacity math** — combines `/packages/{id}` (per-VPS resource footprint) with `/compute/hypervisors/groups/{id}/resources` (live per-hypervisor free/allocated) to compute qty across every group the product can be placed in. Storage matching is by **type code** (`pool.storageType`), so a package targeting e.g. mountpoint storage qualifies on every hypervisor that exposes a mountpoint pool — and picks the largest-fit pool when several share the same type. Group-level IPv4 pool accounted for without double-counting.
|
||||||
|
- **Event-driven refresh** — qty recalculates after every successful provision (`AfterModuleCreate`), termination (`AfterModuleTerminate`), and on cart/order page views for individual products. A 2-hour safety-net cron catches capacity changes made directly in the VirtFusion panel.
|
||||||
|
- **Per-product safety buffer** — `stockSafetyBufferPct` config option (default 10%) reserves headroom so the storefront stops selling before a hypervisor is literally at 100%.
|
||||||
|
- **Fail-safe under API outages** — transient VirtFusion API failures leave `qty` UNCHANGED instead of zeroing it, so a brief network blip doesn't take the catalogue offline.
|
||||||
|
- **Admin recalc on demand** — POST `admin.php?action=stockRecalculate` forces a full re-sweep.
|
||||||
|
|
||||||
### Usage Tracking
|
### Usage Tracking
|
||||||
- **Automated bandwidth sync** - WHMCS daily cron pulls traffic usage from VirtFusion
|
- **Automated bandwidth sync** - WHMCS daily cron pulls traffic usage from VirtFusion
|
||||||
@@ -120,14 +130,42 @@ You also need a VirtFusion API token with the following permissions:
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
The fastest path is the install script. It auto-detects the WHMCS web user from your `modules/servers` directory ownership and applies it to the new files — without that, rsyncing as root would leave files owned by `root:root` and the web server couldn't read them ("module installed but invisible in WHMCS").
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
WHMCS=/path/to/whmcs
|
curl -fsSL https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git /tmp/vf \
|
| sudo bash -s -- install /path/to/whmcs
|
||||||
&& rsync -ahP --delete /tmp/vf/modules/servers/VirtFusionDirect/ "$WHMCS/modules/servers/VirtFusionDirect/" \
|
|
||||||
&& rm -rf /tmp/vf
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `WHMCS` once at the top — it's reused in every path below. The database table, schema migrations, and custom fields are all created automatically on first load.
|
Same thing with `wget`:
|
||||||
|
```bash
|
||||||
|
wget -qO- https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
|
| sudo bash -s -- install /path/to/whmcs
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
- `--with-addon` — also install the PowerDNS reverse-DNS addon (`modules/addons/VirtFusionDns/`).
|
||||||
|
- `--version v1.4.1` — pin a specific release tag (default: latest published release; any tag from [Releases](https://github.com/EZSCALE/virtfusion-whmcs-module/releases)).
|
||||||
|
|
||||||
|
The database table, schema migrations, and custom fields are all created automatically on first load.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Manual install</b> (if you'd rather not pipe a script to bash)</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WHMCS=/path/to/whmcs
|
||||||
|
VERSION=${VERSION:-$(curl -fsSL https://api.github.com/repos/EZSCALE/virtfusion-whmcs-module/releases/latest \
|
||||||
|
| sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')}
|
||||||
|
OWNER=$(stat -c '%U:%G' "$WHMCS/modules/servers")
|
||||||
|
curl -fsSL "https://github.com/EZSCALE/virtfusion-whmcs-module/archive/refs/tags/${VERSION}.tar.gz" -o /tmp/vf.tar.gz \
|
||||||
|
&& mkdir -p /tmp/vf && tar -xzf /tmp/vf.tar.gz -C /tmp/vf --strip-components=1 \
|
||||||
|
&& rsync -ahP --delete --chown="$OWNER" /tmp/vf/modules/servers/VirtFusionDirect/ "$WHMCS/modules/servers/VirtFusionDirect/" \
|
||||||
|
&& rm -rf /tmp/vf /tmp/vf.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
`--chown="$OWNER"` ensures the new files match your WHMCS web user (`www-data`, `apache`, etc.) instead of `root:root`. Requires rsync 3.1+ and root (or already running as the matching user). To pin a version, prepend `VERSION=v1.4.1` before the command.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
Then configure in WHMCS Admin:
|
Then configure in WHMCS Admin:
|
||||||
|
|
||||||
@@ -140,19 +178,42 @@ That's it. Hooks activate automatically and custom fields are created on module
|
|||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
WHMCS=/path/to/whmcs
|
curl -fsSL https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git /tmp/vf \
|
| sudo bash -s -- upgrade /path/to/whmcs
|
||||||
&& rsync -ahP --delete /tmp/vf/modules/servers/VirtFusionDirect/ "$WHMCS/modules/servers/VirtFusionDirect/" \
|
|
||||||
&& rsync -ahP --delete /tmp/vf/modules/addons/VirtFusionDns/ "$WHMCS/modules/addons/VirtFusionDns/" \
|
|
||||||
&& rm -rf /tmp/vf
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The second `rsync` line is only needed if you use the Reverse DNS addon; skip it otherwise. Addon settings live in `tbladdonmodules` and survive file updates.
|
Add `--with-addon` if you also use the PowerDNS addon. Pin a version with `--version v1.4.1` for controlled rollouts or rollbacks. Addon settings live in `tbladdonmodules` and survive file updates. The script automatically backs up and restores any custom `config/ConfigOptionMapping.php` across the rsync `--delete`.
|
||||||
|
|
||||||
> **Note:** If you have a custom `config/ConfigOptionMapping.php`, back it up first — `--delete` will remove it. Restore it after upgrading.
|
To check whether you're current without making any changes:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
|
| bash -s -- check /path/to/whmcs
|
||||||
|
```
|
||||||
|
Exit codes: `0` = up-to-date, `1` = outdated (or version unknown), `2` = not installed. Useful in cron-driven monitoring.
|
||||||
|
|
||||||
If you use theme-overridden templates, review them for any new template variables. Clear the WHMCS template cache after upgrading: **Configuration > System Settings > General Settings > clear template cache**.
|
If you use theme-overridden templates, review them for any new template variables. Clear the WHMCS template cache after upgrading: **Configuration > System Settings > General Settings > clear template cache**.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Manual upgrade</b> (if you'd rather not pipe a script to bash)</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WHMCS=/path/to/whmcs
|
||||||
|
VERSION=${VERSION:-$(curl -fsSL https://api.github.com/repos/EZSCALE/virtfusion-whmcs-module/releases/latest \
|
||||||
|
| sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')}
|
||||||
|
OWNER=$(stat -c '%U:%G' "$WHMCS/modules/servers")
|
||||||
|
curl -fsSL "https://github.com/EZSCALE/virtfusion-whmcs-module/archive/refs/tags/${VERSION}.tar.gz" -o /tmp/vf.tar.gz \
|
||||||
|
&& mkdir -p /tmp/vf && tar -xzf /tmp/vf.tar.gz -C /tmp/vf --strip-components=1 \
|
||||||
|
&& rsync -ahP --delete --chown="$OWNER" /tmp/vf/modules/servers/VirtFusionDirect/ "$WHMCS/modules/servers/VirtFusionDirect/" \
|
||||||
|
&& rsync -ahP --delete --chown="$OWNER" /tmp/vf/modules/addons/VirtFusionDns/ "$WHMCS/modules/addons/VirtFusionDns/" \
|
||||||
|
&& rm -rf /tmp/vf /tmp/vf.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
The second `rsync` line is only needed if you use the Reverse DNS addon; skip it otherwise.
|
||||||
|
|
||||||
|
> **Note:** If you have a custom `config/ConfigOptionMapping.php`, back it up first — `--delete` will remove it. Restore it after. The helper script does this automatically.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Server Setup
|
### Server Setup
|
||||||
@@ -183,7 +244,7 @@ The fields are hidden text boxes that are dynamically replaced by dropdown selec
|
|||||||
|
|
||||||
### Module Configuration Options
|
### Module Configuration Options
|
||||||
|
|
||||||
Each product has three module-specific settings:
|
Each product has these module-specific settings:
|
||||||
|
|
||||||
| Option | Name | Description | Default |
|
| Option | Name | Description | Default |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -193,6 +254,7 @@ Each product has three module-specific settings:
|
|||||||
| Config Option 4 | Self-Service Mode | Enable VirtFusion self-service billing (0=Disabled, 1=Hourly, 2=Resource Packs, 3=Both) | 0 |
|
| Config Option 4 | Self-Service Mode | Enable VirtFusion self-service billing (0=Disabled, 1=Hourly, 2=Resource Packs, 3=Both) | 0 |
|
||||||
| Config Option 5 | Auto Top-Off Threshold | Credit balance below which auto top-off triggers during cron (0=disabled) | 0 |
|
| Config Option 5 | Auto Top-Off Threshold | Credit balance below which auto top-off triggers during cron (0=disabled) | 0 |
|
||||||
| Config Option 6 | Auto Top-Off Amount | Credit amount to add when auto top-off triggers | 100 |
|
| Config Option 6 | Auto Top-Off Amount | Credit amount to add when auto top-off triggers | 100 |
|
||||||
|
| Config Option 7 | Stock Safety Buffer (%) | Headroom reserved per resource during stock calculation (0-100). Only effective with WHMCS Stock Control enabled on the product; blank falls back to the default. | 10 |
|
||||||
|
|
||||||
You can find your Hypervisor Group IDs and Package IDs in the VirtFusion admin panel.
|
You can find your Hypervisor Group IDs and Package IDs in the VirtFusion admin panel.
|
||||||
|
|
||||||
@@ -230,6 +292,55 @@ return [
|
|||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Stock Control (Dynamic Inventory)
|
||||||
|
|
||||||
|
Optional but recommended once the catalogue is backed by real hypervisor capacity. When enabled on a product, the module keeps `tblproducts.qty` synced with the number of VPSes the panel can still actually provision — then WHMCS renders "Out of Stock" badges, disables Add-to-Cart, and refuses checkout entirely on its own.
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- The VirtFusion API token on the WHMCS server must have read access to both `/packages` and `/compute/hypervisors/groups`. The **Test Connection** button (Admin → System Settings → Servers) now probes the compute endpoint explicitly — if the token is missing that scope you'll see a clear error at config time instead of nightly silence.
|
||||||
|
- No addon to activate. Stock control is enabled per product via WHMCS's native toggle.
|
||||||
|
|
||||||
|
**Enabling it on a product:**
|
||||||
|
|
||||||
|
1. WHMCS Admin → **System Settings → Products/Services → Products/Services** → edit the product.
|
||||||
|
2. Under the **Details** tab, tick **Stock Control** and save. (Leave *Quantity* at 0 — the module will populate it on the next recalc.)
|
||||||
|
3. Optionally tune **Config Option 7 — Stock Safety Buffer (%)** in the **Module Settings** tab. Default 10% means the module reserves 10% of each resource's max before counting fits, so you stop selling before a hypervisor is at 100%. Set to 0 for no buffer, higher for more headroom.
|
||||||
|
4. Either wait for the next recalc event (within 2 hours) or force one immediately: POST to `modules/servers/VirtFusionDirect/admin.php?action=stockRecalculate` from an authenticated admin session.
|
||||||
|
|
||||||
|
**How qty is computed:**
|
||||||
|
|
||||||
|
For every stock-controlled VirtFusion product:
|
||||||
|
|
||||||
|
1. Resolve the set of hypervisor groups the product can be placed in — the default group (Config Option 1) plus every numeric value of the `Location` configurable option if one is attached.
|
||||||
|
2. Fetch the product's package via `GET /packages/{id}` for the per-VPS resource footprint (`memory`, `cpuCores`, `primaryStorage`, `primaryStorageProfile`).
|
||||||
|
3. For each eligible group, fetch live resources via `GET /compute/hypervisors/groups/{id}/resources`.
|
||||||
|
4. For each hypervisor in the group that passes eligibility (`enabled` AND `commissioned` AND `!prohibit`), compute `min(memory, cpu, storage)` fits — with the per-product buffer applied — against the matched storage pool. `package.primaryStorageProfile` is a **storage type code** (mirrors VirtFusion's `server_packages.storage_type` column — a *filter*, not a pool id), matched against each `otherStorage[].storageType`. If multiple pools on the same hypervisor share that type (e.g. several mountpoint pools), the one with the largest fit wins; disabled peers are skipped, not fatal. Falls back to `localStorage` only when the package has no profile set.
|
||||||
|
5. Sum across hypervisors in each group, cap by the group-level IPv4 pool (`max()` within a group to avoid double-counting the shared pool), then sum across groups → `qty`.
|
||||||
|
|
||||||
|
**Refresh triggers:**
|
||||||
|
|
||||||
|
| Event | Trigger | Rate limit |
|
||||||
|
|---|---|---|
|
||||||
|
| New provision | `AfterModuleCreate` hook | 30 s shared with termination |
|
||||||
|
| VPS termination | `AfterModuleTerminate` hook | 30 s shared with create |
|
||||||
|
| Cart / order page view | `ClientAreaPageCart` hook | 60 s per product |
|
||||||
|
| Out-of-band panel change safety net | `AfterCronJob` hook | 2 hours (tunable via `STOCK_CRON_INTERVAL_SECONDS` in `hooks.php`) |
|
||||||
|
| Admin manual recalc | `admin.php?action=stockRecalculate` (POST + same-origin) | On demand |
|
||||||
|
|
||||||
|
**Safety properties:**
|
||||||
|
- **Transient API failures leave `qty` UNCHANGED.** `Module::fetchPackage()` and `Module::fetchGroupResources()` return a tri-state `array | false | null`: `false` means "VirtFusion confirmed this doesn't exist → OOS is correct", `null` means "we can't tell right now → don't touch existing qty". Without this distinction the module would either zero out inventory during API blips or show inventory for deleted packages.
|
||||||
|
- **Confirmed-missing → qty=0.** HTTP 404 on the package or `package.enabled=false` forces qty=0, because the product genuinely cannot be provisioned.
|
||||||
|
- **Storage type mismatch → 0 for that hypervisor.** If the package targets storage type code `4` (mountpoint) but the hypervisor only exposes pools of type `0` (local default), that hypervisor contributes zero capacity — not a guess at "maybe placement will work out." This is a filter on `pool.storageType`, not on `pool.id`; identical type codes across different hypervisors all qualify, which is what makes multi-hypervisor mountpoint/datastore placement work.
|
||||||
|
- **Stock Control gate is absolute.** Products without `tblproducts.stockcontrol=1` are never touched, even by the cron safety net.
|
||||||
|
- **`\Throwable` catches** on every stock-path entry point (not just `\Exception`) so a `TypeError` from a malformed API response can't escape the tri-state contract.
|
||||||
|
|
||||||
|
**Caching:**
|
||||||
|
- `pkg:{packageId}` — 10 min TTL (package definitions rarely change)
|
||||||
|
- `grpres:{groupId}` — 120 s TTL (resources change minute-to-minute under load; shared across products that target the same group)
|
||||||
|
- Confirmed 404 responses cached 60 s so re-creating a deleted package/group takes effect quickly.
|
||||||
|
|
||||||
|
**Order auto-accept:** the `AfterModuleCreate` hook additionally calls WHMCS `AcceptOrder` with `autosetup=false` when the service's parent order is still in Pending status. This closes the loop for installs that rely on a pending-order workflow for non-VF products but want VirtFusion provisions to advance to Active automatically. Idempotent — already-accepted orders are skipped.
|
||||||
|
|
||||||
### Reverse DNS Addon (PowerDNS)
|
### Reverse DNS Addon (PowerDNS)
|
||||||
|
|
||||||
Optional. Activate the `VirtFusionDns` addon module to let the provisioning module manage PTR records in a PowerDNS instance automatically (and expose an rDNS editor to clients).
|
Optional. Activate the `VirtFusionDns` addon module to let the provisioning module manage PTR records in a PowerDNS instance automatically (and expose an rDNS editor to clients).
|
||||||
|
|||||||
196
install.sh
Executable file
196
install.sh
Executable file
@@ -0,0 +1,196 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# install.sh — Manage the VirtFusion Direct WHMCS module.
|
||||||
|
#
|
||||||
|
# Subcommands:
|
||||||
|
# install First-time install. Refuses if already present (use upgrade).
|
||||||
|
# upgrade Refresh an existing install. Refuses if nothing is installed.
|
||||||
|
# check Report installed version vs latest available. No changes.
|
||||||
|
#
|
||||||
|
# Flags (install/upgrade only):
|
||||||
|
# --with-addon, -a Also sync the PowerDNS rDNS addon.
|
||||||
|
# --version, -v vX.Y.Z Pin a specific release tag (default: latest).
|
||||||
|
#
|
||||||
|
# Exit codes for `check`:
|
||||||
|
# 0 installed and up-to-date
|
||||||
|
# 1 installed but outdated (or installed-version unknown)
|
||||||
|
# 2 not installed
|
||||||
|
#
|
||||||
|
# Pipeable:
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
|
# | sudo bash -s -- install /path/to/whmcs
|
||||||
|
#
|
||||||
|
# wget -qO- https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
|
# | sudo bash -s -- upgrade --with-addon /path/to/whmcs
|
||||||
|
#
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||||
|
# | bash -s -- check /path/to/whmcs
|
||||||
|
#
|
||||||
|
# Why a script? rsync into a directory owned by the WHMCS web user (e.g.
|
||||||
|
# www-data, apache) lands files as root:root by default, which the web server
|
||||||
|
# can't read — the classic "module installed but invisible in WHMCS" symptom.
|
||||||
|
# This script reads the parent directory's owner and applies it via --chown, so
|
||||||
|
# a `sudo bash` install ends up with correct ownership. It also preserves any
|
||||||
|
# custom config/ConfigOptionMapping.php across --delete.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="EZSCALE/virtfusion-whmcs-module"
|
||||||
|
MARKER=".installed-version"
|
||||||
|
|
||||||
|
err() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; }
|
||||||
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
|
info() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<USAGE
|
||||||
|
Usage:
|
||||||
|
install.sh install [--with-addon] [--version vX.Y.Z] /path/to/whmcs
|
||||||
|
install.sh upgrade [--with-addon] [--version vX.Y.Z] /path/to/whmcs
|
||||||
|
install.sh check /path/to/whmcs
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh \\
|
||||||
|
| sudo bash -s -- install /path/to/whmcs
|
||||||
|
|
||||||
|
wget -qO- https://raw.githubusercontent.com/$REPO/main/install.sh \\
|
||||||
|
| sudo bash -s -- upgrade --with-addon /path/to/whmcs
|
||||||
|
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh \\
|
||||||
|
| bash -s -- check /path/to/whmcs
|
||||||
|
USAGE
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_latest() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \
|
||||||
|
| sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p'
|
||||||
|
}
|
||||||
|
|
||||||
|
read_installed_version() {
|
||||||
|
local marker="$1/modules/servers/VirtFusionDirect/$MARKER"
|
||||||
|
if [ -f "$marker" ]; then
|
||||||
|
tr -d '[:space:]' < "$marker"
|
||||||
|
else
|
||||||
|
echo "unknown"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_check() {
|
||||||
|
local WHMCS="$1"
|
||||||
|
if [ ! -d "$WHMCS/modules/servers/VirtFusionDirect" ]; then
|
||||||
|
warn "Not installed at $WHMCS"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
local current latest
|
||||||
|
current=$(read_installed_version "$WHMCS")
|
||||||
|
latest=$(resolve_latest)
|
||||||
|
[ -n "$latest" ] || { err "Could not resolve latest version from GitHub API"; exit 1; }
|
||||||
|
printf ' installed: %s\n latest: %s\n' "$current" "$latest"
|
||||||
|
if [ "$current" = "$latest" ]; then
|
||||||
|
info "Up to date"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
warn "Update available: $current → $latest"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_sync() {
|
||||||
|
local mode="$1"; shift
|
||||||
|
local WITH_ADDON=0 VERSION="${VERSION:-}" WHMCS=""
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--with-addon|-a) WITH_ADDON=1; shift ;;
|
||||||
|
--version|-v) VERSION="${2:-}"; shift 2 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
-*) err "Unknown flag: $1"; usage ;;
|
||||||
|
*) WHMCS="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$WHMCS" ] || { err "Missing WHMCS path"; usage; }
|
||||||
|
[ -d "$WHMCS/modules/servers" ] || {
|
||||||
|
err "Not a WHMCS install: $WHMCS/modules/servers not found"; exit 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
local target="$WHMCS/modules/servers/VirtFusionDirect"
|
||||||
|
if [ "$mode" = "install" ] && [ -d "$target" ]; then
|
||||||
|
err "Already installed at $target — use 'upgrade' to refresh."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$mode" = "upgrade" ] && [ ! -d "$target" ]; then
|
||||||
|
err "Not currently installed at $target — use 'install' instead."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
VERSION=$(resolve_latest)
|
||||||
|
[ -n "$VERSION" ] || { err "Could not resolve latest version from GitHub API"; exit 1; }
|
||||||
|
fi
|
||||||
|
info "Target version: $VERSION"
|
||||||
|
|
||||||
|
local OWNER
|
||||||
|
OWNER=$(stat -c '%U:%G' "$WHMCS/modules/servers" 2>/dev/null || true)
|
||||||
|
[ -n "$OWNER" ] || { err "Could not detect parent directory owner via stat"; exit 1; }
|
||||||
|
info "Owner (from $WHMCS/modules/servers): $OWNER"
|
||||||
|
|
||||||
|
# NOTE: TMP is intentionally NOT declared `local`. The EXIT trap fires when
|
||||||
|
# the shell exits, not when this function returns — by then a function-local
|
||||||
|
# would be out of scope and `set -u` would explode the trap body with
|
||||||
|
# "TMP: unbound variable", masking the script's real exit code with 1.
|
||||||
|
# The `${TMP:-}` expansion in the trap is belt-and-suspenders: harmless
|
||||||
|
# if TMP somehow ends up unset, and prevents future regressions if anyone
|
||||||
|
# moves the assignment back into a tighter scope.
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "${TMP:-}"' EXIT
|
||||||
|
|
||||||
|
info "Downloading $VERSION..."
|
||||||
|
curl -fsSL "https://github.com/$REPO/archive/refs/tags/$VERSION.tar.gz" -o "$TMP/src.tar.gz"
|
||||||
|
mkdir -p "$TMP/src"
|
||||||
|
tar -xzf "$TMP/src.tar.gz" -C "$TMP/src" --strip-components=1
|
||||||
|
|
||||||
|
local SRC="$TMP/src/modules/servers/VirtFusionDirect"
|
||||||
|
[ -d "$SRC" ] || { err "Tarball did not contain modules/servers/VirtFusionDirect"; exit 1; }
|
||||||
|
|
||||||
|
# Preserve user's custom configurable-option mapping across --delete.
|
||||||
|
local MAP_FILE="$target/config/ConfigOptionMapping.php"
|
||||||
|
local MAP_BACKUP=""
|
||||||
|
if [ -f "$MAP_FILE" ]; then
|
||||||
|
MAP_BACKUP="$TMP/ConfigOptionMapping.php.bak"
|
||||||
|
cp -p "$MAP_FILE" "$MAP_BACKUP"
|
||||||
|
info "Backed up custom ConfigOptionMapping.php"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Syncing server module → $target/"
|
||||||
|
rsync -ahP --delete --chown="$OWNER" "$SRC/" "$target/"
|
||||||
|
|
||||||
|
if [ -n "$MAP_BACKUP" ]; then
|
||||||
|
cp -p "$MAP_BACKUP" "$MAP_FILE"
|
||||||
|
chown "$OWNER" "$MAP_FILE"
|
||||||
|
info "Restored custom ConfigOptionMapping.php"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$VERSION" > "$target/$MARKER"
|
||||||
|
chown "$OWNER" "$target/$MARKER"
|
||||||
|
|
||||||
|
if [ "$WITH_ADDON" = 1 ]; then
|
||||||
|
local addon_src="$TMP/src/modules/addons/VirtFusionDns"
|
||||||
|
local addon_target="$WHMCS/modules/addons/VirtFusionDns"
|
||||||
|
[ -d "$addon_src" ] || { err "Tarball did not contain modules/addons/VirtFusionDns"; exit 1; }
|
||||||
|
info "Syncing PowerDNS addon → $addon_target/"
|
||||||
|
rsync -ahP --delete --chown="$OWNER" "$addon_src/" "$addon_target/"
|
||||||
|
printf '%s\n' "$VERSION" > "$addon_target/$MARKER"
|
||||||
|
chown "$OWNER" "$addon_target/$MARKER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "$mode complete: $VERSION (owner $OWNER)"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
install) shift; cmd_sync install "$@" ;;
|
||||||
|
upgrade) shift; cmd_sync upgrade "$@" ;;
|
||||||
|
check) shift; [ $# -eq 1 ] || usage; cmd_check "$1" ;;
|
||||||
|
-h|--help|"") usage ;;
|
||||||
|
*) err "Unknown command: $1"; usage ;;
|
||||||
|
esac
|
||||||
@@ -114,6 +114,13 @@ function VirtFusionDirect_ConfigOptions()
|
|||||||
'Description' => 'Credit amount to add when auto top-off triggers.',
|
'Description' => 'Credit amount to add when auto top-off triggers.',
|
||||||
'Default' => '100',
|
'Default' => '100',
|
||||||
],
|
],
|
||||||
|
'stockSafetyBufferPct' => [
|
||||||
|
'FriendlyName' => 'Stock Safety Buffer (%)',
|
||||||
|
'Type' => 'text',
|
||||||
|
'Size' => '5',
|
||||||
|
'Description' => 'Reserved headroom applied per resource when calculating stock. Only effective when the WHMCS Stock Control toggle is enabled on this product. 0-100; ignored for resources with no quota set in VirtFusion. Default is 10% if left blank.',
|
||||||
|
'Default' => '10',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +142,28 @@ function VirtFusionDirect_TestConnection(array $params)
|
|||||||
$httpCode = $request->getRequestInfo('http_code');
|
$httpCode = $request->getRequestInfo('http_code');
|
||||||
|
|
||||||
if ($httpCode == 200) {
|
if ($httpCode == 200) {
|
||||||
|
// Probe the compute scope: stock control depends on read access to
|
||||||
|
// /compute/hypervisors/groups. A token scoped only to /servers will pass the
|
||||||
|
// /connect check above but silently break nightly stock recalculation, so we
|
||||||
|
// surface the missing scope at config time rather than a week later.
|
||||||
|
$groupsProbe = $module->initCurl($password);
|
||||||
|
$groupsProbe->get($url . '/compute/hypervisors/groups?results=1');
|
||||||
|
$groupsHttp = (int) $groupsProbe->getRequestInfo('http_code');
|
||||||
|
|
||||||
|
if ($groupsHttp === 401 || $groupsHttp === 403) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'VirtFusion OK but API token lacks read access to /compute/hypervisors/groups (HTTP ' . $groupsHttp . '). Stock Control will not work — re-issue the token with compute:read scope.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($groupsHttp !== 200) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'VirtFusion OK but /compute/hypervisors/groups returned HTTP ' . $groupsHttp . '. Stock Control may not work correctly.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Also verify PowerDNS health when the DNS addon is activated, so the
|
// Also verify PowerDNS health when the DNS addon is activated, so the
|
||||||
// admin's Test Connection button reflects the full provisioning path.
|
// admin's Test Connection button reflects the full provisioning path.
|
||||||
if (PowerDnsConfig::isEnabled()) {
|
if (PowerDnsConfig::isEnabled()) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ use WHMCS\Module\Server\VirtFusionDirect\Module;
|
|||||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
|
use WHMCS\Module\Server\VirtFusionDirect\ServerResource;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\StockControl;
|
||||||
|
|
||||||
$vf = new Module;
|
$vf = new Module;
|
||||||
|
|
||||||
@@ -169,6 +170,46 @@ try {
|
|||||||
$vf->output(['success' => true, 'data' => $summary], true, true, 200);
|
$vf->output(['success' => true, 'data' => $summary], true, true, 200);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Stock Control
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a full stock-quantity recalculation across every VirtFusionDirect
|
||||||
|
* product that has WHMCS stock control enabled. Same logic as the 2-hour
|
||||||
|
* AfterCronJob safety-net hook and the post-provision / post-termination
|
||||||
|
* event hooks in hooks.php, but on-demand. Cache TTLs still govern freshness
|
||||||
|
* of the underlying VirtFusion API reads — run a separate cache bust first
|
||||||
|
* if the admin needs to bypass the 120 s grpres:{id} TTL.
|
||||||
|
*
|
||||||
|
* Usable by admins via POST; returns a JSON map of productId => qty (or null
|
||||||
|
* where the product was skipped / left untouched by the orchestrator).
|
||||||
|
*/
|
||||||
|
case 'stockRecalculate':
|
||||||
|
|
||||||
|
$vf->requirePost();
|
||||||
|
$vf->requireSameOrigin();
|
||||||
|
|
||||||
|
$results = (new StockControl)->recalculateAll();
|
||||||
|
|
||||||
|
// Log a compact summary instead of the full map — the admin client still
|
||||||
|
// gets the detailed per-product map in the JSON response, but the module
|
||||||
|
// log stays readable even on stores with hundreds of VirtFusion products.
|
||||||
|
$summary = ['total' => count($results), 'updated' => 0, 'zeroed' => 0, 'skipped' => 0];
|
||||||
|
foreach ($results as $qty) {
|
||||||
|
if ($qty === null) {
|
||||||
|
$summary['skipped']++;
|
||||||
|
} elseif ((int) $qty === 0) {
|
||||||
|
$summary['zeroed']++;
|
||||||
|
} else {
|
||||||
|
$summary['updated']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log::insert('stockRecalculate:ok', [], $summary);
|
||||||
|
|
||||||
|
$vf->output(['success' => true, 'data' => $results], true, true, 200);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
|
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,12 @@
|
|||||||
*
|
*
|
||||||
* HOOKS REGISTERED HERE
|
* HOOKS REGISTERED HERE
|
||||||
* ---------------------
|
* ---------------------
|
||||||
|
|
||||||
* DailyCronJob — PowerDNS reconciliation across all services
|
* DailyCronJob — PowerDNS reconciliation across all services
|
||||||
|
* AfterCronJob — Every-2-hour stock recalculation safety net
|
||||||
|
* AfterModuleCreate — Stock refresh + order auto-accept after a VPS provisions
|
||||||
|
* AfterModuleTerminate — Stock refresh after a VPS is destroyed
|
||||||
|
* ClientAreaPageCart — Lazy per-product stock refresh during the order flow
|
||||||
* ShoppingCartValidateCheckout — blocks checkout until OS is selected
|
* ShoppingCartValidateCheckout — blocks checkout until OS is selected
|
||||||
* ClientAreaFooterOutput — injects the OS/SSH-key gallery on order form
|
* ClientAreaFooterOutput — injects the OS/SSH-key gallery on order form
|
||||||
*
|
*
|
||||||
@@ -32,12 +37,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use WHMCS\Database\Capsule;
|
use WHMCS\Database\Capsule;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\Cache;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\ConfigureService;
|
use WHMCS\Module\Server\VirtFusionDirect\ConfigureService;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
||||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
|
||||||
|
use WHMCS\Module\Server\VirtFusionDirect\StockControl;
|
||||||
|
|
||||||
if (! defined('WHMCS')) {
|
if (! defined('WHMCS')) {
|
||||||
exit('This file cannot be accessed directly');
|
exit('This file cannot be accessed directly');
|
||||||
@@ -63,6 +70,190 @@ add_hook('DailyCronJob', 1, function ($vars) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every-~2-hour stock recalculation safety net.
|
||||||
|
*
|
||||||
|
* Events (AfterModuleCreate/Terminate) cover every capacity change driven
|
||||||
|
* through WHMCS. But an operator can also create/destroy VMs directly in the
|
||||||
|
* VirtFusion panel — no WHMCS hook fires for that, so stock qty would drift
|
||||||
|
* until the next cart-page visit or the next event-driven refresh. This hook
|
||||||
|
* closes that blind spot.
|
||||||
|
*
|
||||||
|
* AfterCronJob fires on every main WHMCS cron invocation (typically every
|
||||||
|
* 5 minutes). Cache::get on the rate-limit key means the hook is effectively
|
||||||
|
* free on the 99% of invocations where no recalc is due — one cache read,
|
||||||
|
* return. The actual recalc only runs when the key has expired.
|
||||||
|
*
|
||||||
|
* Interval: 2 hours. Tunable via the STOCK_CRON_INTERVAL_SECONDS constant
|
||||||
|
* below. Short enough that out-of-band VirtFusion panel changes surface the
|
||||||
|
* same business day; long enough that the storefront isn't writing
|
||||||
|
* tblproducts.qty every five minutes.
|
||||||
|
*
|
||||||
|
* FAIL-SAFE: StockControl::recalculateAll() returns a map of productId =>
|
||||||
|
* qty|null, where null means the orchestrator left qty UNTOUCHED (transient
|
||||||
|
* API failure, missing CP, etc.). Our catch here only fires on truly unexpected
|
||||||
|
* errors that escape the orchestrator itself.
|
||||||
|
*/
|
||||||
|
const STOCK_CRON_INTERVAL_SECONDS = 2 * 3600; // 2 hours
|
||||||
|
|
||||||
|
add_hook('AfterCronJob', 5, function ($vars) {
|
||||||
|
try {
|
||||||
|
$rateKey = 'stockrefresh:cron';
|
||||||
|
if (Cache::get($rateKey) !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Cache::set($rateKey, 1, STOCK_CRON_INTERVAL_SECONDS);
|
||||||
|
|
||||||
|
(new StockControl)->recalculateAll();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::insert('StockControl:AfterCronJob', [], $e->getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-provision: auto-accept the originating order and refresh stock.
|
||||||
|
*
|
||||||
|
* Fires after every successful VirtFusion CreateAccount. Two responsibilities,
|
||||||
|
* independent try/catch blocks so a failure in one doesn't short-circuit the other:
|
||||||
|
*
|
||||||
|
* 1. AUTO-ACCEPT — if the service's parent order is still 'Pending' (admin
|
||||||
|
* hasn't manually accepted yet), call WHMCS's AcceptOrder API with
|
||||||
|
* autosetup=false (we already provisioned, don't re-trigger CreateAccount).
|
||||||
|
* This closes the loop for installs that rely on pending-order workflows
|
||||||
|
* for non-VF products but want VF provisions to auto-advance.
|
||||||
|
*
|
||||||
|
* 2. STOCK REFRESH — a new VM just consumed memory/cpu/disk/IPv4 on the
|
||||||
|
* target hypervisor group. Bust the grpres:{id} cache and recalculate
|
||||||
|
* every stock-controlled product. A shared 30 s rate-limit key prevents
|
||||||
|
* a burst of 10 parallel provisions from triggering 10 full recalcs.
|
||||||
|
*
|
||||||
|
* Filtering by moduletype='VirtFusionDirect' keeps this hook harmless for
|
||||||
|
* unrelated products that happen to share the WHMCS install.
|
||||||
|
*/
|
||||||
|
add_hook('AfterModuleCreate', 1, function ($vars) {
|
||||||
|
if (($vars['params']['moduletype'] ?? '') !== 'VirtFusionDirect') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Part 1: auto-accept the originating order if still Pending.
|
||||||
|
try {
|
||||||
|
$serviceId = (int) ($vars['params']['serviceid'] ?? 0);
|
||||||
|
if ($serviceId > 0) {
|
||||||
|
$hosting = Capsule::table('tblhosting')->where('id', $serviceId)->first();
|
||||||
|
$orderId = $hosting ? (int) ($hosting->orderid ?? 0) : 0;
|
||||||
|
if ($orderId > 0) {
|
||||||
|
$order = Capsule::table('tblorders')->where('id', $orderId)->first();
|
||||||
|
if ($order && strcasecmp((string) $order->status, 'Pending') === 0) {
|
||||||
|
$resp = localAPI('AcceptOrder', [
|
||||||
|
'orderid' => $orderId,
|
||||||
|
'autosetup' => false, // already provisioned; don't re-run CreateAccount
|
||||||
|
'sendemail' => true,
|
||||||
|
]);
|
||||||
|
Log::insert(
|
||||||
|
'AutoAcceptOrder',
|
||||||
|
['orderid' => $orderId, 'serviceid' => $serviceId],
|
||||||
|
$resp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::insert('AutoAcceptOrder:fail', ['serviceID' => $vars['params']['serviceid'] ?? null], $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Part 2: refresh stock (capacity just decreased).
|
||||||
|
try {
|
||||||
|
if (Cache::get('stockrefresh:event') === null) {
|
||||||
|
Cache::set('stockrefresh:event', 1, 30);
|
||||||
|
|
||||||
|
$groupId = (int) ($vars['params']['configoption1'] ?? 0);
|
||||||
|
if ($groupId > 0) {
|
||||||
|
Cache::forget('grpres:' . $groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
(new StockControl)->recalculateAll();
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::insert('StockControl:AfterModuleCreate', ['serviceID' => $vars['params']['serviceid'] ?? null], $e->getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-termination stock refresh.
|
||||||
|
*
|
||||||
|
* A destroyed VM just freed memory/cpu/disk/IPv4 on the target hypervisor group.
|
||||||
|
* Refresh so the storefront reflects the restored capacity immediately. Shares
|
||||||
|
* the 30 s rate-limit key with AfterModuleCreate — a provision-then-terminate in
|
||||||
|
* quick succession only triggers one full recalc.
|
||||||
|
*/
|
||||||
|
add_hook('AfterModuleTerminate', 1, function ($vars) {
|
||||||
|
if (($vars['params']['moduletype'] ?? '') !== 'VirtFusionDirect') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Cache::get('stockrefresh:event') !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Cache::set('stockrefresh:event', 1, 30);
|
||||||
|
|
||||||
|
$groupId = (int) ($vars['params']['configoption1'] ?? 0);
|
||||||
|
if ($groupId > 0) {
|
||||||
|
Cache::forget('grpres:' . $groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
(new StockControl)->recalculateAll();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::insert('StockControl:AfterModuleTerminate', ['serviceID' => $vars['params']['serviceid'] ?? null], $e->getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy stock refresh on order-flow cart pages.
|
||||||
|
*
|
||||||
|
* Keeps "hot" products fresh between daily cron runs without a polling loop: when a
|
||||||
|
* customer lands on a cart page for a specific product, we opportunistically recalculate
|
||||||
|
* that product's qty. If the upstream grpres:{id} cache is warm (populated in the last
|
||||||
|
* 120 s by an earlier view or the daily cron), recalculateForProduct does no HTTP calls
|
||||||
|
* and just re-writes the same qty — effectively free.
|
||||||
|
*
|
||||||
|
* WHY ClientAreaPageCart (not ClientAreaPageProductDetails)
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* ClientAreaPageProductDetails fires on the My Services → product-details view for an
|
||||||
|
* EXISTING service, which is the wrong place — the stock number only matters during
|
||||||
|
* pre-order. ClientAreaPageCart fires on every cart/order page (product browse, config,
|
||||||
|
* checkout) and WHMCS consults tblproducts.qty on each of those, so this is where a
|
||||||
|
* fresh number pays off.
|
||||||
|
*
|
||||||
|
* RATE LIMIT
|
||||||
|
* ----------
|
||||||
|
* 60 s per product (stockrefresh:{pid}). Short enough that a busy product refreshes
|
||||||
|
* near-continuously across viewers; long enough that two customers arriving within the
|
||||||
|
* same second don't trigger two identical DB UPDATEs. The pid check below filters this
|
||||||
|
* hook to only fire when a specific product is known — generic cart pages (templatefile=
|
||||||
|
* "cart.tpl") pass no pid and are no-ops.
|
||||||
|
*/
|
||||||
|
add_hook('ClientAreaPageCart', 1, function ($vars) {
|
||||||
|
try {
|
||||||
|
$productId = (int) ($vars['pid'] ?? $vars['productid'] ?? ($vars['productinfo']['pid'] ?? 0));
|
||||||
|
if ($productId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rateKey = 'stockrefresh:' . $productId;
|
||||||
|
if (Cache::get($rateKey) !== null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Cache::set($rateKey, 1, 60);
|
||||||
|
|
||||||
|
(new StockControl)->recalculateForProduct($productId);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::insert('StockControl:ClientAreaPageCart', ['pid' => $vars['pid'] ?? null], $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shopping Cart Validation Hook
|
* Shopping Cart Validation Hook
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ use WHMCS\Database\Capsule;
|
|||||||
*/
|
*/
|
||||||
class Module
|
class Module
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var array|false|null Memoised catalogue-level CP connection used by fetchPackage/fetchGroupResources.
|
||||||
|
* Resolved via getCP(false, true) — "any available VirtFusion server" — on first use.
|
||||||
|
* Kept on the instance so a cron loop recalculating 20 products doesn't hit
|
||||||
|
* tblservers 20×N times when N stock helpers are called per product.
|
||||||
|
*/
|
||||||
|
private $catalogueCp = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialises the module and ensures the database schema is up to date.
|
* Initialises the module and ensures the database schema is up to date.
|
||||||
*/
|
*/
|
||||||
@@ -1240,4 +1248,175 @@ class Module
|
|||||||
{
|
{
|
||||||
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
|
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Catalogue helpers — used by StockControl to size the WHMCS inventory from
|
||||||
|
// live VirtFusion data. Pre-order code path: CP is resolved via "any
|
||||||
|
// available server" since no service context exists yet.
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the catalogue-level CP (any available VirtFusion server) and memoise.
|
||||||
|
*
|
||||||
|
* Stock calculations run from a cron loop or product-detail page view — there's
|
||||||
|
* no WHMCS service yet, so we can't dereference a specific panel via
|
||||||
|
* resolveServiceContext. "Any enabled server" is the correct fallback for read-only
|
||||||
|
* catalogue operations (package + hypervisor-group endpoints return the same data
|
||||||
|
* from every VirtFusion node on the same cluster).
|
||||||
|
*
|
||||||
|
* @return array{url: string, base_url: string, token: string}|false
|
||||||
|
*/
|
||||||
|
private function getCatalogueCp()
|
||||||
|
{
|
||||||
|
if ($this->catalogueCp === null) {
|
||||||
|
$this->catalogueCp = $this->getCP(false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->catalogueCp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a VirtFusion package by ID — the authoritative source for "how much RAM,
|
||||||
|
* CPU, and disk does one VPS of this product cost?".
|
||||||
|
*
|
||||||
|
* Return values distinguish confirmed-missing from transient failure:
|
||||||
|
* array — package data (fields: memory, cpuCores, primaryStorage, primaryStorageProfile, enabled, …)
|
||||||
|
* false — HTTP 404: package has been deleted in VirtFusion. Callers treat as OOS.
|
||||||
|
* null — Transient failure (no CP, network error, 5xx, malformed body). Callers must
|
||||||
|
* NOT overwrite WHMCS qty on a null — that would zero out inventory during a blip.
|
||||||
|
*
|
||||||
|
* Success responses are cached 10 min (key "pkg:{id}") since package definitions
|
||||||
|
* rarely change; 404 responses get a short 60 s cache so an admin re-creating a
|
||||||
|
* deleted package doesn't have to wait ten minutes for stock to pick it up again.
|
||||||
|
*
|
||||||
|
* @param int $packageId VirtFusion package ID (from tblproducts.configoption2).
|
||||||
|
* @return array|false|null
|
||||||
|
*/
|
||||||
|
public function fetchPackage($packageId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$packageId = (int) $packageId;
|
||||||
|
if ($packageId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = 'pkg:' . $packageId;
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if ($cached !== null) {
|
||||||
|
// Sentinel marker for a previously-confirmed 404.
|
||||||
|
if (is_array($cached) && ! empty($cached['__notFound'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cp = $this->getCatalogueCp();
|
||||||
|
if (! $cp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->initCurl($cp['token']);
|
||||||
|
$data = $request->get($cp['url'] . '/packages/' . $packageId);
|
||||||
|
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||||||
|
|
||||||
|
$httpCode = (int) $request->getRequestInfo('http_code');
|
||||||
|
|
||||||
|
if ($httpCode === 200) {
|
||||||
|
$decoded = json_decode($data, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$package = $decoded['data'] ?? $decoded;
|
||||||
|
if (is_array($package)) {
|
||||||
|
Cache::set($cacheKey, $package, 600);
|
||||||
|
|
||||||
|
return $package;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode === 404) {
|
||||||
|
Cache::set($cacheKey, ['__notFound' => true], 60);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch free/allocated resources for every hypervisor in a group — the live picture
|
||||||
|
* of how much headroom remains to place more VPSes.
|
||||||
|
*
|
||||||
|
* Same tri-state return contract as fetchPackage():
|
||||||
|
* array — decoded response with a 'data' array of per-hypervisor resource breakdowns.
|
||||||
|
* false — HTTP 404: group has been deleted. Callers may treat as "zero capacity from this group".
|
||||||
|
* null — Transient failure. Callers must NOT overwrite WHMCS qty on a null.
|
||||||
|
*
|
||||||
|
* Cache TTL is 120 s — short enough that customers don't see stale OOS labels for
|
||||||
|
* long after capacity frees up, and long enough to amortise the upstream call across
|
||||||
|
* bursty product-page traffic. Matches the traffic-stats TTL in getTrafficStats().
|
||||||
|
*
|
||||||
|
* @param int $groupId VirtFusion hypervisor group ID.
|
||||||
|
* @return array|false|null
|
||||||
|
*/
|
||||||
|
public function fetchGroupResources($groupId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$groupId = (int) $groupId;
|
||||||
|
if ($groupId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = 'grpres:' . $groupId;
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if ($cached !== null) {
|
||||||
|
if (is_array($cached) && ! empty($cached['__notFound'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cp = $this->getCatalogueCp();
|
||||||
|
if (! $cp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->initCurl($cp['token']);
|
||||||
|
$data = $request->get($cp['url'] . '/compute/hypervisors/groups/' . $groupId . '/resources');
|
||||||
|
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||||||
|
|
||||||
|
$httpCode = (int) $request->getRequestInfo('http_code');
|
||||||
|
|
||||||
|
if ($httpCode === 200) {
|
||||||
|
$decoded = json_decode($data, true);
|
||||||
|
if (is_array($decoded) && isset($decoded['data']) && is_array($decoded['data'])) {
|
||||||
|
Cache::set($cacheKey, $decoded, 120);
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode === 404) {
|
||||||
|
Cache::set($cacheKey, ['__notFound' => true], 60);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
557
modules/servers/VirtFusionDirect/lib/StockControl.php
Normal file
557
modules/servers/VirtFusionDirect/lib/StockControl.php
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||||
|
|
||||||
|
use WHMCS\Database\Capsule as DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes accurate stock quantities for VirtFusionDirect products and writes them
|
||||||
|
* to tblproducts.qty, leveraging WHMCS's native stock-control feature (badges,
|
||||||
|
* disabled Add-to-Cart, checkout block) instead of building parallel UI.
|
||||||
|
*
|
||||||
|
* HOW THE NUMBER IS DERIVED
|
||||||
|
* -------------------------
|
||||||
|
* For every product with tblproducts.stockcontrol=1:
|
||||||
|
*
|
||||||
|
* qty = Σ groupCapacity(g, package, ipv4Req, bufferPct) for every eligible group g
|
||||||
|
*
|
||||||
|
* where groupCapacity is computed from live /compute/hypervisors/groups/{id}/resources
|
||||||
|
* data and package is the VirtFusion /packages/{id} response — the authoritative
|
||||||
|
* per-VPS resource footprint. Each hypervisor's per-metric capacity is
|
||||||
|
* min(memory, cpu, storage), summed across hypervisors in the group; IPv4 is a
|
||||||
|
* group-level pool so its cap is taken as the per-hypervisor max within the group
|
||||||
|
* (not summed) to avoid double-counting.
|
||||||
|
*
|
||||||
|
* ELIGIBLE GROUPS
|
||||||
|
* ---------------
|
||||||
|
* The default group (tblproducts.configoption1) plus every value of the Location
|
||||||
|
* configurable option, if the product exposes one. Location is detected by matching
|
||||||
|
* the configurable option name against the "hypervisorId" label from
|
||||||
|
* config/ConfigOptionMapping.php (falls back to "Location") — same convention
|
||||||
|
* ModuleFunctions::createAccount() uses to map configoptions to VirtFusion fields.
|
||||||
|
* This lets a single product span multiple regions and still get a meaningful qty.
|
||||||
|
*
|
||||||
|
* ELIGIBLE HYPERVISORS
|
||||||
|
* --------------------
|
||||||
|
* enabled=true AND commissioned=true AND prohibit=false. Everything else is skipped
|
||||||
|
* with zero contribution to the group total.
|
||||||
|
*
|
||||||
|
* FAIL-SAFE INVARIANT
|
||||||
|
* -------------------
|
||||||
|
* CRITICAL: if the computation cannot complete (missing CP, transient API failure,
|
||||||
|
* malformed response, no groups resolved), recalculateForProduct() returns null and
|
||||||
|
* the caller MUST NOT touch tblproducts.qty. The reason: a false zero during a
|
||||||
|
* transient failure would pull every product out of the storefront, causing
|
||||||
|
* lost-order incidents that take human intervention to recover. Better to keep a
|
||||||
|
* slightly-stale qty than to silently take the catalogue offline.
|
||||||
|
*
|
||||||
|
* Confirmed-missing cases (package 404 or package.enabled=false) DO return 0 —
|
||||||
|
* that's the right answer, the product genuinely cannot be provisioned.
|
||||||
|
*
|
||||||
|
* CACHING
|
||||||
|
* -------
|
||||||
|
* Packages cached 10 min (rarely change), group resources cached 120 s (change
|
||||||
|
* meaningfully minute-to-minute under load). Both handled inside Module's
|
||||||
|
* fetchPackage / fetchGroupResources helpers, keyed 'pkg:{id}' / 'grpres:{id}' so
|
||||||
|
* multiple products in a cron sweep share cached data for the same upstream call.
|
||||||
|
*/
|
||||||
|
class StockControl
|
||||||
|
{
|
||||||
|
/** Default mapping from internal VF key → WHMCS configurable-option label.
|
||||||
|
* Kept in sync with $configOptionDefaultNaming in ModuleFunctions::createAccount(). */
|
||||||
|
private const DEFAULT_OPTION_LABELS = [
|
||||||
|
'ipv4' => 'IPv4',
|
||||||
|
'packageId' => 'Package',
|
||||||
|
'hypervisorId' => 'Location',
|
||||||
|
'storage' => 'Storage',
|
||||||
|
'memory' => 'Memory',
|
||||||
|
'traffic' => 'Bandwidth',
|
||||||
|
'networkSpeedInbound' => 'Inbound Network Speed',
|
||||||
|
'networkSpeedOutbound' => 'Outbound Network Speed',
|
||||||
|
'cpuCores' => 'CPU Cores',
|
||||||
|
'networkProfile' => 'Network Type',
|
||||||
|
'storageProfile' => 'Storage Type',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var Module Shared for its CP memoisation + initCurl/fetchPackage/fetchGroupResources helpers. */
|
||||||
|
private $module;
|
||||||
|
|
||||||
|
/** @var array<string,string>|null Resolved per-request once. */
|
||||||
|
private $optionLabelMap = null;
|
||||||
|
|
||||||
|
public function __construct(?Module $module = null)
|
||||||
|
{
|
||||||
|
// Dependency-inject for testability; default wires up a real Module so production
|
||||||
|
// callers (hooks.php, admin.php) don't have to know about the dependency.
|
||||||
|
$this->module = $module ?? new Module;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate qty for every VirtFusionDirect product that has WHMCS stock control enabled.
|
||||||
|
*
|
||||||
|
* Called from the every-2-hour AfterCronJob safety-net hook, from the post-provision
|
||||||
|
* and post-termination event hooks in hooks.php, and from the admin stockRecalculate
|
||||||
|
* AJAX endpoint in admin.php. Returns a map of productId => resulting qty (or null
|
||||||
|
* where the product was skipped / left untouched), useful for the admin endpoint's
|
||||||
|
* JSON response and for per-event logging.
|
||||||
|
*
|
||||||
|
* @return array<int,int|null>
|
||||||
|
*/
|
||||||
|
public function recalculateAll(): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$products = DB::table('tblproducts')
|
||||||
|
->where('servertype', 'VirtFusionDirect')
|
||||||
|
->where('stockcontrol', 1)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$results[(int) $product->id] = $this->recalculateForProduct((int) $product->id);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('StockControl:recalculateAll', [], $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate qty for a single product.
|
||||||
|
*
|
||||||
|
* Returns the new qty on success, or null on any unrecoverable failure — in which case
|
||||||
|
* tblproducts.qty is left unchanged (fail-safe invariant).
|
||||||
|
*/
|
||||||
|
public function recalculateForProduct(int $productId): ?int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$product = DB::table('tblproducts')->where('id', $productId)->first();
|
||||||
|
if (! $product) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($product->servertype !== 'VirtFusionDirect') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ((int) $product->stockcontrol !== 1) {
|
||||||
|
// Stock control disabled on this product — don't manage qty.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$qty = $this->computeQtyForProduct($product);
|
||||||
|
if ($qty === null) {
|
||||||
|
// Transient / unrecoverable — preserve existing qty.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('tblproducts')
|
||||||
|
->where('id', $productId)
|
||||||
|
->update(['qty' => (int) $qty]);
|
||||||
|
|
||||||
|
Log::insert(
|
||||||
|
'StockControl:recalculate',
|
||||||
|
[
|
||||||
|
'productId' => $productId,
|
||||||
|
'packageId' => (int) $product->configoption2,
|
||||||
|
'defaultGroupId' => (int) $product->configoption1,
|
||||||
|
],
|
||||||
|
['qty' => $qty],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $qty;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('StockControl:recalculateForProduct', ['productId' => $productId], $e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Computation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the qty integer without touching the DB.
|
||||||
|
*
|
||||||
|
* @param object $product tblproducts row.
|
||||||
|
* @return int|null Non-negative qty, or null when the computation cannot complete.
|
||||||
|
*/
|
||||||
|
private function computeQtyForProduct($product): ?int
|
||||||
|
{
|
||||||
|
$productId = (int) $product->id;
|
||||||
|
|
||||||
|
$packageId = (int) $product->configoption2;
|
||||||
|
if ($packageId <= 0) {
|
||||||
|
Log::insert(
|
||||||
|
'StockControl:compute',
|
||||||
|
['productId' => $productId],
|
||||||
|
'no packageId in configoption2 — skipped',
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$package = $this->module->fetchPackage($packageId);
|
||||||
|
if ($package === null) {
|
||||||
|
// Transient — preserve qty.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($package === false) {
|
||||||
|
// Confirmed 404: package deleted in VirtFusion. Product is unfulfillable.
|
||||||
|
Log::insert(
|
||||||
|
'StockControl:compute',
|
||||||
|
['productId' => $productId, 'packageId' => $packageId],
|
||||||
|
'package 404 — qty forced to 0',
|
||||||
|
);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (empty($package['enabled'])) {
|
||||||
|
Log::insert(
|
||||||
|
'StockControl:compute',
|
||||||
|
['productId' => $productId, 'packageId' => $packageId],
|
||||||
|
'package disabled in VirtFusion — qty forced to 0',
|
||||||
|
);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupIds = $this->resolveHypervisorGroupIds($product);
|
||||||
|
if (empty($groupIds)) {
|
||||||
|
Log::insert(
|
||||||
|
'StockControl:compute',
|
||||||
|
['productId' => $productId],
|
||||||
|
'no hypervisor groups resolved — qty untouched',
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipv4Required = max(1, (int) ($product->configoption3 ?? 1));
|
||||||
|
$bufferPct = $this->bufferPctForProduct($product);
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
foreach ($groupIds as $groupId) {
|
||||||
|
$resources = $this->module->fetchGroupResources($groupId);
|
||||||
|
if ($resources === null) {
|
||||||
|
// Transient failure on any group aborts the whole computation — we can't
|
||||||
|
// safely reduce qty to a partial total and risk under-reporting stock.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($resources === false) {
|
||||||
|
// Group 404 — deleted; contributes 0. Keep going so other eligible groups still count.
|
||||||
|
Log::insert(
|
||||||
|
'StockControl:compute',
|
||||||
|
['productId' => $productId, 'groupId' => $groupId],
|
||||||
|
'group 404 — contributing 0 capacity',
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total += $this->groupCapacity($resources, $package, $ipv4Required, $bufferPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, $total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum of per-hypervisor minimums (mem/cpu/storage), capped by the group-level IPv4 pool.
|
||||||
|
*
|
||||||
|
* IPv4 CAP IS MAX-WITHIN-GROUP, NOT SUMMED
|
||||||
|
* ----------------------------------------
|
||||||
|
* network.total.ipv4.free in the API is a group-level pool visible from every hypervisor
|
||||||
|
* in the group — the same number is reported on each. Summing per-hypervisor IPv4 caps
|
||||||
|
* would overcount the pool by the hypervisor count. Taking max() within a group, then
|
||||||
|
* summing across groups, reflects the real constraint.
|
||||||
|
*/
|
||||||
|
private function groupCapacity(array $resources, array $package, int $ipv4Required, float $bufferPct): int
|
||||||
|
{
|
||||||
|
$hypervisors = $resources['data'] ?? [];
|
||||||
|
if (! is_array($hypervisors) || empty($hypervisors)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hypMinSum = 0;
|
||||||
|
$ipv4CapForGroup = 0;
|
||||||
|
|
||||||
|
foreach ($hypervisors as $h) {
|
||||||
|
$hyp = $h['hypervisor'] ?? [];
|
||||||
|
if (empty($hyp['enabled']) || empty($hyp['commissioned']) || ! empty($hyp['prohibit'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = $h['resources'] ?? [];
|
||||||
|
if (! is_array($res)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$memCap = self::capFor($res['memory'] ?? null, (int) ($package['memory'] ?? 0), $bufferPct);
|
||||||
|
$cpuCap = self::capFor($res['cpuCores'] ?? null, (int) ($package['cpuCores'] ?? 0), $bufferPct);
|
||||||
|
$storeCap = self::capForStorage(
|
||||||
|
$res,
|
||||||
|
(int) ($package['primaryStorageProfile'] ?? 0),
|
||||||
|
(int) ($package['primaryStorage'] ?? 0),
|
||||||
|
$bufferPct,
|
||||||
|
);
|
||||||
|
|
||||||
|
$hypMinSum += min($memCap, $cpuCap, $storeCap);
|
||||||
|
|
||||||
|
$ipv4Free = (int) ($res['network']['total']['ipv4']['free'] ?? 0);
|
||||||
|
if ($ipv4Free > 0) {
|
||||||
|
$ipv4Cap = intdiv($ipv4Free, max(1, $ipv4Required));
|
||||||
|
if ($ipv4Cap > $ipv4CapForGroup) {
|
||||||
|
$ipv4CapForGroup = $ipv4Cap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no hypervisor reported any ipv4 data (unusual but defensible), don't let
|
||||||
|
// the cap kill an otherwise-valid count — treat as "no IPv4 constraint known".
|
||||||
|
if ($ipv4CapForGroup === 0) {
|
||||||
|
foreach ($hypervisors as $h) {
|
||||||
|
if (isset($h['resources']['network']['total']['ipv4']['free'])) {
|
||||||
|
// There WAS an ipv4 value (possibly 0); the cap is genuinely 0.
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No ipv4 data anywhere in the response → don't apply the cap.
|
||||||
|
return max(0, $hypMinSum);
|
||||||
|
}
|
||||||
|
|
||||||
|
return min($hypMinSum, $ipv4CapForGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many VPSes fit into a single (free, max, buffer) cell for one resource.
|
||||||
|
*
|
||||||
|
* Handles three edge cases consistent with live API behaviour:
|
||||||
|
* - need <= 0 → unlimited fit (nothing consumed for this dimension)
|
||||||
|
* - resource.max = 0 → unlimited quota; free can be negative but we don't care
|
||||||
|
* - negative/zero available after buffer → 0 (clamp; never negative qty)
|
||||||
|
*/
|
||||||
|
private static function capFor($resource, int $need, float $bufferPct): int
|
||||||
|
{
|
||||||
|
if ($need <= 0) {
|
||||||
|
return PHP_INT_MAX;
|
||||||
|
}
|
||||||
|
if (! is_array($resource)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$max = (int) ($resource['max'] ?? 0);
|
||||||
|
$free = (int) ($resource['free'] ?? 0);
|
||||||
|
|
||||||
|
if ($max === 0) {
|
||||||
|
// Unlimited quota — buffer doesn't apply (X% of 0 is 0).
|
||||||
|
return PHP_INT_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reserve = (int) ceil(((float) $max) * ($bufferPct / 100.0));
|
||||||
|
$available = $free - $reserve;
|
||||||
|
|
||||||
|
if ($available <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return intdiv($available, $need);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage variant of capFor() that respects the package's primaryStorageProfile.
|
||||||
|
*
|
||||||
|
* NOTE on naming: VirtFusion exposes two confusingly-named fields with the
|
||||||
|
* same numeric domain. `package.primaryStorageProfile` (mirrors the DB column
|
||||||
|
* `server_packages.storage_type`) is a **storage type code** — a filter,
|
||||||
|
* not an ID — and matches `otherStorage[].storageType` on each hypervisor.
|
||||||
|
* The pool's own `id` is unique per hypervisor and is never what the package
|
||||||
|
* targets. Treating $storageTypeId as `pool.id` (as this method previously
|
||||||
|
* did) returned 0 for every package whose type code didn't happen to also
|
||||||
|
* exist as a pool id, silently zeroing qty fleet-wide.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - storageTypeId > 0 → match any enabled otherStorage[] whose storageType
|
||||||
|
* equals this code. If multiple match (e.g. several
|
||||||
|
* mountpoint pools on one hypervisor), pick the one
|
||||||
|
* that fits the most VMs.
|
||||||
|
* - storageTypeId <= 0 → fall back to localStorage. If local is disabled, 0.
|
||||||
|
*/
|
||||||
|
private static function capForStorage(array $res, int $storageTypeId, int $needGb, float $bufferPct): int
|
||||||
|
{
|
||||||
|
if ($needGb <= 0) {
|
||||||
|
return PHP_INT_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($storageTypeId > 0) {
|
||||||
|
$best = 0;
|
||||||
|
$matched = false;
|
||||||
|
foreach ($res['otherStorage'] ?? [] as $pool) {
|
||||||
|
if ((int) ($pool['storageType'] ?? 0) !== $storageTypeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$matched = true;
|
||||||
|
if (empty($pool['enabled'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cap = self::capFor(
|
||||||
|
['max' => (int) ($pool['max'] ?? 0), 'free' => (int) ($pool['free'] ?? 0)],
|
||||||
|
$needGb,
|
||||||
|
$bufferPct,
|
||||||
|
);
|
||||||
|
if ($cap > $best) {
|
||||||
|
$best = $cap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $matched) {
|
||||||
|
// No pool of this storage type on this hypervisor — cannot place the VM.
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $best;
|
||||||
|
}
|
||||||
|
|
||||||
|
$local = $res['localStorage'] ?? null;
|
||||||
|
if (is_array($local) && ! empty($local['enabled'])) {
|
||||||
|
return self::capFor(
|
||||||
|
['max' => (int) ($local['max'] ?? 0), 'free' => (int) ($local['free'] ?? 0)],
|
||||||
|
$needGb,
|
||||||
|
$bufferPct,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin-tunable safety buffer (configoption7), clamped to [0, 100].
|
||||||
|
*
|
||||||
|
* Default is 10% when the field is blank or non-numeric — reserves 10% of each
|
||||||
|
* resource's max so we stop selling a product before the hypervisor is literally
|
||||||
|
* at 100%, which is where placement timing issues and fragmentation start biting.
|
||||||
|
* Admins can override per product (including down to 0) in the module settings.
|
||||||
|
*/
|
||||||
|
private function bufferPctForProduct($product): float
|
||||||
|
{
|
||||||
|
$raw = $product->configoption7 ?? '';
|
||||||
|
if ($raw === null || $raw === '') {
|
||||||
|
return 10.0;
|
||||||
|
}
|
||||||
|
$val = is_numeric($raw) ? (float) $raw : 10.0;
|
||||||
|
|
||||||
|
return max(0.0, min(100.0, $val));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Hypervisor-group resolution
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect every hypervisor group ID this product could be provisioned into:
|
||||||
|
* the default (configoption1) plus every numeric value of the "Location"
|
||||||
|
* configurable option (if one is attached).
|
||||||
|
*
|
||||||
|
* @return int[] Deduplicated list of group IDs, strictly positive.
|
||||||
|
*/
|
||||||
|
private function resolveHypervisorGroupIds($product): array
|
||||||
|
{
|
||||||
|
$groups = [];
|
||||||
|
|
||||||
|
$defaultGroup = (int) ($product->configoption1 ?? 0);
|
||||||
|
if ($defaultGroup > 0) {
|
||||||
|
$groups[] = $defaultGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
$locationLabel = $this->optionLabelFor('hypervisorId');
|
||||||
|
if ($locationLabel !== null && $locationLabel !== '') {
|
||||||
|
foreach ($this->fetchConfigurableOptionValues((int) $product->id, $locationLabel) as $value) {
|
||||||
|
$asInt = (int) $value;
|
||||||
|
if ($asInt > 0) {
|
||||||
|
$groups[] = $asInt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($groups));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up every sub-option value for a given configurable option name on a product.
|
||||||
|
*
|
||||||
|
* WHMCS stores option names as either "Location" or "Location|Display Override" —
|
||||||
|
* this method normalises both by comparing just the part before the pipe.
|
||||||
|
*
|
||||||
|
* @return array<int,string> Raw sub-option names (callers decide numeric parsing).
|
||||||
|
*/
|
||||||
|
private function fetchConfigurableOptionValues(int $productId, string $label): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$options = DB::table('tblproductconfiglinks as l')
|
||||||
|
->join('tblproductconfigoptions as o', 'o.gid', '=', 'l.gid')
|
||||||
|
->where('l.pid', $productId)
|
||||||
|
->select('o.id', 'o.optionname')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$matchedIds = [];
|
||||||
|
foreach ($options as $opt) {
|
||||||
|
$name = (string) $opt->optionname;
|
||||||
|
$pipe = strpos($name, '|');
|
||||||
|
if ($pipe !== false) {
|
||||||
|
$name = substr($name, 0, $pipe);
|
||||||
|
}
|
||||||
|
if ($name === $label) {
|
||||||
|
$matchedIds[] = (int) $opt->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($matchedIds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::table('tblproductconfigoptionssub')
|
||||||
|
->whereIn('configid', $matchedIds)
|
||||||
|
->pluck('optionname')
|
||||||
|
->toArray();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::insert('StockControl:fetchConfigurableOptionValues', ['productId' => $productId, 'label' => $label], $e->getMessage());
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the WHMCS configurable-option label for an internal key, respecting
|
||||||
|
* config/ConfigOptionMapping.php overrides — same contract as ModuleFunctions::createAccount().
|
||||||
|
*/
|
||||||
|
private function optionLabelFor(string $key): ?string
|
||||||
|
{
|
||||||
|
if ($this->optionLabelMap === null) {
|
||||||
|
$this->optionLabelMap = self::DEFAULT_OPTION_LABELS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Resolve the mapping file directly relative to this class — avoids
|
||||||
|
// depending on WHMCS's ROOTDIR, which isn't defined when the module
|
||||||
|
// is loaded outside a full WHMCS request (cron tooling, tests).
|
||||||
|
// __DIR__ is .../modules/servers/VirtFusionDirect/lib, so the config
|
||||||
|
// directory is one level up.
|
||||||
|
$overridePath = dirname(__DIR__) . '/config/ConfigOptionMapping.php';
|
||||||
|
if (is_file($overridePath)) {
|
||||||
|
$override = require $overridePath;
|
||||||
|
if (is_array($override)) {
|
||||||
|
$this->optionLabelMap = array_merge($this->optionLabelMap, $override);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Swallow — mapping override is best-effort; defaults still work.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->optionLabelMap[$key] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user