chutes-miner-cli 0.2.0__tar.gz
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,29 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: chutes-miner-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Chutes miner CLI
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Jon Durbin
|
|
7
|
+
Author-email: jon@jondurbin.com
|
|
8
|
+
Requires-Python: >=3.12,<4.0
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Requires-Dist: aiohttp[speedups] (>3.10,<4)
|
|
18
|
+
Requires-Dist: click (>8.1.0,<8.2.0)
|
|
19
|
+
Requires-Dist: loguru (>=0.7.3,<0.8.0)
|
|
20
|
+
Requires-Dist: py-bip39-bindings (>=0.1.11,<0.2.0)
|
|
21
|
+
Requires-Dist: pydantic (>2.9,<3)
|
|
22
|
+
Requires-Dist: rich (>13.0.0)
|
|
23
|
+
Requires-Dist: setuptools (>80.0)
|
|
24
|
+
Requires-Dist: substrate-interface (>=1.7.11,<2.0.0)
|
|
25
|
+
Requires-Dist: typer (>=0.14.0,<0.15.0)
|
|
26
|
+
Project-URL: Homepage, https://github.com/rayonlabs/chutes-miner/tree/main/cli
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
|
|
File without changes
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import aiohttp
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich import box
|
|
9
|
+
import datetime
|
|
10
|
+
from chutes_miner_cli.util import sign_request
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def format_memory(memory_bytes):
|
|
17
|
+
"""
|
|
18
|
+
Convert memory from bytes to GB and format nicely.
|
|
19
|
+
"""
|
|
20
|
+
return f"{memory_bytes / (1024**3):.1f}GB"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_date(date_str):
|
|
24
|
+
"""
|
|
25
|
+
Format datetime string to a more readable format.
|
|
26
|
+
"""
|
|
27
|
+
dt = datetime.datetime.fromisoformat(date_str)
|
|
28
|
+
return dt.strftime("%Y-%m-%d %H:%M")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_verification(error, verified_at):
|
|
32
|
+
"""
|
|
33
|
+
Helper to format table cell for GPU verification.
|
|
34
|
+
"""
|
|
35
|
+
if verified_at:
|
|
36
|
+
return f"[green]Verified: {format_date(verified_at)}[/green]"
|
|
37
|
+
elif error:
|
|
38
|
+
return f"[red]Error: {error}[/red]"
|
|
39
|
+
return "[yellow]Pending[/yellow]"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def delete_preflight(server, hotkey, miner_api):
|
|
43
|
+
"""
|
|
44
|
+
Check if a node has any jobs running and confirm the miner wants
|
|
45
|
+
to crush their incentives if they terminate regardless.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
|
49
|
+
headers, payload_string = sign_request(hotkey, purpose="management")
|
|
50
|
+
async with session.get(
|
|
51
|
+
f"{miner_api.rstrip('/')}/servers/{server}/delete_preflight",
|
|
52
|
+
headers=headers,
|
|
53
|
+
) as resp:
|
|
54
|
+
data = await resp.json()
|
|
55
|
+
if data.get("jobs"):
|
|
56
|
+
logger.warning(
|
|
57
|
+
f"There are {len(data['jobs'])} jobs running on server {server}, "
|
|
58
|
+
"deleting this node will be highly detrimental to your score."
|
|
59
|
+
)
|
|
60
|
+
user_input = input("Continue (y/n)? ").strip().lower()
|
|
61
|
+
if user_input != "y":
|
|
62
|
+
return False
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def display_local_inventory(inventory):
|
|
67
|
+
"""
|
|
68
|
+
Render inventory in fancy tables.
|
|
69
|
+
"""
|
|
70
|
+
console = Console()
|
|
71
|
+
for server in inventory:
|
|
72
|
+
server_table = Table(title=f"Server: {server['name']}", box=box.ROUNDED)
|
|
73
|
+
server_table.add_column("Property", style="cyan")
|
|
74
|
+
server_table.add_column("Value")
|
|
75
|
+
server_table.add_row("Status", server["status"])
|
|
76
|
+
server_table.add_row("GPUs", str(server["gpu_count"]))
|
|
77
|
+
server_table.add_row("Memory/GPU", f"{server['memory_per_gpu']}GB")
|
|
78
|
+
server_table.add_row("CPU/GPU", str(server["cpu_per_gpu"]))
|
|
79
|
+
server_table.add_row("Hourly Cost", f"${server['hourly_cost']:.2f}")
|
|
80
|
+
server_table.add_row("IP Address", server["ip_address"])
|
|
81
|
+
server_table.add_row("Created", format_date(server["created_at"]))
|
|
82
|
+
console.print(server_table)
|
|
83
|
+
console.print()
|
|
84
|
+
|
|
85
|
+
# Deployments.
|
|
86
|
+
if server["deployments"]:
|
|
87
|
+
deploy_table = Table(title="Active Deployments", box=box.ROUNDED)
|
|
88
|
+
deploy_table.add_column("Model Name")
|
|
89
|
+
deploy_table.add_column("GPUs")
|
|
90
|
+
deploy_table.add_column("Port")
|
|
91
|
+
deploy_table.add_column("Created")
|
|
92
|
+
deploy_table.add_column("Status")
|
|
93
|
+
for deploy in server["deployments"]:
|
|
94
|
+
status_text = (
|
|
95
|
+
"[green]Active[/green]"
|
|
96
|
+
if deploy["active"] and not deploy["stub"]
|
|
97
|
+
else "[red]Inactive[/red]"
|
|
98
|
+
)
|
|
99
|
+
deploy_table.add_row(
|
|
100
|
+
deploy["chute"]["name"],
|
|
101
|
+
str(len(deploy["gpus"])),
|
|
102
|
+
str(deploy["port"]),
|
|
103
|
+
format_date(deploy["created_at"]),
|
|
104
|
+
status_text,
|
|
105
|
+
)
|
|
106
|
+
console.print(deploy_table)
|
|
107
|
+
console.print()
|
|
108
|
+
|
|
109
|
+
# GPU details.
|
|
110
|
+
gpu_table = Table(title="GPU Details", box=box.ROUNDED)
|
|
111
|
+
gpu_table.add_column("Name")
|
|
112
|
+
gpu_table.add_column("Memory")
|
|
113
|
+
gpu_table.add_column("Clock (MHz)")
|
|
114
|
+
gpu_table.add_column("Processors")
|
|
115
|
+
gpu_table.add_column("Status")
|
|
116
|
+
for gpu in server["gpus"]:
|
|
117
|
+
status_text = "[green]Verified[/green]" if gpu["verified"] else "[red]Unverified[/red]"
|
|
118
|
+
gpu_table.add_row(
|
|
119
|
+
gpu["device_info"]["name"],
|
|
120
|
+
format_memory(gpu["device_info"]["memory"]),
|
|
121
|
+
str(int(gpu["device_info"]["clock_rate"] / 1000)),
|
|
122
|
+
str(gpu["device_info"]["processors"]),
|
|
123
|
+
status_text,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
console.print(gpu_table)
|
|
127
|
+
console.print("\n" + "=" * 80 + "\n")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def display_remote_inventory(inventory):
|
|
131
|
+
"""
|
|
132
|
+
Render remote/validator inventory.
|
|
133
|
+
"""
|
|
134
|
+
console = Console()
|
|
135
|
+
table = Table(title="GPU Information")
|
|
136
|
+
table.add_column("Name", style="cyan")
|
|
137
|
+
table.add_column("Chute", style="cyan")
|
|
138
|
+
table.add_column("Memory (GB)", justify="right", style="green")
|
|
139
|
+
table.add_column("Clock (MHz)", justify="right", style="red")
|
|
140
|
+
table.add_column("Created At", style="blue")
|
|
141
|
+
table.add_column("GPU Verification", style="white")
|
|
142
|
+
table.add_column("Instance verification", style="white")
|
|
143
|
+
for gpu in inventory:
|
|
144
|
+
table.add_row(
|
|
145
|
+
gpu["name"],
|
|
146
|
+
f"{gpu['chute_id']} {gpu['chute']}",
|
|
147
|
+
format_memory(gpu["memory"]),
|
|
148
|
+
f"{gpu['clock_rate'] / 1000:.0f}",
|
|
149
|
+
format_date(gpu["created_at"]),
|
|
150
|
+
format_verification(gpu["verification_error"], gpu["verified_at"]),
|
|
151
|
+
format_verification(gpu["inst_verification_error"], gpu["inst_verified_at"]),
|
|
152
|
+
)
|
|
153
|
+
console.print(table)
|
|
154
|
+
console.print("\n" + "=" * 80 + "\n")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def local_inventory(
|
|
158
|
+
raw_json: bool = typer.Option(False, help="Display raw JSON output"),
|
|
159
|
+
hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
|
|
160
|
+
miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
|
|
161
|
+
):
|
|
162
|
+
"""
|
|
163
|
+
Show local inventory.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
async def _local_inventory():
|
|
167
|
+
nonlocal hotkey, miner_api, raw_json
|
|
168
|
+
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
|
169
|
+
headers, _ = sign_request(hotkey, purpose="management")
|
|
170
|
+
async with session.get(
|
|
171
|
+
f"{miner_api.rstrip('/')}/servers/",
|
|
172
|
+
headers=headers,
|
|
173
|
+
timeout=30,
|
|
174
|
+
) as resp:
|
|
175
|
+
inventory = await resp.json()
|
|
176
|
+
if raw_json:
|
|
177
|
+
print(json.dumps(inventory, indent=2))
|
|
178
|
+
else:
|
|
179
|
+
display_local_inventory(inventory)
|
|
180
|
+
|
|
181
|
+
asyncio.run(_local_inventory())
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def remote_inventory(
|
|
185
|
+
raw_json: bool = typer.Option(False, help="Display raw JSON output"),
|
|
186
|
+
hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
|
|
187
|
+
validator_api: str = typer.Option("https://api.chutes.ai", help="Validator API base URL"),
|
|
188
|
+
):
|
|
189
|
+
"""
|
|
190
|
+
Show remote (i.e., what the validator has tracked) inventory.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
async def _remote_inventory():
|
|
194
|
+
nonlocal hotkey, validator_api, raw_json
|
|
195
|
+
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
|
196
|
+
headers, _ = sign_request(hotkey, purpose="miner", remote=True)
|
|
197
|
+
inventory = []
|
|
198
|
+
gpu_map = {}
|
|
199
|
+
async with session.get(f"{validator_api}/miner/nodes/", headers=headers) as resp:
|
|
200
|
+
async for content_enc in resp.content:
|
|
201
|
+
content = content_enc.decode()
|
|
202
|
+
if content.startswith("data: "):
|
|
203
|
+
inventory.append(json.loads(content[6:]))
|
|
204
|
+
gpu_map[inventory[-1]["uuid"]] = inventory[-1]
|
|
205
|
+
gpu_map[inventory[-1]["uuid"]].update(
|
|
206
|
+
{
|
|
207
|
+
"chute": None,
|
|
208
|
+
"chute_id": None,
|
|
209
|
+
"inst_verification_error": None,
|
|
210
|
+
"inst_verified_at": None,
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
async with session.get(f"{validator_api}/miner/inventory", headers=headers) as resp:
|
|
214
|
+
for item in await resp.json():
|
|
215
|
+
if item["gpu_id"] in gpu_map:
|
|
216
|
+
gpu_map[item["gpu_id"]].update(
|
|
217
|
+
{
|
|
218
|
+
"chute": item["chute_name"],
|
|
219
|
+
"chute_id": item["chute_id"],
|
|
220
|
+
"inst_verification_error": item["verification_error"],
|
|
221
|
+
"inst_verified_at": item["last_verified_at"],
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
inventory = sorted(inventory, key=lambda o: o["created_at"])
|
|
225
|
+
if raw_json:
|
|
226
|
+
print(json.dumps(inventory, indent=2))
|
|
227
|
+
else:
|
|
228
|
+
display_remote_inventory(inventory)
|
|
229
|
+
|
|
230
|
+
asyncio.run(_remote_inventory())
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def add_node(
|
|
234
|
+
name: str = typer.Option(..., help="Name of the server/node"),
|
|
235
|
+
validator: str = typer.Option(..., help="Validator ss58 this node is allocated to"),
|
|
236
|
+
hourly_cost: float = typer.Option(..., help="Hourly cost, used in optimizing autoscaling"),
|
|
237
|
+
gpu_short_ref: str = typer.Option(..., help="GPU short reference"),
|
|
238
|
+
hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
|
|
239
|
+
agent_api: Optional[str] = typer.Option(None, help="Agent API base URL (K3s Only)"),
|
|
240
|
+
miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
|
|
241
|
+
):
|
|
242
|
+
"""
|
|
243
|
+
Entrypoint for adding a new kubernetes node.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
async def _add_node():
|
|
247
|
+
nonlocal name, validator, hourly_cost, gpu_short_ref, hotkey, miner_api
|
|
248
|
+
async with aiohttp.ClientSession(raise_for_status=False) as session:
|
|
249
|
+
payload = {
|
|
250
|
+
"name": name,
|
|
251
|
+
"validator": validator,
|
|
252
|
+
"hourly_cost": hourly_cost,
|
|
253
|
+
"gpu_short_ref": gpu_short_ref,
|
|
254
|
+
"agent_api": agent_api,
|
|
255
|
+
}
|
|
256
|
+
headers, payload_string = sign_request(hotkey, payload=payload)
|
|
257
|
+
async with session.post(
|
|
258
|
+
f"{miner_api.rstrip('/')}/servers/",
|
|
259
|
+
headers=headers,
|
|
260
|
+
data=payload_string,
|
|
261
|
+
timeout=900,
|
|
262
|
+
) as resp:
|
|
263
|
+
if resp.status != 200:
|
|
264
|
+
print(f"\033[31mError adding node:\n{await resp.text()}\033[0m")
|
|
265
|
+
resp.raise_for_status()
|
|
266
|
+
async for content in resp.content:
|
|
267
|
+
if content.strip():
|
|
268
|
+
payload = json.loads(content.decode()[6:])
|
|
269
|
+
print(f"\033[34m{payload['timestamp']}\033[0m {payload['message']}")
|
|
270
|
+
|
|
271
|
+
asyncio.run(_add_node())
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def delete_node(
|
|
275
|
+
name: str = typer.Option(..., help="Name of the server/node"),
|
|
276
|
+
hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
|
|
277
|
+
miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
|
|
278
|
+
):
|
|
279
|
+
"""
|
|
280
|
+
Entrypoint for deleting a kubernetes node.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
async def _delete_node():
|
|
284
|
+
nonlocal name, hotkey, miner_api
|
|
285
|
+
|
|
286
|
+
# Before we do anything, check if the server has any jobs running. Deleting
|
|
287
|
+
# a job is very, very bad.
|
|
288
|
+
if not await delete_preflight(name, hotkey, miner_api):
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
# Proceed with deletion.
|
|
292
|
+
async with aiohttp.ClientSession(raise_for_status=False) as session:
|
|
293
|
+
headers, payload_string = sign_request(hotkey, purpose="management")
|
|
294
|
+
async with session.delete(
|
|
295
|
+
f"{miner_api.rstrip('/')}/servers/{name}",
|
|
296
|
+
headers=headers,
|
|
297
|
+
) as resp:
|
|
298
|
+
if resp.status == 404:
|
|
299
|
+
logger.warning(f"Node not found: {name}")
|
|
300
|
+
return
|
|
301
|
+
resp.raise_for_status()
|
|
302
|
+
print(json.dumps(await resp.json(), indent=2))
|
|
303
|
+
|
|
304
|
+
asyncio.run(_delete_node())
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def purge_deployments(
|
|
308
|
+
hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
|
|
309
|
+
miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
|
|
310
|
+
):
|
|
311
|
+
"""
|
|
312
|
+
Rebalance all chutes - this just deletes all current instances and let's gepetto re-scale for max $$$
|
|
313
|
+
This does not purge jobs!
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
async def _purge_deployments():
|
|
317
|
+
nonlocal hotkey, miner_api
|
|
318
|
+
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
|
319
|
+
headers, payload_string = sign_request(hotkey, purpose="management")
|
|
320
|
+
async with session.delete(
|
|
321
|
+
f"{miner_api.rstrip('/')}/deployments/purge",
|
|
322
|
+
headers=headers,
|
|
323
|
+
) as resp:
|
|
324
|
+
print(json.dumps(await resp.json(), indent=2))
|
|
325
|
+
|
|
326
|
+
asyncio.run(_purge_deployments())
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def purge_deployment(
|
|
330
|
+
deployment_id: str = typer.Option(
|
|
331
|
+
None, "--deployment-id", "-d", help="The ID of the deployment to purge."
|
|
332
|
+
),
|
|
333
|
+
node_id: str = typer.Option(
|
|
334
|
+
None, "--node-id", "-n", help="The ID of the node to purge the deployment from."
|
|
335
|
+
),
|
|
336
|
+
hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
|
|
337
|
+
miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
|
|
338
|
+
):
|
|
339
|
+
"""
|
|
340
|
+
Purge the target deployment
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
if (deployment_id is None and node_id is None) or (
|
|
344
|
+
deployment_id is not None and node_id is not None
|
|
345
|
+
):
|
|
346
|
+
typer.echo("Error: Either deployment_id or node_id must be provided, but not both.")
|
|
347
|
+
raise typer.Exit(1)
|
|
348
|
+
|
|
349
|
+
target_id = deployment_id or node_id
|
|
350
|
+
endpoint = f"deployments/{target_id}" if deployment_id else f"servers/{target_id}/deployments"
|
|
351
|
+
|
|
352
|
+
async def _purge_deployment():
|
|
353
|
+
nonlocal target_id, hotkey, miner_api, endpoint
|
|
354
|
+
|
|
355
|
+
# Make sure there aren't jobs and or miner wants to delete anyways.
|
|
356
|
+
if node_id and not await delete_preflight(node_id, hotkey, miner_api):
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
|
360
|
+
headers, payload_string = sign_request(hotkey, purpose="management")
|
|
361
|
+
async with session.delete(
|
|
362
|
+
f"{miner_api.rstrip('/')}/{endpoint}",
|
|
363
|
+
headers=headers,
|
|
364
|
+
) as resp:
|
|
365
|
+
print(json.dumps(await resp.json(), indent=2))
|
|
366
|
+
|
|
367
|
+
asyncio.run(_purge_deployment())
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def scorch_remote(
|
|
371
|
+
hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
|
|
372
|
+
validator_api: str = typer.Option("https://api.chutes.ai", help="Validator API base URL"),
|
|
373
|
+
):
|
|
374
|
+
"""
|
|
375
|
+
Purge all inventory from the validator API.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
async def _scorch_remote():
|
|
379
|
+
nonlocal hotkey, validator_api
|
|
380
|
+
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
|
381
|
+
headers, _ = sign_request(hotkey, purpose="miner", remote=True)
|
|
382
|
+
async with session.get(
|
|
383
|
+
f"{validator_api.rstrip('/')}/miner/nodes/",
|
|
384
|
+
headers=headers,
|
|
385
|
+
) as resp:
|
|
386
|
+
async for line in resp.content:
|
|
387
|
+
if not line or not line.startswith(b"data:"):
|
|
388
|
+
continue
|
|
389
|
+
gpu = json.loads(line.decode()[6:])
|
|
390
|
+
print(f"Deleting {gpu['name']} with uuid {gpu['uuid']}")
|
|
391
|
+
headers, _ = sign_request(hotkey, purpose="nodes", remote=True)
|
|
392
|
+
async with session.delete(
|
|
393
|
+
f"{validator_api.rstrip('/')}/nodes/{gpu['uuid']}",
|
|
394
|
+
headers=headers,
|
|
395
|
+
) as resp:
|
|
396
|
+
print(f" successfully deleted {gpu['name']} with uuid {gpu['uuid']}")
|
|
397
|
+
|
|
398
|
+
asyncio.run(_scorch_remote())
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def delete_remote(
|
|
402
|
+
gpu_id: str = typer.Option(help="GPU UUID to delete, aka node_node on validator side"),
|
|
403
|
+
hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
|
|
404
|
+
validator_api: str = typer.Option("https://api.chutes.ai", help="Validator API base URL"),
|
|
405
|
+
):
|
|
406
|
+
"""
|
|
407
|
+
Delete a single GPU from validator.
|
|
408
|
+
"""
|
|
409
|
+
|
|
410
|
+
async def _delete_remote():
|
|
411
|
+
nonlocal hotkey, validator_api, gpu_id
|
|
412
|
+
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
|
413
|
+
headers, _ = sign_request(hotkey, purpose="nodes", remote=True)
|
|
414
|
+
async with session.delete(
|
|
415
|
+
f"{validator_api.rstrip('/')}/nodes/{gpu_id}",
|
|
416
|
+
headers=headers,
|
|
417
|
+
) as _:
|
|
418
|
+
print(f" successfully deleted {gpu_id}")
|
|
419
|
+
|
|
420
|
+
asyncio.run(_delete_remote())
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def _lock_or_unlock_server(lock: bool, name: str, hotkey: str, miner_api: str):
|
|
424
|
+
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
|
425
|
+
headers, _ = sign_request(hotkey, purpose="management")
|
|
426
|
+
path = "/lock" if lock else "/unlock"
|
|
427
|
+
async with session.get(
|
|
428
|
+
f"{miner_api.rstrip('/')}/servers/{name}{path}",
|
|
429
|
+
headers=headers,
|
|
430
|
+
) as resp:
|
|
431
|
+
server = await resp.json()
|
|
432
|
+
print(f"Server {server['name']} lock status is now: {server['locked']}")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def lock_server(
|
|
436
|
+
name: str = typer.Option(..., help="Name of the server/node"),
|
|
437
|
+
hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
|
|
438
|
+
miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
|
|
439
|
+
):
|
|
440
|
+
"""
|
|
441
|
+
Lock a server's deployments.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
async def _lock_server():
|
|
445
|
+
nonlocal name, hotkey, miner_api
|
|
446
|
+
await _lock_or_unlock_server(lock=True, name=name, hotkey=hotkey, miner_api=miner_api)
|
|
447
|
+
|
|
448
|
+
asyncio.run(_lock_server())
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def unlock_server(
|
|
452
|
+
name: str = typer.Option(..., help="Name of the server/node"),
|
|
453
|
+
hotkey: str = typer.Option(..., help="Path to the hotkey file for your miner"),
|
|
454
|
+
miner_api: str = typer.Option("http://127.0.0.1:32000", help="Miner API base URL"),
|
|
455
|
+
):
|
|
456
|
+
"""
|
|
457
|
+
Unlock a server's deployments.
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
async def _unlock_server():
|
|
461
|
+
nonlocal name, hotkey, miner_api
|
|
462
|
+
await _lock_or_unlock_server(lock=False, name=name, hotkey=hotkey, miner_api=miner_api)
|
|
463
|
+
|
|
464
|
+
asyncio.run(_unlock_server())
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
app.command(name="add-node", help="Add a new kubernetes node to your cluster")(add_node)
|
|
468
|
+
app.command(name="delete-node", help="Delete a kubernetes node from your cluster")(delete_node)
|
|
469
|
+
app.command(
|
|
470
|
+
name="purge-deployments",
|
|
471
|
+
help="Purge all deployments, allowing autoscale from scratch",
|
|
472
|
+
)(purge_deployments)
|
|
473
|
+
app.command(name="purge-deployment", help="Purge the target deployment")(purge_deployment)
|
|
474
|
+
app.command(name="local-inventory", help="Show local inventory")(local_inventory)
|
|
475
|
+
app.command(name="remote-inventory", help="Show remote inventory")(remote_inventory)
|
|
476
|
+
app.command(name="scorch-remote", help="Purge all GPUs/instances/etc. from validator")(
|
|
477
|
+
scorch_remote
|
|
478
|
+
)
|
|
479
|
+
app.command(name="delete-remote", help="Remove a single GPU from validator inventory")(
|
|
480
|
+
delete_remote
|
|
481
|
+
)
|
|
482
|
+
app.command(name="lock", help="Lock a server's deployments")(lock_server)
|
|
483
|
+
app.command(name="unlock", help="Unlock a server's deployments")(unlock_server)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
if __name__ == "__main__":
|
|
487
|
+
app()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import hashlib
|
|
5
|
+
import time
|
|
6
|
+
from substrateinterface import Keypair
|
|
7
|
+
from typing import Dict, Any
|
|
8
|
+
from chutes_miner_cli.constants import (
|
|
9
|
+
VALIDATOR_HEADER,
|
|
10
|
+
HOTKEY_HEADER,
|
|
11
|
+
MINER_HEADER,
|
|
12
|
+
NONCE_HEADER,
|
|
13
|
+
SIGNATURE_HEADER,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_signing_message(
|
|
18
|
+
hotkey: str,
|
|
19
|
+
nonce: str,
|
|
20
|
+
payload_str: str | bytes | None,
|
|
21
|
+
purpose: str | None = None,
|
|
22
|
+
payload_hash: str | None = None,
|
|
23
|
+
) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Get the signing message for a given hotkey, nonce, and payload.
|
|
26
|
+
"""
|
|
27
|
+
if payload_str:
|
|
28
|
+
if isinstance(payload_str, str):
|
|
29
|
+
payload_str = payload_str.encode()
|
|
30
|
+
return f"{hotkey}:{nonce}:{hashlib.sha256(payload_str).hexdigest()}"
|
|
31
|
+
elif purpose:
|
|
32
|
+
return f"{hotkey}:{nonce}:{purpose}"
|
|
33
|
+
elif payload_hash:
|
|
34
|
+
return f"{hotkey}:{nonce}:{payload_hash}"
|
|
35
|
+
else:
|
|
36
|
+
raise ValueError("Either payload_str or purpose must be provided")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sign_request(
|
|
40
|
+
hotkey: str,
|
|
41
|
+
payload: Dict[str, Any] | str | None = None,
|
|
42
|
+
purpose: str = None,
|
|
43
|
+
remote: bool = False,
|
|
44
|
+
):
|
|
45
|
+
"""
|
|
46
|
+
Generate a signed request (for miner requests to validators).
|
|
47
|
+
"""
|
|
48
|
+
hotkey_data = json.loads(open(hotkey).read())
|
|
49
|
+
nonce = str(int(time.time()))
|
|
50
|
+
headers = {
|
|
51
|
+
MINER_HEADER: hotkey_data["ss58Address"],
|
|
52
|
+
NONCE_HEADER: nonce,
|
|
53
|
+
}
|
|
54
|
+
if remote:
|
|
55
|
+
headers[HOTKEY_HEADER] = headers.pop(MINER_HEADER)
|
|
56
|
+
signature_string = None
|
|
57
|
+
payload_string = None
|
|
58
|
+
if payload is not None:
|
|
59
|
+
if isinstance(payload, (list, dict)):
|
|
60
|
+
headers["Content-Type"] = "application/json"
|
|
61
|
+
payload_string = json.dumps(payload)
|
|
62
|
+
else:
|
|
63
|
+
payload_string = payload
|
|
64
|
+
signature_string = get_signing_message(
|
|
65
|
+
hotkey_data["ss58Address"],
|
|
66
|
+
nonce,
|
|
67
|
+
payload_str=payload_string,
|
|
68
|
+
purpose=None,
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
signature_string = get_signing_message(
|
|
72
|
+
hotkey_data["ss58Address"], nonce, payload_str=None, purpose=purpose
|
|
73
|
+
)
|
|
74
|
+
if not remote:
|
|
75
|
+
signature_string = hotkey_data["ss58Address"] + ":" + signature_string
|
|
76
|
+
headers[VALIDATOR_HEADER] = headers[MINER_HEADER]
|
|
77
|
+
keypair = Keypair.create_from_seed(hotkey_data["secretSeed"])
|
|
78
|
+
headers[SIGNATURE_HEADER] = keypair.sign(signature_string.encode()).hex()
|
|
79
|
+
return headers, payload_string
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "chutes-miner-cli"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Chutes miner CLI"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [ "Jon Durbin <jon@jondurbin.com>" ]
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 3 - Alpha",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"Operating System :: POSIX :: Linux",
|
|
12
|
+
"Programming Language :: Python :: 3.10",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.poetry.dependencies]
|
|
16
|
+
python = "^3.12"
|
|
17
|
+
loguru = "^0.7.3"
|
|
18
|
+
pydantic = ">2.9,<3"
|
|
19
|
+
setuptools = ">80.0"
|
|
20
|
+
substrate-interface = "^1.7.11"
|
|
21
|
+
py-bip39-bindings = "^0.1.11"
|
|
22
|
+
rich = ">13.0.0"
|
|
23
|
+
typer = "^0.14.0"
|
|
24
|
+
click = ">8.1.0,<8.2.0"
|
|
25
|
+
aiohttp = {extras = ["speedups"], version = ">3.10,<4"}
|
|
26
|
+
|
|
27
|
+
[tool.poetry.group.dev.dependencies]
|
|
28
|
+
ruff = "^0.12.2"
|
|
29
|
+
wheel = "^0.45.1"
|
|
30
|
+
|
|
31
|
+
[tool.poetry.urls]
|
|
32
|
+
Homepage = "https://github.com/rayonlabs/chutes-miner/tree/main/cli"
|
|
33
|
+
|
|
34
|
+
[tool.poetry.scripts]
|
|
35
|
+
chutes-miner = "chutes_miner_cli.cli:app"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["poetry-core"]
|
|
39
|
+
build-backend = "poetry.core.masonry.api"
|