@dzhechkov/cloudru-hub 0.1.14 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +21 -5
- package/CHANGELOG.md +9 -0
- package/README.md +9 -1
- package/docs/COST-COMPARE.md +21 -0
- package/docs/cost-compare.example.json +84 -0
- package/package.json +2 -2
- package/sbom.json +44 -4
- package/src/cli.js +19 -7
- package/src/cost-compare.js +348 -0
- package/test/cost-compare.test.mjs +280 -0
package/.dz-manifest.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"files": [
|
|
6
6
|
{
|
|
7
7
|
"path": "CHANGELOG.md",
|
|
8
|
-
"sha256": "
|
|
8
|
+
"sha256": "2b0dbd83deb813844a658814b9a6fa1640f1e5255c676210e61dceaa6b42195b"
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"path": "LICENSE",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "README.md",
|
|
16
|
-
"sha256": "
|
|
16
|
+
"sha256": "4f6a2d5928fec42f021eb10471677b902e831b99d53096661a821f6460bd55a6"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"path": "THIRD_PARTY_NOTICES.md",
|
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
"path": "data/tools-list-snapshot.json",
|
|
40
40
|
"sha256": "dff98bb859dfbbf8ed2fb6f16f17b767444cc2172ecc7684ca3df8188c95707c"
|
|
41
41
|
},
|
|
42
|
+
{
|
|
43
|
+
"path": "docs/COST-COMPARE.md",
|
|
44
|
+
"sha256": "80b4d7ac3868f17d93565274cd6736fe5f2bd7fb059cbed0c2f0afe3f4924317"
|
|
45
|
+
},
|
|
42
46
|
{
|
|
43
47
|
"path": "docs/HERMES-CODEX.md",
|
|
44
48
|
"sha256": "3605797972f9a7c79c13fbcce0cd9ac8b8fb3a8249abbab938f2b1a45f515420"
|
|
@@ -47,9 +51,13 @@
|
|
|
47
51
|
"path": "docs/LOCAL-TESTING.md",
|
|
48
52
|
"sha256": "de6f709e820fbb831f390e274238b28ba938644670a8db04fd50d1fa0d86da75"
|
|
49
53
|
},
|
|
54
|
+
{
|
|
55
|
+
"path": "docs/cost-compare.example.json",
|
|
56
|
+
"sha256": "7eafb1ff555fb5443ded1a2c578b17eae6e73e3056efb9cbb5129b424c8c5097"
|
|
57
|
+
},
|
|
50
58
|
{
|
|
51
59
|
"path": "package.json",
|
|
52
|
-
"sha256": "
|
|
60
|
+
"sha256": "1b355df670fb092da7ce8956141e702f9f78e1e2f2724c6aaba61bbab2acd8c8"
|
|
53
61
|
},
|
|
54
62
|
{
|
|
55
63
|
"path": "src/classification.js",
|
|
@@ -57,7 +65,11 @@
|
|
|
57
65
|
},
|
|
58
66
|
{
|
|
59
67
|
"path": "src/cli.js",
|
|
60
|
-
"sha256": "
|
|
68
|
+
"sha256": "b1bd4a0ce7f1ca869c2873ca1979a74630483ee2909ff8b4f91189622f5aa2a7"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"path": "src/cost-compare.js",
|
|
72
|
+
"sha256": "6084ecd7f7fc53bbd441fb9e91dcd7c7bd5c409d1d4e95613fad0fd0735b7b05"
|
|
61
73
|
},
|
|
62
74
|
{
|
|
63
75
|
"path": "src/credentials.js",
|
|
@@ -107,6 +119,10 @@
|
|
|
107
119
|
"path": "test/classification.test.mjs",
|
|
108
120
|
"sha256": "026e3031180ce9227b4d31775b9c886b10ad0cb418ed0754aed75b12ded84e31"
|
|
109
121
|
},
|
|
122
|
+
{
|
|
123
|
+
"path": "test/cost-compare.test.mjs",
|
|
124
|
+
"sha256": "b354913220bf725ec6b8a708b7851e2f5779a3c594cd411732d19acad9001870"
|
|
125
|
+
},
|
|
110
126
|
{
|
|
111
127
|
"path": "test/deploy-approval.test.mjs",
|
|
112
128
|
"sha256": "d72e4363fd50d0fcc65fd23b40601516bd73368c3659f2a55a394c4d1ed3f85c"
|
|
@@ -149,5 +165,5 @@
|
|
|
149
165
|
}
|
|
150
166
|
]
|
|
151
167
|
},
|
|
152
|
-
"signature": "
|
|
168
|
+
"signature": "dwHxhW1FRdiGNeOrlmZDXpWHEV3i/V9QenSrenI7+SvF6HNfxc1cM++fUKzWXZKRvTfbreooCqZHfcipil4vAw=="
|
|
153
169
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog — @dzhechkov/cloudru-hub
|
|
2
2
|
|
|
3
|
+
## Unreleased — staged, no version change
|
|
4
|
+
|
|
5
|
+
- Add `cost-compare --input <json> [--json]`, an offline VM/Kubernetes cost worksheet
|
|
6
|
+
with explicit topology, dated tariff provenance, exact decimal sums and per-node
|
|
7
|
+
normal/failure capacity bounds. Incomplete prices, insufficient capacity or unknown
|
|
8
|
+
architecture equivalence suppress a winner. The packaged official-rate example
|
|
9
|
+
leaves traffic and equivalence unknown. See `docs/COST-COMPARE.md` for the schema
|
|
10
|
+
and limits. No engine or MCP tool changes are included.
|
|
11
|
+
|
|
3
12
|
## 0.1.14 — 2026-10-20
|
|
4
13
|
|
|
5
14
|
- Prepare an exact optional dependency on `@dzhechkov/cloudru-vm-linux-x64@0.2.6`.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @dzhechkov/cloudru-hub
|
|
2
2
|
|
|
3
|
-
Site: https://aicoding.space · Source: https://github.com/djd1m/dz-harness/tree/main/packages
|
|
3
|
+
Site: https://aicoding.space · Source: https://github.com/djd1m/dz-harness/tree/main/packages/cloudru-hub
|
|
4
4
|
|
|
5
5
|
> **Linux/x64 distribution:** version 0.1.14 installs the exact optional engine
|
|
6
6
|
> package `@dzhechkov/cloudru-vm-linux-x64@0.2.6`. The `dz guard` `licence-hold`
|
|
@@ -42,12 +42,14 @@ The JS launcher remains MIT-licensed; its archive contains no Go engine bytes.
|
|
|
42
42
|
| Target integration | `src/install.js`, `src/safe-mcp.js` | Codex, OpenCode and Pi get project MCP configs using a pinned proxy; Hermes gets a guarded `$HERMES_HOME/config.yaml` entry with a verified engine path. Claude Code's general install keeps its permissions and hook probe. The opt-in `vm-deploy` profile requires an exact-plan approval and confines remote commands to the returned VM. Codex, Hermes, OpenCode and Pi each completed both live static-site scenarios on 2026-10-20 with HTTP byte checks and exact resource cleanup. The `k8s-deploy` profile uses a separate exact-manifest approval and create-only Kubernetes operation; the same four hosts completed live Kubernetes deployment, readiness, exact-byte HTTP and cleanup checks. Claude Code has installer/registration checks but no live inference pass. |
|
|
43
43
|
| Skill dialect compiler (ADR-006) | `src/dialects.js`, `data/dialects.json` | generates per-target variants of the canonical skill tree (which is NOT shipped — point `--canonical` at an engine distribution); hard-fails if any Hermes-dialect token survives AND if the engine codename survives in any case in any emitted file (positive invariant, not just the enumerated denylist); unlinks dangling links; enforces the 12 000-char router cap on lossy layouts; byte-deterministic |
|
|
44
44
|
| Golden classification | `data/tools-classification.json` | every engine tool → allow/ask/deny; `allow` requires a BEHAVIOUR audit of the handler (read-only proven at the pinned baseline, engine file:line cited per entry). The previous `stack_status` behavior was unsafe; the imported engine now makes it observational and tests that invariant. A live tool unknown to the classifier is a drift finding. |
|
|
45
|
+
| Offline cost worksheet | `src/cost-compare.js`, `docs/cost-compare.example.json` | compares supplied VM and Kubernetes topology, capacity and dated tariff lines with exact decimal arithmetic; needs no engine, credentials or network access |
|
|
45
46
|
|
|
46
47
|
## Usage
|
|
47
48
|
|
|
48
49
|
```bash
|
|
49
50
|
cloudru-hub resolve # where is the engine, which sha256, is it the pinned baseline
|
|
50
51
|
cloudru-hub self-test # resolve → sha256 → engine version → live tools/list → coverage
|
|
52
|
+
cloudru-hub cost-compare --input docs/cost-compare.example.json --json # offline cost worksheet
|
|
51
53
|
cloudru-hub mcp # run the engine as an MCP stdio server (what .mcp.json points at)
|
|
52
54
|
cloudru-hub mcp-safe --toolsets inventory # pinned MCP proxy with a small allow-classified surface
|
|
53
55
|
cloudru-hub install --target codex --toolsets billing # read-only balance, consumption and FinOps
|
|
@@ -65,6 +67,12 @@ cloudru-hub install --target agents-md # pointer-only section (no tools, no
|
|
|
65
67
|
cloudru-hub compile-skill --canonical <engine>/skill/cloudru-hub --target claude-code --out ./skill-out
|
|
66
68
|
```
|
|
67
69
|
|
|
70
|
+
The [cost worksheet guide](docs/COST-COMPARE.md) describes the versioned input, supported
|
|
71
|
+
units, exact calculations and limits. The packaged example uses checked official
|
|
72
|
+
VAT-inclusive tariff rows but leaves traffic unpriced and architecture equivalence
|
|
73
|
+
unknown; it therefore reports known subtotals with no winner. Supplied quantities
|
|
74
|
+
and tariff applicability remain operator assumptions, not verified inventory or a live bill.
|
|
75
|
+
|
|
68
76
|
## Using an owner-supplied engine instead
|
|
69
77
|
|
|
70
78
|
```bash
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Offline VM / Kubernetes cost worksheet
|
|
2
|
+
|
|
3
|
+
Run from an installed package or this directory:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
cloudru-hub cost-compare --input docs/cost-compare.example.json --json
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The command reads one local JSON file and does not resolve the engine, request credentials, call Cloud.ru, create resources or refresh tariffs. `--json` prints exact line, category and known-subtotal decimal strings plus two-decimal display strings. Without it, the command prints a compact human summary. Invalid input exits nonzero and emits no partial JSON. Only `--input <path>` and one optional `--json` are accepted. The file must be a regular, non-symlink file of at most 128 KiB.
|
|
10
|
+
|
|
11
|
+
The [runnable example](cost-compare.example.json) models 24 active hours. Its listed official tariff rows were checked on 2026-10-21 and are pinned as supplied metadata, not live prices or an applicability guarantee. It calculates known subtotals of **251.6616 RUB for VM** and **466.1376 RUB for Kubernetes**. Both leave traffic unpriced and architecture/HA equivalence unknown, so `winner` and `savingsRubExact` are `null`. The 60GB/80GB SSD allocations, IP counts, one Kubernetes SNAT and absence of load balancers are declared scenario assumptions. The worksheet cannot establish whether root, worker or control-plane volumes are separately charged for a real deployment.
|
|
12
|
+
|
|
13
|
+
The v1 input has `schemaVersion: 1`, one decimal-string `horizonHours` (greater than zero, at most 8760), `currency: "RUB"`, and `vatBasis: "included"`. `workload` supplies integer `replicas`, `survivingReplicas` and `failedNodes`, plus decimal-string `cpuPerReplica` and `ramGBPerReplica`. Both scenarios share those targets. Raw JSON numeric tokens must be canonical non-negative safe integers; fractional, exponent, negative and oversized tokens are rejected before parsing. Decimal values belong in strings. Duplicate object member names are rejected at every depth, including escaped spellings of the same name. `equivalence` is either `{ "status": "unknown", "reason": "...", "assumptions": [...] }` or `{ "status": "assumed", "assumptions": ["..."] }`. An assumed comparison remains conditional; this tool has no verified-equivalence state.
|
|
14
|
+
|
|
15
|
+
Exactly one `vm` and one `k8s` scenario are required, each with a unique `id`. Each declares homogeneous `nodes` (`count`, `cpu`, `ramGB`, `cpuShare`, `reservedCpu`, `reservedRamGB`), `controlPlaneCount`, `resources`, and all seven `categories`. CPU reservations are in guaranteed-core units: usable CPU is `cpu × cpuShare − reservedCpu`. Usable RAM is `ramGB − reservedRamGB`. Per-node replica slots are the smaller of the two exact decimal floors. Normal slots use all worker nodes; survival slots subtract the declared failed worker-node count. Control-plane instances are excluded from workload capacity. This is a numerical homogeneous packing bound, not proof of placement, scheduling, service performance, availability or SLA. Kubernetes requires a positive billed control-plane service count; VM requires zero.
|
|
16
|
+
|
|
17
|
+
`resources` declares `storageGB`, `publicIpCount`, `snatGatewayCount`, `loadBalancerCount` and `trafficGB`. Storage and traffic are decimal strings; IP, SNAT and load-balancer counts are integers. `trafficGB` alone may be `null`, meaning usage is unknown and its category must be `unpriced` with a reason. Zero is an explicit declaration and requires `not-used`, never an implicit free charge. Every other positive or unknown declared quantity must be `priced` or `unpriced` with a reason. Compute cannot be `not-used`; VM control-plane must be `not-used`.
|
|
18
|
+
|
|
19
|
+
Every category is `{ "status": "priced", "items": [...] }`, `{ "status": "not-used", "reason": "..." }`, or `{ "status": "unpriced", "reason": "..." }`. Priced items need a unique `id`, description, positive decimal-string `quantity` and `rateRub`, exact category unit, and tariff provenance `{ "url": "https://...", "version": "...", "asOf": "YYYY-MM-DD", "vatBasis": "included", "pageOrRow": "..." }` with optional lowercase SHA-256. The tariff URL and metadata are user assertions; they are checked for format and consistency, not fetched or certified. Supported units are `instance-hour` for compute/control-plane, `GB-hour` for storage, `IP-hour`, `gateway-hour`, `LB-hour`, and `traffic-GB`. Worker and control-plane instance counts and every other priced category quantity must sum exactly to their declared scenario counts or allocations. Traffic is charged once over the whole horizon; time units multiply by the common horizon. Monthly, tiered, stopped-resource and scheduled rates are outside v1.
|
|
20
|
+
|
|
21
|
+
Exact decimal multiplication and summation precede display rounding. Each input decimal has at most nine fractional places, and file, topology, line-count, quantity, rate and result magnitudes are bounded. `knownSubtotalRubExact` is only a sum of priced lines, not a guaranteed lower bound on an actual bill. A winner appears only when both scenarios have all categories resolved, sufficient normal and declared node-failure capacity, and explicit assumed equivalence. It identifies the lower **modeled infrastructure cost under supplied assumptions**, with a tie reported separately. It is not a deployment recommendation. Discounts, taxes beyond the common VAT-inclusive basis, heterogeneous fleets, autoscaling, live billing, network traffic tariffs and real resource inventory need separate evidence.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"horizonHours": "24",
|
|
4
|
+
"currency": "RUB",
|
|
5
|
+
"vatBasis": "included",
|
|
6
|
+
"workload": {
|
|
7
|
+
"replicas": 3,
|
|
8
|
+
"cpuPerReplica": "1",
|
|
9
|
+
"ramGBPerReplica": "1",
|
|
10
|
+
"survivingReplicas": 2,
|
|
11
|
+
"failedNodes": 1
|
|
12
|
+
},
|
|
13
|
+
"equivalence": {
|
|
14
|
+
"status": "unknown",
|
|
15
|
+
"reason": "Application placement, availability and performance equivalence have not been verified.",
|
|
16
|
+
"assumptions": ["Both scenarios run the same three replicas for the full 24-hour horizon."]
|
|
17
|
+
},
|
|
18
|
+
"scenarios": [
|
|
19
|
+
{
|
|
20
|
+
"id": "vm",
|
|
21
|
+
"kind": "vm",
|
|
22
|
+
"nodes": { "count": 3, "cpu": "2", "ramGB": "4", "reservedCpu": "0", "reservedRamGB": "0", "cpuShare": "1" },
|
|
23
|
+
"controlPlaneCount": 0,
|
|
24
|
+
"resources": { "storageGB": "60", "publicIpCount": 3, "snatGatewayCount": 0, "loadBalancerCount": 0, "trafficGB": null },
|
|
25
|
+
"categories": {
|
|
26
|
+
"compute": { "status": "priced", "items": [{
|
|
27
|
+
"id": "vm-2cpu-4gb", "description": "Three active 2vCPU/4GB VM instances",
|
|
28
|
+
"quantity": "3", "unit": "instance-hour", "rateRub": "2.9707",
|
|
29
|
+
"tariff": { "url": "https://cdn.cloud.ru/docs/legal/tariffs/evolution/current-version/evolution-compute.pdf", "version": "261014", "asOf": "2026-10-21", "vatBasis": "included", "pageOrRow": "VM 2vCPU/4GB row 18", "sha256": "a459226e73c8db4dc650307f3f07d99d9f17be31e0e22cd65c7a360dc1685dc5" }
|
|
30
|
+
}] },
|
|
31
|
+
"controlPlane": { "status": "not-used", "reason": "VM scenario has no separately billed control-plane service." },
|
|
32
|
+
"storage": { "status": "priced", "items": [{
|
|
33
|
+
"id": "ssd-60gb", "description": "Assumed 60GB billed SSD allocation across the VM scenario",
|
|
34
|
+
"quantity": "60", "unit": "GB-hour", "rateRub": "0.01586",
|
|
35
|
+
"tariff": { "url": "https://cdn.cloud.ru/docs/legal/tariffs/evolution/current-version/evolution-compute.pdf", "version": "261014", "asOf": "2026-10-21", "vatBasis": "included", "pageOrRow": "SSD row 85", "sha256": "a459226e73c8db4dc650307f3f07d99d9f17be31e0e22cd65c7a360dc1685dc5" }
|
|
36
|
+
}] },
|
|
37
|
+
"publicIp": { "status": "priced", "items": [{
|
|
38
|
+
"id": "vm-public-ip", "description": "Three assumed active public IP allocations",
|
|
39
|
+
"quantity": "3", "unit": "IP-hour", "rateRub": "0.2074",
|
|
40
|
+
"tariff": { "url": "https://cdn.cloud.ru/docs/legal/tariffs/evolution/current-version/evolution-compute.pdf", "version": "261014", "asOf": "2026-10-21", "vatBasis": "included", "pageOrRow": "Public IP row 2", "sha256": "a459226e73c8db4dc650307f3f07d99d9f17be31e0e22cd65c7a360dc1685dc5" }
|
|
41
|
+
}] },
|
|
42
|
+
"snat": { "status": "not-used", "reason": "No SNAT gateway is declared in this VM scenario." },
|
|
43
|
+
"loadBalancer": { "status": "not-used", "reason": "No load balancer is declared; direct public IP exposure is assumed." },
|
|
44
|
+
"traffic": { "status": "unpriced", "reason": "Traffic quantity and applicable tariff are unknown; do not assume zero traffic cost." }
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "k8s",
|
|
49
|
+
"kind": "k8s",
|
|
50
|
+
"nodes": { "count": 3, "cpu": "2", "ramGB": "4", "reservedCpu": "0", "reservedRamGB": "0", "cpuShare": "1" },
|
|
51
|
+
"controlPlaneCount": 1,
|
|
52
|
+
"resources": { "storageGB": "80", "publicIpCount": 2, "snatGatewayCount": 1, "loadBalancerCount": 0, "trafficGB": null },
|
|
53
|
+
"categories": {
|
|
54
|
+
"compute": { "status": "priced", "items": [{
|
|
55
|
+
"id": "worker-2cpu-4gb", "description": "Three active 2vCPU/4GB Kubernetes workers",
|
|
56
|
+
"quantity": "3", "unit": "instance-hour", "rateRub": "2.9768",
|
|
57
|
+
"tariff": { "url": "https://cdn.cloud.ru/docs/legal/tariffs/evolution/current-version/managed-kubernetes.pdf", "version": "260918", "asOf": "2026-10-21", "vatBasis": "included", "pageOrRow": "Worker 2vCPU/4GB row 6", "sha256": "5493f6da595b67482277ca420512db8f7f953375536a6169db8d1b282f24f345" }
|
|
58
|
+
}] },
|
|
59
|
+
"controlPlane": { "status": "priced", "items": [{
|
|
60
|
+
"id": "zonal-master", "description": "One billed zonal control-plane service instance",
|
|
61
|
+
"quantity": "1", "unit": "instance-hour", "rateRub": "8.601",
|
|
62
|
+
"tariff": { "url": "https://cdn.cloud.ru/docs/legal/tariffs/evolution/current-version/managed-kubernetes.pdf", "version": "260918", "asOf": "2026-10-21", "vatBasis": "included", "pageOrRow": "Zonal master 2vCPU/4GB row 42", "sha256": "5493f6da595b67482277ca420512db8f7f953375536a6169db8d1b282f24f345" }
|
|
63
|
+
}] },
|
|
64
|
+
"storage": { "status": "priced", "items": [{
|
|
65
|
+
"id": "ssd-80gb", "description": "Assumed 80GB billed SSD allocation across the Kubernetes scenario",
|
|
66
|
+
"quantity": "80", "unit": "GB-hour", "rateRub": "0.01586",
|
|
67
|
+
"tariff": { "url": "https://cdn.cloud.ru/docs/legal/tariffs/evolution/current-version/managed-kubernetes.pdf", "version": "260918", "asOf": "2026-10-21", "vatBasis": "included", "pageOrRow": "SSD row 39", "sha256": "5493f6da595b67482277ca420512db8f7f953375536a6169db8d1b282f24f345" }
|
|
68
|
+
}] },
|
|
69
|
+
"publicIp": { "status": "priced", "items": [{
|
|
70
|
+
"id": "k8s-public-ip", "description": "Two assumed active public IP allocations",
|
|
71
|
+
"quantity": "2", "unit": "IP-hour", "rateRub": "0.2074",
|
|
72
|
+
"tariff": { "url": "https://cdn.cloud.ru/docs/legal/tariffs/evolution/current-version/managed-kubernetes.pdf", "version": "260918", "asOf": "2026-10-21", "vatBasis": "included", "pageOrRow": "Public IP row 40", "sha256": "5493f6da595b67482277ca420512db8f7f953375536a6169db8d1b282f24f345" }
|
|
73
|
+
}] },
|
|
74
|
+
"snat": { "status": "priced", "items": [{
|
|
75
|
+
"id": "public-snat", "description": "One assumed active public SNAT gateway",
|
|
76
|
+
"quantity": "1", "unit": "gateway-hour", "rateRub": "0.2074",
|
|
77
|
+
"tariff": { "url": "https://cdn.cloud.ru/docs/legal/tariffs/evolution/current-version/evolution-compute.pdf", "version": "261014", "asOf": "2026-10-21", "vatBasis": "included", "pageOrRow": "Public SNAT row 3", "sha256": "a459226e73c8db4dc650307f3f07d99d9f17be31e0e22cd65c7a360dc1685dc5" }
|
|
78
|
+
}] },
|
|
79
|
+
"loadBalancer": { "status": "not-used", "reason": "No load balancer is declared; the assumed application architecture avoids it." },
|
|
80
|
+
"traffic": { "status": "unpriced", "reason": "Traffic quantity and applicable tariff are unknown; do not assume zero traffic cost." }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dzhechkov/cloudru-hub",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"licenseHold": {
|
|
5
5
|
"adr": "features/hermes-claude-adaptation/03_adr/001-licence-and-provenance-precondition.md",
|
|
6
6
|
"note": "SATISFIED hold. Timur is credited for substantial Cloud.ru API testing. The 2026-10-20 correction is in the historical grant record. This field keeps the dz guard licence-hold rule checking LICENSE, THIRD_PARTY_NOTICES and SPDX on every publish."
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"repository": {
|
|
62
62
|
"type": "git",
|
|
63
63
|
"url": "git+https://github.com/djd1m/dz-harness.git",
|
|
64
|
-
"directory": "packages
|
|
64
|
+
"directory": "packages/cloudru-hub"
|
|
65
65
|
},
|
|
66
66
|
"homepage": "https://aicoding.space",
|
|
67
67
|
"bugs": {
|
package/sbom.json
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"hashes": [
|
|
16
16
|
{
|
|
17
17
|
"alg": "SHA-256",
|
|
18
|
-
"content": "
|
|
18
|
+
"content": "2b0dbd83deb813844a658814b9a6fa1640f1e5255c676210e61dceaa6b42195b"
|
|
19
19
|
}
|
|
20
20
|
]
|
|
21
21
|
},
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"hashes": [
|
|
36
36
|
{
|
|
37
37
|
"alg": "SHA-256",
|
|
38
|
-
"content": "
|
|
38
|
+
"content": "4f6a2d5928fec42f021eb10471677b902e831b99d53096661a821f6460bd55a6"
|
|
39
39
|
}
|
|
40
40
|
]
|
|
41
41
|
},
|
|
@@ -99,6 +99,16 @@
|
|
|
99
99
|
}
|
|
100
100
|
]
|
|
101
101
|
},
|
|
102
|
+
{
|
|
103
|
+
"type": "file",
|
|
104
|
+
"name": "docs/COST-COMPARE.md",
|
|
105
|
+
"hashes": [
|
|
106
|
+
{
|
|
107
|
+
"alg": "SHA-256",
|
|
108
|
+
"content": "80b4d7ac3868f17d93565274cd6736fe5f2bd7fb059cbed0c2f0afe3f4924317"
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
},
|
|
102
112
|
{
|
|
103
113
|
"type": "file",
|
|
104
114
|
"name": "docs/HERMES-CODEX.md",
|
|
@@ -119,6 +129,16 @@
|
|
|
119
129
|
}
|
|
120
130
|
]
|
|
121
131
|
},
|
|
132
|
+
{
|
|
133
|
+
"type": "file",
|
|
134
|
+
"name": "docs/cost-compare.example.json",
|
|
135
|
+
"hashes": [
|
|
136
|
+
{
|
|
137
|
+
"alg": "SHA-256",
|
|
138
|
+
"content": "7eafb1ff555fb5443ded1a2c578b17eae6e73e3056efb9cbb5129b424c8c5097"
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
},
|
|
122
142
|
{
|
|
123
143
|
"type": "file",
|
|
124
144
|
"name": "package.json",
|
|
@@ -129,7 +149,7 @@
|
|
|
129
149
|
},
|
|
130
150
|
{
|
|
131
151
|
"name": "dz:canonical-json-sha256-v2",
|
|
132
|
-
"value": "
|
|
152
|
+
"value": "1b355df670fb092da7ce8956141e702f9f78e1e2f2724c6aaba61bbab2acd8c8"
|
|
133
153
|
}
|
|
134
154
|
]
|
|
135
155
|
},
|
|
@@ -149,7 +169,17 @@
|
|
|
149
169
|
"hashes": [
|
|
150
170
|
{
|
|
151
171
|
"alg": "SHA-256",
|
|
152
|
-
"content": "
|
|
172
|
+
"content": "b1bd4a0ce7f1ca869c2873ca1979a74630483ee2909ff8b4f91189622f5aa2a7"
|
|
173
|
+
}
|
|
174
|
+
]
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"type": "file",
|
|
178
|
+
"name": "src/cost-compare.js",
|
|
179
|
+
"hashes": [
|
|
180
|
+
{
|
|
181
|
+
"alg": "SHA-256",
|
|
182
|
+
"content": "6084ecd7f7fc53bbd441fb9e91dcd7c7bd5c409d1d4e95613fad0fd0735b7b05"
|
|
153
183
|
}
|
|
154
184
|
]
|
|
155
185
|
},
|
|
@@ -273,6 +303,16 @@
|
|
|
273
303
|
}
|
|
274
304
|
]
|
|
275
305
|
},
|
|
306
|
+
{
|
|
307
|
+
"type": "file",
|
|
308
|
+
"name": "test/cost-compare.test.mjs",
|
|
309
|
+
"hashes": [
|
|
310
|
+
{
|
|
311
|
+
"alg": "SHA-256",
|
|
312
|
+
"content": "b354913220bf725ec6b8a708b7851e2f5779a3c594cd411732d19acad9001870"
|
|
313
|
+
}
|
|
314
|
+
]
|
|
315
|
+
},
|
|
276
316
|
{
|
|
277
317
|
"type": "file",
|
|
278
318
|
"name": "test/deploy-approval.test.mjs",
|
package/src/cli.js
CHANGED
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
// Zero dependencies, CommonJS, boots from any cwd (dz release smoke contract).
|
|
4
4
|
|
|
5
5
|
const { spawn, spawnSync } = require('child_process');
|
|
6
|
-
const { resolveEngine } = require('./resolve');
|
|
7
|
-
const { loadClassification, loadSnapshot, coverageGaps } = require('./classification');
|
|
8
|
-
const { install } = require('./install');
|
|
9
|
-
const { runSafeMcp } = require('./safe-mcp');
|
|
10
|
-
const { issueApproval } = require('./deploy-approval');
|
|
11
|
-
const { issueK8sApproval } = require('./k8s-approval');
|
|
12
|
-
const { compileSkill, writeCompiled } = require('./dialects');
|
|
13
6
|
const PKG = require('../package.json');
|
|
7
|
+
let resolveEngine, loadClassification, loadSnapshot, coverageGaps, install, runSafeMcp;
|
|
8
|
+
let issueApproval, issueK8sApproval, compileSkill, writeCompiled;
|
|
9
|
+
let legacyLoaded = false;
|
|
10
|
+
function loadLegacyCommands() {
|
|
11
|
+
if (legacyLoaded) return;
|
|
12
|
+
({ resolveEngine } = require('./resolve'));
|
|
13
|
+
({ loadClassification, loadSnapshot, coverageGaps } = require('./classification'));
|
|
14
|
+
({ install } = require('./install'));
|
|
15
|
+
({ runSafeMcp } = require('./safe-mcp'));
|
|
16
|
+
({ issueApproval } = require('./deploy-approval'));
|
|
17
|
+
({ issueK8sApproval } = require('./k8s-approval'));
|
|
18
|
+
({ compileSkill, writeCompiled } = require('./dialects'));
|
|
19
|
+
legacyLoaded = true;
|
|
20
|
+
}
|
|
14
21
|
|
|
15
22
|
function showHelp(write) {
|
|
16
23
|
write(`cloudru-hub ${PKG.version} — unofficial Cloud.ru Evolution launcher (NOT affiliated with Cloud.ru)
|
|
@@ -32,6 +39,8 @@ Commands:
|
|
|
32
39
|
Static sites require the artifact digest shown by deploy_plan.
|
|
33
40
|
k8s-approve --cluster-id <uuid> --namespace <name> --manifest <file> --manifest-sha256 <digest>
|
|
34
41
|
Authorize one exact three-resource Snake manifest on an existing cluster.
|
|
42
|
+
cost-compare --input <json> [--json]
|
|
43
|
+
Offline VM vs Kubernetes cost worksheet; no engine or credentials.
|
|
35
44
|
install --target <t> claude-code: permissions + executed hook probe;
|
|
36
45
|
codex|opencode|pi: project MCP config using mcp-safe;
|
|
37
46
|
hermes: guarded $HERMES_HOME/config.yaml entry using mcp-safe;
|
|
@@ -139,6 +148,9 @@ function cmdMcp() {
|
|
|
139
148
|
}
|
|
140
149
|
|
|
141
150
|
function main(argv, write = (s) => process.stdout.write(s + '\n')) {
|
|
151
|
+
// This command validates its raw flags before the permissive legacy parser sees them.
|
|
152
|
+
if (argv[0] === 'cost-compare') return require('./cost-compare').run(argv.slice(1), write);
|
|
153
|
+
loadLegacyCommands();
|
|
142
154
|
const { flags, opts, positional } = parseArgs(argv);
|
|
143
155
|
const cmd = positional[0] || 'help';
|
|
144
156
|
if (flags.has('help') || flags.has('h') || cmd === 'help') { showHelp(write); return 0; }
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Offline worksheet. All money and resource divisions use integer decimal arithmetic.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
const MAX_INPUT = 128 * 1024;
|
|
7
|
+
const MAX_MONEY = 10n ** 18n;
|
|
8
|
+
const CATEGORIES = Object.freeze({
|
|
9
|
+
compute: 'instance-hour',
|
|
10
|
+
controlPlane: 'instance-hour',
|
|
11
|
+
storage: 'GB-hour',
|
|
12
|
+
publicIp: 'IP-hour',
|
|
13
|
+
snat: 'gateway-hour',
|
|
14
|
+
loadBalancer: 'LB-hour',
|
|
15
|
+
traffic: 'traffic-GB',
|
|
16
|
+
});
|
|
17
|
+
const TIME_UNITS = new Set(['instance-hour', 'GB-hour', 'IP-hour', 'gateway-hour', 'LB-hour']);
|
|
18
|
+
|
|
19
|
+
function fail(path, message) { throw new Error(`${path}: ${message}`); }
|
|
20
|
+
function record(value, path, keys) {
|
|
21
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail(path, 'must be an object');
|
|
22
|
+
for (const key of Object.keys(value)) if (!keys.includes(key)) fail(`${path}.${key}`, 'unknown field');
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function required(obj, key, path) {
|
|
26
|
+
if (!Object.hasOwn(obj, key)) fail(`${path}.${key}`, 'required');
|
|
27
|
+
return obj[key];
|
|
28
|
+
}
|
|
29
|
+
function positiveInteger(value, path, max = 1000000, zero = false) {
|
|
30
|
+
if (!Number.isSafeInteger(value) || value < (zero ? 0 : 1) || value > max) fail(path, `must be an integer from ${zero ? 0 : 1} to ${max}`);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function words(value, path, max = 500) {
|
|
34
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x1f\x7f]/.test(value)) fail(path, 'must be a nonempty bounded single-line string');
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
function decimal(value, path, { zero = false, max = 1000000000n } = {}) {
|
|
38
|
+
if (typeof value !== 'string' || !/^(?:0|[1-9]\d{0,12})(?:\.\d{1,9})?$/.test(value)) fail(path, 'must be a non-negative decimal string with at most 9 fractional places');
|
|
39
|
+
const [whole, fraction = ''] = value.split('.');
|
|
40
|
+
const coefficient = BigInt(whole + fraction);
|
|
41
|
+
const scale = 10n ** BigInt(fraction.length);
|
|
42
|
+
if ((!zero && coefficient === 0n) || coefficient > max * scale) fail(path, `must be ${zero ? 'non-negative' : 'positive'} and at most ${max}`);
|
|
43
|
+
return { coefficient, scale };
|
|
44
|
+
}
|
|
45
|
+
const integerDecimal = value => ({ coefficient: BigInt(value), scale: 1n });
|
|
46
|
+
const multiply = (a, b) => ({ coefficient: a.coefficient * b.coefficient, scale: a.scale * b.scale });
|
|
47
|
+
const subtract = (a, b) => ({ coefficient: a.coefficient * b.scale - b.coefficient * a.scale, scale: a.scale * b.scale });
|
|
48
|
+
const compare = (a, b) => a.coefficient * b.scale < b.coefficient * a.scale ? -1 : a.coefficient * b.scale > b.coefficient * a.scale ? 1 : 0;
|
|
49
|
+
function add(a, b) { return { coefficient: a.coefficient * b.scale + b.coefficient * a.scale, scale: a.scale * b.scale }; }
|
|
50
|
+
function exact(value) {
|
|
51
|
+
const whole = value.coefficient / value.scale;
|
|
52
|
+
const remainder = value.coefficient % value.scale;
|
|
53
|
+
if (!remainder) return String(whole);
|
|
54
|
+
return `${whole}.${String(remainder).padStart(String(value.scale).length - 1, '0').replace(/0+$/, '')}`;
|
|
55
|
+
}
|
|
56
|
+
function display(value) {
|
|
57
|
+
const cents = (value.coefficient * 200n + value.scale) / (2n * value.scale);
|
|
58
|
+
return `${cents / 100n}.${String(cents % 100n).padStart(2, '0')}`;
|
|
59
|
+
}
|
|
60
|
+
function monetary(value, path) {
|
|
61
|
+
if (value.coefficient < 0n || value.coefficient > MAX_MONEY * value.scale) fail(path, 'amount exceeds supported RUB magnitude');
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
function slots(usable, perReplica, path) {
|
|
65
|
+
const count = usable.coefficient * perReplica.scale / (usable.scale * perReplica.coefficient);
|
|
66
|
+
if (count > 1000000n) fail(path, 'per-node capacity exceeds supported bound');
|
|
67
|
+
return Number(count);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// JSON.parse discards duplicate members and rounds large/fractional numeric tokens.
|
|
71
|
+
// Inspect the bounded original text first, including decoded spellings of object keys.
|
|
72
|
+
function validateJsonLexemes(text) {
|
|
73
|
+
const number = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
74
|
+
let offset = 0, tokens = 0;
|
|
75
|
+
const bad = message => fail('--input', message);
|
|
76
|
+
const space = () => { while (offset < text.length && /[ \t\r\n]/.test(text[offset])) offset++; };
|
|
77
|
+
const count = () => { if (++tokens > 10000) bad('JSON has too many members or values'); };
|
|
78
|
+
const string = () => {
|
|
79
|
+
const start = offset++;
|
|
80
|
+
while (offset < text.length) {
|
|
81
|
+
if (text[offset] === '\\') { offset += 2; continue; }
|
|
82
|
+
if (text[offset++] === '"') {
|
|
83
|
+
try { return JSON.parse(text.slice(start, offset)); }
|
|
84
|
+
catch { bad('invalid JSON string'); }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
bad('unterminated JSON string');
|
|
88
|
+
};
|
|
89
|
+
const value = depth => {
|
|
90
|
+
count();
|
|
91
|
+
if (depth > 64) bad('JSON nesting exceeds 64 levels');
|
|
92
|
+
space();
|
|
93
|
+
const marker = text[offset];
|
|
94
|
+
if (marker === '{') {
|
|
95
|
+
offset++; space();
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
if (text[offset] === '}') { offset++; return; }
|
|
98
|
+
while (true) {
|
|
99
|
+
if (text[offset] !== '"') bad('object member must be a JSON string');
|
|
100
|
+
count();
|
|
101
|
+
const key = string();
|
|
102
|
+
if (seen.has(key)) bad('duplicate decoded object member');
|
|
103
|
+
seen.add(key);
|
|
104
|
+
space();
|
|
105
|
+
if (text[offset++] !== ':') bad('object member requires a colon');
|
|
106
|
+
value(depth + 1); space();
|
|
107
|
+
const separator = text[offset++];
|
|
108
|
+
if (separator === '}') return;
|
|
109
|
+
if (separator !== ',') bad('object member requires a comma or closing brace');
|
|
110
|
+
space();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (marker === '[') {
|
|
114
|
+
offset++; space();
|
|
115
|
+
if (text[offset] === ']') { offset++; return; }
|
|
116
|
+
while (true) {
|
|
117
|
+
value(depth + 1); space();
|
|
118
|
+
const separator = text[offset++];
|
|
119
|
+
if (separator === ']') return;
|
|
120
|
+
if (separator !== ',') bad('array element requires a comma or closing bracket');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (marker === '"') { string(); return; }
|
|
124
|
+
for (const literal of ['true', 'false', 'null']) if (text.startsWith(literal, offset)) { offset += literal.length; return; }
|
|
125
|
+
number.lastIndex = offset;
|
|
126
|
+
const match = number.exec(text);
|
|
127
|
+
if (!match) bad('invalid JSON value or number');
|
|
128
|
+
const lexeme = match[0];
|
|
129
|
+
offset = number.lastIndex;
|
|
130
|
+
if (!/^(?:0|[1-9]\d*)$/.test(lexeme) || lexeme.length > 16 || BigInt(lexeme) > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
131
|
+
bad('numeric tokens must be canonical non-negative safe integers; decimals must be strings');
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
space(); value(0); space();
|
|
135
|
+
if (offset !== text.length) bad('trailing or malformed JSON token');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readInput(path) {
|
|
139
|
+
words(path, '--input', 4096);
|
|
140
|
+
const flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK;
|
|
141
|
+
const fd = fs.openSync(path, flags);
|
|
142
|
+
try {
|
|
143
|
+
const stat = fs.fstatSync(fd);
|
|
144
|
+
if (!stat.isFile() || stat.size > MAX_INPUT) fail('--input', 'must be a regular file of at most 128 KiB');
|
|
145
|
+
const chunks = [], buffer = Buffer.alloc(8192);
|
|
146
|
+
let length = 0, read;
|
|
147
|
+
while ((read = fs.readSync(fd, buffer, 0, Math.min(buffer.length, MAX_INPUT + 1 - length), null)) > 0) {
|
|
148
|
+
chunks.push(Buffer.from(buffer.subarray(0, read)));
|
|
149
|
+
length += read;
|
|
150
|
+
if (length > MAX_INPUT) fail('--input', 'file exceeds 128 KiB');
|
|
151
|
+
}
|
|
152
|
+
const bytes = Buffer.concat(chunks), text = bytes.toString('utf8');
|
|
153
|
+
if (!Buffer.from(text).equals(bytes)) fail('--input', 'must be valid UTF-8');
|
|
154
|
+
validateJsonLexemes(text);
|
|
155
|
+
return JSON.parse(text);
|
|
156
|
+
} finally { fs.closeSync(fd); }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function tariff(value, path, vatBasis) {
|
|
160
|
+
record(value, path, ['url', 'version', 'asOf', 'vatBasis', 'pageOrRow', 'sha256']);
|
|
161
|
+
const url = words(required(value, 'url', path), `${path}.url`, 1000);
|
|
162
|
+
if (!/^https:\/\/[^\s/@?#]+(?:[/?#][^\s]*)?$/.test(url)) fail(`${path}.url`, 'must be an HTTPS tariff URL');
|
|
163
|
+
words(required(value, 'version', path), `${path}.version`, 100);
|
|
164
|
+
const asOf = required(value, 'asOf', path);
|
|
165
|
+
if (typeof asOf !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(asOf) || Number.isNaN(Date.parse(`${asOf}T00:00:00Z`)) || new Date(`${asOf}T00:00:00Z`).toISOString().slice(0, 10) !== asOf) fail(`${path}.asOf`, 'must be a valid YYYY-MM-DD date');
|
|
166
|
+
if (required(value, 'vatBasis', path) !== vatBasis) fail(`${path}.vatBasis`, 'must match the common VAT basis');
|
|
167
|
+
words(required(value, 'pageOrRow', path), `${path}.pageOrRow`, 100);
|
|
168
|
+
if (value.sha256 !== undefined && (typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256))) fail(`${path}.sha256`, 'must be lowercase SHA-256');
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function category(value, path, name, expected, horizon, vatBasis) {
|
|
173
|
+
record(value, path, ['status', 'reason', 'items']);
|
|
174
|
+
const status = required(value, 'status', path);
|
|
175
|
+
if (!['priced', 'not-used', 'unpriced'].includes(status)) fail(`${path}.status`, 'must be priced, not-used or unpriced');
|
|
176
|
+
if (status !== 'priced') {
|
|
177
|
+
if (value.items !== undefined) fail(`${path}.items`, 'allowed only when priced');
|
|
178
|
+
const reason = words(required(value, 'reason', path), `${path}.reason`);
|
|
179
|
+
if (status === 'not-used' && (expected === null || expected.coefficient !== 0n)) fail(path, 'not-used requires a declared zero quantity');
|
|
180
|
+
if (status === 'unpriced' && expected !== null && expected.coefficient === 0n) fail(path, 'declared zero quantity must be not-used');
|
|
181
|
+
return { status, reason, items: [], totalRubExact: null, totalRubDisplay: null };
|
|
182
|
+
}
|
|
183
|
+
if (value.reason !== undefined) fail(`${path}.reason`, 'allowed only when unpriced or not-used');
|
|
184
|
+
if (expected === null || expected.coefficient === 0n) fail(path, 'priced requires a positive declared quantity');
|
|
185
|
+
const items = required(value, 'items', path);
|
|
186
|
+
if (!Array.isArray(items) || !items.length || items.length > 16) fail(`${path}.items`, 'must have 1 to 16 items');
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
let quantitySum = integerDecimal(0), total = integerDecimal(0);
|
|
189
|
+
const result = items.map((item, index) => {
|
|
190
|
+
const at = `${path}.items[${index}]`;
|
|
191
|
+
record(item, at, ['id', 'description', 'quantity', 'unit', 'rateRub', 'tariff']);
|
|
192
|
+
const id = words(required(item, 'id', at), `${at}.id`, 80);
|
|
193
|
+
if (!/^[a-z][a-z0-9-]*$/.test(id) || seen.has(id)) fail(`${at}.id`, 'must be a unique slug');
|
|
194
|
+
seen.add(id);
|
|
195
|
+
words(required(item, 'description', at), `${at}.description`);
|
|
196
|
+
const q = decimal(required(item, 'quantity', at), `${at}.quantity`);
|
|
197
|
+
if (required(item, 'unit', at) !== CATEGORIES[name]) fail(`${at}.unit`, `must be ${CATEGORIES[name]}`);
|
|
198
|
+
if (['compute', 'controlPlane', 'publicIp', 'snat', 'loadBalancer'].includes(name) && q.scale !== 1n) fail(`${at}.quantity`, 'must be an integer service count');
|
|
199
|
+
const rate = decimal(required(item, 'rateRub', at), `${at}.rateRub`);
|
|
200
|
+
tariff(required(item, 'tariff', at), `${at}.tariff`, vatBasis);
|
|
201
|
+
quantitySum = add(quantitySum, q);
|
|
202
|
+
const amount = monetary(multiply(multiply(q, rate), TIME_UNITS.has(item.unit) ? horizon : integerDecimal(1)), `${at}.amount`);
|
|
203
|
+
total = monetary(add(total, amount), `${path}.total`);
|
|
204
|
+
return { ...item, amountRubExact: exact(amount), amountRubDisplay: display(amount), formula: TIME_UNITS.has(item.unit) ? `${item.quantity} ${item.unit.slice(0, -5)} × ${item.rateRub} RUB/${item.unit} × ${exact(horizon)} hours` : `${item.quantity} ${item.unit} × ${item.rateRub} RUB/${item.unit} once` };
|
|
205
|
+
});
|
|
206
|
+
if (compare(quantitySum, expected) !== 0) fail(`${path}.items`, `quantities must sum to declared ${exact(expected)} ${CATEGORIES[name]}`);
|
|
207
|
+
return { status, items: result, totalRubExact: exact(total), totalRubDisplay: display(total), _total: total };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function scenario(value, path, workload, horizon, vatBasis) {
|
|
211
|
+
record(value, path, ['id', 'kind', 'nodes', 'controlPlaneCount', 'resources', 'categories']);
|
|
212
|
+
const id = words(required(value, 'id', path), `${path}.id`, 80);
|
|
213
|
+
if (!/^[a-z][a-z0-9-]*$/.test(id)) fail(`${path}.id`, 'must be a slug');
|
|
214
|
+
const kind = required(value, 'kind', path);
|
|
215
|
+
if (!['vm', 'k8s'].includes(kind)) fail(`${path}.kind`, 'must be vm or k8s');
|
|
216
|
+
const node = record(required(value, 'nodes', path), `${path}.nodes`, ['count', 'cpu', 'ramGB', 'reservedCpu', 'reservedRamGB', 'cpuShare']);
|
|
217
|
+
const count = positiveInteger(required(node, 'count', `${path}.nodes`), `${path}.nodes.count`, 1000);
|
|
218
|
+
if (workload.failedNodes > count) fail(`${path}.nodes.count`, 'must be at least the declared failedNodes');
|
|
219
|
+
const cpu = decimal(required(node, 'cpu', `${path}.nodes`), `${path}.nodes.cpu`, { max: 10000n });
|
|
220
|
+
const ram = decimal(required(node, 'ramGB', `${path}.nodes`), `${path}.nodes.ramGB`, { max: 1000000n });
|
|
221
|
+
const share = decimal(required(node, 'cpuShare', `${path}.nodes`), `${path}.nodes.cpuShare`, { max: 1n });
|
|
222
|
+
const reservedCpu = decimal(required(node, 'reservedCpu', `${path}.nodes`), `${path}.nodes.reservedCpu`, { zero: true, max: 10000n });
|
|
223
|
+
const reservedRam = decimal(required(node, 'reservedRamGB', `${path}.nodes`), `${path}.nodes.reservedRamGB`, { zero: true, max: 1000000n });
|
|
224
|
+
const guaranteedCpu = multiply(cpu, share);
|
|
225
|
+
if (compare(reservedCpu, guaranteedCpu) > 0 || compare(reservedRam, ram) > 0) fail(`${path}.nodes`, 'reservations exceed guaranteed CPU or RAM');
|
|
226
|
+
const controlPlaneCount = positiveInteger(required(value, 'controlPlaneCount', path), `${path}.controlPlaneCount`, 1000, kind === 'vm');
|
|
227
|
+
if (kind === 'vm' && controlPlaneCount !== 0) fail(`${path}.controlPlaneCount`, 'VM must declare zero control-plane service instances');
|
|
228
|
+
const resources = record(required(value, 'resources', path), `${path}.resources`, ['storageGB', 'publicIpCount', 'snatGatewayCount', 'loadBalancerCount', 'trafficGB']);
|
|
229
|
+
const storage = decimal(required(resources, 'storageGB', `${path}.resources`), `${path}.resources.storageGB`, { zero: true });
|
|
230
|
+
const publicIp = positiveInteger(required(resources, 'publicIpCount', `${path}.resources`), `${path}.resources.publicIpCount`, 1000000, true);
|
|
231
|
+
const snat = positiveInteger(required(resources, 'snatGatewayCount', `${path}.resources`), `${path}.resources.snatGatewayCount`, 1000000, true);
|
|
232
|
+
const lb = positiveInteger(required(resources, 'loadBalancerCount', `${path}.resources`), `${path}.resources.loadBalancerCount`, 1000000, true);
|
|
233
|
+
const trafficValue = required(resources, 'trafficGB', `${path}.resources`);
|
|
234
|
+
const traffic = trafficValue === null ? null : decimal(trafficValue, `${path}.resources.trafficGB`, { zero: true });
|
|
235
|
+
const expected = { compute: integerDecimal(count), controlPlane: integerDecimal(controlPlaneCount), storage, publicIp: integerDecimal(publicIp), snat: integerDecimal(snat), loadBalancer: integerDecimal(lb), traffic };
|
|
236
|
+
const raw = record(required(value, 'categories', path), `${path}.categories`, Object.keys(CATEGORIES));
|
|
237
|
+
const categories = {}, unpriced = [];
|
|
238
|
+
let known = integerDecimal(0);
|
|
239
|
+
for (const name of Object.keys(CATEGORIES)) {
|
|
240
|
+
const result = category(required(raw, name, `${path}.categories`), `${path}.categories.${name}`, name, expected[name], horizon, vatBasis);
|
|
241
|
+
if (result._total) { known = monetary(add(known, result._total), `${path}.knownSubtotal`); delete result._total; }
|
|
242
|
+
if (result.status === 'unpriced') unpriced.push({ category: name, reason: result.reason });
|
|
243
|
+
categories[name] = result;
|
|
244
|
+
}
|
|
245
|
+
const perNodeCpu = slots(subtract(guaranteedCpu, reservedCpu), workload.cpu, `${path}.nodes.cpu`);
|
|
246
|
+
const perNodeRam = slots(subtract(ram, reservedRam), workload.ram, `${path}.nodes.ramGB`);
|
|
247
|
+
const perNodeSlots = Math.min(perNodeCpu, perNodeRam);
|
|
248
|
+
const normalSlots = count * perNodeSlots;
|
|
249
|
+
const survivingNodes = Math.max(0, count - workload.failedNodes);
|
|
250
|
+
const survivingSlots = survivingNodes * perNodeSlots;
|
|
251
|
+
const feasible = normalSlots >= workload.replicas && survivingSlots >= workload.survivingReplicas;
|
|
252
|
+
return {
|
|
253
|
+
id, kind, nodes: node, controlPlaneCount, resources, categories,
|
|
254
|
+
capacity: { perNodeCpuSlots: perNodeCpu, perNodeRamSlots: perNodeRam, perNodeSlots, normalSlots, survivingNodes, survivingSlots, feasible,
|
|
255
|
+
formula: 'usable guaranteed CPU = cpu × cpuShare − reservedCpu; usable RAM = ramGB − reservedRamGB; per-node slots = min(floor(usable CPU / cpuPerReplica), floor(usable RAM / ramGBPerReplica)); normal/surviving slots = per-node slots × available nodes' },
|
|
256
|
+
unpriced, complete: unpriced.length === 0, knownSubtotalRubExact: exact(known), knownSubtotalRubDisplay: display(known), _known: known,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function calculate(input) {
|
|
261
|
+
record(input, 'input', ['schemaVersion', 'horizonHours', 'currency', 'vatBasis', 'workload', 'equivalence', 'scenarios']);
|
|
262
|
+
if (required(input, 'schemaVersion', 'input') !== 1) fail('input.schemaVersion', 'must be 1');
|
|
263
|
+
const horizon = decimal(required(input, 'horizonHours', 'input'), 'input.horizonHours', { max: 8760n });
|
|
264
|
+
if (required(input, 'currency', 'input') !== 'RUB') fail('input.currency', 'must be RUB');
|
|
265
|
+
const vatBasis = required(input, 'vatBasis', 'input');
|
|
266
|
+
if (vatBasis !== 'included') fail('input.vatBasis', 'must be included');
|
|
267
|
+
const rawWorkload = record(required(input, 'workload', 'input'), 'input.workload', ['replicas', 'cpuPerReplica', 'ramGBPerReplica', 'survivingReplicas', 'failedNodes']);
|
|
268
|
+
const workload = {
|
|
269
|
+
replicas: positiveInteger(required(rawWorkload, 'replicas', 'input.workload'), 'input.workload.replicas'),
|
|
270
|
+
cpu: decimal(required(rawWorkload, 'cpuPerReplica', 'input.workload'), 'input.workload.cpuPerReplica', { max: 10000n }),
|
|
271
|
+
ram: decimal(required(rawWorkload, 'ramGBPerReplica', 'input.workload'), 'input.workload.ramGBPerReplica', { max: 1000000n }),
|
|
272
|
+
survivingReplicas: positiveInteger(required(rawWorkload, 'survivingReplicas', 'input.workload'), 'input.workload.survivingReplicas', 1000000, true),
|
|
273
|
+
failedNodes: positiveInteger(required(rawWorkload, 'failedNodes', 'input.workload'), 'input.workload.failedNodes', 1000, true),
|
|
274
|
+
};
|
|
275
|
+
if (workload.survivingReplicas > workload.replicas) fail('input.workload.survivingReplicas', 'cannot exceed replicas');
|
|
276
|
+
const equivalence = record(required(input, 'equivalence', 'input'), 'input.equivalence', ['status', 'assumptions', 'reason']);
|
|
277
|
+
if (!['assumed', 'unknown'].includes(required(equivalence, 'status', 'input.equivalence'))) fail('input.equivalence.status', 'must be assumed or unknown');
|
|
278
|
+
const assumptions = required(equivalence, 'assumptions', 'input.equivalence');
|
|
279
|
+
if (!Array.isArray(assumptions) || assumptions.length > 16 || (equivalence.status === 'assumed' && assumptions.length === 0)) fail('input.equivalence.assumptions', 'must have 1 to 16 assumptions when assumed');
|
|
280
|
+
assumptions.forEach((item, i) => words(item, `input.equivalence.assumptions[${i}]`));
|
|
281
|
+
if (equivalence.status === 'unknown') words(required(equivalence, 'reason', 'input.equivalence'), 'input.equivalence.reason');
|
|
282
|
+
else if (equivalence.reason !== undefined) fail('input.equivalence.reason', 'allowed only when unknown');
|
|
283
|
+
const rawScenarios = required(input, 'scenarios', 'input');
|
|
284
|
+
if (!Array.isArray(rawScenarios) || rawScenarios.length !== 2) fail('input.scenarios', 'must contain exactly one VM and one K8s scenario');
|
|
285
|
+
const scenarios = rawScenarios.map((item, i) => scenario(item, `input.scenarios[${i}]`, workload, horizon, vatBasis));
|
|
286
|
+
if (new Set(scenarios.map(item => item.kind)).size !== 2 || new Set(scenarios.map(item => item.id)).size !== 2) fail('input.scenarios', 'must have unique IDs and one scenario of each kind');
|
|
287
|
+
const reasons = [];
|
|
288
|
+
if (equivalence.status === 'unknown') reasons.push('architecture/HA equivalence is unknown');
|
|
289
|
+
for (const item of scenarios) {
|
|
290
|
+
if (!item.capacity.feasible) reasons.push(`${item.id}: declared normal or node-failure capacity is insufficient`);
|
|
291
|
+
if (!item.complete) reasons.push(`${item.id}: unpriced categories remain`);
|
|
292
|
+
}
|
|
293
|
+
const eligible = reasons.length === 0;
|
|
294
|
+
let winner = null, savings = null, tie = false;
|
|
295
|
+
if (eligible) {
|
|
296
|
+
const [a, b] = scenarios;
|
|
297
|
+
const relation = compare(a._known, b._known);
|
|
298
|
+
tie = relation === 0;
|
|
299
|
+
if (!tie) { winner = relation < 0 ? a.id : b.id; savings = subtract(relation < 0 ? b._known : a._known, relation < 0 ? a._known : b._known); }
|
|
300
|
+
}
|
|
301
|
+
for (const item of scenarios) delete item._known;
|
|
302
|
+
return {
|
|
303
|
+
schemaVersion: 1, horizonHours: input.horizonHours, currency: 'RUB', vatBasis,
|
|
304
|
+
workload: rawWorkload, equivalence,
|
|
305
|
+
scenarios,
|
|
306
|
+
comparison: { eligible, reasons, basis: eligible ? 'conditional lower modeled infrastructure cost under supplied assumptions' : null,
|
|
307
|
+
winner, tie: eligible ? tie : null, savingsRubExact: savings ? exact(savings) : null, savingsRubDisplay: savings ? display(savings) : null },
|
|
308
|
+
limitations: ['Declared quantities and tariff provenance are user assertions, not verified inventory or live prices.',
|
|
309
|
+
'Capacity is a homogeneous per-node packing bound, not a placement, performance, HA or SLA proof.',
|
|
310
|
+
'Static full-horizon resources only; no tiered, monthly, stopped-resource, discount or heterogeneous-fleet modeling.'],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function parseFlags(argv) {
|
|
315
|
+
let input = null, json = false;
|
|
316
|
+
for (let i = 0; i < argv.length; i++) {
|
|
317
|
+
const arg = argv[i];
|
|
318
|
+
if (arg === '--json') { if (json) fail('flags', 'duplicate --json'); json = true; }
|
|
319
|
+
else if (arg === '--input') { if (input !== null) fail('flags', 'duplicate --input'); input = argv[++i]; if (!input || input.startsWith('--')) fail('flags', '--input requires a path'); }
|
|
320
|
+
else fail('flags', `unknown argument ${JSON.stringify(arg)}`);
|
|
321
|
+
}
|
|
322
|
+
if (input === null) fail('flags', '--input is required');
|
|
323
|
+
return { input, json };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function run(argv, write = s => process.stdout.write(s + '\n'), error = s => process.stderr.write(s + '\n')) {
|
|
327
|
+
try {
|
|
328
|
+
const flags = parseFlags(argv);
|
|
329
|
+
const result = calculate(readInput(flags.input));
|
|
330
|
+
if (flags.json) write(JSON.stringify(result, null, 2));
|
|
331
|
+
else {
|
|
332
|
+
write(`Offline Cloud.ru cost worksheet — ${result.horizonHours} hours, RUB, VAT included`);
|
|
333
|
+
for (const item of result.scenarios) {
|
|
334
|
+
write(`${item.id} (${item.kind}): known subtotal ${item.knownSubtotalRubExact} RUB (${item.knownSubtotalRubDisplay} displayed); capacity ${item.capacity.normalSlots} normal / ${item.capacity.survivingSlots} after ${result.workload.failedNodes} node failure(s): ${item.capacity.feasible ? 'feasible packing bound' : 'INSUFFICIENT'}`);
|
|
335
|
+
for (const [name, category] of Object.entries(item.categories)) for (const line of category.items) write(` ${name}/${line.id}: ${line.formula} = ${line.amountRubExact} RUB; tariff ${line.tariff.url} version ${line.tariff.version} as of ${line.tariff.asOf}, ${line.tariff.pageOrRow}, VAT ${line.tariff.vatBasis}`);
|
|
336
|
+
for (const missing of item.unpriced) write(` unpriced ${missing.category}: ${missing.reason}`);
|
|
337
|
+
}
|
|
338
|
+
write(`Architecture equivalence: ${result.equivalence.status}${result.equivalence.reason ? ` — ${result.equivalence.reason}` : ''}`);
|
|
339
|
+
for (const assumption of result.equivalence.assumptions) write(` assumption: ${assumption}`);
|
|
340
|
+
write(result.comparison.eligible ? (result.comparison.tie ? 'Conditional modeled cost: tie under supplied assumptions' : `Conditional lower modeled infrastructure cost: ${result.comparison.winner}; savings ${result.comparison.savingsRubExact} RUB (${result.comparison.savingsRubDisplay} displayed)`) : `No winner: ${result.comparison.reasons.join('; ')}`);
|
|
341
|
+
for (const limitation of result.limitations) write(`Limit: ${limitation}`);
|
|
342
|
+
write(`Formulas and tariff provenance: use --json or inspect ${flags.input}`);
|
|
343
|
+
}
|
|
344
|
+
return 0;
|
|
345
|
+
} catch (e) { error(`cloudru-hub cost-compare: ${e.message}`); return 1; }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports = { run, calculate, readInput };
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { after, test } from 'node:test';
|
|
8
|
+
|
|
9
|
+
const pkg = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
const cli = join(pkg, 'bin/cloudru-hub.js');
|
|
11
|
+
const example = join(pkg, 'docs/cost-compare.example.json');
|
|
12
|
+
const exampleText = readFileSync(example, 'utf8');
|
|
13
|
+
const source = JSON.parse(exampleText);
|
|
14
|
+
const dirs = [];
|
|
15
|
+
after(() => { for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); });
|
|
16
|
+
function directory() { const dir = mkdtempSync(join(tmpdir(), 'cloudru-cost-test-')); dirs.push(dir); return dir; }
|
|
17
|
+
function invoke(input, flags = [], env = {}) {
|
|
18
|
+
const dir = directory(), path = join(dir, 'input.json');
|
|
19
|
+
writeFileSync(path, JSON.stringify(input));
|
|
20
|
+
const result = spawnSync(process.execPath, [cli, 'cost-compare', '--input', path, '--json', ...flags], {
|
|
21
|
+
cwd: dir, env: { ...process.env, CLOUDRU_VM_BIN: join(dir, 'no-engine'), CLOUDRU_HUB_CONFIG: join(dir, 'no-config'), ...env },
|
|
22
|
+
encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024,
|
|
23
|
+
});
|
|
24
|
+
assert.ifError(result.error);
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
function invokeRaw(raw) {
|
|
28
|
+
const dir = directory(), path = join(dir, 'input.json');
|
|
29
|
+
writeFileSync(path, raw);
|
|
30
|
+
const result = spawnSync(process.execPath, [cli, 'cost-compare', '--input', path, '--json'], {
|
|
31
|
+
cwd: dir, encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024,
|
|
32
|
+
});
|
|
33
|
+
assert.ifError(result.error);
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
function editRaw(from, to) {
|
|
37
|
+
assert.ok(exampleText.includes(from), `missing fixture anchor ${from}`);
|
|
38
|
+
return exampleText.replace(from, to);
|
|
39
|
+
}
|
|
40
|
+
function run(input) {
|
|
41
|
+
const result = invoke(input);
|
|
42
|
+
assert.equal(result.status, 0, result.stderr);
|
|
43
|
+
assert.equal(result.stderr, '');
|
|
44
|
+
return JSON.parse(result.stdout);
|
|
45
|
+
}
|
|
46
|
+
function reject(mutator, pattern) {
|
|
47
|
+
const input = structuredClone(source);
|
|
48
|
+
mutator(input);
|
|
49
|
+
const result = invoke(input);
|
|
50
|
+
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
51
|
+
assert.equal(result.stdout, '', 'invalid input must not emit partial success JSON');
|
|
52
|
+
assert.match(result.stderr, pattern);
|
|
53
|
+
}
|
|
54
|
+
function complete(input) {
|
|
55
|
+
input.equivalence = { status: 'assumed', assumptions: ['Only modeled infrastructure cost is compared; application equivalence is an operator assumption.'] };
|
|
56
|
+
for (const scenario of input.scenarios) {
|
|
57
|
+
scenario.resources.trafficGB = '7.5';
|
|
58
|
+
scenario.categories.traffic = { status: 'priced', items: [{
|
|
59
|
+
id: 'synthetic-traffic', description: 'Test-only invented traffic tariff', quantity: '7.5', unit: 'traffic-GB', rateRub: '0.12',
|
|
60
|
+
tariff: { url: 'https://example.invalid/test-only-rate', version: 'synthetic-test', asOf: '2026-10-21', vatBasis: 'included', pageOrRow: 'test fixture only' },
|
|
61
|
+
}] };
|
|
62
|
+
}
|
|
63
|
+
return input;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test('packaged official-rate example has independent 24h subtotals, capacity and no winner', () => {
|
|
67
|
+
const result = run(source);
|
|
68
|
+
assert.deepEqual(result.scenarios.map(item => item.knownSubtotalRubExact), ['251.6616', '466.1376']);
|
|
69
|
+
assert.deepEqual(result.scenarios.map(item => item.categories.storage.totalRubExact), ['22.8384', '30.4512']);
|
|
70
|
+
assert.deepEqual(result.scenarios.map(item => item.capacity.normalSlots), [6, 6]);
|
|
71
|
+
assert.deepEqual(result.scenarios.map(item => item.capacity.survivingSlots), [4, 4]);
|
|
72
|
+
assert.deepEqual(result.scenarios.map(item => item.capacity.feasible), [true, true]);
|
|
73
|
+
assert.deepEqual(result.scenarios.map(item => item.unpriced.map(entry => entry.category)), [['traffic'], ['traffic']]);
|
|
74
|
+
assert.equal(result.comparison.eligible, false);
|
|
75
|
+
assert.equal(result.comparison.winner, null);
|
|
76
|
+
assert.equal(result.comparison.savingsRubExact, null);
|
|
77
|
+
assert.match(result.scenarios[0].categories.compute.items[0].formula, /24 hours/);
|
|
78
|
+
assert.equal(result.scenarios[1].categories.compute.items[0].tariff.version, '260918');
|
|
79
|
+
assert.match(result.scenarios[0].categories.compute.items[0].formula, /3 instance × 2\.9707 RUB\/instance-hour × 24 hours/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('human output includes modeled formulas, tariff provenance, unknowns and assumptions', () => {
|
|
83
|
+
const dir = directory(), path = join(dir, 'input.json'); writeFileSync(path, JSON.stringify(source));
|
|
84
|
+
const result = spawnSync(process.execPath, [cli, 'cost-compare', '--input', path], { cwd: dir, encoding: 'utf8' });
|
|
85
|
+
assert.equal(result.status, 0, result.stderr);
|
|
86
|
+
assert.match(result.stdout, /3 instance × 2\.9707 RUB\/instance-hour × 24 hours/);
|
|
87
|
+
assert.match(result.stdout, /evolution-compute\.pdf version 261014/);
|
|
88
|
+
assert.match(result.stdout, /unpriced traffic/);
|
|
89
|
+
assert.match(result.stdout, /assumption:/);
|
|
90
|
+
assert.match(result.stdout, /No winner/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('fractional horizon keeps exact sums and rounds display only at output', () => {
|
|
94
|
+
const input = structuredClone(source); input.horizonHours = '1.5';
|
|
95
|
+
const result = run(input);
|
|
96
|
+
assert.deepEqual(result.scenarios.map(item => item.knownSubtotalRubExact), ['15.72885', '29.1336']);
|
|
97
|
+
assert.deepEqual(result.scenarios.map(item => item.categories.storage.totalRubExact), ['1.4274', '1.9032']);
|
|
98
|
+
assert.deepEqual(result.scenarios.map(item => item.knownSubtotalRubDisplay), ['15.73', '29.13']);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('test-only traffic is charged once, then conditional lower modeled cost is identified', () => {
|
|
102
|
+
for (const [horizon, expected, savings] of [
|
|
103
|
+
['24', ['252.5616', '467.0376'], '214.476'],
|
|
104
|
+
['1.5', ['16.62885', '30.0336'], '13.40475'],
|
|
105
|
+
]) {
|
|
106
|
+
const input = complete(structuredClone(source)); input.horizonHours = horizon;
|
|
107
|
+
const result = run(input);
|
|
108
|
+
assert.deepEqual(result.scenarios.map(item => item.knownSubtotalRubExact), expected);
|
|
109
|
+
assert.deepEqual(result.scenarios.map(item => item.categories.traffic.totalRubExact), ['0.9', '0.9']);
|
|
110
|
+
assert.equal(result.comparison.eligible, true);
|
|
111
|
+
assert.equal(result.comparison.winner, 'vm');
|
|
112
|
+
assert.equal(result.comparison.savingsRubExact, savings);
|
|
113
|
+
assert.match(result.comparison.basis, /conditional/);
|
|
114
|
+
assert.deepEqual(result.equivalence.assumptions, input.equivalence.assumptions);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('aggregate CPU cannot conceal per-node packing failure', () => {
|
|
119
|
+
const input = complete(structuredClone(source));
|
|
120
|
+
input.workload.replicas = 4;
|
|
121
|
+
input.workload.cpuPerReplica = '1.5';
|
|
122
|
+
const result = run(input);
|
|
123
|
+
assert.deepEqual(result.scenarios.map(item => item.capacity.normalSlots), [3, 3]);
|
|
124
|
+
assert.deepEqual(result.scenarios.map(item => item.capacity.feasible), [false, false]);
|
|
125
|
+
assert.equal(result.comparison.winner, null);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('one-node failure capacity is distinct from normal capacity', () => {
|
|
129
|
+
const input = complete(structuredClone(source));
|
|
130
|
+
input.workload.replicas = 5;
|
|
131
|
+
input.workload.survivingReplicas = 5;
|
|
132
|
+
const failed = run(input);
|
|
133
|
+
assert.deepEqual(failed.scenarios.map(item => item.capacity.normalSlots), [6, 6]);
|
|
134
|
+
assert.deepEqual(failed.scenarios.map(item => item.capacity.survivingSlots), [4, 4]);
|
|
135
|
+
assert.equal(failed.comparison.winner, null);
|
|
136
|
+
input.workload.survivingReplicas = 4;
|
|
137
|
+
const fit = run(input);
|
|
138
|
+
assert.deepEqual(fit.scenarios.map(item => item.capacity.feasible), [true, true]);
|
|
139
|
+
assert.equal(fit.comparison.winner, 'vm');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('guaranteed CPU share, reservation and exact decimal floor determine per-node slots', () => {
|
|
143
|
+
const input = complete(structuredClone(source));
|
|
144
|
+
for (const scenario of input.scenarios) {
|
|
145
|
+
scenario.nodes.cpu = '4'; scenario.nodes.cpuShare = '0.5'; scenario.nodes.reservedCpu = '0.5';
|
|
146
|
+
}
|
|
147
|
+
input.workload.cpuPerReplica = '0.5';
|
|
148
|
+
const result = run(input);
|
|
149
|
+
assert.deepEqual(result.scenarios.map(item => item.capacity.perNodeCpuSlots), [3, 3]);
|
|
150
|
+
const tenth = complete(structuredClone(source));
|
|
151
|
+
for (const scenario of tenth.scenarios) { scenario.nodes.cpu = '0.3'; scenario.nodes.cpuShare = '1'; }
|
|
152
|
+
tenth.workload.cpuPerReplica = '0.1';
|
|
153
|
+
assert.deepEqual(run(tenth).scenarios.map(item => item.capacity.perNodeCpuSlots), [3, 3]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('a tie is explicit and carries no winner or savings', () => {
|
|
157
|
+
const input = complete(structuredClone(source));
|
|
158
|
+
const vm = input.scenarios[0];
|
|
159
|
+
const k8s = input.scenarios[1];
|
|
160
|
+
// Test-only unit rates: 66 VM hourly units versus 87 K8s hourly units,
|
|
161
|
+
// a 21 × 24 = 504 RUB difference, offset by 7.5 × (67.32 − 0.12).
|
|
162
|
+
vm.categories.compute.items[0].rateRub = '1';
|
|
163
|
+
vm.categories.storage.items[0].rateRub = '1';
|
|
164
|
+
vm.categories.publicIp.items[0].rateRub = '1';
|
|
165
|
+
k8s.categories.compute.items[0].rateRub = '1';
|
|
166
|
+
k8s.categories.controlPlane.items[0].rateRub = '1';
|
|
167
|
+
k8s.categories.storage.items[0].rateRub = '1';
|
|
168
|
+
k8s.categories.publicIp.items[0].rateRub = '1';
|
|
169
|
+
k8s.categories.snat.items[0].rateRub = '1';
|
|
170
|
+
// VM has 66 hourly units; K8s has 87. Add the same 7.5GB traffic to both; tie via VM traffic premium.
|
|
171
|
+
vm.categories.traffic.items[0].rateRub = '67.32';
|
|
172
|
+
k8s.categories.traffic.items[0].rateRub = '0.12';
|
|
173
|
+
const result = run(input);
|
|
174
|
+
assert.deepEqual(result.scenarios.map(item => item.knownSubtotalRubExact), ['2088.9', '2088.9']);
|
|
175
|
+
assert.equal(result.comparison.eligible, true);
|
|
176
|
+
assert.equal(result.comparison.tie, true);
|
|
177
|
+
assert.equal(result.comparison.winner, null);
|
|
178
|
+
assert.equal(result.comparison.savingsRubExact, null);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('unknown equivalence alone suppresses a winner even with complete prices', () => {
|
|
182
|
+
const input = complete(structuredClone(source));
|
|
183
|
+
input.equivalence = source.equivalence;
|
|
184
|
+
const result = run(input);
|
|
185
|
+
assert.deepEqual(result.scenarios.map(item => item.complete), [true, true]);
|
|
186
|
+
assert.equal(result.comparison.winner, null);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('raw flags reject duplicates, missing values, unknown options and positionals', () => {
|
|
190
|
+
const dir = directory(), path = join(dir, 'input.json'); writeFileSync(path, JSON.stringify(source));
|
|
191
|
+
for (const flags of [[], ['--input'], ['--input', path, '--input', path], ['--input', path, '--json', '--json'], ['--input', path, '--rate', '1'], ['--input', path, 'extra']]) {
|
|
192
|
+
const result = spawnSync(process.execPath, [cli, 'cost-compare', ...flags], { cwd: dir, encoding: 'utf8' });
|
|
193
|
+
assert.equal(result.status, 1, JSON.stringify(flags)); assert.equal(result.stdout, ''); assert.match(result.stderr, /cost-compare/);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('input must be a bounded regular file and cannot be a symlink', () => {
|
|
198
|
+
const dir = directory();
|
|
199
|
+
const large = join(dir, 'large.json'); writeFileSync(large, ' '.repeat(128 * 1024 + 1));
|
|
200
|
+
const link = join(dir, 'link.json'); symlinkSync(example, link);
|
|
201
|
+
for (const path of [large, link, '/dev/null']) {
|
|
202
|
+
const result = spawnSync(process.execPath, [cli, 'cost-compare', '--input', path, '--json'], { encoding: 'utf8' });
|
|
203
|
+
assert.equal(result.status, 1, path); assert.equal(result.stdout, ''); assert.match(result.stderr, /cost-compare/);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
for (const [name, raw, pattern] of [
|
|
208
|
+
['top-level duplicate horizon', editRaw('"horizonHours": "24"', '"horizonHours": "8760", "horizonHours": "24"'), /duplicate decoded object member/],
|
|
209
|
+
['nested duplicate CPU declaration', editRaw('"cpu": "2", "ramGB": "4"', '"cpu": "4", "cpu": "2", "ramGB": "4"'), /duplicate decoded object member/],
|
|
210
|
+
['sibling scenario duplicate id', editRaw('"id": "k8s",', '"id": "other", "id": "k8s",'), /duplicate decoded object member/],
|
|
211
|
+
['escaped-equivalent duplicate member', editRaw('"horizonHours": "24"', '"horizonHours": "8760", "horizon\\u0048ours": "24"'), /duplicate decoded object member/],
|
|
212
|
+
['rounded fractional replica token', JSON.stringify(source).replace('"replicas":3', '"replicas":3.0000000000000001'), /numeric tokens must be canonical/],
|
|
213
|
+
['unsafe integer replica token', JSON.stringify(source).replace('"replicas":3', '"replicas":9007199254740993'), /numeric tokens must be canonical/],
|
|
214
|
+
['infinite exponent token', JSON.stringify(source).replace('"replicas":3', '"replicas":1e309'), /numeric tokens must be canonical/],
|
|
215
|
+
['malformed exponent token', JSON.stringify(source).replace('"replicas":3', '"replicas":3e+'), /object member requires/],
|
|
216
|
+
['excessive JSON nesting', `${'['.repeat(65)}0${']'.repeat(65)}`, /nesting exceeds/],
|
|
217
|
+
['raw numeric value for decimal-string horizon', editRaw('"horizonHours": "24"', '"horizonHours": 24'), /horizonHours/],
|
|
218
|
+
]) test(`raw JSON rejects ${name} before accepting a result`, () => {
|
|
219
|
+
const result = invokeRaw(raw);
|
|
220
|
+
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
221
|
+
assert.equal(result.stdout, '');
|
|
222
|
+
assert.match(result.stderr, pattern);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('canonical safe integer tokens and quoted braces/commas remain accepted', () => {
|
|
226
|
+
const input = structuredClone(source);
|
|
227
|
+
input.workload.replicas = 3;
|
|
228
|
+
input.equivalence.reason = 'unknown: text contains "quotes", braces { } and comma, without object members';
|
|
229
|
+
const result = invokeRaw(JSON.stringify(input));
|
|
230
|
+
assert.equal(result.status, 0, result.stderr);
|
|
231
|
+
assert.equal(JSON.parse(result.stdout).workload.replicas, 3);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('CLI path cannot resolve an engine, spawn, access network or require credentials', () => {
|
|
235
|
+
const dir = directory(), preload = join(dir, 'forbid.cjs');
|
|
236
|
+
writeFileSync(preload, `
|
|
237
|
+
const Module = require('module'); const original = Module._load;
|
|
238
|
+
Module._load = function(request, parent, main) {
|
|
239
|
+
if (parent?.filename.endsWith('/src/cli.js') && ['./resolve','./classification','./install','./safe-mcp','./deploy-approval','./k8s-approval','./dialects'].includes(request)) throw Error('legacy engine/auth module load forbidden');
|
|
240
|
+
if (request === 'yaml') throw Error('external dependency load forbidden');
|
|
241
|
+
return original.apply(this, arguments);
|
|
242
|
+
};
|
|
243
|
+
const child = require('child_process'); for (const name of ['spawn','spawnSync','exec','execFile']) child[name] = () => { throw Error('spawn forbidden'); };
|
|
244
|
+
for (const name of ['http','https','net']) { const mod = require(name); for (const key of ['request','get','connect','createConnection']) if (key in mod) mod[key] = () => { throw Error('network forbidden'); }; }
|
|
245
|
+
global.fetch = () => { throw Error('fetch forbidden'); };
|
|
246
|
+
`);
|
|
247
|
+
const input = join(dir, 'input.json'); writeFileSync(input, JSON.stringify(source));
|
|
248
|
+
const result = spawnSync(process.execPath, ['--require', preload, cli, 'cost-compare', '--input', input, '--json'], {
|
|
249
|
+
cwd: dir, env: { PATH: '/nonexistent', CLOUDRU_VM_BIN: '/no-engine', CLOUDRU_HUB_CONFIG: '/no-config' }, encoding: 'utf8', timeout: 5000,
|
|
250
|
+
});
|
|
251
|
+
assert.ifError(result.error); assert.equal(result.status, 0, result.stderr);
|
|
252
|
+
assert.equal(JSON.parse(result.stdout).comparison.winner, null);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
for (const [label, mutate, pattern] of [
|
|
256
|
+
['unknown schema field', x => { x.hidden = true; }, /unknown field/],
|
|
257
|
+
['non-RUB currency', x => { x.currency = 'USD'; }, /currency/],
|
|
258
|
+
['different VAT basis', x => { x.vatBasis = 'excluded'; }, /vatBasis/],
|
|
259
|
+
['scientific rate', x => { x.scenarios[0].categories.compute.items[0].rateRub = '1e3'; }, /rateRub/],
|
|
260
|
+
['monthly unit', x => { x.scenarios[0].categories.compute.items[0].unit = 'instance-month'; }, /unit/],
|
|
261
|
+
['wrong unit category', x => { x.scenarios[0].categories.storage.items[0].unit = 'instance-hour'; }, /unit/],
|
|
262
|
+
['missing tariff version', x => { delete x.scenarios[0].categories.compute.items[0].tariff.version; }, /version/],
|
|
263
|
+
['HTTP tariff URL', x => { x.scenarios[0].categories.compute.items[0].tariff.url = 'http://example.invalid'; }, /url/],
|
|
264
|
+
['wrong tariff VAT basis', x => { x.scenarios[0].categories.compute.items[0].tariff.vatBasis = 'excluded'; }, /vatBasis/],
|
|
265
|
+
['unbound worker quantity', x => { x.scenarios[0].categories.compute.items[0].quantity = '2'; }, /quantities must sum/],
|
|
266
|
+
['unbound storage quantity', x => { x.scenarios[0].categories.storage.items[0].quantity = '59'; }, /quantities must sum/],
|
|
267
|
+
['unbound IP quantity', x => { x.scenarios[0].categories.publicIp.items[0].quantity = '2'; }, /quantities must sum/],
|
|
268
|
+
['noninteger service count', x => { x.scenarios[0].categories.compute.items[0].quantity = '3.5'; }, /integer service count/],
|
|
269
|
+
['missing K8s control plane', x => { x.scenarios[1].controlPlaneCount = 0; }, /controlPlaneCount/],
|
|
270
|
+
['unpriced compute with zero declaration', x => { x.scenarios[0].nodes.count = 0; }, /nodes.count/],
|
|
271
|
+
['not-used positive storage', x => { x.scenarios[0].categories.storage = { status: 'not-used', reason: 'omitted' }; }, /not-used requires/],
|
|
272
|
+
['unknown traffic silently zero', x => { x.scenarios[0].resources.trafficGB = '0'; }, /declared zero quantity/],
|
|
273
|
+
['priced unknown traffic', x => { x.scenarios[0].categories.traffic = { status: 'priced', items: [] }; }, /priced requires/],
|
|
274
|
+
['oversubscribed reservations', x => { x.scenarios[0].nodes.reservedCpu = '3'; }, /reservations exceed/],
|
|
275
|
+
['declared node failure exceeds topology', x => { x.workload.failedNodes = 4; }, /failedNodes/],
|
|
276
|
+
['bad CPU share', x => { x.scenarios[0].nodes.cpuShare = '1.1'; }, /cpuShare/],
|
|
277
|
+
['survival above replicas', x => { x.workload.survivingReplicas = 4; }, /survivingReplicas/],
|
|
278
|
+
['duplicate kinds', x => { x.scenarios[1].kind = 'vm'; x.scenarios[1].controlPlaneCount = 0; x.scenarios[1].categories.controlPlane = { status: 'not-used', reason: 'none' }; }, /one scenario of each kind/],
|
|
279
|
+
['missing equivalence assumption', x => { x.equivalence = { status: 'assumed', assumptions: [] }; }, /assumptions/],
|
|
280
|
+
]) test(`rejects ${label} without partial output`, () => reject(mutate, pattern));
|