Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8caf8c0c01 | ||
|
|
589442e59c | ||
|
|
c90cbd7399 | ||
|
|
bb12cae954 | ||
|
|
5249d6bc19 | ||
|
|
3ea21dfb60 | ||
|
|
fecbf701b7 | ||
|
|
02e059274b | ||
|
|
e9772ed29f | ||
|
|
a3c4154fb2 | ||
|
|
cece1f5ae0 |
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
|
||||
draft: 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
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -2,6 +2,27 @@
|
||||
|
||||
All notable changes to the VirtFusion Direct Provisioning Module for WHMCS.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -141,7 +141,7 @@ Opt-in per product via WHMCS's native stock-control toggle (`tblproducts.stockco
|
||||
|
||||
**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 (matched by package.primaryStorageProfile), and a group-level IPv4 pool
|
||||
- `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.
|
||||
|
||||
@@ -160,7 +160,7 @@ Opt-in per product via WHMCS's native stock-control toggle (`tblproducts.stockco
|
||||
- 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 match is strict: the package's `primaryStorageProfile` must exist and be enabled on the target hypervisor, otherwise that hypervisor contributes 0. Falls back to `localStorage` only when the package has no profile set.
|
||||
- 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%.
|
||||
|
||||
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)
|
||||
- [Configurable Options (Dynamic Pricing)](#configurable-options-dynamic-pricing)
|
||||
- [Custom Option Name Mapping](#custom-option-name-mapping)
|
||||
- [Stock Control (Dynamic Inventory)](#stock-control-dynamic-inventory)
|
||||
- [Reverse DNS Addon (PowerDNS)](#reverse-dns-addon-powerdns)
|
||||
- [Client Area Features](#client-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
|
||||
- **Resource sliders** - Configurable option dropdowns are replaced with interactive range sliders
|
||||
- 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
|
||||
- **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
|
||||
|
||||
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
|
||||
WHMCS=/path/to/whmcs
|
||||
git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git /tmp/vf \
|
||||
&& rsync -ahP --delete /tmp/vf/modules/servers/VirtFusionDirect/ "$WHMCS/modules/servers/VirtFusionDirect/" \
|
||||
&& rm -rf /tmp/vf
|
||||
curl -fsSL https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||
| sudo bash -s -- install /path/to/whmcs
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
@@ -140,19 +178,42 @@ That's it. Hooks activate automatically and custom fields are created on module
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
WHMCS=/path/to/whmcs
|
||||
git clone https://github.com/EZSCALE/virtfusion-whmcs-module.git /tmp/vf \
|
||||
&& 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
|
||||
curl -fsSL https://raw.githubusercontent.com/EZSCALE/virtfusion-whmcs-module/main/install.sh \
|
||||
| sudo bash -s -- upgrade /path/to/whmcs
|
||||
```
|
||||
|
||||
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**.
|
||||
|
||||
<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
|
||||
|
||||
### Server Setup
|
||||
@@ -183,7 +244,7 @@ The fields are hidden text boxes that are dynamically replaced by dropdown selec
|
||||
|
||||
### Module Configuration Options
|
||||
|
||||
Each product has three module-specific settings:
|
||||
Each product has these module-specific settings:
|
||||
|
||||
| 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 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 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
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).
|
||||
|
||||
190
install.sh
Executable file
190
install.sh
Executable file
@@ -0,0 +1,190 @@
|
||||
#!/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"
|
||||
|
||||
local TMP
|
||||
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
|
||||
@@ -365,38 +365,58 @@ class StockControl
|
||||
/**
|
||||
* 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:
|
||||
* - profileId > 0 → must match an otherStorage[].id on the hypervisor; if the
|
||||
* matched pool is disabled or missing, this hypervisor has
|
||||
* zero storage capacity for this product (can't place there).
|
||||
* - profileId <= 0 → fall back to localStorage. If local is disabled, 0.
|
||||
* - 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 $profileId, int $needGb, float $bufferPct): int
|
||||
private static function capForStorage(array $res, int $storageTypeId, int $needGb, float $bufferPct): int
|
||||
{
|
||||
if ($needGb <= 0) {
|
||||
return PHP_INT_MAX;
|
||||
}
|
||||
|
||||
if ($profileId > 0) {
|
||||
if ($storageTypeId > 0) {
|
||||
$best = 0;
|
||||
$matched = false;
|
||||
foreach ($res['otherStorage'] ?? [] as $pool) {
|
||||
if ((int) ($pool['id'] ?? 0) !== $profileId) {
|
||||
if ((int) ($pool['storageType'] ?? 0) !== $storageTypeId) {
|
||||
continue;
|
||||
}
|
||||
$matched = true;
|
||||
if (empty($pool['enabled'])) {
|
||||
return 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
return self::capFor(
|
||||
$cap = self::capFor(
|
||||
['max' => (int) ($pool['max'] ?? 0), 'free' => (int) ($pool['free'] ?? 0)],
|
||||
$needGb,
|
||||
$bufferPct,
|
||||
);
|
||||
if ($cap > $best) {
|
||||
$best = $cap;
|
||||
}
|
||||
}
|
||||
|
||||
// Storage profile not present on this hypervisor — cannot place the VM.
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user