@dzhechkov/cloudru-hub 0.1.14 → 0.1.16

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.
@@ -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 };
package/src/install.js CHANGED
@@ -45,7 +45,7 @@ const TIERS = {
45
45
  const DEGRADED_REASONS = {
46
46
  openclaude: 'all three legs exist upstream (.mcp.json, PreToolUse deny, .openclaude/skills) but NOTHING has been probed live — degraded-until-proven (ADR-005).',
47
47
  cursor: 'veto hooks documented only; R3 broken by the .mdc adapter, R4 truncation history (~40 tools) unresolved (ADR-005).',
48
- windsurf: 'R4 FAILS as-is: Cascade budget 100 tools < 144 — needs the engine tool-profile before any delivery (ADR-005).',
48
+ windsurf: 'R4 FAILS as-is: Cascade budget 100 tools < 146 — needs the engine tool-profile before any delivery (ADR-005).',
49
49
  copilot: 'only Copilot CLI is a candidate; the coding agent fails R2 by construction ("will not ask for your approval" + hook timeout fail-open) (ADR-005).',
50
50
  };
51
51
 
@@ -10,6 +10,7 @@ const { isDeepStrictEqual } = require('util');
10
10
  const YAML = require('yaml');
11
11
  const { readCredentials, UUID } = require('./credentials');
12
12
  const { approvalRoot } = require('./deploy-approval');
13
+ const { validateImageManifest } = require('./k8s-image-manifest');
13
14
 
14
15
  const APPROVAL_ID = /^k8s-[0-9a-f]{12}$/;
15
16
  const DNS_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
@@ -49,7 +50,9 @@ function privateJson(file) {
49
50
  return JSON.parse(fs.readFileSync(file, 'utf8'));
50
51
  }
51
52
 
52
- function validateManifest(raw, namespace) {
53
+ function validateManifest(raw, namespace, profile = 'snake') {
54
+ if (profile === 'web-image') return validateImageManifest(raw, namespace);
55
+ if (profile !== 'snake') throw new Error('unsupported Kubernetes profile');
53
56
  if (typeof raw !== 'string' || Buffer.byteLength(raw) < 1 || Buffer.byteLength(raw) > MAX_MANIFEST) {
54
57
  throw new Error('manifest must be non-empty and at most 64 KiB');
55
58
  }
@@ -143,7 +146,7 @@ function approvalFile(id, env = process.env) {
143
146
  return path.join(approvalRoot(env), `${id}.json`);
144
147
  }
145
148
 
146
- function issueK8sApproval(clusterId, namespace, manifestFile, reviewedSha256, env = process.env, now = Date.now()) {
149
+ function issueK8sApproval(clusterId, namespace, manifestFile, reviewedSha256, env = process.env, now = Date.now(), profile = 'snake') {
147
150
  if (!UUID.test(clusterId || '')) throw new Error('cluster_id must be a UUID');
148
151
  if (!DIGEST.test(reviewedSha256 || '')) throw new Error('reviewed manifest SHA-256 is required');
149
152
  const st = fs.lstatSync(manifestFile);
@@ -151,17 +154,17 @@ function issueK8sApproval(clusterId, namespace, manifestFile, reviewedSha256, en
151
154
  throw new Error('manifest source must be a bounded regular file');
152
155
  }
153
156
  const manifest = fs.readFileSync(manifestFile, 'utf8');
154
- const details = validateManifest(manifest, namespace);
157
+ const details = validateManifest(manifest, namespace, profile);
155
158
  const digest = sha256(manifest);
156
159
  if (digest !== reviewedSha256) throw new Error('manifest digest differs from reviewed SHA-256');
157
160
  const credentials = readCredentials(env);
158
161
  const root = approvalRoot(env);
159
162
  privateDir(root);
160
163
  const id = `k8s-${crypto.randomBytes(6).toString('hex')}`;
161
- const record = { id, projectId: credentials.projectId, clusterId, namespace, manifest, digest,
164
+ const record = { id, projectId: credentials.projectId, clusterId, namespace, manifest, digest, profile,
162
165
  app: details.app, issuedAt: now, expiresAt: now + 10 * 60_000 };
163
166
  fs.writeFileSync(approvalFile(id, env), JSON.stringify(record) + '\n', { flag: 'wx', mode: 0o600 });
164
- return { approvalId: id, projectId: record.projectId, clusterId, namespace, manifestSha256: digest,
167
+ return { approvalId: id, projectId: record.projectId, clusterId, namespace, manifestSha256: digest, profile,
165
168
  resources: details.resources, image: details.image, expiresAt: record.expiresAt,
166
169
  estimateScope: 'existing cluster only; no cluster, worker, NAT or public load balancer is created' };
167
170
  }
@@ -172,7 +175,7 @@ function consumeK8sApproval(id, env = process.env, now = Date.now()) {
172
175
  const credentials = readCredentials(env);
173
176
  if (a.id !== id || a.projectId !== credentials.projectId || !UUID.test(a.clusterId || '') ||
174
177
  !Number.isFinite(a.expiresAt) || a.expiresAt <= now || !DIGEST.test(a.digest || '') ||
175
- sha256(a.manifest) !== a.digest || validateManifest(a.manifest, a.namespace).app !== a.app) {
178
+ sha256(a.manifest) !== a.digest || validateManifest(a.manifest, a.namespace, a.profile).app !== a.app) {
176
179
  throw new Error('Kubernetes approval does not match manifest, project or time');
177
180
  }
178
181
  const spent = path.join(approvalRoot(env), `${id}.used-${crypto.randomBytes(8).toString('hex')}.json`);
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ // A narrow generic web workload: one digest-pinned image, one Deployment and
4
+ // one internal Service. The reviewed digest is the security boundary; source
5
+ // build and image registry publication are separate operator steps.
6
+ const YAML = require('yaml');
7
+ const { isDeepStrictEqual } = require('util');
8
+ const DNS = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
9
+ const SERVICE_NAME = /^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
10
+ const IMAGE = /^[a-z0-9][a-z0-9._:/-]*@sha256:[a-f0-9]{64}$/;
11
+ const isRecord = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
12
+ const fields = (obj, allowed, label) => {
13
+ if (!isRecord(obj) || Object.keys(obj).some((key) => !allowed.includes(key))) throw new Error(`${label} contains unsupported fields`);
14
+ };
15
+ const equal = (actual, expected, label) => {
16
+ if (!isDeepStrictEqual(actual, expected)) throw new Error(`${label} differs from the reviewed web-image profile`);
17
+ };
18
+
19
+ function validateImageManifest(raw, namespace) {
20
+ if (typeof raw !== 'string' || Buffer.byteLength(raw) < 1 || Buffer.byteLength(raw) > 64 * 1024) throw new Error('manifest must be 1..65536 bytes');
21
+ if (typeof namespace !== 'string' || !DNS.test(namespace) || namespace.length > 63 || namespace === 'default' || namespace === 'kube-system') {
22
+ throw new Error('a dedicated DNS namespace is required');
23
+ }
24
+ const docs = YAML.parseAllDocuments(raw, { uniqueKeys: true, maxAliasCount: 0 });
25
+ if (docs.length !== 2 || docs.some((doc) => doc.errors.length)) throw new Error('web-image manifest needs exactly Deployment and Service');
26
+ const resources = docs.map((doc) => doc.toJS({ maxAliasCount: 0 }));
27
+ const deployment = resources.find((r) => r?.kind === 'Deployment');
28
+ const service = resources.find((r) => r?.kind === 'Service');
29
+ if (!deployment || !service || deployment === service) throw new Error('web-image manifest needs Deployment and Service');
30
+ for (const [r, kind, api] of [[deployment, 'Deployment', 'apps/v1'], [service, 'Service', 'v1']]) {
31
+ fields(r, ['apiVersion', 'kind', 'metadata', 'spec'], kind);
32
+ if (r.apiVersion !== api || r.kind !== kind) throw new Error(`invalid ${kind} API`);
33
+ fields(r.metadata, ['name', 'namespace'], `${kind} metadata`);
34
+ if (!SERVICE_NAME.test(r.metadata.name || '') || r.metadata.namespace !== namespace) throw new Error(`${kind} must use the reviewed namespace and a Service-compatible name`);
35
+ }
36
+ const app = deployment.metadata.name;
37
+ if (service.metadata.name !== app) throw new Error('Service name must match Deployment');
38
+ const spec = deployment.spec;
39
+ fields(spec, ['replicas', 'selector', 'template'], 'Deployment spec');
40
+ if (spec.replicas !== 1) throw new Error('only one replica is allowed');
41
+ fields(spec.selector, ['matchLabels'], 'selector');
42
+ equal(spec.selector.matchLabels, { app }, 'selector');
43
+ fields(spec.template, ['metadata', 'spec'], 'pod template');
44
+ fields(spec.template.metadata, ['labels'], 'pod metadata');
45
+ equal(spec.template.metadata.labels, { app }, 'pod labels');
46
+ const pod = spec.template.spec;
47
+ fields(pod, ['automountServiceAccountToken', 'securityContext', 'containers'], 'pod spec');
48
+ if (pod.automountServiceAccountToken !== false) throw new Error('service account token must be disabled');
49
+ equal(pod.securityContext, { runAsNonRoot: true }, 'pod security context');
50
+ if (!Array.isArray(pod.containers) || pod.containers.length !== 1) throw new Error('exactly one container is required');
51
+ const container = pod.containers[0];
52
+ fields(container, ['name', 'image', 'ports', 'readinessProbe', 'resources', 'securityContext'], 'container');
53
+ if (container.name !== app || !IMAGE.test(container.image || '') || container.image.includes('://') || container.image.includes('//')) throw new Error('container needs a digest-pinned OCI image');
54
+ equal(container.ports, [{ containerPort: 8080 }], 'container ports');
55
+ equal(container.readinessProbe, { httpGet: { path: '/health', port: 8080 } }, 'readiness probe');
56
+ equal(container.securityContext, { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true,
57
+ runAsUser: 10001, runAsGroup: 10001, capabilities: { drop: ['ALL'] } }, 'container security context');
58
+ const requests = container.resources?.requests;
59
+ const limits = container.resources?.limits;
60
+ fields(container.resources, ['requests', 'limits'], 'resources');
61
+ fields(requests, ['cpu', 'memory'], 'requests');
62
+ fields(limits, ['cpu', 'memory'], 'limits');
63
+ const cpu = (v, max) => typeof v === 'string' && /^[1-9][0-9]{0,3}m$/.test(v) && Number.parseInt(v, 10) <= max;
64
+ const mem = (v, max) => typeof v === 'string' && /^[1-9][0-9]{0,3}Mi$/.test(v) && Number.parseInt(v, 10) <= max;
65
+ if (!cpu(requests.cpu, 500) || !cpu(limits.cpu, 1000) || !mem(requests.memory, 512) || !mem(limits.memory, 1024) ||
66
+ Number.parseInt(requests.cpu, 10) > Number.parseInt(limits.cpu, 10) ||
67
+ Number.parseInt(requests.memory, 10) > Number.parseInt(limits.memory, 10)) throw new Error('resources exceed bounded web-image limits');
68
+ fields(service.spec, ['type', 'selector', 'ports'], 'Service spec');
69
+ if (service.spec.type !== 'ClusterIP') throw new Error('Service must be internal ClusterIP');
70
+ equal(service.spec.selector, { app }, 'Service selector');
71
+ equal(service.spec.ports, [{ port: 80, targetPort: 8080 }], 'Service ports');
72
+ return { app, image: container.image, resources: [{ kind: 'Deployment', name: app }, { kind: 'Service', name: app }] };
73
+ }
74
+
75
+ module.exports = { validateImageManifest };
package/src/safe-mcp.js CHANGED
@@ -6,13 +6,18 @@ const { spawn } = require('child_process');
6
6
  const { loadClassification } = require('./classification');
7
7
  const { resolveEngine } = require('./resolve');
8
8
  const { selectedToolNames } = require('./toolsets');
9
- const { readCredentials, readOptionalCredentials } = require('./credentials');
9
+ const { readCredentials, readOptionalCredentials, UUID } = require('./credentials');
10
10
  const { consumeApproval, recordDeployedVM, approvedVM } = require('./deploy-approval');
11
11
  const { consumeK8sApproval, k8sSession, verificationArgs } = require('./k8s-approval');
12
12
 
13
13
  const MAX_LINE = 1024 * 1024;
14
14
  const idKey = (id) => JSON.stringify(id);
15
15
  const errorResponse = (id, message, code = -32000) => ({ jsonrpc: '2.0', id, error: { code, message } });
16
+ const uncertainDeploy = (id, approval, reason) => ({ jsonrpc: '2.0', id,
17
+ result: { isError: true, content: [{ type: 'text', text: JSON.stringify({
18
+ status: 'recovery_required', plan_id: approval.planId, verdict: 'unknown',
19
+ reason, next: 'Call deploy_reconcile with this plan_id; inspect live inventory before any new plan or mutation.',
20
+ }) }] } });
16
21
 
17
22
  function redactDeploySecrets(value) {
18
23
  if (Array.isArray(value)) return value.map(redactDeploySecrets);
@@ -81,8 +86,10 @@ function runSafeMcp(options = {}) {
81
86
  if (stopped) return;
82
87
  stopped = true;
83
88
  stderr.write(`cloudru-hub mcp-safe: ${reason}\n`);
84
- for (const key of pending.keys()) {
85
- emit(errorResponse(JSON.parse(key), 'Cloud.ru engine connection closed'));
89
+ for (const [key, request] of pending) {
90
+ const id = JSON.parse(key);
91
+ emit(request.approval ? uncertainDeploy(id, request.approval, 'Cloud.ru engine connection closed during deploy_apply') :
92
+ errorResponse(id, 'Cloud.ru engine connection closed'));
86
93
  }
87
94
  pending.clear();
88
95
  stdin.destroy();
@@ -210,10 +217,10 @@ function runSafeMcp(options = {}) {
210
217
  tool && typeof tool.name === 'string' && selected.has(tool.name)).map((tool) => {
211
218
  if (!k8sProfile) return tool;
212
219
  if (tool.name === 'k8s_apply') return { ...tool,
213
- description: 'Apply one locally reviewed Snake manifest to one existing cluster. Pass only the local approval ID.',
220
+ description: 'Apply one locally reviewed bounded Snake or web-image manifest to one existing cluster. Pass only the local approval ID.',
214
221
  inputSchema: { type: 'object', properties: { approval_id: { type: 'string' } }, required: ['approval_id'], additionalProperties: false } };
215
222
  if (tool.name === 'k8s_kubectl') return { ...tool,
216
- description: 'Read only pods, deployment, service or rollout status for the previously approved Snake workload.',
223
+ description: 'Read only pods, deployment, service or rollout status for the previously approved workload.',
217
224
  inputSchema: { type: 'object', properties: { approval_id: { type: 'string' },
218
225
  query: { type: 'string', enum: ['pods', 'deployment', 'service', 'rollout'] } },
219
226
  required: ['approval_id', 'query'], additionalProperties: false } };
@@ -226,7 +233,11 @@ function runSafeMcp(options = {}) {
226
233
  if (method === 'initialize' && message.result && message.result.capabilities) {
227
234
  message.result = { ...message.result, capabilities: { tools: message.result.capabilities.tools || {} } };
228
235
  }
229
- if (approval && !message.error) {
236
+ if (approval && message.error) {
237
+ emit(uncertainDeploy(message.id, approval, 'Cloud.ru engine returned an error after approval was consumed'));
238
+ closeIfDone(); return;
239
+ }
240
+ if (approval) {
230
241
  // The engine returns an MCP text result containing its JSON deploy result.
231
242
  // Keep the model from seeing a generated login password: vm_exec uses the
232
243
  // engine-managed SSH key. Ambiguous responses require inspection.
@@ -234,28 +245,32 @@ function runSafeMcp(options = {}) {
234
245
  let deploy;
235
246
  try { deploy = JSON.parse(item?.text || ''); }
236
247
  catch {
237
- message.result = { isError: true, content: [{ type: 'text', text: 'deploy_apply returned an unreadable outcome; inspect the live VM inventory before retrying' }] };
238
- emit(message); closeIfDone(); return;
248
+ emit(uncertainDeploy(message.id, approval, 'deploy_apply returned an unreadable outcome'));
249
+ closeIfDone(); return;
239
250
  }
240
251
  if (!deploy || typeof deploy !== 'object' || Array.isArray(deploy)) {
241
- message.result = { isError: true, content: [{ type: 'text', text: 'deploy_apply returned an invalid outcome; inspect the live VM inventory before retrying' }] };
242
- emit(message); closeIfDone(); return;
252
+ emit(uncertainDeploy(message.id, approval, 'deploy_apply returned an invalid outcome'));
253
+ closeIfDone(); return;
243
254
  }
244
255
  const safe = redactDeploySecrets(deploy);
245
- if (!message.result?.isError && approval.siteMode && deploy.status === 'running' && deploy.http_verification?.verified === true) {
256
+ safe.operation = { plan_id: approval.planId, verdict: 'unknown', observed_at: new Date().toISOString() };
257
+ if (!message.result?.isError && approval.siteMode && deploy.status === 'running' && deploy.http_verification?.verified === true &&
258
+ UUID.test(deploy.vm_id || '')) {
246
259
  // Static-site delivery is verified over HTTP. It does not imply SSH is
247
260
  // available, so never create a vm_exec session for this VM.
261
+ safe.operation.verdict = 'http_verified';
248
262
  } else if (!message.result?.isError && !approval.siteMode && deploy.status === 'running') {
249
263
  try {
250
264
  const session = recordDeployedVM(approval, deploy, options.env || process.env);
251
265
  safe.approved_vm_session_expires_at = new Date(session.expiresAt).toISOString();
266
+ safe.operation.verdict = 'vm_running';
252
267
  } catch (error) {
253
268
  message.result.isError = true;
254
269
  safe.recovery = `VM may exist but the approved execution session was not recorded: ${error.message}. Inspect VM inventory before retrying.`;
255
270
  }
256
271
  } else {
257
272
  message.result.isError = true;
258
- safe.recovery = 'A VM may exist. Inspect the returned VM UUID and live inventory before retrying; a fresh plan and approval may be needed.';
273
+ safe.recovery = 'A VM may exist. Call deploy_reconcile with the plan_id. Inspect live inventory before retrying; a fresh plan and approval may be needed.';
259
274
  }
260
275
  item.text = JSON.stringify(safe);
261
276
  }
package/src/toolsets.js CHANGED
@@ -15,7 +15,7 @@ const GROUPS = Object.freeze({
15
15
  // A separate, opt-in workflow. Mutators pass the proxy's local plan/VM guard;
16
16
  // combining this with a read-only group would blur that boundary.
17
17
  const VM_DEPLOY = Object.freeze([
18
- 'deploy_plan', 'deploy_apply', 'vm_exec', 'vm_list', 'vm_get',
18
+ 'deploy_plan', 'deploy_preflight', 'deploy_apply', 'deploy_reconcile', 'vm_exec', 'vm_list', 'vm_get',
19
19
  'list_zones', 'list_images', 'list_flavors', 'list_disk_types',
20
20
  'list_subnets', 'list_security_groups', 'status',
21
21
  ]);
File without changes
@@ -16,15 +16,15 @@ test('every snapshot tool is classified, and nothing extra', () => {
16
16
  const snapNames = snap.tools.map((t) => t.name).sort();
17
17
  const clsNames = Object.keys(cls.tools).sort();
18
18
  assert.deepEqual(clsNames, snapNames);
19
- assert.equal(snapNames.length, 144);
19
+ assert.equal(snapNames.length, 146);
20
20
  });
21
21
 
22
- test('counts are the reviewed golden split: 64 ask / 2 deny / 78 allow', () => {
23
- assert.deepEqual(cls.counts, { ask: 64, deny: 2, allow: 78 });
22
+ test('counts are the reviewed golden split: 64 ask / 2 deny / 80 allow', () => {
23
+ assert.deepEqual(cls.counts, { ask: 64, deny: 2, allow: 80 });
24
24
  const by = namesByPermission(cls);
25
25
  assert.equal(by.ask.length, 64);
26
26
  assert.equal(by.deny.length, 2);
27
- assert.equal(by.allow.length, 78);
27
+ assert.equal(by.allow.length, 80);
28
28
  });
29
29
 
30
30
  // The imported engine now makes status observational. The Go regression test
@@ -121,7 +121,7 @@ test('permissionRules expands every name in BOTH prefix spellings (unverified no
121
121
  assert.equal(SERVER_PREFIXES.length, 2);
122
122
  assert.equal(rules.ask.length, 64 * 2);
123
123
  assert.equal(rules.deny.length, 2 * 2);
124
- assert.equal(rules.allow.length, 78 * 2);
124
+ assert.equal(rules.allow.length, 80 * 2);
125
125
  assert.ok(rules.ask.includes('mcp__cloudru-vm__deploy'));
126
126
  assert.ok(rules.ask.includes('mcp__cloudru_vm__deploy'));
127
127
  assert.ok(rules.deny.includes('mcp__cloudru-vm__secret_value'));