redundanet 2.0.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.
redundanet/cli/node.py ADDED
@@ -0,0 +1,305 @@
1
+ """Node management CLI commands for RedundaNet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from redundanet.core.config import NodeRole, NodeStatus
13
+ from redundanet.core.manifest import Manifest
14
+
15
+ app = typer.Typer(help="Node management commands")
16
+ console = Console()
17
+
18
+
19
+ @app.command("list")
20
+ def list_nodes(
21
+ manifest_path: Annotated[
22
+ Optional[Path],
23
+ typer.Option("--manifest", "-m", help="Path to manifest file"),
24
+ ] = None,
25
+ role: Annotated[
26
+ Optional[str],
27
+ typer.Option("--role", "-r", help="Filter by role"),
28
+ ] = None,
29
+ status: Annotated[
30
+ Optional[str],
31
+ typer.Option("--status", "-s", help="Filter by status"),
32
+ ] = None,
33
+ ) -> None:
34
+ """List all nodes in the network."""
35
+ if manifest_path is None:
36
+ from redundanet.core.config import get_default_manifest_path
37
+
38
+ manifest_path = get_default_manifest_path()
39
+
40
+ if not manifest_path.exists():
41
+ console.print(f"[red]Error:[/red] Manifest not found: {manifest_path}")
42
+ raise typer.Exit(1)
43
+
44
+ manifest = Manifest.from_file(manifest_path)
45
+ nodes = manifest.nodes
46
+
47
+ # Apply filters
48
+ if role:
49
+ nodes = [n for n in nodes if role in [r.value for r in n.roles]]
50
+ if status:
51
+ nodes = [n for n in nodes if n.status.value == status]
52
+
53
+ # Create table
54
+ table = Table(title=f"RedundaNet Nodes ({len(nodes)})")
55
+ table.add_column("Name", style="cyan")
56
+ table.add_column("VPN IP", style="green")
57
+ table.add_column("Status")
58
+ table.add_column("Roles")
59
+ table.add_column("Region")
60
+
61
+ for node in nodes:
62
+ status_style = {
63
+ "active": "[green]active[/green]",
64
+ "pending": "[yellow]pending[/yellow]",
65
+ "inactive": "[red]inactive[/red]",
66
+ }.get(node.status.value, node.status.value)
67
+
68
+ roles_str = ", ".join(r.value for r in node.roles)
69
+
70
+ table.add_row(
71
+ node.name,
72
+ node.vpn_ip or node.internal_ip,
73
+ status_style,
74
+ roles_str or "[dim]none[/dim]",
75
+ node.region or "[dim]unknown[/dim]",
76
+ )
77
+
78
+ console.print(table)
79
+
80
+
81
+ @app.command("info")
82
+ def node_info(
83
+ node_name: Annotated[str, typer.Argument(help="Name of the node")],
84
+ manifest_path: Annotated[
85
+ Optional[Path],
86
+ typer.Option("--manifest", "-m", help="Path to manifest file"),
87
+ ] = None,
88
+ ) -> None:
89
+ """Show detailed information about a node."""
90
+ if manifest_path is None:
91
+ from redundanet.core.config import get_default_manifest_path
92
+
93
+ manifest_path = get_default_manifest_path()
94
+
95
+ if not manifest_path.exists():
96
+ console.print(f"[red]Error:[/red] Manifest not found: {manifest_path}")
97
+ raise typer.Exit(1)
98
+
99
+ manifest = Manifest.from_file(manifest_path)
100
+ node = manifest.get_node(node_name)
101
+
102
+ if node is None:
103
+ console.print(f"[red]Error:[/red] Node '{node_name}' not found")
104
+ raise typer.Exit(1)
105
+
106
+ # Display node info
107
+ table = Table(title=f"Node: {node.name}", show_header=False, box=None)
108
+ table.add_column("Property", style="cyan")
109
+ table.add_column("Value")
110
+
111
+ table.add_row("Name", node.name)
112
+ table.add_row("Internal IP", node.internal_ip)
113
+ table.add_row("VPN IP", node.vpn_ip or "[dim]same as internal[/dim]")
114
+ table.add_row("Public IP", node.public_ip or "[dim]not set[/dim]")
115
+ table.add_row("Status", node.status.value)
116
+ table.add_row("Region", node.region or "[dim]unknown[/dim]")
117
+ table.add_row("GPG Key ID", node.gpg_key_id or "[dim]not set[/dim]")
118
+ table.add_row("Publicly Accessible", "Yes" if node.is_publicly_accessible else "No")
119
+ table.add_row("Roles", ", ".join(r.value for r in node.roles) or "[dim]none[/dim]")
120
+ table.add_row("Storage Contribution", node.storage_contribution or "[dim]none[/dim]")
121
+ table.add_row("Storage Allocation", node.storage_allocation or "[dim]none[/dim]")
122
+
123
+ # Ports
124
+ table.add_row("", "")
125
+ table.add_row("[bold]Ports[/bold]", "")
126
+ table.add_row(" Tinc", str(node.ports.tinc))
127
+ table.add_row(" Tahoe Storage", str(node.ports.tahoe_storage))
128
+ table.add_row(" Tahoe Client", str(node.ports.tahoe_client))
129
+ table.add_row(" Tahoe Introducer", str(node.ports.tahoe_introducer))
130
+
131
+ console.print(table)
132
+
133
+
134
+ @app.command("add")
135
+ def add_node(
136
+ name: Annotated[str, typer.Option("--name", "-n", prompt=True, help="Node name")],
137
+ internal_ip: Annotated[
138
+ str,
139
+ typer.Option("--ip", "-i", prompt=True, help="Internal/VPN IP address"),
140
+ ],
141
+ public_ip: Annotated[
142
+ Optional[str],
143
+ typer.Option("--public-ip", "-p", help="Public IP address"),
144
+ ] = None,
145
+ gpg_key_id: Annotated[
146
+ Optional[str],
147
+ typer.Option("--gpg-key", "-g", help="GPG key ID"),
148
+ ] = None,
149
+ region: Annotated[
150
+ Optional[str],
151
+ typer.Option("--region", "-r", help="Geographic region"),
152
+ ] = None,
153
+ roles: Annotated[
154
+ Optional[list[str]],
155
+ typer.Option("--role", help="Node roles (can be specified multiple times)"),
156
+ ] = None,
157
+ storage: Annotated[
158
+ Optional[str],
159
+ typer.Option("--storage", "-s", help="Storage contribution"),
160
+ ] = None,
161
+ publicly_accessible: Annotated[
162
+ bool,
163
+ typer.Option("--public", help="Node is publicly accessible"),
164
+ ] = False,
165
+ manifest_path: Annotated[
166
+ Optional[Path],
167
+ typer.Option("--manifest", "-m", help="Path to manifest file"),
168
+ ] = None,
169
+ ) -> None:
170
+ """Add a new node to the manifest."""
171
+ from redundanet.core.config import NodeConfig, PortConfig
172
+
173
+ if manifest_path is None:
174
+ from redundanet.core.config import get_default_manifest_path
175
+
176
+ manifest_path = get_default_manifest_path()
177
+
178
+ if not manifest_path.exists():
179
+ console.print(f"[red]Error:[/red] Manifest not found: {manifest_path}")
180
+ console.print("Create a new manifest first or specify a valid path.")
181
+ raise typer.Exit(1)
182
+
183
+ manifest = Manifest.from_file(manifest_path)
184
+
185
+ # Check if node already exists
186
+ if manifest.get_node(name):
187
+ console.print(f"[red]Error:[/red] Node '{name}' already exists")
188
+ raise typer.Exit(1)
189
+
190
+ # Parse roles
191
+ node_roles = []
192
+ if roles:
193
+ for role in roles:
194
+ try:
195
+ node_roles.append(NodeRole(role))
196
+ except ValueError:
197
+ valid_roles = [r.value for r in NodeRole]
198
+ console.print(f"[red]Error:[/red] Invalid role '{role}'")
199
+ console.print(f"Valid roles: {', '.join(valid_roles)}")
200
+ raise typer.Exit(1) from None
201
+
202
+ # Create node config
203
+ node = NodeConfig(
204
+ name=name,
205
+ internal_ip=internal_ip,
206
+ vpn_ip=internal_ip,
207
+ public_ip=public_ip,
208
+ gpg_key_id=gpg_key_id,
209
+ region=region,
210
+ status=NodeStatus.PENDING,
211
+ roles=node_roles,
212
+ ports=PortConfig(),
213
+ storage_contribution=storage,
214
+ is_publicly_accessible=publicly_accessible,
215
+ )
216
+
217
+ # Add to manifest
218
+ manifest.add_node(node)
219
+ manifest.save(manifest_path)
220
+
221
+ console.print(f"[green]Added node '{name}' to manifest[/green]")
222
+ console.print(f"Manifest saved to: {manifest_path}")
223
+
224
+
225
+ @app.command("remove")
226
+ def remove_node(
227
+ node_name: Annotated[str, typer.Argument(help="Name of the node to remove")],
228
+ manifest_path: Annotated[
229
+ Optional[Path],
230
+ typer.Option("--manifest", "-m", help="Path to manifest file"),
231
+ ] = None,
232
+ force: Annotated[
233
+ bool,
234
+ typer.Option("--force", "-f", help="Skip confirmation"),
235
+ ] = False,
236
+ ) -> None:
237
+ """Remove a node from the manifest."""
238
+ if manifest_path is None:
239
+ from redundanet.core.config import get_default_manifest_path
240
+
241
+ manifest_path = get_default_manifest_path()
242
+
243
+ if not manifest_path.exists():
244
+ console.print(f"[red]Error:[/red] Manifest not found: {manifest_path}")
245
+ raise typer.Exit(1)
246
+
247
+ manifest = Manifest.from_file(manifest_path)
248
+
249
+ if not manifest.get_node(node_name):
250
+ console.print(f"[red]Error:[/red] Node '{node_name}' not found")
251
+ raise typer.Exit(1)
252
+
253
+ if not force:
254
+ confirm = typer.confirm(f"Are you sure you want to remove node '{node_name}'?")
255
+ if not confirm:
256
+ console.print("Aborted.")
257
+ raise typer.Exit(0)
258
+
259
+ manifest.remove_node(node_name)
260
+ manifest.save(manifest_path)
261
+
262
+ console.print(f"[green]Removed node '{node_name}' from manifest[/green]")
263
+
264
+
265
+ @app.command("keys")
266
+ def manage_keys(
267
+ action: Annotated[
268
+ str,
269
+ typer.Argument(help="Action: generate, export, import"),
270
+ ],
271
+ node_name: Annotated[
272
+ Optional[str],
273
+ typer.Option("--name", "-n", help="Node name"),
274
+ ] = None,
275
+ ) -> None:
276
+ """Manage GPG keys for node authentication."""
277
+ from redundanet.core.config import load_settings
278
+
279
+ settings = load_settings()
280
+ node_name = node_name or settings.node_name
281
+
282
+ if not node_name:
283
+ console.print("[red]Error:[/red] No node name specified")
284
+ console.print("Use --name or set REDUNDANET_NODE_NAME")
285
+ raise typer.Exit(1)
286
+
287
+ if action == "generate":
288
+ console.print(f"[bold]Generating GPG key for node: {node_name}[/bold]")
289
+ console.print("[yellow]Note:[/yellow] This will create a new GPG keypair.")
290
+ console.print("Use 'redundanet node keys export' to share your public key.")
291
+ # In a real implementation, we'd call the GPG module
292
+ console.print("[dim]GPG key generation not yet implemented[/dim]")
293
+
294
+ elif action == "export":
295
+ console.print(f"[bold]Exporting public key for node: {node_name}[/bold]")
296
+ console.print("[dim]Key export not yet implemented[/dim]")
297
+
298
+ elif action == "import":
299
+ console.print("[bold]Importing public key[/bold]")
300
+ console.print("[dim]Key import not yet implemented[/dim]")
301
+
302
+ else:
303
+ console.print(f"[red]Error:[/red] Unknown action '{action}'")
304
+ console.print("Valid actions: generate, export, import")
305
+ raise typer.Exit(1)
@@ -0,0 +1,267 @@
1
+ """Storage management CLI commands for RedundaNet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.progress import Progress, SpinnerColumn, TextColumn
12
+ from rich.table import Table
13
+
14
+ app = typer.Typer(help="Storage management commands")
15
+ console = Console()
16
+
17
+
18
+ @app.command("status")
19
+ def storage_status(
20
+ verbose: Annotated[
21
+ bool,
22
+ typer.Option("--verbose", "-v", help="Show detailed status"),
23
+ ] = False,
24
+ ) -> None:
25
+ """Show storage status and statistics."""
26
+ console.print(Panel("[bold]Storage Status[/bold]", expand=False))
27
+
28
+ # Storage overview
29
+ table = Table(title="Storage Overview", show_header=True)
30
+ table.add_column("Metric", style="cyan")
31
+ table.add_column("Value", style="green")
32
+
33
+ table.add_row("Contribution", "[dim]Not configured[/dim]")
34
+ table.add_row("Allocation", "[dim]Not configured[/dim]")
35
+ table.add_row("Used", "[dim]Unknown[/dim]")
36
+ table.add_row("Available", "[dim]Unknown[/dim]")
37
+
38
+ console.print(table)
39
+
40
+ if verbose:
41
+ # Tahoe-LAFS status
42
+ console.print("\n[bold]Tahoe-LAFS Configuration:[/bold]")
43
+ tahoe_table = Table(show_header=False)
44
+ tahoe_table.add_column("Property", style="cyan")
45
+ tahoe_table.add_column("Value")
46
+
47
+ tahoe_table.add_row("Shares Needed (k)", "[dim]3[/dim]")
48
+ tahoe_table.add_row("Shares Happy", "[dim]7[/dim]")
49
+ tahoe_table.add_row("Shares Total (n)", "[dim]10[/dim]")
50
+ tahoe_table.add_row("Introducer FURL", "[dim]Not set[/dim]")
51
+
52
+ console.print(tahoe_table)
53
+
54
+
55
+ @app.command("start")
56
+ def storage_start(
57
+ client: Annotated[
58
+ bool,
59
+ typer.Option("--client", "-c", help="Start client service"),
60
+ ] = True,
61
+ storage: Annotated[
62
+ bool,
63
+ typer.Option("--storage", "-s", help="Start storage service"),
64
+ ] = True,
65
+ ) -> None:
66
+ """Start storage services."""
67
+ services = []
68
+ if client:
69
+ services.append("client")
70
+ if storage:
71
+ services.append("storage")
72
+
73
+ if not services:
74
+ console.print("[yellow]No services specified[/yellow]")
75
+ return
76
+
77
+ with Progress(
78
+ SpinnerColumn(),
79
+ TextColumn("[progress.description]{task.description}"),
80
+ console=console,
81
+ ) as progress:
82
+ for service in services:
83
+ task = progress.add_task(f"Starting {service}...", total=None)
84
+ # In a real implementation, we'd start the service
85
+ progress.update(task, completed=True)
86
+
87
+ console.print(f"[green]Started services: {', '.join(services)}[/green]")
88
+
89
+
90
+ @app.command("stop")
91
+ def storage_stop(
92
+ client: Annotated[
93
+ bool,
94
+ typer.Option("--client", "-c", help="Stop client service"),
95
+ ] = True,
96
+ storage: Annotated[
97
+ bool,
98
+ typer.Option("--storage", "-s", help="Stop storage service"),
99
+ ] = True,
100
+ ) -> None:
101
+ """Stop storage services."""
102
+ services = []
103
+ if client:
104
+ services.append("client")
105
+ if storage:
106
+ services.append("storage")
107
+
108
+ if not services:
109
+ console.print("[yellow]No services specified[/yellow]")
110
+ return
111
+
112
+ with console.status("[bold yellow]Stopping services..."):
113
+ pass
114
+
115
+ console.print(f"[yellow]Stopped services: {', '.join(services)}[/yellow]")
116
+
117
+
118
+ @app.command("mount")
119
+ def mount_storage(
120
+ mountpoint: Annotated[
121
+ Path,
122
+ typer.Argument(help="Directory to mount Tahoe filesystem"),
123
+ ] = Path("/mnt/redundanet"),
124
+ ) -> None:
125
+ """Mount the Tahoe-LAFS filesystem."""
126
+ if not mountpoint.exists():
127
+ console.print(f"[yellow]Creating mountpoint: {mountpoint}[/yellow]")
128
+ mountpoint.mkdir(parents=True, exist_ok=True)
129
+
130
+ with console.status(f"[bold green]Mounting at {mountpoint}..."):
131
+ # In a real implementation, we'd mount the FUSE filesystem
132
+ pass
133
+
134
+ console.print(f"[green]Mounted at: {mountpoint}[/green]")
135
+
136
+
137
+ @app.command("unmount")
138
+ def unmount_storage(
139
+ mountpoint: Annotated[
140
+ Path,
141
+ typer.Argument(help="Mountpoint to unmount"),
142
+ ] = Path("/mnt/redundanet"),
143
+ force: Annotated[
144
+ bool,
145
+ typer.Option("--force", "-f", help="Force unmount"),
146
+ ] = False,
147
+ ) -> None:
148
+ """Unmount the Tahoe-LAFS filesystem."""
149
+ with console.status(f"[bold yellow]Unmounting {mountpoint}..."):
150
+ # In a real implementation, we'd unmount
151
+ pass
152
+
153
+ console.print(f"[yellow]Unmounted: {mountpoint}[/yellow]")
154
+
155
+
156
+ @app.command("upload")
157
+ def upload_file(
158
+ source: Annotated[Path, typer.Argument(help="File or directory to upload")],
159
+ destination: Annotated[
160
+ Optional[str],
161
+ typer.Argument(help="Destination path in storage"),
162
+ ] = None,
163
+ ) -> None:
164
+ """Upload a file or directory to the storage grid."""
165
+ if not source.exists():
166
+ console.print(f"[red]Error:[/red] Source not found: {source}")
167
+ raise typer.Exit(1)
168
+
169
+ dest = destination or source.name
170
+
171
+ with Progress(
172
+ SpinnerColumn(),
173
+ TextColumn("[progress.description]{task.description}"),
174
+ console=console,
175
+ ) as progress:
176
+ task = progress.add_task(f"Uploading {source.name}...", total=None)
177
+ # In a real implementation, we'd upload using Tahoe-LAFS
178
+ progress.update(task, completed=True)
179
+
180
+ console.print(f"[green]Uploaded:[/green] {source} -> {dest}")
181
+
182
+
183
+ @app.command("download")
184
+ def download_file(
185
+ source: Annotated[str, typer.Argument(help="Source path in storage")],
186
+ destination: Annotated[
187
+ Optional[Path],
188
+ typer.Argument(help="Local destination path"),
189
+ ] = None,
190
+ ) -> None:
191
+ """Download a file from the storage grid."""
192
+ dest = destination or Path(source).name
193
+
194
+ with Progress(
195
+ SpinnerColumn(),
196
+ TextColumn("[progress.description]{task.description}"),
197
+ console=console,
198
+ ) as progress:
199
+ task = progress.add_task(f"Downloading {source}...", total=None)
200
+ # In a real implementation, we'd download using Tahoe-LAFS
201
+ progress.update(task, completed=True)
202
+
203
+ console.print(f"[green]Downloaded:[/green] {source} -> {dest}")
204
+
205
+
206
+ @app.command("ls")
207
+ def list_files(
208
+ path: Annotated[str, typer.Argument(help="Path to list")] = "/",
209
+ long: Annotated[
210
+ bool,
211
+ typer.Option("--long", "-l", help="Show detailed listing"),
212
+ ] = False,
213
+ ) -> None:
214
+ """List files in the storage grid."""
215
+ console.print(f"[bold]Listing: {path}[/bold]")
216
+
217
+ # In a real implementation, we'd query Tahoe-LAFS
218
+ table = Table(show_header=True)
219
+ table.add_column("Name")
220
+ if long:
221
+ table.add_column("Size")
222
+ table.add_column("Modified")
223
+ table.add_column("Cap")
224
+
225
+ console.print("[dim]No files found (storage not connected)[/dim]")
226
+ console.print(table)
227
+
228
+
229
+ @app.command("info")
230
+ def file_info(
231
+ path: Annotated[str, typer.Argument(help="File path to inspect")],
232
+ ) -> None:
233
+ """Show detailed information about a file."""
234
+ console.print(f"[bold]File Info: {path}[/bold]")
235
+
236
+ table = Table(show_header=False)
237
+ table.add_column("Property", style="cyan")
238
+ table.add_column("Value")
239
+
240
+ table.add_row("Path", path)
241
+ table.add_row("Size", "[dim]Unknown[/dim]")
242
+ table.add_row("Type", "[dim]Unknown[/dim]")
243
+ table.add_row("Shares", "[dim]Unknown[/dim]")
244
+ table.add_row("Health", "[dim]Unknown[/dim]")
245
+
246
+ console.print(table)
247
+
248
+
249
+ @app.command("repair")
250
+ def repair_file(
251
+ path: Annotated[str, typer.Argument(help="File path to repair")],
252
+ check_only: Annotated[
253
+ bool,
254
+ typer.Option("--check", "-c", help="Only check, don't repair"),
255
+ ] = False,
256
+ ) -> None:
257
+ """Check and repair file redundancy."""
258
+ action = "Checking" if check_only else "Repairing"
259
+
260
+ with console.status(f"[bold green]{action} {path}..."):
261
+ # In a real implementation, we'd run tahoe check/repair
262
+ pass
263
+
264
+ if check_only:
265
+ console.print("[green]File health: OK[/green]")
266
+ else:
267
+ console.print("[green]Repair complete[/green]")
@@ -0,0 +1,31 @@
1
+ """Core business logic for RedundaNet."""
2
+
3
+ from redundanet.core.config import NetworkConfig, NodeConfig, NodeRole, TahoeConfig
4
+ from redundanet.core.exceptions import (
5
+ ConfigurationError,
6
+ ManifestError,
7
+ NetworkError,
8
+ NodeError,
9
+ RedundaNetError,
10
+ StorageError,
11
+ VPNError,
12
+ )
13
+ from redundanet.core.manifest import Manifest
14
+ from redundanet.core.node import Node, NodeStatus
15
+
16
+ __all__ = [
17
+ "ConfigurationError",
18
+ "Manifest",
19
+ "ManifestError",
20
+ "NetworkConfig",
21
+ "NetworkError",
22
+ "Node",
23
+ "NodeConfig",
24
+ "NodeError",
25
+ "NodeRole",
26
+ "NodeStatus",
27
+ "RedundaNetError",
28
+ "StorageError",
29
+ "TahoeConfig",
30
+ "VPNError",
31
+ ]