firecloud-devnet 0.1.0__py3-none-any.whl

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.
firecloud/cli.py ADDED
@@ -0,0 +1,540 @@
1
+ """FireCloud CLI — click-based command-line interface.
2
+
3
+ Provides commands to initialise a network, start/stop a node,
4
+ upload/download/delete files, list files and peers, connect to
5
+ peers, and sync a folder.
6
+ """
7
+
8
+ import asyncio
9
+ import os
10
+ import signal
11
+ from pathlib import Path
12
+
13
+ import click
14
+
15
+ from firecloud.network import Network
16
+ from firecloud.exceptions import FireCloudError
17
+
18
+
19
+ # Default configuration directory
20
+ _DEFAULT_DIR = Path.home() / ".firecloud"
21
+ _KEYSTORE_FILE = "network.key"
22
+ _PID_FILE = "firecloud.pid"
23
+
24
+
25
+ def _config_dir() -> Path:
26
+ """Return (and create) the config directory."""
27
+ d = _DEFAULT_DIR
28
+ d.mkdir(parents=True, exist_ok=True)
29
+ return d
30
+
31
+
32
+ def _load_network(passphrase: str) -> Network:
33
+ """Load the network from the default keystore."""
34
+ keystore = _config_dir() / _KEYSTORE_FILE
35
+ if not keystore.exists():
36
+ raise click.ClickException(
37
+ "Network not initialised. Run 'firecloud init' first."
38
+ )
39
+ try:
40
+ return Network.load(keystore, passphrase)
41
+ except Exception as exc:
42
+ raise click.ClickException(f"Failed to load network: {exc}") from exc
43
+
44
+ def _human_size(num_bytes: int) -> str:
45
+ """Convert bytes to a human-readable string."""
46
+ for unit in ("B", "KB", "MB", "GB", "TB"):
47
+ if abs(num_bytes) < 1024:
48
+ return f"{num_bytes:.1f} {unit}"
49
+ num_bytes /= 1024
50
+ return f"{num_bytes:.1f} PB"
51
+
52
+
53
+ # ========================================================================
54
+ # CLI Group
55
+ # ========================================================================
56
+
57
+
58
+ @click.group()
59
+ @click.version_option(version="0.1.0", prog_name="firecloud")
60
+ def cli():
61
+ """FireCloud — Private, encrypted, distributed storage."""
62
+ pass
63
+
64
+
65
+ # ========================================================================
66
+ # init
67
+ # ========================================================================
68
+
69
+
70
+ @cli.command()
71
+ @click.option("--join", "join_addr", default=None, help="Join an existing network via peer address (host:port).")
72
+ @click.option("--passphrase", prompt=True, hide_input=True, confirmation_prompt=True, help="Passphrase to protect the network key.")
73
+ def init(join_addr: str | None, passphrase: str):
74
+ """Initialise a new FireCloud network or join an existing one."""
75
+ cfg = _config_dir()
76
+ keystore = cfg / _KEYSTORE_FILE
77
+
78
+ if join_addr:
79
+ # Join mode: connect to peer and get network key
80
+ click.echo(f"Joining network via {join_addr}...")
81
+ # For v1, joining requires the same passphrase — the peer shares the
82
+ # keystore file out-of-band. We simply create the config directory.
83
+ click.echo(
84
+ click.style("⚠ Copy the network.key file from an existing node to:", fg="yellow")
85
+ )
86
+ click.echo(f" {keystore}")
87
+ return
88
+
89
+ if keystore.exists():
90
+ click.echo(
91
+ click.style("Network already initialised.", fg="yellow")
92
+ + f" Keystore at {keystore}"
93
+ )
94
+ return
95
+
96
+ net = Network.create(passphrase)
97
+ net.save(keystore, passphrase)
98
+ click.echo(click.style("✓ Network initialised.", fg="green"))
99
+ click.echo(f" Network ID : {net.network_id}")
100
+ click.echo(f" Keystore : {keystore}")
101
+
102
+
103
+ # ========================================================================
104
+ # start
105
+ # ========================================================================
106
+
107
+
108
+ @cli.command()
109
+ @click.option("--passphrase", prompt=True, hide_input=True, help="Network passphrase.")
110
+ @click.option("--port", default=7474, type=int, help="TCP port to listen on.")
111
+ @click.option("--daemon", is_flag=True, help="Run in the background (Unix only).")
112
+ @click.option("--storage", default=None, type=click.Path(), help="Storage directory.")
113
+ def start(passphrase: str, port: int, daemon: bool, storage: str | None):
114
+ """Start the FireCloud node."""
115
+ net = _load_network(passphrase)
116
+ storage_path = Path(storage) if storage else _config_dir() / "storage"
117
+
118
+ if daemon:
119
+ _start_daemon(net, storage_path, port)
120
+ else:
121
+ _start_foreground(net, storage_path, port)
122
+
123
+
124
+ def _start_foreground(net: Network, storage_path: Path, port: int):
125
+ """Run the node in the foreground."""
126
+ from firecloud.node import Node
127
+
128
+ async def _run():
129
+ node = Node(network=net, storage_path=storage_path, port=port)
130
+ await node.start()
131
+ click.echo(click.style(f"✓ Node {node.node_id} running on port {port}", fg="green"))
132
+ click.echo(" Press Ctrl+C to stop.")
133
+
134
+ stop_event = asyncio.Event()
135
+ loop = asyncio.get_running_loop()
136
+
137
+ def _signal_handler():
138
+ stop_event.set()
139
+
140
+ for sig in (signal.SIGINT, signal.SIGTERM):
141
+ try:
142
+ loop.add_signal_handler(sig, _signal_handler)
143
+ except NotImplementedError:
144
+ pass # Windows
145
+
146
+ await stop_event.wait()
147
+ click.echo("\nStopping node...")
148
+ await node.stop()
149
+ click.echo(click.style("✓ Node stopped.", fg="green"))
150
+
151
+ asyncio.run(_run())
152
+
153
+
154
+ def _start_daemon(net: Network, storage_path: Path, port: int):
155
+ """Fork to background and write a PID file (Unix only)."""
156
+ pid_file = _config_dir() / _PID_FILE
157
+
158
+ if pid_file.exists():
159
+ pid = int(pid_file.read_text().strip())
160
+ try:
161
+ os.kill(pid, 0)
162
+ click.echo(click.style(f"Node already running (PID {pid}).", fg="yellow"))
163
+ return
164
+ except OSError:
165
+ pid_file.unlink(missing_ok=True)
166
+
167
+ try:
168
+ child_pid = os.fork()
169
+ except AttributeError:
170
+ raise click.ClickException("--daemon is not supported on this platform (no os.fork).")
171
+
172
+ if child_pid > 0:
173
+ # Parent — write PID and exit
174
+ pid_file.write_text(str(child_pid))
175
+ click.echo(click.style(f"✓ Node daemonised (PID {child_pid}).", fg="green"))
176
+ return
177
+
178
+ # Child — detach and run
179
+ os.setsid()
180
+ _start_foreground(net, storage_path, port)
181
+
182
+
183
+ # ========================================================================
184
+ # stop
185
+ # ========================================================================
186
+
187
+
188
+ @cli.command()
189
+ def stop():
190
+ """Stop a running FireCloud daemon."""
191
+ pid_file = _config_dir() / _PID_FILE
192
+ if not pid_file.exists():
193
+ click.echo(click.style("No running daemon found.", fg="yellow"))
194
+ return
195
+
196
+ pid = int(pid_file.read_text().strip())
197
+ try:
198
+ os.kill(pid, signal.SIGTERM)
199
+ click.echo(click.style(f"✓ Sent SIGTERM to PID {pid}.", fg="green"))
200
+ except OSError as exc:
201
+ click.echo(click.style(f"Failed to stop PID {pid}: {exc}", fg="red"))
202
+ finally:
203
+ pid_file.unlink(missing_ok=True)
204
+
205
+
206
+ # ========================================================================
207
+ # status
208
+ # ========================================================================
209
+
210
+
211
+ @cli.command()
212
+ @click.option("--passphrase", prompt=True, hide_input=True, help="Network passphrase.")
213
+ @click.option("--port", default=7474, type=int)
214
+ @click.option("--storage", default=None, type=click.Path())
215
+ def status(passphrase: str, port: int, storage: str | None):
216
+ """Show node and network status."""
217
+ net = _load_network(passphrase)
218
+ storage_path = Path(storage) if storage else _config_dir() / "storage"
219
+
220
+ from firecloud.node import Node
221
+
222
+ async def _run():
223
+ node = Node(network=net, storage_path=storage_path, port=port,
224
+ enable_discovery=False)
225
+ s = node.status()
226
+ click.echo(click.style("FireCloud Status", fg="cyan", bold=True))
227
+ click.echo(f" Node ID : {s['node_id']}")
228
+ click.echo(f" Network ID : {s['network_id']}")
229
+ click.echo(f" Port : {s['port']}")
230
+ click.echo(f" Running : {s['running']}")
231
+ click.echo(f" Peers connected : {s['peers_connected']}")
232
+ click.echo(f" Files stored : {s['files_stored']}")
233
+ click.echo(f" Chunks stored : {s['chunks_stored']}")
234
+ click.echo(f" Storage used : {_human_size(s['storage_used'])}")
235
+ click.echo(f" Storage available: {_human_size(s['storage_available'])}")
236
+
237
+ asyncio.run(_run())
238
+
239
+
240
+ # ========================================================================
241
+ # upload
242
+ # ========================================================================
243
+
244
+
245
+ @cli.command()
246
+ @click.argument("path", type=click.Path(exists=True))
247
+ @click.option("--passphrase", prompt=True, hide_input=True, help="Network passphrase.")
248
+ @click.option("--port", default=7474, type=int)
249
+ @click.option("--storage", default=None, type=click.Path())
250
+ def upload(path: str, passphrase: str, port: int, storage: str | None):
251
+ """Upload a file to the FireCloud network."""
252
+ net = _load_network(passphrase)
253
+ storage_path = Path(storage) if storage else _config_dir() / "storage"
254
+
255
+ from firecloud.node import Node
256
+
257
+ async def _run():
258
+ node = Node(network=net, storage_path=storage_path, port=port,
259
+ enable_discovery=False)
260
+ await node.start()
261
+ try:
262
+ file_id = await node.upload(path)
263
+ click.echo(click.style("✓ Uploaded successfully.", fg="green"))
264
+ click.echo(f" File ID: {file_id}")
265
+ except FireCloudError as exc:
266
+ raise click.ClickException(str(exc))
267
+ finally:
268
+ await node.stop()
269
+
270
+ asyncio.run(_run())
271
+
272
+
273
+ # ========================================================================
274
+ # download
275
+ # ========================================================================
276
+
277
+
278
+ @cli.command()
279
+ @click.argument("file_id")
280
+ @click.argument("output", type=click.Path())
281
+ @click.option("--passphrase", prompt=True, hide_input=True, help="Network passphrase.")
282
+ @click.option("--port", default=7474, type=int)
283
+ @click.option("--storage", default=None, type=click.Path())
284
+ def download(file_id: str, output: str, passphrase: str, port: int, storage: str | None):
285
+ """Download a file from the FireCloud network."""
286
+ net = _load_network(passphrase)
287
+ storage_path = Path(storage) if storage else _config_dir() / "storage"
288
+
289
+ from firecloud.node import Node
290
+
291
+ async def _run():
292
+ node = Node(network=net, storage_path=storage_path, port=port,
293
+ enable_discovery=False)
294
+ await node.start()
295
+ try:
296
+ await node.download(file_id, output)
297
+ click.echo(click.style(f"✓ Downloaded to {output}", fg="green"))
298
+ except FireCloudError as exc:
299
+ raise click.ClickException(str(exc))
300
+ finally:
301
+ await node.stop()
302
+
303
+ asyncio.run(_run())
304
+
305
+
306
+ # ========================================================================
307
+ # delete
308
+ # ========================================================================
309
+
310
+
311
+ @cli.command("delete")
312
+ @click.argument("file_id")
313
+ @click.option("--passphrase", prompt=True, hide_input=True, help="Network passphrase.")
314
+ @click.option("--port", default=7474, type=int)
315
+ @click.option("--storage", default=None, type=click.Path())
316
+ def delete_file(file_id: str, passphrase: str, port: int, storage: str | None):
317
+ """Delete a file from the FireCloud network."""
318
+ net = _load_network(passphrase)
319
+ storage_path = Path(storage) if storage else _config_dir() / "storage"
320
+
321
+ from firecloud.node import Node
322
+
323
+ async def _run():
324
+ node = Node(network=net, storage_path=storage_path, port=port,
325
+ enable_discovery=False)
326
+ await node.start()
327
+ try:
328
+ await node.delete(file_id)
329
+ click.echo(click.style(f"✓ File {file_id[:16]}... deleted.", fg="green"))
330
+ except FireCloudError as exc:
331
+ raise click.ClickException(str(exc))
332
+ finally:
333
+ await node.stop()
334
+
335
+ asyncio.run(_run())
336
+
337
+
338
+ # ========================================================================
339
+ # list
340
+ # ========================================================================
341
+
342
+
343
+ @cli.command("list")
344
+ @click.option("--passphrase", prompt=True, hide_input=True, help="Network passphrase.")
345
+ @click.option("--port", default=7474, type=int)
346
+ @click.option("--storage", default=None, type=click.Path())
347
+ def list_files(passphrase: str, port: int, storage: str | None):
348
+ """List all files stored in the FireCloud network."""
349
+ net = _load_network(passphrase)
350
+ storage_path = Path(storage) if storage else _config_dir() / "storage"
351
+
352
+ from firecloud.node import Node
353
+
354
+ async def _run():
355
+ node = Node(network=net, storage_path=storage_path, port=port,
356
+ enable_discovery=False)
357
+ files = node.list_files()
358
+ if not files:
359
+ click.echo("No files stored.")
360
+ return
361
+
362
+ # Table header
363
+ click.echo(
364
+ click.style(
365
+ f"{'Name':<30} {'Size':>10} {'Chunks':>7} {'FEC':>5} {'Uploaded At':<26} {'File ID':<20}",
366
+ bold=True,
367
+ )
368
+ )
369
+ click.echo("─" * 105)
370
+ for f in files:
371
+ click.echo(
372
+ f"{f['name']:<30} "
373
+ f"{_human_size(f['size']):>10} "
374
+ f"{f['chunk_count']:>7} "
375
+ f"{'yes' if f['fec_enabled'] else 'no':>5} "
376
+ f"{f['uploaded_at']:<26} "
377
+ f"{f['file_id'][:20]}"
378
+ )
379
+
380
+ asyncio.run(_run())
381
+
382
+
383
+ # ========================================================================
384
+ # peers
385
+ # ========================================================================
386
+
387
+
388
+ @cli.command()
389
+ @click.option("--passphrase", prompt=True, hide_input=True, help="Network passphrase.")
390
+ @click.option("--port", default=7474, type=int)
391
+ @click.option("--storage", default=None, type=click.Path())
392
+ def peers(passphrase: str, port: int, storage: str | None):
393
+ """List known peers."""
394
+ net = _load_network(passphrase)
395
+ storage_path = Path(storage) if storage else _config_dir() / "storage"
396
+
397
+ from firecloud.node import Node
398
+
399
+ async def _run():
400
+ node = Node(network=net, storage_path=storage_path, port=port,
401
+ enable_discovery=False)
402
+ peer_list = node.peers()
403
+ if not peer_list:
404
+ click.echo("No known peers.")
405
+ return
406
+
407
+ click.echo(
408
+ click.style(f"{'Node ID':<20} {'Host':<16} {'Port':>6} {'Connected':>10}", bold=True)
409
+ )
410
+ click.echo("─" * 55)
411
+ for p in peer_list:
412
+ click.echo(
413
+ f"{p['node_id']:<20} "
414
+ f"{p['host']:<16} "
415
+ f"{p['port']:>6} "
416
+ f"{'yes' if p['connected'] else 'no':>10}"
417
+ )
418
+
419
+ asyncio.run(_run())
420
+
421
+
422
+ # ========================================================================
423
+ # connect
424
+ # ========================================================================
425
+
426
+
427
+ @cli.command()
428
+ @click.argument("address")
429
+ @click.option("--passphrase", prompt=True, hide_input=True, help="Network passphrase.")
430
+ @click.option("--port", default=7474, type=int)
431
+ @click.option("--storage", default=None, type=click.Path())
432
+ def connect(address: str, passphrase: str, port: int, storage: str | None):
433
+ """Connect to a peer at ADDRESS (host:port)."""
434
+ net = _load_network(passphrase)
435
+ storage_path = Path(storage) if storage else _config_dir() / "storage"
436
+
437
+ from firecloud.node import Node
438
+
439
+ async def _run():
440
+ node = Node(network=net, storage_path=storage_path, port=port,
441
+ enable_discovery=False)
442
+ await node.start()
443
+ try:
444
+ await node.connect(address)
445
+ click.echo(click.style(f"✓ Connected to {address}", fg="green"))
446
+ except FireCloudError as exc:
447
+ raise click.ClickException(str(exc))
448
+ finally:
449
+ await node.stop()
450
+
451
+ asyncio.run(_run())
452
+
453
+
454
+ # ========================================================================
455
+ # remove-node
456
+ # ========================================================================
457
+
458
+
459
+ @cli.command("remove-node")
460
+ @click.argument("node_id")
461
+ @click.option("--passphrase", prompt=True, hide_input=True, help="Network passphrase.")
462
+ @click.option("--port", default=7474, type=int)
463
+ @click.option("--storage", default=None, type=click.Path())
464
+ def remove_node(node_id: str, passphrase: str, port: int, storage: str | None):
465
+ """Remove a node from the network and trigger re-replication."""
466
+ net = _load_network(passphrase)
467
+ storage_path = Path(storage) if storage else _config_dir() / "storage"
468
+
469
+ from firecloud.node import Node
470
+
471
+ async def _run():
472
+ node = Node(network=net, storage_path=storage_path, port=port,
473
+ enable_discovery=False)
474
+ await node.start()
475
+ try:
476
+ await node.remove_node(node_id)
477
+ click.echo(click.style(f"✓ Removed node {node_id[:16]}... and triggered re-replication", fg="green"))
478
+ except FireCloudError as exc:
479
+ raise click.ClickException(str(exc))
480
+ finally:
481
+ await node.stop()
482
+
483
+ asyncio.run(_run())
484
+
485
+
486
+ # ========================================================================
487
+ # sync
488
+ # ========================================================================
489
+
490
+
491
+ @cli.command()
492
+ @click.argument("folder", type=click.Path())
493
+ @click.option("--passphrase", prompt=True, hide_input=True, help="Network passphrase.")
494
+ @click.option("--port", default=7474, type=int)
495
+ @click.option("--storage", default=None, type=click.Path())
496
+ @click.option("--daemon", is_flag=True, help="Run in the background (Unix only).")
497
+ def sync(folder: str, passphrase: str, port: int, storage: str | None, daemon: bool):
498
+ """Sync a local folder with the FireCloud network."""
499
+ net = _load_network(passphrase)
500
+ storage_path = Path(storage) if storage else _config_dir() / "storage"
501
+
502
+ from firecloud.node import Node
503
+ from firecloud.sync import FolderSync
504
+
505
+ async def _run():
506
+ node = Node(network=net, storage_path=storage_path, port=port)
507
+ await node.start()
508
+ fs = FolderSync(node, Path(folder))
509
+ await fs.start()
510
+ click.echo(click.style(f"✓ Syncing {folder}", fg="green"))
511
+ click.echo(" Press Ctrl+C to stop.")
512
+
513
+ stop_event = asyncio.Event()
514
+ loop = asyncio.get_running_loop()
515
+
516
+ def _signal_handler():
517
+ stop_event.set()
518
+
519
+ for sig in (signal.SIGINT, signal.SIGTERM):
520
+ try:
521
+ loop.add_signal_handler(sig, _signal_handler)
522
+ except NotImplementedError:
523
+ pass
524
+
525
+ await stop_event.wait()
526
+ click.echo("\nStopping sync...")
527
+ await fs.stop()
528
+ await node.stop()
529
+ click.echo(click.style("✓ Sync stopped.", fg="green"))
530
+
531
+ asyncio.run(_run())
532
+
533
+
534
+ # ========================================================================
535
+ # Entry point
536
+ # ========================================================================
537
+
538
+
539
+ if __name__ == "__main__":
540
+ cli()