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.
@@ -0,0 +1,200 @@
1
+ """Configuration management for RedundaNet using Pydantic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import re
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Annotated, Self
10
+
11
+ from pydantic import BaseModel, Field, field_validator, model_validator
12
+ from pydantic_settings import BaseSettings, SettingsConfigDict
13
+
14
+
15
+ class NodeRole(str, Enum):
16
+ """Available roles for a RedundaNet node."""
17
+
18
+ TINC_VPN = "tinc_vpn"
19
+ TAHOE_INTRODUCER = "tahoe_introducer"
20
+ TAHOE_STORAGE = "tahoe_storage"
21
+ TAHOE_CLIENT = "tahoe_client"
22
+
23
+
24
+ class NodeStatus(str, Enum):
25
+ """Status of a node in the network."""
26
+
27
+ ACTIVE = "active"
28
+ PENDING = "pending"
29
+ INACTIVE = "inactive"
30
+
31
+
32
+ class TahoeConfig(BaseModel):
33
+ """Tahoe-LAFS erasure coding configuration."""
34
+
35
+ shares_needed: Annotated[int, Field(ge=1, le=256)] = 3
36
+ shares_happy: Annotated[int, Field(ge=1, le=256)] = 7
37
+ shares_total: Annotated[int, Field(ge=1, le=256)] = 10
38
+ reserved_space: str = "50G"
39
+
40
+ @model_validator(mode="after")
41
+ def validate_shares(self) -> Self:
42
+ """Ensure shares configuration is valid: needed <= happy <= total."""
43
+ if not (self.shares_needed <= self.shares_happy <= self.shares_total):
44
+ raise ValueError(
45
+ f"Invalid shares configuration: needed ({self.shares_needed}) <= "
46
+ f"happy ({self.shares_happy}) <= total ({self.shares_total}) must be satisfied"
47
+ )
48
+ return self
49
+
50
+ @field_validator("reserved_space")
51
+ @classmethod
52
+ def validate_reserved_space(cls, v: str) -> str:
53
+ """Validate reserved space format (e.g., 50G, 100M, 1T)."""
54
+ pattern = r"^\d+[KMGT]?$"
55
+ if not re.match(pattern, v.upper()):
56
+ raise ValueError(
57
+ f"Invalid reserved_space format: {v}. "
58
+ "Expected format: number followed by K, M, G, or T (e.g., 50G)"
59
+ )
60
+ return v.upper()
61
+
62
+
63
+ class PortConfig(BaseModel):
64
+ """Port configuration for node services."""
65
+
66
+ tinc: int = 655
67
+ tahoe_storage: int = 3457
68
+ tahoe_client: int = 3456
69
+ tahoe_introducer: int = 3458
70
+
71
+
72
+ class NodeConfig(BaseModel):
73
+ """Configuration for a single RedundaNet node."""
74
+
75
+ name: Annotated[str, Field(min_length=1, max_length=64, pattern=r"^[a-zA-Z][a-zA-Z0-9_-]*$")]
76
+ internal_ip: str
77
+ vpn_ip: str | None = None
78
+ public_ip: str | None = None
79
+ gpg_key_id: str | None = None
80
+ region: str | None = None
81
+ status: NodeStatus = NodeStatus.PENDING
82
+ roles: list[NodeRole] = Field(default_factory=list)
83
+ ports: PortConfig = Field(default_factory=PortConfig)
84
+ storage_contribution: str | None = None
85
+ storage_allocation: str | None = None
86
+ is_publicly_accessible: bool = False
87
+
88
+ @field_validator("internal_ip", "vpn_ip", "public_ip", mode="before")
89
+ @classmethod
90
+ def validate_ip(cls, v: str | None) -> str | None:
91
+ """Validate IP address format."""
92
+ if v is None or v == "auto":
93
+ return v
94
+ try:
95
+ ipaddress.ip_address(v)
96
+ return v
97
+ except ValueError as e:
98
+ raise ValueError(f"Invalid IP address: {v}") from e
99
+
100
+ @field_validator("gpg_key_id")
101
+ @classmethod
102
+ def validate_gpg_key_id(cls, v: str | None) -> str | None:
103
+ """Validate GPG key ID format (8, 16, or 40 character hex string)."""
104
+ if v is None:
105
+ return None
106
+ v = v.upper().replace(" ", "")
107
+ if not re.match(r"^[A-F0-9]{8}$|^[A-F0-9]{16}$|^[A-F0-9]{40}$", v):
108
+ raise ValueError(
109
+ f"Invalid GPG key ID: {v}. Expected 8, 16, or 40 character hex string."
110
+ )
111
+ return v
112
+
113
+ @model_validator(mode="after")
114
+ def set_vpn_ip_default(self) -> Self:
115
+ """Set vpn_ip to internal_ip if not specified."""
116
+ if self.vpn_ip is None:
117
+ self.vpn_ip = self.internal_ip
118
+ return self
119
+
120
+ def has_role(self, role: NodeRole) -> bool:
121
+ """Check if this node has a specific role."""
122
+ return role in self.roles
123
+
124
+
125
+ class NetworkConfig(BaseModel):
126
+ """Network-wide configuration for RedundaNet."""
127
+
128
+ name: Annotated[str, Field(min_length=1, max_length=64)] = "redundanet"
129
+ version: str = "2.0.0"
130
+ domain: str = "redundanet.local"
131
+ vpn_network: str = "10.100.0.0/16"
132
+ tahoe: TahoeConfig = Field(default_factory=TahoeConfig)
133
+
134
+ @field_validator("vpn_network")
135
+ @classmethod
136
+ def validate_vpn_network(cls, v: str) -> str:
137
+ """Validate VPN network CIDR format."""
138
+ try:
139
+ ipaddress.ip_network(v)
140
+ return v
141
+ except ValueError as e:
142
+ raise ValueError(f"Invalid VPN network CIDR: {v}") from e
143
+
144
+
145
+ class AppSettings(BaseSettings):
146
+ """Application-wide settings loaded from environment variables."""
147
+
148
+ model_config = SettingsConfigDict(
149
+ env_prefix="REDUNDANET_",
150
+ env_file=".env",
151
+ env_file_encoding="utf-8",
152
+ case_sensitive=False,
153
+ )
154
+
155
+ # Node identification
156
+ node_name: str = ""
157
+ internal_vpn_ip: str = ""
158
+ public_ip: str = "auto"
159
+ gpg_key_id: str = ""
160
+
161
+ # Repository settings
162
+ manifest_repo: str = ""
163
+ manifest_branch: str = "main"
164
+ manifest_filename: str = "manifest.yaml"
165
+
166
+ # Paths
167
+ config_dir: Path = Path("/etc/redundanet")
168
+ data_dir: Path = Path("/var/lib/redundanet")
169
+ log_dir: Path = Path("/var/log/redundanet")
170
+
171
+ # Runtime settings
172
+ debug: bool = False
173
+ test_mode: bool = False
174
+ log_level: str = "INFO"
175
+
176
+ # Network settings
177
+ enable_auto_discovery: bool = True
178
+ sync_interval: int = 300 # seconds
179
+
180
+ @field_validator("log_level")
181
+ @classmethod
182
+ def validate_log_level(cls, v: str) -> str:
183
+ """Validate log level."""
184
+ valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
185
+ v = v.upper()
186
+ if v not in valid_levels:
187
+ raise ValueError(f"Invalid log level: {v}. Valid levels: {valid_levels}")
188
+ return v
189
+
190
+
191
+ def load_settings() -> AppSettings:
192
+ """Load application settings from environment."""
193
+ return AppSettings()
194
+
195
+
196
+ def get_default_manifest_path(settings: AppSettings | None = None) -> Path:
197
+ """Get the default manifest file path."""
198
+ if settings is None:
199
+ settings = load_settings()
200
+ return settings.data_dir / "manifest" / settings.manifest_filename
@@ -0,0 +1,84 @@
1
+ """Custom exceptions for RedundaNet."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class RedundaNetError(Exception):
7
+ """Base exception for all RedundaNet errors."""
8
+
9
+ def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
10
+ super().__init__(message)
11
+ self.message = message
12
+ self.details = details or {}
13
+
14
+ def __str__(self) -> str:
15
+ if self.details:
16
+ return f"{self.message} - Details: {self.details}"
17
+ return self.message
18
+
19
+
20
+ class ConfigurationError(RedundaNetError):
21
+ """Raised when there's a configuration error."""
22
+
23
+ pass
24
+
25
+
26
+ class ManifestError(RedundaNetError):
27
+ """Raised when there's a manifest parsing or validation error."""
28
+
29
+ pass
30
+
31
+
32
+ class NodeError(RedundaNetError):
33
+ """Raised when there's a node-related error."""
34
+
35
+ pass
36
+
37
+
38
+ class VPNError(RedundaNetError):
39
+ """Raised when there's a VPN-related error."""
40
+
41
+ pass
42
+
43
+
44
+ class NetworkError(RedundaNetError):
45
+ """Raised when there's a network-related error."""
46
+
47
+ pass
48
+
49
+
50
+ class StorageError(RedundaNetError):
51
+ """Raised when there's a storage-related error."""
52
+
53
+ pass
54
+
55
+
56
+ class GPGError(RedundaNetError):
57
+ """Raised when there's a GPG-related error."""
58
+
59
+ pass
60
+
61
+
62
+ class KeyServerError(RedundaNetError):
63
+ """Raised when there's a keyserver-related error."""
64
+
65
+ pass
66
+
67
+
68
+ class ValidationError(RedundaNetError):
69
+ """Raised when validation fails."""
70
+
71
+ def __init__(
72
+ self,
73
+ message: str,
74
+ errors: list[str] | None = None,
75
+ details: dict[str, Any] | None = None,
76
+ ) -> None:
77
+ super().__init__(message, details)
78
+ self.errors = errors or []
79
+
80
+ def __str__(self) -> str:
81
+ if self.errors:
82
+ error_list = "\n - ".join(self.errors)
83
+ return f"{self.message}:\n - {error_list}"
84
+ return super().__str__()
@@ -0,0 +1,325 @@
1
+ """Manifest management for RedundaNet network configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+ from jsonschema import ValidationError as JsonSchemaValidationError
11
+ from jsonschema import validate
12
+
13
+ from redundanet.core.config import NetworkConfig, NodeConfig
14
+ from redundanet.core.exceptions import ManifestError, ValidationError
15
+
16
+ # JSON Schema for manifest validation
17
+ MANIFEST_SCHEMA: dict[str, Any] = {
18
+ "$schema": "http://json-schema.org/draft-07/schema#",
19
+ "type": "object",
20
+ "required": ["network", "nodes"],
21
+ "properties": {
22
+ "network": {
23
+ "type": "object",
24
+ "required": ["name", "version", "domain", "vpn_network"],
25
+ "properties": {
26
+ "name": {"type": "string"},
27
+ "version": {"type": "string"},
28
+ "domain": {"type": "string"},
29
+ "vpn_network": {
30
+ "type": "string",
31
+ "pattern": r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$",
32
+ },
33
+ "tahoe": {
34
+ "type": "object",
35
+ "properties": {
36
+ "shares_needed": {"type": "integer", "minimum": 1},
37
+ "shares_happy": {"type": "integer", "minimum": 1},
38
+ "shares_total": {"type": "integer", "minimum": 1},
39
+ "reserved_space": {"type": "string"},
40
+ },
41
+ },
42
+ },
43
+ },
44
+ "introducer_furl": {"type": ["string", "null"]},
45
+ "nodes": {
46
+ "type": "array",
47
+ "items": {
48
+ "type": "object",
49
+ "required": ["name", "internal_ip"],
50
+ "properties": {
51
+ "name": {"type": "string"},
52
+ "internal_ip": {"type": "string"},
53
+ "vpn_ip": {"type": "string"},
54
+ "public_ip": {"type": "string"},
55
+ "gpg_key_id": {"type": "string"},
56
+ "region": {"type": "string"},
57
+ "status": {"type": "string", "enum": ["active", "pending", "inactive"]},
58
+ "roles": {
59
+ "type": "array",
60
+ "items": {
61
+ "type": "string",
62
+ "enum": [
63
+ "tinc_vpn",
64
+ "tahoe_introducer",
65
+ "tahoe_storage",
66
+ "tahoe_client",
67
+ ],
68
+ },
69
+ },
70
+ "ports": {
71
+ "type": "object",
72
+ "properties": {
73
+ "tinc": {"type": "integer"},
74
+ "tahoe_storage": {"type": "integer"},
75
+ "tahoe_client": {"type": "integer"},
76
+ "tahoe_introducer": {"type": "integer"},
77
+ },
78
+ },
79
+ "storage_contribution": {"type": "string"},
80
+ "storage_allocation": {"type": "string"},
81
+ "is_publicly_accessible": {"type": "boolean"},
82
+ },
83
+ },
84
+ },
85
+ },
86
+ }
87
+
88
+
89
+ class Manifest:
90
+ """Manages the RedundaNet network manifest."""
91
+
92
+ def __init__(
93
+ self,
94
+ network: NetworkConfig,
95
+ nodes: list[NodeConfig],
96
+ introducer_furl: str | None = None,
97
+ ) -> None:
98
+ self.network = network
99
+ self.nodes = nodes
100
+ self.introducer_furl = introducer_furl
101
+ self._path: Path | None = None
102
+
103
+ @classmethod
104
+ def from_file(cls, path: Path | str) -> Manifest:
105
+ """Load manifest from a YAML file."""
106
+ path = Path(path)
107
+ if not path.exists():
108
+ raise ManifestError(f"Manifest file not found: {path}")
109
+
110
+ try:
111
+ with path.open() as f:
112
+ data = yaml.safe_load(f)
113
+ except yaml.YAMLError as e:
114
+ raise ManifestError(f"Failed to parse YAML: {e}") from e
115
+
116
+ manifest = cls.from_dict(data)
117
+ manifest._path = path
118
+ return manifest
119
+
120
+ @classmethod
121
+ def from_dict(cls, data: dict[str, Any]) -> Manifest:
122
+ """Create a Manifest from a dictionary."""
123
+ # Validate against schema first
124
+ try:
125
+ validate(instance=data, schema=MANIFEST_SCHEMA)
126
+ except JsonSchemaValidationError as e:
127
+ raise ValidationError(
128
+ "Manifest validation failed",
129
+ errors=[str(e.message)],
130
+ ) from e
131
+
132
+ # Parse network config
133
+ network_data = data.get("network", {})
134
+ tahoe_data = network_data.get("tahoe", {})
135
+
136
+ from redundanet.core.config import (
137
+ NodeRole,
138
+ NodeStatus,
139
+ PortConfig,
140
+ TahoeConfig,
141
+ )
142
+
143
+ network = NetworkConfig(
144
+ name=network_data.get("name", "redundanet"),
145
+ version=network_data.get("version", "2.0.0"),
146
+ domain=network_data.get("domain", "redundanet.local"),
147
+ vpn_network=network_data.get("vpn_network", "10.100.0.0/16"),
148
+ tahoe=TahoeConfig(**tahoe_data) if tahoe_data else TahoeConfig(),
149
+ )
150
+
151
+ # Parse nodes
152
+ nodes: list[NodeConfig] = []
153
+ for node_data in data.get("nodes", []):
154
+ ports_data = node_data.get("ports", {})
155
+ roles_data = node_data.get("roles", [])
156
+
157
+ node = NodeConfig(
158
+ name=node_data["name"],
159
+ internal_ip=node_data["internal_ip"],
160
+ vpn_ip=node_data.get("vpn_ip"),
161
+ public_ip=node_data.get("public_ip"),
162
+ gpg_key_id=node_data.get("gpg_key_id"),
163
+ region=node_data.get("region"),
164
+ status=NodeStatus(node_data.get("status", "pending")),
165
+ roles=[NodeRole(r) for r in roles_data],
166
+ ports=PortConfig(**ports_data) if ports_data else PortConfig(),
167
+ storage_contribution=node_data.get("storage_contribution"),
168
+ storage_allocation=node_data.get("storage_allocation"),
169
+ is_publicly_accessible=node_data.get("is_publicly_accessible", False),
170
+ )
171
+ nodes.append(node)
172
+
173
+ return cls(
174
+ network=network,
175
+ nodes=nodes,
176
+ introducer_furl=data.get("introducer_furl"),
177
+ )
178
+
179
+ def to_dict(self) -> dict[str, Any]:
180
+ """Convert manifest to dictionary representation."""
181
+
182
+ def filter_none(d: dict[str, Any]) -> dict[str, Any]:
183
+ """Remove None values from dictionary."""
184
+ return {k: v for k, v in d.items() if v is not None}
185
+
186
+ nodes_list = []
187
+ for node in self.nodes:
188
+ node_dict = filter_none(
189
+ {
190
+ "name": node.name,
191
+ "internal_ip": node.internal_ip,
192
+ "vpn_ip": node.vpn_ip,
193
+ "public_ip": node.public_ip,
194
+ "gpg_key_id": node.gpg_key_id,
195
+ "region": node.region,
196
+ "status": node.status.value,
197
+ "roles": [r.value for r in node.roles],
198
+ "ports": {
199
+ "tinc": node.ports.tinc,
200
+ "tahoe_storage": node.ports.tahoe_storage,
201
+ "tahoe_client": node.ports.tahoe_client,
202
+ "tahoe_introducer": node.ports.tahoe_introducer,
203
+ },
204
+ "storage_contribution": node.storage_contribution,
205
+ "storage_allocation": node.storage_allocation,
206
+ "is_publicly_accessible": node.is_publicly_accessible,
207
+ }
208
+ )
209
+ nodes_list.append(node_dict)
210
+
211
+ result = {
212
+ "network": {
213
+ "name": self.network.name,
214
+ "version": self.network.version,
215
+ "domain": self.network.domain,
216
+ "vpn_network": self.network.vpn_network,
217
+ "tahoe": {
218
+ "shares_needed": self.network.tahoe.shares_needed,
219
+ "shares_happy": self.network.tahoe.shares_happy,
220
+ "shares_total": self.network.tahoe.shares_total,
221
+ "reserved_space": self.network.tahoe.reserved_space,
222
+ },
223
+ },
224
+ "nodes": nodes_list,
225
+ }
226
+
227
+ if self.introducer_furl:
228
+ result["introducer_furl"] = self.introducer_furl
229
+
230
+ return result
231
+
232
+ def save(self, path: Path | str | None = None) -> None:
233
+ """Save manifest to a YAML file."""
234
+ path = Path(path) if path else self._path
235
+ if path is None:
236
+ raise ManifestError("No path specified for saving manifest")
237
+
238
+ path.parent.mkdir(parents=True, exist_ok=True)
239
+
240
+ with path.open("w") as f:
241
+ yaml.dump(self.to_dict(), f, default_flow_style=False, sort_keys=False)
242
+
243
+ self._path = path
244
+
245
+ def validate(self) -> list[str]:
246
+ """Validate the manifest and return a list of warnings/errors."""
247
+ errors: list[str] = []
248
+
249
+ # Check for duplicate node names
250
+ names = [node.name for node in self.nodes]
251
+ duplicates = [name for name in set(names) if names.count(name) > 1]
252
+ if duplicates:
253
+ errors.append(f"Duplicate node names: {duplicates}")
254
+
255
+ # Check for duplicate IPs
256
+ ips = [node.internal_ip for node in self.nodes]
257
+ ips.extend([node.vpn_ip for node in self.nodes if node.vpn_ip])
258
+ duplicate_ips = [ip for ip in set(ips) if ips.count(ip) > 1]
259
+ if duplicate_ips:
260
+ errors.append(f"Duplicate IP addresses: {duplicate_ips}")
261
+
262
+ # Check introducer count
263
+ introducers = [n for n in self.nodes if "tahoe_introducer" in [r.value for r in n.roles]]
264
+ if len(introducers) > 1:
265
+ errors.append(
266
+ f"Found {len(introducers)} introducer nodes. "
267
+ "Having more than one introducer is unusual."
268
+ )
269
+
270
+ # Check storage node count vs shares_happy
271
+ storage_nodes = [n for n in self.nodes if "tahoe_storage" in [r.value for r in n.roles]]
272
+ if len(storage_nodes) < self.network.tahoe.shares_happy:
273
+ errors.append(
274
+ f"Not enough storage nodes ({len(storage_nodes)}) "
275
+ f"to satisfy shares_happy ({self.network.tahoe.shares_happy})"
276
+ )
277
+
278
+ return errors
279
+
280
+ def get_node(self, name: str) -> NodeConfig | None:
281
+ """Get a node by name."""
282
+ for node in self.nodes:
283
+ if node.name == name:
284
+ return node
285
+ return None
286
+
287
+ def get_nodes_by_role(self, role: str) -> list[NodeConfig]:
288
+ """Get all nodes with a specific role."""
289
+ return [node for node in self.nodes if role in [r.value for r in node.roles]]
290
+
291
+ def get_introducers(self) -> list[NodeConfig]:
292
+ """Get all introducer nodes."""
293
+ return self.get_nodes_by_role("tahoe_introducer")
294
+
295
+ def get_storage_nodes(self) -> list[NodeConfig]:
296
+ """Get all storage nodes."""
297
+ return self.get_nodes_by_role("tahoe_storage")
298
+
299
+ def get_publicly_accessible_nodes(self) -> list[NodeConfig]:
300
+ """Get all nodes that are publicly accessible."""
301
+ return [node for node in self.nodes if node.is_publicly_accessible]
302
+
303
+ def add_node(self, node: NodeConfig) -> None:
304
+ """Add a new node to the manifest."""
305
+ if self.get_node(node.name):
306
+ raise ManifestError(f"Node '{node.name}' already exists")
307
+ self.nodes.append(node)
308
+
309
+ def remove_node(self, name: str) -> bool:
310
+ """Remove a node from the manifest."""
311
+ node = self.get_node(name)
312
+ if node:
313
+ self.nodes.remove(node)
314
+ return True
315
+ return False
316
+
317
+ def update_introducer_furl(self, furl: str) -> None:
318
+ """Update the introducer FURL."""
319
+ self.introducer_furl = furl
320
+
321
+ def export_schema(self, path: Path | str) -> None:
322
+ """Export the JSON schema to a file."""
323
+ path = Path(path)
324
+ with path.open("w") as f:
325
+ json.dump(MANIFEST_SCHEMA, f, indent=2)