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/__init__.py +16 -0
- redundanet/__main__.py +6 -0
- redundanet/auth/__init__.py +9 -0
- redundanet/auth/gpg.py +323 -0
- redundanet/auth/keyserver.py +219 -0
- redundanet/cli/__init__.py +5 -0
- redundanet/cli/main.py +247 -0
- redundanet/cli/network.py +194 -0
- redundanet/cli/node.py +305 -0
- redundanet/cli/storage.py +267 -0
- redundanet/core/__init__.py +31 -0
- redundanet/core/config.py +200 -0
- redundanet/core/exceptions.py +84 -0
- redundanet/core/manifest.py +325 -0
- redundanet/core/node.py +135 -0
- redundanet/network/__init__.py +11 -0
- redundanet/network/discovery.py +218 -0
- redundanet/network/dns.py +180 -0
- redundanet/network/validation.py +279 -0
- redundanet/storage/__init__.py +13 -0
- redundanet/storage/client.py +306 -0
- redundanet/storage/furl.py +196 -0
- redundanet/storage/introducer.py +175 -0
- redundanet/storage/storage.py +195 -0
- redundanet/utils/__init__.py +15 -0
- redundanet/utils/files.py +165 -0
- redundanet/utils/logging.py +93 -0
- redundanet/utils/process.py +226 -0
- redundanet/vpn/__init__.py +12 -0
- redundanet/vpn/keys.py +173 -0
- redundanet/vpn/mesh.py +201 -0
- redundanet/vpn/tinc.py +323 -0
- redundanet-2.0.0.dist-info/LICENSE +674 -0
- redundanet-2.0.0.dist-info/METADATA +265 -0
- redundanet-2.0.0.dist-info/RECORD +37 -0
- redundanet-2.0.0.dist-info/WHEEL +4 -0
- redundanet-2.0.0.dist-info/entry_points.txt +3 -0
|
@@ -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)
|