@runonflux/flux-cloud-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +178 -0
- package/dist/chain.d.ts +71 -0
- package/dist/chain.js +144 -0
- package/dist/chain.js.map +1 -0
- package/dist/config.d.ts +28 -0
- package/dist/config.js +36 -0
- package/dist/config.js.map +1 -0
- package/dist/deploy.d.ts +111 -0
- package/dist/deploy.js +216 -0
- package/dist/deploy.js.map +1 -0
- package/dist/docs.d.ts +5 -0
- package/dist/docs.js +164 -0
- package/dist/docs.js.map +1 -0
- package/dist/enterprise.d.ts +29 -0
- package/dist/enterprise.js +45 -0
- package/dist/enterprise.js.map +1 -0
- package/dist/fluxapi.d.ts +71 -0
- package/dist/fluxapi.js +165 -0
- package/dist/fluxapi.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +44 -0
- package/dist/keys.js +79 -0
- package/dist/keys.js.map +1 -0
- package/dist/pricing.d.ts +93 -0
- package/dist/pricing.js +192 -0
- package/dist/pricing.js.map +1 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +877 -0
- package/dist/server.js.map +1 -0
- package/dist/spec.d.ts +144 -0
- package/dist/spec.js +312 -0
- package/dist/spec.js.map +1 -0
- package/package.json +69 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Flux Cloud MCP server: tools, resources and prompts.
|
|
3
|
+
*
|
|
4
|
+
* Every price shown to an agent is the Flux Cloud USD price. The consensus
|
|
5
|
+
* minimum is never surfaced as a quote.
|
|
6
|
+
*/
|
|
7
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { toFlux } from './chain.js';
|
|
10
|
+
import { api, appLocations, execute, explorer, plan, publishedSpec, waitForApp, walletFromConfig, } from './deploy.js';
|
|
11
|
+
import { GOTCHAS, OVERVIEW, PRICING, SPEC_FORMAT } from './docs.js';
|
|
12
|
+
import { FluxClient, instanceEndpoint } from './fluxapi.js';
|
|
13
|
+
import { currentSession, generateIdentity, identityFromWif } from './keys.js';
|
|
14
|
+
import { DEFAULT_USD_RATES, estimateQuote, fetchFluxUsdRate, fetchUsdRates, quoteFromNetwork, } from './pricing.js';
|
|
15
|
+
import { BLOCKS_PER_MONTH, buildSpecification, componentsOf, durabilityWarnings, expireOf, formatSpecification, normalizePublished, validateSpecification, } from './spec.js';
|
|
16
|
+
export const SERVER_NAME = 'flux-cloud';
|
|
17
|
+
export const SERVER_VERSION = '0.1.0';
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Schemas shared by several tools
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const ComponentSchema = z.object({
|
|
22
|
+
name: z.string().max(63).optional().describe('Component name. Defaults to the app name.'),
|
|
23
|
+
description: z.string().max(256).optional(),
|
|
24
|
+
repotag: z.string().describe('Docker image with an explicit tag, e.g. "nginx:1.27-alpine".'),
|
|
25
|
+
ports: z.array(z.number().int()).default([]).describe('Public ports. Prefer 31000-39999.'),
|
|
26
|
+
containerPorts: z
|
|
27
|
+
.array(z.number().int())
|
|
28
|
+
.default([])
|
|
29
|
+
.describe('Ports the container listens on, parallel to ports.'),
|
|
30
|
+
domains: z
|
|
31
|
+
.array(z.string())
|
|
32
|
+
.default([])
|
|
33
|
+
.describe('Custom domain per port, "" for none. Parallel to ports.'),
|
|
34
|
+
environmentParameters: z.array(z.string()).default([]).describe('"KEY=value" strings, max 20.'),
|
|
35
|
+
commands: z.array(z.string()).default([]).describe('Container Cmd, max 20 strings.'),
|
|
36
|
+
containerData: z
|
|
37
|
+
.string()
|
|
38
|
+
.describe('Persisted path, e.g. "r:/data" (r: = replicated across instances).'),
|
|
39
|
+
cpu: z.number().describe('Cores, 0.1 to 15 in 0.1 steps.'),
|
|
40
|
+
ram: z.number().int().describe('MB, 100 to 59000 in steps of 100.'),
|
|
41
|
+
hdd: z.number().int().describe('GB, 1 to 820.'),
|
|
42
|
+
repoauth: z.string().default('').describe('"user:token" for a private registry, else "".'),
|
|
43
|
+
});
|
|
44
|
+
const SpecSchema = z.object({
|
|
45
|
+
version: z.literal(8).default(8),
|
|
46
|
+
name: z.string().min(1).max(63),
|
|
47
|
+
description: z.string().min(1).max(256),
|
|
48
|
+
owner: z
|
|
49
|
+
.string()
|
|
50
|
+
.optional()
|
|
51
|
+
.describe('Flux ID. Defaults to the configured FLUX_ID_PRIVATE_KEY identity.'),
|
|
52
|
+
compose: z.array(ComponentSchema).min(0).max(10),
|
|
53
|
+
instances: z.number().int().min(1).max(100).default(3),
|
|
54
|
+
contacts: z.array(z.string()).default([]),
|
|
55
|
+
geolocation: z
|
|
56
|
+
.array(z.string())
|
|
57
|
+
.default([])
|
|
58
|
+
.describe('e.g. ["acEU"] allow Europe, ["a!cAS"] deny Asia.'),
|
|
59
|
+
expire: z
|
|
60
|
+
.number()
|
|
61
|
+
.int()
|
|
62
|
+
.min(1)
|
|
63
|
+
.max(1056000)
|
|
64
|
+
.default(BLOCKS_PER_MONTH)
|
|
65
|
+
.describe('Term in blocks; 88000 = 1 month.'),
|
|
66
|
+
nodes: z.array(z.string()).default([]),
|
|
67
|
+
staticip: z.boolean().default(false),
|
|
68
|
+
enterprise: z.string().default(''),
|
|
69
|
+
});
|
|
70
|
+
const SimpleComponentSchema = z.object({
|
|
71
|
+
name: z.string().max(63).optional(),
|
|
72
|
+
description: z.string().max(256).optional(),
|
|
73
|
+
image: z.string().describe('Docker image with an explicit tag.'),
|
|
74
|
+
ports: z
|
|
75
|
+
.array(z.object({
|
|
76
|
+
containerPort: z.number().int().min(1).max(65535),
|
|
77
|
+
port: z
|
|
78
|
+
.number()
|
|
79
|
+
.int()
|
|
80
|
+
.min(1)
|
|
81
|
+
.max(65535)
|
|
82
|
+
.optional()
|
|
83
|
+
.describe('Public port; auto-picked from 31000-39999 when omitted.'),
|
|
84
|
+
domain: z.string().optional(),
|
|
85
|
+
}))
|
|
86
|
+
.optional(),
|
|
87
|
+
env: z.union([z.record(z.string(), z.string()), z.array(z.string())]).optional(),
|
|
88
|
+
commands: z.array(z.string()).optional(),
|
|
89
|
+
dataPath: z
|
|
90
|
+
.string()
|
|
91
|
+
.optional()
|
|
92
|
+
.describe('Path inside the container to persist. Default "/data".'),
|
|
93
|
+
replicateData: z
|
|
94
|
+
.boolean()
|
|
95
|
+
.optional()
|
|
96
|
+
.describe('Replicate the data path across instances. Default true.'),
|
|
97
|
+
cpu: z.number().describe('Cores, 0.1 to 15 in 0.1 steps.'),
|
|
98
|
+
ram: z.number().int().describe('MB, multiple of 100.'),
|
|
99
|
+
hdd: z.number().int().describe('GB.'),
|
|
100
|
+
repoauth: z.string().optional(),
|
|
101
|
+
});
|
|
102
|
+
const SimpleAppSchema = z.object({
|
|
103
|
+
name: z
|
|
104
|
+
.string()
|
|
105
|
+
.min(1)
|
|
106
|
+
.max(63)
|
|
107
|
+
.describe('Unique app name: letters, digits, inner hyphens. Not starting with "flux" or "zel".'),
|
|
108
|
+
description: z.string().max(256).optional(),
|
|
109
|
+
components: z.array(SimpleComponentSchema).min(1).max(10),
|
|
110
|
+
instances: z
|
|
111
|
+
.number()
|
|
112
|
+
.int()
|
|
113
|
+
.min(1)
|
|
114
|
+
.max(100)
|
|
115
|
+
.optional()
|
|
116
|
+
.describe('Copies on distinct nodes. Default 3.'),
|
|
117
|
+
months: z
|
|
118
|
+
.number()
|
|
119
|
+
.positive()
|
|
120
|
+
.max(12)
|
|
121
|
+
.optional()
|
|
122
|
+
.describe('Term in network months (88000 blocks each). Default 1.'),
|
|
123
|
+
expireBlocks: z
|
|
124
|
+
.number()
|
|
125
|
+
.int()
|
|
126
|
+
.min(1)
|
|
127
|
+
.max(1056000)
|
|
128
|
+
.optional()
|
|
129
|
+
.describe('Exact term in blocks; overrides months.'),
|
|
130
|
+
contacts: z.array(z.string()).max(5).optional(),
|
|
131
|
+
geolocation: z.array(z.string()).max(10).optional(),
|
|
132
|
+
nodes: z.array(z.string()).optional(),
|
|
133
|
+
staticip: z.boolean().optional(),
|
|
134
|
+
});
|
|
135
|
+
function ok(value) {
|
|
136
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
137
|
+
}
|
|
138
|
+
function fail(error) {
|
|
139
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: 'text', text: JSON.stringify({ error: message }, null, 2) }],
|
|
142
|
+
isError: true,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function ownerOf(config, explicit) {
|
|
146
|
+
if (explicit)
|
|
147
|
+
return explicit;
|
|
148
|
+
if (!config.ownerWif) {
|
|
149
|
+
throw new Error('No owner: set FLUX_ID_PRIVATE_KEY or pass an explicit owner.');
|
|
150
|
+
}
|
|
151
|
+
return identityFromWif(config.ownerWif).zelid;
|
|
152
|
+
}
|
|
153
|
+
function requireOwnerWif(config) {
|
|
154
|
+
if (!config.ownerWif)
|
|
155
|
+
throw new Error('FLUX_ID_PRIVATE_KEY is not configured; this action needs the app owner key.');
|
|
156
|
+
return config.ownerWif;
|
|
157
|
+
}
|
|
158
|
+
function summarizeSpec(spec) {
|
|
159
|
+
const expire = expireOf(spec);
|
|
160
|
+
return {
|
|
161
|
+
name: spec.name,
|
|
162
|
+
owner: spec.owner,
|
|
163
|
+
instances: spec.instances,
|
|
164
|
+
termBlocks: expire,
|
|
165
|
+
termMonths: Number((expire / BLOCKS_PER_MONTH).toFixed(2)),
|
|
166
|
+
components: componentsOf(spec).map((c) => ({
|
|
167
|
+
name: c.name,
|
|
168
|
+
image: c.repotag,
|
|
169
|
+
cpu: c.cpu,
|
|
170
|
+
ramMb: c.ram,
|
|
171
|
+
hddGb: c.hdd,
|
|
172
|
+
ports: c.ports,
|
|
173
|
+
containerPorts: c.containerPorts,
|
|
174
|
+
})),
|
|
175
|
+
private: Boolean(spec.enterprise),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function describeQuote(q) {
|
|
179
|
+
return {
|
|
180
|
+
priceUsd: q.usd,
|
|
181
|
+
priceUsdPerMonth: q.usdPerMonth,
|
|
182
|
+
termMonths: q.months,
|
|
183
|
+
payFlux: q.flux,
|
|
184
|
+
payInFluxDiscountPercent: q.fluxDiscountPercent,
|
|
185
|
+
fluxUsdRate: q.fluxUsdRate,
|
|
186
|
+
source: q.source,
|
|
187
|
+
note: 'Prices are Flux Cloud USD prices converted to FLUX at market rate.',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function urlsFor(spec, locations) {
|
|
191
|
+
const lower = spec.name.toLowerCase();
|
|
192
|
+
const components = componentsOf(spec);
|
|
193
|
+
const shared = [`https://${lower}.app.runonflux.io`];
|
|
194
|
+
for (const c of components)
|
|
195
|
+
for (const p of c.ports)
|
|
196
|
+
shared.push(`https://${lower}_${p}.app.runonflux.io`);
|
|
197
|
+
const direct = locations.flatMap((l) => {
|
|
198
|
+
const host = l.ip.split(':')[0];
|
|
199
|
+
return components.flatMap((c) => c.ports.map((p) => `http://${host}:${p}`));
|
|
200
|
+
});
|
|
201
|
+
return { shared, direct };
|
|
202
|
+
}
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Server
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
export function createServer(config) {
|
|
207
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, {
|
|
208
|
+
instructions: 'Deploy and manage apps on Flux Cloud, the decentralized cloud. Read the flux://guide/overview resource first. ' +
|
|
209
|
+
'All prices are Flux Cloud USD prices (converted to FLUX for payment). Always show the user the USD quote from ' +
|
|
210
|
+
'flux_quote_app and get their agreement before calling flux_deploy_app with confirm=true, which spends FLUX.',
|
|
211
|
+
});
|
|
212
|
+
// ----- resources --------------------------------------------------------
|
|
213
|
+
const guides = [
|
|
214
|
+
[
|
|
215
|
+
'overview',
|
|
216
|
+
'Flux Cloud overview for agents',
|
|
217
|
+
'How identity, deployment, pricing and reachability work.',
|
|
218
|
+
OVERVIEW,
|
|
219
|
+
],
|
|
220
|
+
[
|
|
221
|
+
'spec-format',
|
|
222
|
+
'Flux v8 app specification',
|
|
223
|
+
'Field-by-field reference for the app specification.',
|
|
224
|
+
SPEC_FORMAT,
|
|
225
|
+
],
|
|
226
|
+
[
|
|
227
|
+
'pricing',
|
|
228
|
+
'Flux Cloud pricing',
|
|
229
|
+
'The USD rate card, discounts and how FLUX amounts are derived.',
|
|
230
|
+
PRICING,
|
|
231
|
+
],
|
|
232
|
+
[
|
|
233
|
+
'gotchas',
|
|
234
|
+
'Flux Cloud gotchas',
|
|
235
|
+
'Failure modes that are easy to hit and how this server avoids them.',
|
|
236
|
+
GOTCHAS,
|
|
237
|
+
],
|
|
238
|
+
];
|
|
239
|
+
for (const [slug, title, description, text] of guides) {
|
|
240
|
+
server.registerResource(`guide-${slug}`, `flux://guide/${slug}`, { title, description, mimeType: 'text/markdown' }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: 'text/markdown', text }] }));
|
|
241
|
+
}
|
|
242
|
+
// ----- prompts ----------------------------------------------------------
|
|
243
|
+
server.registerPrompt('deploy_on_flux', {
|
|
244
|
+
title: 'Deploy an app on Flux Cloud',
|
|
245
|
+
description: 'Walks through describing, pricing, confirming and deploying an app.',
|
|
246
|
+
argsSchema: { app: z.string().describe('What to deploy, in plain words.') },
|
|
247
|
+
}, ({ app }) => ({
|
|
248
|
+
messages: [
|
|
249
|
+
{
|
|
250
|
+
role: 'user',
|
|
251
|
+
content: {
|
|
252
|
+
type: 'text',
|
|
253
|
+
text: `I want to deploy this on Flux Cloud: ${app}\n\n` +
|
|
254
|
+
'Steps: 1) call flux_get_identity to confirm keys and balance; 2) call flux_build_spec with sensible ' +
|
|
255
|
+
'resources and validate it with flux_validate_spec; 3) call flux_quote_app and show me the USD price and ' +
|
|
256
|
+
'the FLUX to pay; 4) only after I agree, call flux_deploy_app with confirm=true; 5) call flux_wait_for_app ' +
|
|
257
|
+
'and give me the URLs. If the payment address is underfunded, tell me the address and how much to send.',
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
}));
|
|
262
|
+
/**
|
|
263
|
+
* FluxOS names the container of a compose app `<component>_<app>`; legacy
|
|
264
|
+
* single-container apps are just `<app>`. Pick a running node when none is given.
|
|
265
|
+
*/
|
|
266
|
+
async function resolveContainer(name, component, nodeIp) {
|
|
267
|
+
const lb = api(config);
|
|
268
|
+
const [spec, locations] = await Promise.all([publishedSpec(lb, name), appLocations(lb, name)]);
|
|
269
|
+
if (!spec)
|
|
270
|
+
throw new Error(`${name} is not registered on the network.`);
|
|
271
|
+
const target = nodeIp ?? locations[0]?.ip;
|
|
272
|
+
if (!target)
|
|
273
|
+
throw new Error(`${name} has no running instance.`);
|
|
274
|
+
const components = componentsOf(spec);
|
|
275
|
+
let container = name;
|
|
276
|
+
if (Array.isArray(spec.compose)) {
|
|
277
|
+
const chosen = component ?? (components.length === 1 ? components[0]?.name : undefined);
|
|
278
|
+
if (!chosen) {
|
|
279
|
+
throw new Error(`${name} has ${components.length} components; pass component (one of ${components.map((c) => c.name).join(', ')}).`);
|
|
280
|
+
}
|
|
281
|
+
container = `${chosen}_${name}`;
|
|
282
|
+
}
|
|
283
|
+
return { target, container };
|
|
284
|
+
}
|
|
285
|
+
// ----- identity ---------------------------------------------------------
|
|
286
|
+
server.registerTool('flux_get_identity', {
|
|
287
|
+
title: 'Show the configured Flux identity and balance',
|
|
288
|
+
description: 'Returns the Flux ID (app owner address), the payment address and its spendable FLUX balance with its USD value. ' +
|
|
289
|
+
'Explains what to configure if keys are missing. Never returns private keys.',
|
|
290
|
+
inputSchema: {},
|
|
291
|
+
}, async () => {
|
|
292
|
+
try {
|
|
293
|
+
const owner = config.ownerWif ? identityFromWif(config.ownerWif) : undefined;
|
|
294
|
+
const payer = config.payerWif ? identityFromWif(config.payerWif) : undefined;
|
|
295
|
+
let balance;
|
|
296
|
+
let rate = null;
|
|
297
|
+
if (payer) {
|
|
298
|
+
const [bal, r] = await Promise.all([
|
|
299
|
+
explorer(config).balance(payer.fluxAddress),
|
|
300
|
+
fetchFluxUsdRate(config.ratesUrl).catch(() => null),
|
|
301
|
+
]);
|
|
302
|
+
rate = r;
|
|
303
|
+
balance = {
|
|
304
|
+
totalFlux: toFlux(bal.total),
|
|
305
|
+
spendableFlux: toFlux(bal.spendable),
|
|
306
|
+
spendableUsd: r === null ? null : Number((toFlux(bal.spendable) * r).toFixed(2)),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return ok({
|
|
310
|
+
fluxId: owner?.zelid ?? null,
|
|
311
|
+
paymentAddress: payer?.fluxAddress ?? null,
|
|
312
|
+
balance: balance ?? null,
|
|
313
|
+
fluxUsdRate: rate,
|
|
314
|
+
configured: { ownerKey: Boolean(owner), paymentKey: Boolean(payer) },
|
|
315
|
+
...(owner && payer
|
|
316
|
+
? {}
|
|
317
|
+
: {
|
|
318
|
+
setup: 'Set FLUX_ID_PRIVATE_KEY (owner) and FLUX_PAYMENT_PRIVATE_KEY (payer) in the MCP server environment, or run flux_generate_keys.',
|
|
319
|
+
}),
|
|
320
|
+
...(payer && balance && balance.spendableFlux === 0
|
|
321
|
+
? {
|
|
322
|
+
fund: `Send FLUX to ${payer.fluxAddress} before deploying. Exchanges and wallets like SSP, Zelcore and Kucoin support FLUX.`,
|
|
323
|
+
}
|
|
324
|
+
: {}),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
return fail(error);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
server.registerTool('flux_generate_keys', {
|
|
332
|
+
title: 'Generate a new Flux ID and payment key pair',
|
|
333
|
+
description: 'Creates two fresh private keys (WIF): one for the Flux ID that will own apps, one for the address that pays. ' +
|
|
334
|
+
'The response contains the secrets; store them in the MCP server environment as FLUX_ID_PRIVATE_KEY and ' +
|
|
335
|
+
'FLUX_PAYMENT_PRIVATE_KEY, then restart the server. Nothing is stored or sent anywhere by this tool.',
|
|
336
|
+
inputSchema: {},
|
|
337
|
+
}, async () => {
|
|
338
|
+
const owner = generateIdentity();
|
|
339
|
+
const payer = generateIdentity();
|
|
340
|
+
return ok({
|
|
341
|
+
fluxId: { address: owner.zelid, privateKeyWif: owner.wif },
|
|
342
|
+
payment: { address: payer.fluxAddress, privateKeyWif: payer.wif },
|
|
343
|
+
env: { FLUX_ID_PRIVATE_KEY: owner.wif, FLUX_PAYMENT_PRIVATE_KEY: payer.wif },
|
|
344
|
+
next: [
|
|
345
|
+
'Save both private keys somewhere safe; they cannot be recovered.',
|
|
346
|
+
'Put the env values into the MCP server configuration and restart it.',
|
|
347
|
+
`Send FLUX to ${payer.fluxAddress} to fund deployments.`,
|
|
348
|
+
],
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
// ----- pricing ----------------------------------------------------------
|
|
352
|
+
server.registerTool('flux_get_pricing', {
|
|
353
|
+
title: 'Get the Flux Cloud rate card',
|
|
354
|
+
description: 'Returns the current USD rate card, the live FLUX/USD rate, the pay-in-FLUX discount and instant USD estimates for ' +
|
|
355
|
+
'a few reference sizes. Use flux_quote_app for the exact price of a specific app.',
|
|
356
|
+
inputSchema: {},
|
|
357
|
+
}, async () => {
|
|
358
|
+
try {
|
|
359
|
+
const [rates, fluxUsd] = await Promise.all([
|
|
360
|
+
fetchUsdRates(config.statsUrl),
|
|
361
|
+
fetchFluxUsdRate(config.ratesUrl).catch(() => null),
|
|
362
|
+
]);
|
|
363
|
+
const owner = '1FluxCloudPricingExample00000000000';
|
|
364
|
+
const sample = (cpu, ram, hdd, instances) => buildSpecification({
|
|
365
|
+
name: 'example',
|
|
366
|
+
owner,
|
|
367
|
+
components: [{ image: 'nginx:1.27', cpu, ram, hdd, ports: [{ containerPort: 80 }] }],
|
|
368
|
+
instances,
|
|
369
|
+
});
|
|
370
|
+
const examples = [
|
|
371
|
+
{ label: 'tiny: 0.3 cpu, 300 MB, 3 GB, 3 instances', spec: sample(0.3, 300, 3, 3) },
|
|
372
|
+
{ label: 'small: 1 cpu, 1000 MB, 10 GB, 3 instances', spec: sample(1, 1000, 10, 3) },
|
|
373
|
+
{ label: 'medium: 2 cpu, 4000 MB, 40 GB, 3 instances', spec: sample(2, 4000, 40, 3) },
|
|
374
|
+
{ label: 'large: 4 cpu, 8000 MB, 100 GB, 3 instances', spec: sample(4, 8000, 100, 3) },
|
|
375
|
+
].map(({ label, spec }) => ({
|
|
376
|
+
size: label,
|
|
377
|
+
...(fluxUsd
|
|
378
|
+
? describeQuote(estimateQuote(spec, rates, fluxUsd))
|
|
379
|
+
: { priceUsd: estimateQuote(spec, rates, 1).usd }),
|
|
380
|
+
}));
|
|
381
|
+
return ok({
|
|
382
|
+
currency: 'USD',
|
|
383
|
+
perMonth: {
|
|
384
|
+
per01Cpu: rates.cpu,
|
|
385
|
+
per100MbRam: rates.ram,
|
|
386
|
+
perGbSsd: rates.hdd,
|
|
387
|
+
perEnterprisePort: rates.port,
|
|
388
|
+
nodePinningOrPrivateApp: rates.scope,
|
|
389
|
+
staticIp: rates.staticip,
|
|
390
|
+
minimumPerApp: rates.minUSDPrice,
|
|
391
|
+
globalMultiplier: rates.multiplier,
|
|
392
|
+
},
|
|
393
|
+
discounts: {
|
|
394
|
+
payInFluxPercent: Number((100 - rates.fluxmultiplier * 100).toFixed(2)),
|
|
395
|
+
smallApp: '20% when total < 3 cpu, < 6000 MB, < 150 GB and fewer than 4 instances',
|
|
396
|
+
mediumApp: '10% when total < 7 cpu, < 29000 MB, < 370 GB and fewer than 4 instances',
|
|
397
|
+
primaryStandbyStorage: '20% when a component uses g: storage',
|
|
398
|
+
term: '3% at 3+ months, 6% at 6+, 12% at 9+',
|
|
399
|
+
},
|
|
400
|
+
formula: 'monthly USD = ceil2((cpu*rate*10 + ram*rate/100 + hdd*rate + surcharges) / 3) * instances, min $0.99; FLUX = USD / rate * (1 - discount)',
|
|
401
|
+
fluxUsdRate: fluxUsd,
|
|
402
|
+
blocksPerMonth: BLOCKS_PER_MONTH,
|
|
403
|
+
examples,
|
|
404
|
+
ratesSource: rates === DEFAULT_USD_RATES ? 'bundled' : config.statsUrl,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
return fail(error);
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
// ----- spec building & validation --------------------------------------
|
|
412
|
+
server.registerTool('flux_build_spec', {
|
|
413
|
+
title: 'Build a Flux app specification from a simple description',
|
|
414
|
+
description: 'Turns images, ports, resources and a term into a complete, correctly formatted v8 specification, with public ' +
|
|
415
|
+
'ports auto-picked and data replication enabled. Returns the spec plus local validation errors and warnings.',
|
|
416
|
+
inputSchema: SimpleAppSchema.shape,
|
|
417
|
+
}, async (input) => {
|
|
418
|
+
try {
|
|
419
|
+
const spec = buildSpecification({ ...input, owner: ownerOf(config) });
|
|
420
|
+
const errors = validateSpecification(spec);
|
|
421
|
+
return ok({
|
|
422
|
+
spec,
|
|
423
|
+
valid: errors.length === 0,
|
|
424
|
+
errors,
|
|
425
|
+
warnings: durabilityWarnings(spec),
|
|
426
|
+
summary: summarizeSpec(spec),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
return fail(error);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
server.registerTool('flux_validate_spec', {
|
|
434
|
+
title: 'Validate a specification locally and on the network',
|
|
435
|
+
description: 'Runs the local rule checks, then asks a FluxOS node to verify the specification exactly as it would at registration ' +
|
|
436
|
+
'(image reachable, architecture, ports, name availability). Returns the node-formatted spec on success.',
|
|
437
|
+
inputSchema: {
|
|
438
|
+
spec: SpecSchema,
|
|
439
|
+
network: z.boolean().default(true).describe('Also verify on a FluxOS node.'),
|
|
440
|
+
},
|
|
441
|
+
}, async ({ spec, network }) => {
|
|
442
|
+
try {
|
|
443
|
+
const formatted = formatSpecification({ ...spec, owner: ownerOf(config, spec.owner) });
|
|
444
|
+
const errors = validateSpecification(formatted);
|
|
445
|
+
const warnings = durabilityWarnings(formatted);
|
|
446
|
+
if (errors.length || !network)
|
|
447
|
+
return ok({ valid: errors.length === 0, errors, warnings, spec: formatted });
|
|
448
|
+
const existing = await publishedSpec(api(config), formatted.name);
|
|
449
|
+
const action = existing ? 'update' : 'register';
|
|
450
|
+
if (existing && existing.owner !== formatted.owner) {
|
|
451
|
+
return ok({
|
|
452
|
+
valid: false,
|
|
453
|
+
errors: [`name ${formatted.name} is taken by ${existing.owner}`],
|
|
454
|
+
warnings,
|
|
455
|
+
spec: formatted,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
const path = existing
|
|
459
|
+
? '/apps/verifyappupdatespecifications'
|
|
460
|
+
: '/apps/verifyappregistrationspecifications';
|
|
461
|
+
const nodeFormatted = await api(config).post(path, formatted, {
|
|
462
|
+
timeoutMs: 120000,
|
|
463
|
+
});
|
|
464
|
+
return ok({
|
|
465
|
+
valid: true,
|
|
466
|
+
action,
|
|
467
|
+
errors: [],
|
|
468
|
+
warnings,
|
|
469
|
+
spec: nodeFormatted,
|
|
470
|
+
summary: summarizeSpec(nodeFormatted),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
return fail(error);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
server.registerTool('flux_quote_app', {
|
|
478
|
+
title: 'Quote the price of an app in USD and FLUX',
|
|
479
|
+
description: 'Returns the Flux Cloud price for registering the given specification, or for updating it if an app of that name ' +
|
|
480
|
+
'already exists (the unused part of the current term is credited). Price is in USD with the FLUX amount at market rate.',
|
|
481
|
+
inputSchema: { spec: SpecSchema },
|
|
482
|
+
}, async ({ spec }) => {
|
|
483
|
+
try {
|
|
484
|
+
const formatted = formatSpecification({ ...spec, owner: ownerOf(config, spec.owner) });
|
|
485
|
+
const errors = validateSpecification(formatted);
|
|
486
|
+
if (errors.length)
|
|
487
|
+
return ok({ valid: false, errors });
|
|
488
|
+
const [quote, existing] = await Promise.all([
|
|
489
|
+
quoteFromNetwork(api(config), formatted),
|
|
490
|
+
publishedSpec(api(config), formatted.name),
|
|
491
|
+
]);
|
|
492
|
+
return ok({
|
|
493
|
+
action: existing ? 'update' : 'register',
|
|
494
|
+
...describeQuote(quote),
|
|
495
|
+
app: summarizeSpec(formatted),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
return fail(error);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
// ----- deploy -----------------------------------------------------------
|
|
503
|
+
const EnterpriseSchema = z
|
|
504
|
+
.object({
|
|
505
|
+
compose: z.array(ComponentSchema).min(1).max(10),
|
|
506
|
+
contacts: z.array(z.string()).default([]),
|
|
507
|
+
})
|
|
508
|
+
.optional()
|
|
509
|
+
.describe('Make the app private: these components and contacts are encrypted so only the nodes running the app can read them. ' +
|
|
510
|
+
'When set, spec.compose should be [] and spec.enterprise "".');
|
|
511
|
+
server.registerTool('flux_deploy_app', {
|
|
512
|
+
title: 'Deploy (register or update) an app and pay for it',
|
|
513
|
+
description: 'Validates the specification on a node, quotes it, and with confirm=true signs it with the Flux ID, broadcasts ' +
|
|
514
|
+
'it and pays the quoted FLUX from the payment address. Without confirm it only returns the plan (price, balance, ' +
|
|
515
|
+
'warnings) and spends nothing. Registers a new name or updates an existing app of the same owner. Then call ' +
|
|
516
|
+
'flux_wait_for_app with the returned txid.',
|
|
517
|
+
inputSchema: {
|
|
518
|
+
spec: SpecSchema,
|
|
519
|
+
enterprise: EnterpriseSchema,
|
|
520
|
+
confirm: z
|
|
521
|
+
.boolean()
|
|
522
|
+
.default(false)
|
|
523
|
+
.describe('Set true to actually sign, broadcast and pay.'),
|
|
524
|
+
},
|
|
525
|
+
}, async ({ spec, enterprise, confirm }) => {
|
|
526
|
+
const log = [];
|
|
527
|
+
try {
|
|
528
|
+
const enterpriseInput = enterprise
|
|
529
|
+
? {
|
|
530
|
+
compose: enterprise.compose.map((c) => c),
|
|
531
|
+
contacts: enterprise.contacts,
|
|
532
|
+
}
|
|
533
|
+
: undefined;
|
|
534
|
+
const prepared = await plan(config, { ...spec, owner: ownerOf(config, spec.owner) }, {
|
|
535
|
+
enterprise: enterpriseInput,
|
|
536
|
+
log: (m) => log.push(m),
|
|
537
|
+
});
|
|
538
|
+
const summary = {
|
|
539
|
+
action: prepared.action,
|
|
540
|
+
app: summarizeSpec(enterpriseInput
|
|
541
|
+
? { ...prepared.spec, compose: enterpriseInput.compose }
|
|
542
|
+
: prepared.formatted),
|
|
543
|
+
price: describeQuote(prepared.quote),
|
|
544
|
+
payment: {
|
|
545
|
+
payer: prepared.payer,
|
|
546
|
+
balanceFlux: prepared.balanceFlux,
|
|
547
|
+
requiredFlux: prepared.requiredFlux,
|
|
548
|
+
funded: prepared.funded,
|
|
549
|
+
to: prepared.deployment.address,
|
|
550
|
+
},
|
|
551
|
+
warnings: prepared.warnings,
|
|
552
|
+
previous: prepared.previous
|
|
553
|
+
? {
|
|
554
|
+
hash: prepared.previous.hash,
|
|
555
|
+
height: prepared.previous.height,
|
|
556
|
+
expire: prepared.previous.expire,
|
|
557
|
+
}
|
|
558
|
+
: null,
|
|
559
|
+
};
|
|
560
|
+
if (!confirm) {
|
|
561
|
+
return ok({
|
|
562
|
+
...summary,
|
|
563
|
+
executed: false,
|
|
564
|
+
next: prepared.funded
|
|
565
|
+
? 'Call again with confirm=true to deploy.'
|
|
566
|
+
: `Fund ${prepared.payer} with at least ${prepared.requiredFlux} FLUX.`,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const result = await execute(config, prepared, (m) => log.push(m));
|
|
570
|
+
return ok({
|
|
571
|
+
...summary,
|
|
572
|
+
executed: true,
|
|
573
|
+
result,
|
|
574
|
+
urls: urlsFor(prepared.formatted, []),
|
|
575
|
+
next: `Call flux_wait_for_app with name "${result.name}" and txid "${result.txid}". Acceptance usually takes 2-10 minutes.`,
|
|
576
|
+
log,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
return {
|
|
581
|
+
...fail(error),
|
|
582
|
+
content: [
|
|
583
|
+
{
|
|
584
|
+
type: 'text',
|
|
585
|
+
text: JSON.stringify({ error: error.message, log }, null, 2),
|
|
586
|
+
},
|
|
587
|
+
],
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
server.registerTool('flux_wait_for_app', {
|
|
592
|
+
title: 'Wait for a deployment to be accepted and its instances to run',
|
|
593
|
+
description: 'Polls the network for up to timeoutSeconds (default 300, max 600). Returns payment confirmations, whether the ' +
|
|
594
|
+
'spec was accepted, and the running instances with URLs. Safe to call repeatedly until done=true.',
|
|
595
|
+
inputSchema: {
|
|
596
|
+
name: z.string(),
|
|
597
|
+
txid: z
|
|
598
|
+
.string()
|
|
599
|
+
.optional()
|
|
600
|
+
.describe('Payment txid from flux_deploy_app, to report confirmations.'),
|
|
601
|
+
previousHash: z
|
|
602
|
+
.string()
|
|
603
|
+
.optional()
|
|
604
|
+
.describe('For updates: the hash of the previous spec, so acceptance means a new hash.'),
|
|
605
|
+
timeoutSeconds: z.number().int().min(10).max(600).default(300),
|
|
606
|
+
},
|
|
607
|
+
}, async ({ name, txid, previousHash, timeoutSeconds }) => {
|
|
608
|
+
try {
|
|
609
|
+
const result = await waitForApp(config, name, {
|
|
610
|
+
txid,
|
|
611
|
+
previousHash,
|
|
612
|
+
timeoutMs: timeoutSeconds * 1000,
|
|
613
|
+
});
|
|
614
|
+
return ok({
|
|
615
|
+
done: result.done,
|
|
616
|
+
timedOut: result.timedOut,
|
|
617
|
+
paymentConfirmations: result.paymentConfirmations,
|
|
618
|
+
accepted: result.accepted
|
|
619
|
+
? {
|
|
620
|
+
hash: result.accepted.hash,
|
|
621
|
+
height: result.accepted.height,
|
|
622
|
+
expiresAtHeight: result.accepted.height + expireOf(result.accepted),
|
|
623
|
+
}
|
|
624
|
+
: null,
|
|
625
|
+
instances: {
|
|
626
|
+
running: result.instances.length,
|
|
627
|
+
wanted: result.wantedInstances,
|
|
628
|
+
nodes: result.instances.map((l) => l.ip),
|
|
629
|
+
},
|
|
630
|
+
urls: result.accepted ? urlsFor(result.accepted, result.instances) : null,
|
|
631
|
+
next: result.done ? 'Deployed.' : 'Call again to keep waiting.',
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
return fail(error);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
// ----- inspect ----------------------------------------------------------
|
|
639
|
+
server.registerTool('flux_get_app', {
|
|
640
|
+
title: 'Get a deployed app: spec, status, instances, URLs',
|
|
641
|
+
description: 'Looks up any app on the network by name and returns its published specification, expiry, running instances and URLs.',
|
|
642
|
+
inputSchema: { name: z.string() },
|
|
643
|
+
}, async ({ name }) => {
|
|
644
|
+
try {
|
|
645
|
+
const lb = api(config);
|
|
646
|
+
const [spec, locations, info] = await Promise.all([
|
|
647
|
+
publishedSpec(lb, name),
|
|
648
|
+
appLocations(lb, name),
|
|
649
|
+
lb.get('/daemon/getinfo', { timeoutMs: 20000 }),
|
|
650
|
+
]);
|
|
651
|
+
if (!spec)
|
|
652
|
+
return ok({ found: false, name, message: `${name} is not registered on the network.` });
|
|
653
|
+
const expiresAt = spec.height + expireOf(spec);
|
|
654
|
+
const blocksLeft = expiresAt - info.blocks;
|
|
655
|
+
return ok({
|
|
656
|
+
found: true,
|
|
657
|
+
summary: summarizeSpec(spec),
|
|
658
|
+
registeredAtHeight: spec.height,
|
|
659
|
+
expiresAtHeight: expiresAt,
|
|
660
|
+
blocksLeft,
|
|
661
|
+
daysLeft: Number(((blocksLeft * 30) / 86400).toFixed(1)),
|
|
662
|
+
hash: spec.hash,
|
|
663
|
+
instances: {
|
|
664
|
+
running: locations.length,
|
|
665
|
+
wanted: spec.instances,
|
|
666
|
+
nodes: locations.map((l) => ({ ip: l.ip, since: l.runningSince ?? null })),
|
|
667
|
+
},
|
|
668
|
+
urls: urlsFor(spec, locations),
|
|
669
|
+
spec,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
return fail(error);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
server.registerTool('flux_list_my_apps', {
|
|
677
|
+
title: 'List apps owned by the configured Flux ID',
|
|
678
|
+
description: 'Lists every app registered by the owner (or a given Flux ID) with expiry and instance counts.',
|
|
679
|
+
inputSchema: {
|
|
680
|
+
owner: z.string().optional().describe('Flux ID to list; defaults to the configured one.'),
|
|
681
|
+
nameContains: z.string().optional().describe('Only apps whose name contains this text.'),
|
|
682
|
+
},
|
|
683
|
+
}, async ({ owner, nameContains }) => {
|
|
684
|
+
try {
|
|
685
|
+
const zelid = ownerOf(config, owner);
|
|
686
|
+
const lb = api(config);
|
|
687
|
+
const [apps, info, allLocations] = await Promise.all([
|
|
688
|
+
lb.get(`/apps/globalappsspecifications?owner=${encodeURIComponent(zelid)}`, { timeoutMs: 60000 }),
|
|
689
|
+
lb.get('/daemon/getinfo', { timeoutMs: 20000 }),
|
|
690
|
+
lb.get('/apps/locations', { timeoutMs: 60000 }),
|
|
691
|
+
]);
|
|
692
|
+
const running = new Map();
|
|
693
|
+
for (const l of allLocations)
|
|
694
|
+
running.set(l.name, (running.get(l.name) ?? 0) + 1);
|
|
695
|
+
const needle = nameContains?.toLowerCase();
|
|
696
|
+
const rows = apps
|
|
697
|
+
.filter((app) => !needle || app.name.toLowerCase().includes(needle))
|
|
698
|
+
.map((app) => {
|
|
699
|
+
const blocksLeft = app.height + expireOf(app) - info.blocks;
|
|
700
|
+
return {
|
|
701
|
+
name: app.name,
|
|
702
|
+
version: app.version,
|
|
703
|
+
instances: { running: running.get(app.name) ?? 0, wanted: app.instances },
|
|
704
|
+
blocksLeft,
|
|
705
|
+
daysLeft: Number(((blocksLeft * 30) / 86400).toFixed(1)),
|
|
706
|
+
components: componentsOf(app).map((c) => `${c.name} ${c.repotag} ${c.cpu}cpu/${c.ram}MB/${c.hdd}GB`),
|
|
707
|
+
private: Boolean(app.enterprise),
|
|
708
|
+
url: `https://${app.name.toLowerCase()}.app.runonflux.io`,
|
|
709
|
+
};
|
|
710
|
+
});
|
|
711
|
+
return ok({ owner: zelid, count: rows.length, apps: rows });
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
return fail(error);
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
server.registerTool('flux_get_app_logs', {
|
|
718
|
+
title: 'Read container logs from a running instance',
|
|
719
|
+
description: 'Fetches the last N log lines of an app (or one component of it) from one of the nodes running it. Requires the owner key.',
|
|
720
|
+
inputSchema: {
|
|
721
|
+
name: z.string(),
|
|
722
|
+
component: z.string().optional().describe('Component name for multi-component apps.'),
|
|
723
|
+
lines: z.number().int().min(1).max(2000).default(200),
|
|
724
|
+
nodeIp: z
|
|
725
|
+
.string()
|
|
726
|
+
.optional()
|
|
727
|
+
.describe('ip[:port] of the instance; defaults to the first running one.'),
|
|
728
|
+
},
|
|
729
|
+
}, async ({ name, component, lines, nodeIp }) => {
|
|
730
|
+
try {
|
|
731
|
+
const session = currentSession(requireOwnerWif(config));
|
|
732
|
+
const { target, container } = await resolveContainer(name, component, nodeIp);
|
|
733
|
+
const node = new FluxClient(instanceEndpoint(target), config.requestTimeoutMs);
|
|
734
|
+
const logs = await node.get(`/apps/applog/${container}/${lines}`, {
|
|
735
|
+
session,
|
|
736
|
+
timeoutMs: 60000,
|
|
737
|
+
});
|
|
738
|
+
return ok({ node: target, container, logs });
|
|
739
|
+
}
|
|
740
|
+
catch (error) {
|
|
741
|
+
return fail(error);
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
server.registerTool('flux_get_app_stats', {
|
|
745
|
+
title: 'Get live resource usage of a running instance',
|
|
746
|
+
description: 'CPU, memory and network stats of the containers of an app on one node. Requires the owner key.',
|
|
747
|
+
inputSchema: {
|
|
748
|
+
name: z.string(),
|
|
749
|
+
component: z.string().optional().describe('Component name for multi-component apps.'),
|
|
750
|
+
nodeIp: z.string().optional(),
|
|
751
|
+
},
|
|
752
|
+
}, async ({ name, component, nodeIp }) => {
|
|
753
|
+
try {
|
|
754
|
+
const session = currentSession(requireOwnerWif(config));
|
|
755
|
+
const { target, container } = await resolveContainer(name, component, nodeIp);
|
|
756
|
+
const node = new FluxClient(instanceEndpoint(target), config.requestTimeoutMs);
|
|
757
|
+
const stats = await node.get(`/apps/appstats/${container}`, {
|
|
758
|
+
session,
|
|
759
|
+
timeoutMs: 60000,
|
|
760
|
+
});
|
|
761
|
+
return ok({ node: target, container, stats });
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
return fail(error);
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
// ----- control ----------------------------------------------------------
|
|
768
|
+
server.registerTool('flux_control_app', {
|
|
769
|
+
title: 'Restart, redeploy or remove app instances',
|
|
770
|
+
description: 'restart: restarts containers. redeploy: pulls the image again and recreates containers (hard=true also wipes data). ' +
|
|
771
|
+
'remove: uninstalls from nodes; the registration stays and the network re-spawns it elsewhere, so use flux_cancel_app ' +
|
|
772
|
+
'to stop paying. Scope is one node (nodeIp) or every node running the app (global). Requires the owner key.',
|
|
773
|
+
inputSchema: {
|
|
774
|
+
name: z.string(),
|
|
775
|
+
action: z.enum(['restart', 'redeploy', 'remove']),
|
|
776
|
+
scope: z.enum(['node', 'global']).default('global'),
|
|
777
|
+
nodeIp: z
|
|
778
|
+
.string()
|
|
779
|
+
.optional()
|
|
780
|
+
.describe('Required for scope=node; also the node the global command is sent through.'),
|
|
781
|
+
hard: z.boolean().default(false).describe('For redeploy: also delete the app data.'),
|
|
782
|
+
},
|
|
783
|
+
}, async ({ name, action, scope, nodeIp, hard }) => {
|
|
784
|
+
try {
|
|
785
|
+
const session = currentSession(requireOwnerWif(config));
|
|
786
|
+
const target = nodeIp ?? (await appLocations(api(config), name))[0]?.ip;
|
|
787
|
+
if (!target)
|
|
788
|
+
throw new Error(`${name} has no running instance.`);
|
|
789
|
+
const node = new FluxClient(instanceEndpoint(target), config.requestTimeoutMs);
|
|
790
|
+
const global = scope === 'global';
|
|
791
|
+
const path = action === 'restart'
|
|
792
|
+
? `/apps/apprestart/${name}/${global}`
|
|
793
|
+
: action === 'redeploy'
|
|
794
|
+
? `/apps/redeploy/${name}/${hard}/${global}`
|
|
795
|
+
: `/apps/appremove/${name}/true/${global}`;
|
|
796
|
+
const result = await node.raw('GET', path, { session, timeoutMs: 120000 });
|
|
797
|
+
return ok({ node: target, action, scope, result });
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
return fail(error);
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
server.registerTool('flux_cancel_app', {
|
|
804
|
+
title: 'Cancel an app (stop it and stop paying)',
|
|
805
|
+
description: 'Ends an app early by updating its term so it expires within about an hour. The network then uninstalls it everywhere. ' +
|
|
806
|
+
'With confirm=true this signs and pays the (usually minimal) update; without it, returns the plan.',
|
|
807
|
+
inputSchema: { name: z.string(), confirm: z.boolean().default(false) },
|
|
808
|
+
}, async ({ name, confirm }) => {
|
|
809
|
+
try {
|
|
810
|
+
const existing = await publishedSpec(api(config), name);
|
|
811
|
+
if (!existing)
|
|
812
|
+
throw new Error(`${name} is not registered.`);
|
|
813
|
+
const owner = ownerOf(config);
|
|
814
|
+
if (existing.owner !== owner)
|
|
815
|
+
throw new Error(`${name} is owned by ${existing.owner}, not by ${owner}.`);
|
|
816
|
+
if (existing.enterprise) {
|
|
817
|
+
throw new Error('Private (enterprise) apps must be cancelled with flux_deploy_app: resend the components with a short expire.');
|
|
818
|
+
}
|
|
819
|
+
const spec = { ...normalizePublished(existing), expire: 120 };
|
|
820
|
+
const log = [];
|
|
821
|
+
const prepared = await plan(config, spec, { log: (m) => log.push(m) });
|
|
822
|
+
if (!confirm)
|
|
823
|
+
return ok({
|
|
824
|
+
executed: false,
|
|
825
|
+
price: describeQuote(prepared.quote),
|
|
826
|
+
funded: prepared.funded,
|
|
827
|
+
next: 'Call again with confirm=true.',
|
|
828
|
+
});
|
|
829
|
+
const result = await execute(config, prepared, (m) => log.push(m));
|
|
830
|
+
return ok({
|
|
831
|
+
executed: true,
|
|
832
|
+
result,
|
|
833
|
+
next: `The app expires about an hour after the update is accepted. Verify with flux_get_app "${name}".`,
|
|
834
|
+
log,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
catch (error) {
|
|
838
|
+
return fail(error);
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
// ----- network ----------------------------------------------------------
|
|
842
|
+
server.registerTool('flux_get_network_info', {
|
|
843
|
+
title: 'Network overview',
|
|
844
|
+
description: 'Node counts by tier, current block height, FLUX/USD rate and the deployment payment address.',
|
|
845
|
+
inputSchema: {},
|
|
846
|
+
}, async () => {
|
|
847
|
+
try {
|
|
848
|
+
const lb = api(config);
|
|
849
|
+
const [count, info, deployment, rate] = await Promise.all([
|
|
850
|
+
lb.get('/daemon/getzelnodecount', { timeoutMs: 30000 }),
|
|
851
|
+
lb.get('/daemon/getinfo', { timeoutMs: 20000 }),
|
|
852
|
+
lb.get('/apps/deploymentinformation', { timeoutMs: 30000 }),
|
|
853
|
+
fetchFluxUsdRate(config.ratesUrl).catch(() => null),
|
|
854
|
+
]);
|
|
855
|
+
return ok({
|
|
856
|
+
nodes: {
|
|
857
|
+
total: count.total,
|
|
858
|
+
enabled: count['stable'] ?? count.enabled ?? null,
|
|
859
|
+
cumulus: count['cumulus-enabled'] ?? null,
|
|
860
|
+
nimbus: count['nimbus-enabled'] ?? null,
|
|
861
|
+
stratus: count['stratus-enabled'] ?? null,
|
|
862
|
+
},
|
|
863
|
+
blockHeight: info.blocks,
|
|
864
|
+
blocksPerMonth: BLOCKS_PER_MONTH,
|
|
865
|
+
fluxUsdRate: rate,
|
|
866
|
+
deploymentAddress: deployment.address,
|
|
867
|
+
api: config.apiUrl,
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
return fail(error);
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
return server;
|
|
875
|
+
}
|
|
876
|
+
export { walletFromConfig };
|
|
877
|
+
//# sourceMappingURL=server.js.map
|