flux-config-shared 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.
- flux_config_shared/__init__.py +23 -0
- flux_config_shared/app_config.py +102 -0
- flux_config_shared/config_locations.py +46 -0
- flux_config_shared/daemon_state.py +163 -0
- flux_config_shared/delegate_config.py +112 -0
- flux_config_shared/log.py +124 -0
- flux_config_shared/models.py +48 -0
- flux_config_shared/protocol.py +402 -0
- flux_config_shared/user_config.py +660 -0
- flux_config_shared-0.1.0.dist-info/METADATA +40 -0
- flux_config_shared-0.1.0.dist-info/RECORD +12 -0
- flux_config_shared-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""User provided configuration models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import asdict, field, fields
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from time import time
|
|
11
|
+
from typing import Annotated, ClassVar
|
|
12
|
+
|
|
13
|
+
import aiofiles
|
|
14
|
+
import yaml
|
|
15
|
+
from flux_delegate_starter import decode_wif, privkey_to_pubkey
|
|
16
|
+
from pydantic import ConfigDict, EmailStr, Field, TypeAdapter, field_validator
|
|
17
|
+
from pydantic.dataclasses import dataclass as py_dataclass
|
|
18
|
+
from pydantic.networks import HttpUrl
|
|
19
|
+
from pydantic.types import StringConstraints
|
|
20
|
+
from pydantic_core import to_jsonable_python
|
|
21
|
+
from sshpubkeys import InvalidKeyError, SSHKey
|
|
22
|
+
|
|
23
|
+
from flux_config_shared.config_locations import ConfigLocations
|
|
24
|
+
from flux_config_shared.delegate_config import Delegate
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# File write lock registry - prevents concurrent writes to same file
|
|
30
|
+
_file_write_locks: dict[str, asyncio.Lock] = {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_file_lock(path: Path | str) -> asyncio.Lock:
|
|
34
|
+
"""Get or create an asyncio.Lock for the given file path.
|
|
35
|
+
|
|
36
|
+
Returns the same lock instance for the same path across all calls,
|
|
37
|
+
ensuring serialized writes to each config file.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
path: The file path to get a lock for (Path or str).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
asyncio.Lock: The lock for the given path.
|
|
44
|
+
"""
|
|
45
|
+
key = str(path)
|
|
46
|
+
if key not in _file_write_locks:
|
|
47
|
+
_file_write_locks[key] = asyncio.Lock()
|
|
48
|
+
return _file_write_locks[key]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@py_dataclass(config=ConfigDict(populate_by_name=True))
|
|
52
|
+
class Identity:
|
|
53
|
+
"""FluxNode identity configuration."""
|
|
54
|
+
|
|
55
|
+
flux_id: str = Field(alias="fluxId")
|
|
56
|
+
identity_key: str = Field(alias="identityKey")
|
|
57
|
+
tx_id: str = Field(alias="txId")
|
|
58
|
+
output_id: int = Field(alias="outputId")
|
|
59
|
+
|
|
60
|
+
asdict = asdict
|
|
61
|
+
|
|
62
|
+
@field_validator("flux_id", mode="after")
|
|
63
|
+
@classmethod
|
|
64
|
+
def validate_flux_id(cls, value: str) -> str:
|
|
65
|
+
"""Validate FluxID length.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
value: FluxID value
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Validated FluxID
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If FluxID length is invalid
|
|
75
|
+
"""
|
|
76
|
+
id_len = len(value)
|
|
77
|
+
|
|
78
|
+
if id_len > 72 or id_len < 14:
|
|
79
|
+
raise ValueError("FluxId must be between 14 and 72 characters")
|
|
80
|
+
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
@field_validator("identity_key", mode="after")
|
|
84
|
+
@classmethod
|
|
85
|
+
def validate_identity_key(cls, value: str) -> str:
|
|
86
|
+
"""Validate identity key length.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
value: Identity key value
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Validated identity key
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If identity key length is invalid
|
|
96
|
+
"""
|
|
97
|
+
key_len = len(value)
|
|
98
|
+
|
|
99
|
+
if key_len < 51 or key_len > 52:
|
|
100
|
+
raise ValueError("Identity key must be 51 or 52 characters")
|
|
101
|
+
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
def get_identity_pubkey(self) -> bytes:
|
|
105
|
+
"""Get public key from identity WIF key.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Public key bytes (33 bytes compressed)
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
ValueError: If identity_key is invalid WIF format
|
|
112
|
+
"""
|
|
113
|
+
privkey, compressed = decode_wif(self.identity_key)
|
|
114
|
+
return privkey_to_pubkey(privkey, compressed=compressed)
|
|
115
|
+
|
|
116
|
+
@field_validator("tx_id", mode="after")
|
|
117
|
+
@classmethod
|
|
118
|
+
def validate_txid(cls, value: str) -> str:
|
|
119
|
+
"""Validate transaction ID length.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
value: Transaction ID value
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Validated transaction ID
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ValueError: If transaction ID length is invalid
|
|
129
|
+
"""
|
|
130
|
+
if len(value) != 64:
|
|
131
|
+
raise ValueError("Transaction Id must be 64 characters")
|
|
132
|
+
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
@field_validator("output_id", mode="before")
|
|
136
|
+
@classmethod
|
|
137
|
+
def validate_output_id(cls, value: str | int) -> int:
|
|
138
|
+
"""Validate output ID range.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
value: Output ID value
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Validated output ID as integer
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: If output ID is out of range
|
|
148
|
+
"""
|
|
149
|
+
value = int(value)
|
|
150
|
+
|
|
151
|
+
if value < 0 or value > 999:
|
|
152
|
+
raise ValueError("OutputId must be between 0 and 999")
|
|
153
|
+
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
def to_ui_dict(self) -> dict:
|
|
157
|
+
"""Convert to UI dictionary format with camelCase keys.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Dictionary with camelCase keys for UI
|
|
161
|
+
"""
|
|
162
|
+
return to_jsonable_python(self, by_alias=True)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@py_dataclass(config=ConfigDict(populate_by_name=True))
|
|
166
|
+
class DiscordNotification:
|
|
167
|
+
"""Discord notification configuration."""
|
|
168
|
+
|
|
169
|
+
watchdog_property_map: ClassVar[dict] = {
|
|
170
|
+
"webhook_url": "web_hook_url",
|
|
171
|
+
"user_id": "ping",
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
webhook_url: str | None = Field(default=None, alias="discordWebhookUrl")
|
|
175
|
+
user_id: str | None = Field(default=None, alias="discordUserId")
|
|
176
|
+
|
|
177
|
+
@field_validator("webhook_url", mode="after")
|
|
178
|
+
@classmethod
|
|
179
|
+
def validate_webhook_url(cls, value: str | None) -> str | None:
|
|
180
|
+
"""Validate Discord webhook URL.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
value: Webhook URL value
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Validated webhook URL
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
ValueError: If webhook URL is invalid
|
|
190
|
+
"""
|
|
191
|
+
if not value:
|
|
192
|
+
return value
|
|
193
|
+
|
|
194
|
+
# this will raise Validation error (and be caught)
|
|
195
|
+
url = HttpUrl(value)
|
|
196
|
+
# discordapp.com is the deprecated endpoint
|
|
197
|
+
valid_hosts = ["discordapp.com", "discord.com"]
|
|
198
|
+
|
|
199
|
+
if url.host not in valid_hosts:
|
|
200
|
+
raise ValueError("Discord webhook url must have discord as the host")
|
|
201
|
+
|
|
202
|
+
if not url.scheme == "https":
|
|
203
|
+
raise ValueError("discord webhook url scheme must be https")
|
|
204
|
+
|
|
205
|
+
if not url.path or not url.path.startswith("/api/webhooks"):
|
|
206
|
+
raise ValueError("discord webhook path must start with /api/webhooks")
|
|
207
|
+
|
|
208
|
+
return value
|
|
209
|
+
|
|
210
|
+
@field_validator("user_id", mode="before")
|
|
211
|
+
@classmethod
|
|
212
|
+
def validate_user_id(cls, value: str | int | None) -> str | None:
|
|
213
|
+
"""Validate Discord user ID.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
value: User ID value
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Validated user ID as string
|
|
220
|
+
|
|
221
|
+
Raises:
|
|
222
|
+
ValueError: If user ID length is invalid
|
|
223
|
+
"""
|
|
224
|
+
if not value:
|
|
225
|
+
raise ValueError("user_id cannot be empty")
|
|
226
|
+
|
|
227
|
+
as_str = str(value)
|
|
228
|
+
|
|
229
|
+
len_user_id = len(as_str)
|
|
230
|
+
|
|
231
|
+
if len_user_id < 17 or len_user_id > 19:
|
|
232
|
+
raise ValueError("Discord user id must be between 17 and 19 characters")
|
|
233
|
+
|
|
234
|
+
return as_str
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def watchdog_dict(self) -> dict:
|
|
238
|
+
"""Get watchdog configuration dictionary.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Dictionary with watchdog format
|
|
242
|
+
"""
|
|
243
|
+
return {
|
|
244
|
+
self.watchdog_property_map[field.name]: getattr(self, field.name) or "0"
|
|
245
|
+
for field in fields(self)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def ui_dict(self) -> dict:
|
|
250
|
+
"""Get UI dictionary with camelCase keys.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Dictionary with camelCase keys for UI
|
|
254
|
+
"""
|
|
255
|
+
return to_jsonable_python(self, by_alias=True)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@py_dataclass(config=ConfigDict(populate_by_name=True))
|
|
259
|
+
class TelegramNotification:
|
|
260
|
+
"""Telegram notification configuration."""
|
|
261
|
+
|
|
262
|
+
watchdog_property_map: ClassVar[dict] = {
|
|
263
|
+
"bot_token": "telegram_bot_token",
|
|
264
|
+
"chat_id": "telegram_chat_id",
|
|
265
|
+
"telegram_alert": "telegram_alert",
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
bot_token: str | None = Field(
|
|
269
|
+
default=None, alias="telegramBotToken", pattern=r"^[0-9]{8,10}:[a-zA-Z0-9_-]{35}$"
|
|
270
|
+
)
|
|
271
|
+
chat_id: str | None = Field(
|
|
272
|
+
default=None, alias="telegramChatId", min_length=6, max_length=1000
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def telegram_alert(self) -> str:
|
|
277
|
+
"""Check if telegram alerts are enabled.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
"1" if enabled (both token and chat_id present), "0" otherwise
|
|
281
|
+
"""
|
|
282
|
+
return "1" if self.bot_token and self.chat_id else "0"
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def watchdog_dict(self) -> dict:
|
|
286
|
+
"""Get watchdog configuration dictionary.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Dictionary with watchdog format
|
|
290
|
+
"""
|
|
291
|
+
# we use the watchdog_property_map so we can include telegram_alert
|
|
292
|
+
return {
|
|
293
|
+
self.watchdog_property_map[key]: getattr(self, key) or "0"
|
|
294
|
+
for key in self.watchdog_property_map
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def ui_dict(self) -> dict:
|
|
299
|
+
"""Get UI dictionary with camelCase keys.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Dictionary with camelCase keys for UI
|
|
303
|
+
"""
|
|
304
|
+
return to_jsonable_python(self, by_alias=True)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@py_dataclass(config=ConfigDict(populate_by_name=True))
|
|
308
|
+
class Notifications:
|
|
309
|
+
"""Notification configuration for all channels."""
|
|
310
|
+
|
|
311
|
+
discord: DiscordNotification | None = None
|
|
312
|
+
telegram: TelegramNotification | None = None
|
|
313
|
+
email: (
|
|
314
|
+
Annotated[EmailStr, StringConstraints(strip_whitespace=True, to_lower=True)] | None
|
|
315
|
+
) = Field(default=None, alias="emailAddress")
|
|
316
|
+
webhook: str | None = Field(default=None, alias="genericWebhookUrl")
|
|
317
|
+
node_name: str | None = Field(default=None, alias="nodeName")
|
|
318
|
+
|
|
319
|
+
asdict = asdict
|
|
320
|
+
|
|
321
|
+
@field_validator("webhook", mode="after")
|
|
322
|
+
@classmethod
|
|
323
|
+
def validate_webhook(cls, value: str | None) -> str | None:
|
|
324
|
+
"""Validate generic webhook URL.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
value: Webhook URL value
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Validated webhook URL
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
ValidationError: If webhook URL is invalid
|
|
334
|
+
"""
|
|
335
|
+
if not value:
|
|
336
|
+
return value
|
|
337
|
+
|
|
338
|
+
# this will raise ValidationError for us
|
|
339
|
+
HttpUrl(value)
|
|
340
|
+
|
|
341
|
+
return value
|
|
342
|
+
|
|
343
|
+
def to_ui_dict(self) -> dict:
|
|
344
|
+
"""Convert to UI dictionary format with camelCase keys and nested structure.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Dictionary with camelCase keys for UI, with nested discord and telegram objects
|
|
348
|
+
"""
|
|
349
|
+
return to_jsonable_python(self, by_alias=True)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@py_dataclass(config=ConfigDict(populate_by_name=True))
|
|
353
|
+
class Miscellaneous:
|
|
354
|
+
"""Miscellaneous configuration settings."""
|
|
355
|
+
|
|
356
|
+
ssh_pubkey: str | None = Field(default=None, alias="sshPubKey")
|
|
357
|
+
debug: bool = False
|
|
358
|
+
development: bool = False
|
|
359
|
+
testnet: bool = False
|
|
360
|
+
blocked_ports: list[int] = Field(default_factory=list, alias="blockedPorts")
|
|
361
|
+
blocked_repositories: list[str] = Field(default_factory=list, alias="blockedRepositories")
|
|
362
|
+
|
|
363
|
+
asdict = asdict
|
|
364
|
+
|
|
365
|
+
@field_validator("blocked_ports", mode="before")
|
|
366
|
+
@classmethod
|
|
367
|
+
def validate_blocked_ports(cls, value: list[int | str]) -> list[int]:
|
|
368
|
+
"""Validate blocked ports.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
value: List of port numbers
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
List of validated port numbers as integers
|
|
375
|
+
|
|
376
|
+
Raises:
|
|
377
|
+
ValueError: If port is out of valid range
|
|
378
|
+
"""
|
|
379
|
+
as_int = []
|
|
380
|
+
|
|
381
|
+
# Port validation regex: matches 1-65535
|
|
382
|
+
pattern = (
|
|
383
|
+
r"^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|"
|
|
384
|
+
r"65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
for port in value:
|
|
388
|
+
if not re.search(pattern, str(port)):
|
|
389
|
+
raise ValueError(f"Port: {port} must be in the range 0-65535")
|
|
390
|
+
|
|
391
|
+
as_int.append(int(port))
|
|
392
|
+
|
|
393
|
+
return as_int
|
|
394
|
+
|
|
395
|
+
@field_validator("blocked_repositories", mode="before")
|
|
396
|
+
@classmethod
|
|
397
|
+
def validate_blocked_repositories(cls, value: list) -> list:
|
|
398
|
+
"""Validate blocked repositories.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
value: List of repository names
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
List of validated repository names
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
ValueError: If repository format is invalid
|
|
408
|
+
"""
|
|
409
|
+
# Docker repository validation regex
|
|
410
|
+
pattern = (
|
|
411
|
+
r"^(?:(?:(?:(?:[\w-]+(?:\.[\w-]+)+)(?::\d+)?)|[\w]+:\d+)\/)?\/?(?:(?:(?:[a-z0-9]+"
|
|
412
|
+
r"(?:(?:[._]|__|[-]*)[a-z0-9]+)*)\/){0,2})(?:[a-z0-9-_.]+\/{0,1}[a-z0-9-_.]+)"
|
|
413
|
+
r"[:]?(?:[\w][\w.-]{0,127})?$"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
for repo in value:
|
|
417
|
+
if not re.search(pattern, repo):
|
|
418
|
+
raise ValueError(f"Repository: {repo} must be a valid format")
|
|
419
|
+
|
|
420
|
+
return value
|
|
421
|
+
|
|
422
|
+
@field_validator("ssh_pubkey", mode="before")
|
|
423
|
+
@classmethod
|
|
424
|
+
def validate_ssh_pubkey(cls, value: str | None) -> str | None:
|
|
425
|
+
"""Validate SSH public key.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
value: SSH public key value
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
Validated SSH public key
|
|
432
|
+
|
|
433
|
+
Raises:
|
|
434
|
+
ValueError: If SSH key is invalid
|
|
435
|
+
"""
|
|
436
|
+
if not value:
|
|
437
|
+
return value
|
|
438
|
+
|
|
439
|
+
key = SSHKey(value, strict=True)
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
key.parse()
|
|
443
|
+
except InvalidKeyError as e:
|
|
444
|
+
raise ValueError("A public key in OpenSSH format is required") from e
|
|
445
|
+
|
|
446
|
+
return value
|
|
447
|
+
|
|
448
|
+
def to_ui_dict(self) -> dict:
|
|
449
|
+
"""Convert to UI dictionary format with camelCase keys.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Dictionary with camelCase keys for UI
|
|
453
|
+
"""
|
|
454
|
+
return to_jsonable_python(self, by_alias=True)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@py_dataclass
|
|
458
|
+
class InstallerProvidedConfig:
|
|
459
|
+
"""Configuration provided by the installer."""
|
|
460
|
+
|
|
461
|
+
config_path: ClassVar[Path] = ConfigLocations().installer_config
|
|
462
|
+
# system
|
|
463
|
+
hostname: str
|
|
464
|
+
# network
|
|
465
|
+
upnp_enabled: bool
|
|
466
|
+
router_address: str
|
|
467
|
+
upnp_port: int = 16127
|
|
468
|
+
local_chain_sources: list[str] = field(default_factory=list)
|
|
469
|
+
chain_sources_timestamp: float = 0.0
|
|
470
|
+
private_chain_sources: list[str] = field(default_factory=list)
|
|
471
|
+
|
|
472
|
+
@classmethod
|
|
473
|
+
async def from_installer_file(cls) -> InstallerProvidedConfig:
|
|
474
|
+
"""Load installer config from YAML file.
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
InstallerProvidedConfig instance
|
|
478
|
+
|
|
479
|
+
Raises:
|
|
480
|
+
FileNotFoundError: If config file doesn't exist
|
|
481
|
+
yaml.YAMLError: If YAML is malformed
|
|
482
|
+
KeyError: If required keys are missing
|
|
483
|
+
"""
|
|
484
|
+
async with aiofiles.open(cls.config_path) as f:
|
|
485
|
+
data = await f.read()
|
|
486
|
+
|
|
487
|
+
conf = yaml.safe_load(data)
|
|
488
|
+
|
|
489
|
+
# these will raise if not found when unpacked
|
|
490
|
+
# configs needs to be filtered in case of bad keywords
|
|
491
|
+
system_config = conf.get("system")
|
|
492
|
+
network_config = conf.get("network")
|
|
493
|
+
|
|
494
|
+
return cls(**system_config, **network_config)
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def local_chain_sources_stale(self) -> bool:
|
|
498
|
+
"""Check if local chain sources are stale (>24h old).
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
True if stale or empty
|
|
502
|
+
"""
|
|
503
|
+
return self.chain_sources_timestamp + 86400 < time() or not self.local_chain_sources
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def has_chain_sources(self) -> bool:
|
|
507
|
+
"""Check if any chain sources are configured.
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
True if local or private chain sources exist
|
|
511
|
+
"""
|
|
512
|
+
return any([self.local_chain_sources, self.private_chain_sources])
|
|
513
|
+
|
|
514
|
+
@property
|
|
515
|
+
def fluxos_properties(self) -> dict:
|
|
516
|
+
"""Get FluxOS properties dictionary.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
Dictionary with FluxOS configuration
|
|
520
|
+
"""
|
|
521
|
+
return {
|
|
522
|
+
"upnp": self.upnp_enabled,
|
|
523
|
+
"api_port": self.upnp_port,
|
|
524
|
+
"router_ip": self.router_address,
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@py_dataclass
|
|
529
|
+
class UserProvidedConfig:
|
|
530
|
+
"""User provided configuration for FluxNode."""
|
|
531
|
+
|
|
532
|
+
config_path: ClassVar[Path] = ConfigLocations().user_config
|
|
533
|
+
|
|
534
|
+
identity: Identity
|
|
535
|
+
notifications: Notifications = field(
|
|
536
|
+
default_factory=lambda: Notifications(discord=None, telegram=None)
|
|
537
|
+
)
|
|
538
|
+
miscellaneous: Miscellaneous = field(default_factory=Miscellaneous)
|
|
539
|
+
delegate: Delegate | None = field(default=None)
|
|
540
|
+
|
|
541
|
+
asdict = asdict
|
|
542
|
+
|
|
543
|
+
@classmethod
|
|
544
|
+
def from_dict(cls, params: dict) -> UserProvidedConfig:
|
|
545
|
+
"""Create UserProvidedConfig from UI dictionary format.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
params: Dictionary with nested identity, notifications, miscellaneous
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
UserProvidedConfig instance
|
|
552
|
+
|
|
553
|
+
Raises:
|
|
554
|
+
ValidationError: If validation fails (raised by Pydantic)
|
|
555
|
+
"""
|
|
556
|
+
return TypeAdapter(cls).validate_python(params)
|
|
557
|
+
|
|
558
|
+
@classmethod
|
|
559
|
+
async def from_config_file(cls) -> UserProvidedConfig | None:
|
|
560
|
+
"""Load user config from YAML file.
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
UserProvidedConfig instance or None if file doesn't exist
|
|
564
|
+
|
|
565
|
+
Raises:
|
|
566
|
+
ValidationError: If validation fails
|
|
567
|
+
"""
|
|
568
|
+
try:
|
|
569
|
+
async with aiofiles.open(cls.config_path) as f:
|
|
570
|
+
data = await f.read()
|
|
571
|
+
except FileNotFoundError:
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
conf: dict = yaml.safe_load(data)
|
|
576
|
+
except yaml.YAMLError as e:
|
|
577
|
+
logger.warning("Error parsing YAML config: %s", e)
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
# These will raise ValidationError if malformed
|
|
581
|
+
identity = Identity(**conf.get("identity"))
|
|
582
|
+
|
|
583
|
+
notifications_data = conf.get("notifications")
|
|
584
|
+
notifications = Notifications(**notifications_data) if notifications_data else Notifications()
|
|
585
|
+
|
|
586
|
+
miscellaneous_data = conf.get("miscellaneous")
|
|
587
|
+
miscellaneous = Miscellaneous(**miscellaneous_data) if miscellaneous_data else Miscellaneous()
|
|
588
|
+
|
|
589
|
+
delegate_data = conf.get("delegate")
|
|
590
|
+
delegate = Delegate(**delegate_data) if delegate_data else None
|
|
591
|
+
|
|
592
|
+
return cls(identity, notifications, miscellaneous, delegate)
|
|
593
|
+
|
|
594
|
+
@property
|
|
595
|
+
def fluxd_properties(self) -> dict:
|
|
596
|
+
"""Get FluxD properties dictionary.
|
|
597
|
+
|
|
598
|
+
Returns:
|
|
599
|
+
Dictionary with FluxD configuration
|
|
600
|
+
"""
|
|
601
|
+
identity = self.identity
|
|
602
|
+
return {
|
|
603
|
+
"zelnodeprivkey": identity.identity_key,
|
|
604
|
+
"zelnodeoutpoint": identity.tx_id,
|
|
605
|
+
"zelnodeindex": identity.output_id,
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
@property
|
|
609
|
+
def fluxos_properties(self) -> dict:
|
|
610
|
+
"""Get FluxOS properties dictionary.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
Dictionary with FluxOS configuration
|
|
614
|
+
"""
|
|
615
|
+
return {
|
|
616
|
+
"flux_id": self.identity.flux_id,
|
|
617
|
+
"debug": self.miscellaneous.debug,
|
|
618
|
+
"development": self.miscellaneous.development,
|
|
619
|
+
"testnet": self.miscellaneous.testnet,
|
|
620
|
+
"blocked_ports": self.miscellaneous.blocked_ports,
|
|
621
|
+
"blocked_repositories": self.miscellaneous.blocked_repositories,
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async def to_config_file(self, path: Path | None = None) -> None:
|
|
625
|
+
"""Save user config to YAML file.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
path: Optional custom path (defaults to config_path)
|
|
629
|
+
"""
|
|
630
|
+
conf = yaml.safe_dump(self.asdict())
|
|
631
|
+
|
|
632
|
+
write_path = path or UserProvidedConfig.config_path
|
|
633
|
+
|
|
634
|
+
lock = get_file_lock(write_path)
|
|
635
|
+
async with lock:
|
|
636
|
+
async with aiofiles.open(write_path, "w") as f:
|
|
637
|
+
await f.write(conf)
|
|
638
|
+
|
|
639
|
+
def purge(self, path: Path | None = None) -> None:
|
|
640
|
+
"""Delete the config file.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
path: Optional custom path (defaults to config_path)
|
|
644
|
+
"""
|
|
645
|
+
file_path = path or UserProvidedConfig.config_path
|
|
646
|
+
|
|
647
|
+
file_path.unlink(missing_ok=True)
|
|
648
|
+
|
|
649
|
+
def to_ui_dict(self) -> dict:
|
|
650
|
+
"""Convert to UI dictionary format with camelCase keys.
|
|
651
|
+
|
|
652
|
+
Returns:
|
|
653
|
+
Dictionary with camelCase keys for UI
|
|
654
|
+
"""
|
|
655
|
+
result = {}
|
|
656
|
+
for f in fields(self):
|
|
657
|
+
value = getattr(self, f.name)
|
|
658
|
+
if value is not None:
|
|
659
|
+
result[f.name] = value.to_ui_dict()
|
|
660
|
+
return result
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: flux-config-shared
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared protocol and configuration definitions for Flux Config packages
|
|
5
|
+
Author: David White
|
|
6
|
+
Author-email: David White <david@runonflux.io>
|
|
7
|
+
License: GPL-3.0-or-later
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: System Administrators
|
|
10
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: System :: Systems Administration
|
|
14
|
+
Classifier: Topic :: System :: Monitoring
|
|
15
|
+
Requires-Dist: pyyaml>=6.0.2,<7
|
|
16
|
+
Requires-Dist: aiofiles>=25.1.0,<26
|
|
17
|
+
Requires-Dist: textual>=6.11.0,<7
|
|
18
|
+
Requires-Dist: pydantic>=2.10.6,<3
|
|
19
|
+
Requires-Dist: sshpubkeys>=3.3.1,<4
|
|
20
|
+
Requires-Dist: email-validator>=2.2.0,<2.3
|
|
21
|
+
Requires-Dist: pyrage>=1.3.0
|
|
22
|
+
Requires-Dist: flux-delegate-starter>=0.1.0
|
|
23
|
+
Requires-Python: >=3.13, <4
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# flux-config-shared
|
|
27
|
+
|
|
28
|
+
Shared protocol and configuration definitions for Flux Config packages.
|
|
29
|
+
|
|
30
|
+
This package contains:
|
|
31
|
+
- JSON-RPC protocol definitions
|
|
32
|
+
- Daemon state models
|
|
33
|
+
- User configuration models
|
|
34
|
+
- Application configuration (AppConfig)
|
|
35
|
+
- Delegate configuration
|
|
36
|
+
- Pydantic validation models
|
|
37
|
+
|
|
38
|
+
Used by:
|
|
39
|
+
- flux-configd (daemon)
|
|
40
|
+
- flux-config-tui (TUI client)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
flux_config_shared/__init__.py,sha256=TmvZd2U3Jf4NX6kSUVIwIWufNqwP-zPX65fLRHn6I4E,432
|
|
2
|
+
flux_config_shared/app_config.py,sha256=UzG2KoAmDOC8A1HaXZ0UHhw5fWvzj4A0o3b7z6-Urpk,2917
|
|
3
|
+
flux_config_shared/config_locations.py,sha256=zyw71Kd8MujcRudRSIVfWaoz6zdUskIYmRB_3WoqsSY,1754
|
|
4
|
+
flux_config_shared/daemon_state.py,sha256=T-1D-jKqLgKAaDmLlF-HUdEfUhS7_6WDGo40UzWG6VA,4183
|
|
5
|
+
flux_config_shared/delegate_config.py,sha256=C1cdgmzTK5HPX13Kz7BIRkeir1CVxGk6_HvRKL4OHVs,3207
|
|
6
|
+
flux_config_shared/log.py,sha256=26ZXyIaYozIi_n8_rEgjN0S489wL5oA2d_Gh-5RdYRQ,3861
|
|
7
|
+
flux_config_shared/models.py,sha256=zfYN2d4CFErTBI4vpIwESnopCTInUz68xGSPswpyIOo,1230
|
|
8
|
+
flux_config_shared/protocol.py,sha256=pKZAuLBqYqpSq9_dqiwMkFJrYMI-SNztSUnTliwZc2U,13381
|
|
9
|
+
flux_config_shared/user_config.py,sha256=l5onT-Ojz2xxErQE4HGfMCqKbFobi2QVaFyHAYTiy74,19202
|
|
10
|
+
flux_config_shared-0.1.0.dist-info/WHEEL,sha256=XV0cjMrO7zXhVAIyyc8aFf1VjZ33Fen4IiJk5zFlC3g,80
|
|
11
|
+
flux_config_shared-0.1.0.dist-info/METADATA,sha256=vu_jeGX0Ebn0EBDdpOr6obU6erx0eueD4YszPVScviY,1332
|
|
12
|
+
flux_config_shared-0.1.0.dist-info/RECORD,,
|