@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.
- package/.dz-manifest.json +43 -19
- package/CHANGELOG.md +32 -0
- package/README.md +71 -15
- package/bin/cloudru-hub.js +0 -0
- package/data/pointer-section.md +1 -1
- package/data/tools-classification.json +13 -3
- package/data/tools-list-snapshot.json +12 -4
- package/docs/COST-COMPARE.md +21 -0
- package/docs/HERMES-CODEX.md +4 -4
- package/docs/LOCAL-TESTING.md +7 -7
- package/docs/WEB-IMAGE.md +57 -0
- package/docs/cost-compare.example.json +84 -0
- package/package.json +9 -9
- package/sbom.json +78 -18
- package/src/cli.js +22 -9
- package/src/cost-compare.js +348 -0
- package/src/install.js +1 -1
- package/src/k8s-approval.js +9 -6
- package/src/k8s-image-manifest.js +75 -0
- package/src/safe-mcp.js +27 -12
- package/src/toolsets.js +1 -1
- package/templates/.claude/hooks/cloudru-ssh-guard.cjs +0 -0
- package/test/classification.test.mjs +5 -5
- package/test/cost-compare.test.mjs +280 -0
- package/test/deploy-approval.test.mjs +12 -0
- package/test/k8s-approval.test.mjs +40 -0
- package/test/resolve.test.mjs +1 -1
- package/test/safe-mcp.test.mjs +20 -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 };
|
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 <
|
|
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
|
|
package/src/k8s-approval.js
CHANGED
|
@@ -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
|
|
85
|
-
|
|
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
|
|
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 &&
|
|
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.
|
|
238
|
-
|
|
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.
|
|
242
|
-
|
|
252
|
+
emit(uncertainDeploy(message.id, approval, 'deploy_apply returned an invalid outcome'));
|
|
253
|
+
closeIfDone(); return;
|
|
243
254
|
}
|
|
244
255
|
const safe = redactDeploySecrets(deploy);
|
|
245
|
-
|
|
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.
|
|
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,
|
|
19
|
+
assert.equal(snapNames.length, 146);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
test('counts are the reviewed golden split: 64 ask / 2 deny /
|
|
23
|
-
assert.deepEqual(cls.counts, { ask: 64, deny: 2, allow:
|
|
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,
|
|
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,
|
|
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'));
|