flux-config-shared 0.1.0__tar.gz
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-0.1.0/PKG-INFO +40 -0
- flux_config_shared-0.1.0/README.md +15 -0
- flux_config_shared-0.1.0/pyproject.toml +57 -0
- flux_config_shared-0.1.0/src/flux_config_shared/__init__.py +23 -0
- flux_config_shared-0.1.0/src/flux_config_shared/app_config.py +102 -0
- flux_config_shared-0.1.0/src/flux_config_shared/config_locations.py +46 -0
- flux_config_shared-0.1.0/src/flux_config_shared/daemon_state.py +163 -0
- flux_config_shared-0.1.0/src/flux_config_shared/delegate_config.py +112 -0
- flux_config_shared-0.1.0/src/flux_config_shared/log.py +124 -0
- flux_config_shared-0.1.0/src/flux_config_shared/models.py +48 -0
- flux_config_shared-0.1.0/src/flux_config_shared/protocol.py +402 -0
- flux_config_shared-0.1.0/src/flux_config_shared/user_config.py +660 -0
|
@@ -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,15 @@
|
|
|
1
|
+
# flux-config-shared
|
|
2
|
+
|
|
3
|
+
Shared protocol and configuration definitions for Flux Config packages.
|
|
4
|
+
|
|
5
|
+
This package contains:
|
|
6
|
+
- JSON-RPC protocol definitions
|
|
7
|
+
- Daemon state models
|
|
8
|
+
- User configuration models
|
|
9
|
+
- Application configuration (AppConfig)
|
|
10
|
+
- Delegate configuration
|
|
11
|
+
- Pydantic validation models
|
|
12
|
+
|
|
13
|
+
Used by:
|
|
14
|
+
- flux-configd (daemon)
|
|
15
|
+
- flux-config-tui (TUI client)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "flux-config-shared"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared protocol and configuration definitions for Flux Config packages"
|
|
5
|
+
authors = [{ name = "David White", email = "david@runonflux.io" }]
|
|
6
|
+
requires-python = ">=3.13, <4"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
license = { text = "GPL-3.0-or-later" }
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Intended Audience :: System Administrators",
|
|
12
|
+
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Topic :: System :: Systems Administration",
|
|
16
|
+
"Topic :: System :: Monitoring",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
dependencies = [
|
|
20
|
+
"pyyaml>=6.0.2,<7",
|
|
21
|
+
"aiofiles>=25.1.0,<26",
|
|
22
|
+
"textual>=6.11.0,<7",
|
|
23
|
+
"pydantic>=2.10.6,<3",
|
|
24
|
+
"sshpubkeys>=3.3.1,<4",
|
|
25
|
+
"email_validator>=2.2.0,<2.3",
|
|
26
|
+
"pyrage>=1.3.0",
|
|
27
|
+
"flux-delegate-starter>=0.1.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["uv_build>=0.9.18,<0.10.0"]
|
|
32
|
+
build-backend = "uv_build"
|
|
33
|
+
|
|
34
|
+
[tool.uv.build-backend]
|
|
35
|
+
module-name = ["flux_config_shared"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 100
|
|
39
|
+
target-version = "py313"
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint]
|
|
42
|
+
select = [
|
|
43
|
+
"E", # pycodestyle errors
|
|
44
|
+
"F", # pyflakes
|
|
45
|
+
"I", # isort (import sorting)
|
|
46
|
+
"N", # pep8-naming
|
|
47
|
+
"UP", # pyupgrade
|
|
48
|
+
"ANN", # flake8-annotations
|
|
49
|
+
"ASYNC", # flake8-async
|
|
50
|
+
"S", # flake8-bandit (security)
|
|
51
|
+
"B", # flake8-bugbear
|
|
52
|
+
"C4", # flake8-comprehensions
|
|
53
|
+
"DTZ", # flake8-datetimez
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint.per-file-ignores]
|
|
57
|
+
"__init__.py" = ["F401"] # Unused imports OK in __init__
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared protocol definitions for flux_config."""
|
|
2
|
+
|
|
3
|
+
from flux_config_shared.app_config import AppConfig
|
|
4
|
+
from flux_config_shared.protocol import (
|
|
5
|
+
Event,
|
|
6
|
+
EventType,
|
|
7
|
+
JsonRpcError,
|
|
8
|
+
JsonRpcRequest,
|
|
9
|
+
JsonRpcResponse,
|
|
10
|
+
MethodName,
|
|
11
|
+
RPCErrorCode,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AppConfig",
|
|
16
|
+
"Event",
|
|
17
|
+
"EventType",
|
|
18
|
+
"JsonRpcError",
|
|
19
|
+
"JsonRpcRequest",
|
|
20
|
+
"JsonRpcResponse",
|
|
21
|
+
"MethodName",
|
|
22
|
+
"RPCErrorCode",
|
|
23
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Application configuration for TUI and other clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
import aiofiles
|
|
9
|
+
import yaml
|
|
10
|
+
from textual.theme import BUILTIN_THEMES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class AppConfig:
|
|
15
|
+
config_path: ClassVar[str] = "/mnt/root/config/app_config.yaml"
|
|
16
|
+
theme: str = "flexoki"
|
|
17
|
+
poweroff_screen: int = 0
|
|
18
|
+
screen_resolutions: list[str] = field(default_factory=list)
|
|
19
|
+
selected_resolution: str | None = None
|
|
20
|
+
|
|
21
|
+
asdict = asdict
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_file(cls) -> AppConfig:
|
|
25
|
+
try:
|
|
26
|
+
with open(cls.config_path) as f:
|
|
27
|
+
data = f.read()
|
|
28
|
+
except FileNotFoundError:
|
|
29
|
+
instance = cls()
|
|
30
|
+
instance.persist_sync()
|
|
31
|
+
return instance
|
|
32
|
+
|
|
33
|
+
conf: dict = yaml.safe_load(data)
|
|
34
|
+
|
|
35
|
+
filtered = {field.name: conf.get(field.name) for field in fields(cls) if field.name in conf}
|
|
36
|
+
|
|
37
|
+
return cls(**filtered)
|
|
38
|
+
|
|
39
|
+
async def update(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
theme: str | None = None,
|
|
43
|
+
poweroff_screen: int | None = None,
|
|
44
|
+
resolutions: list[str] | None = None,
|
|
45
|
+
selected_resolution: str | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
needs_persist = False
|
|
48
|
+
|
|
49
|
+
if theme and theme in BUILTIN_THEMES and self.theme != theme:
|
|
50
|
+
self.theme = theme
|
|
51
|
+
needs_persist = True
|
|
52
|
+
|
|
53
|
+
if poweroff_screen is not None and self.poweroff_screen != poweroff_screen:
|
|
54
|
+
self.poweroff_screen = poweroff_screen
|
|
55
|
+
needs_persist = True
|
|
56
|
+
|
|
57
|
+
if resolutions:
|
|
58
|
+
self.screen_resolutions = resolutions
|
|
59
|
+
needs_persist = True
|
|
60
|
+
|
|
61
|
+
if selected_resolution and self.selected_resolution != selected_resolution:
|
|
62
|
+
self.selected_resolution = selected_resolution
|
|
63
|
+
needs_persist = True
|
|
64
|
+
|
|
65
|
+
if needs_persist:
|
|
66
|
+
await self.persist()
|
|
67
|
+
|
|
68
|
+
def update_sync(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
theme: str | None = None,
|
|
72
|
+
poweroff_screen: int | None = None,
|
|
73
|
+
resolutions: list[str] | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
needs_persist = False
|
|
76
|
+
|
|
77
|
+
if theme and theme in BUILTIN_THEMES and self.theme != theme:
|
|
78
|
+
self.theme = theme
|
|
79
|
+
needs_persist = True
|
|
80
|
+
|
|
81
|
+
if poweroff_screen is not None and self.poweroff_screen != poweroff_screen:
|
|
82
|
+
self.poweroff_screen = poweroff_screen
|
|
83
|
+
needs_persist = True
|
|
84
|
+
|
|
85
|
+
if resolutions:
|
|
86
|
+
self.screen_resolutions = resolutions
|
|
87
|
+
needs_persist = True
|
|
88
|
+
|
|
89
|
+
if needs_persist:
|
|
90
|
+
self.persist_sync()
|
|
91
|
+
|
|
92
|
+
async def persist(self) -> None:
|
|
93
|
+
conf = yaml.dump(self.asdict())
|
|
94
|
+
|
|
95
|
+
async with aiofiles.open(AppConfig.config_path, "w") as f:
|
|
96
|
+
await f.write(conf)
|
|
97
|
+
|
|
98
|
+
def persist_sync(self) -> None:
|
|
99
|
+
conf = yaml.dump(self.asdict())
|
|
100
|
+
|
|
101
|
+
with open(AppConfig.config_path, "w") as f:
|
|
102
|
+
f.write(conf)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Configuration file locations for Flux services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ConfigLocations:
|
|
11
|
+
"""Standard configuration file locations for Flux services.
|
|
12
|
+
|
|
13
|
+
This matches the original ConfigLocations class from config_builder.py
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# Daemon configs
|
|
17
|
+
fluxd: Path = Path("/dat/var/lib/fluxd/flux.conf")
|
|
18
|
+
fluxd_seed_nodes: Path = Path("/mnt/root/config/seed_nodes.yaml")
|
|
19
|
+
fluxbenchd: Path = Path("/dat/usr/lib/fluxbenchd/fluxbench.conf")
|
|
20
|
+
fluxos: Path = Path("/dat/usr/lib/fluxos/config/userconfig.js")
|
|
21
|
+
flux_watchdog: Path = Path("/dat/usr/lib/fluxwatchdog/config.js")
|
|
22
|
+
syncthing: Path = Path("/dat/usr/lib/syncthing/config.xml")
|
|
23
|
+
|
|
24
|
+
# SSH configs
|
|
25
|
+
ssh_auth_flux: Path = Path("/mnt/root/config/ssh/authorized_keys")
|
|
26
|
+
ssh_auth_fs: Path = Path("/home/operator/.ssh/authorized_keys")
|
|
27
|
+
|
|
28
|
+
# Systemd configs
|
|
29
|
+
fluxadm_ssh_socket: Path = Path("/etc/systemd/system/fluxadm-ssh.socket.d/override.conf")
|
|
30
|
+
|
|
31
|
+
# Firewall configs
|
|
32
|
+
fluxadm_ufw: Path = Path("/etc/ufw/applications.d/fluxadm-ssh")
|
|
33
|
+
fail2ban: Path = Path("/etc/fail2ban/jail.d/defaults-debian.conf")
|
|
34
|
+
|
|
35
|
+
# X11/Display configs
|
|
36
|
+
xorg_serverflags: Path = Path("/etc/X11/xorg.conf.d/10-serverflags.conf")
|
|
37
|
+
xorg_serverflags_persistent: Path = Path(
|
|
38
|
+
"/mnt/root/config/etc/X11/xorg.conf.d/10-serverflags.conf"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Application configs
|
|
42
|
+
config_dir: Path = Path("/mnt/root/config")
|
|
43
|
+
logs_dir: Path = Path("/var/log/flux_config")
|
|
44
|
+
user_config: Path = Path("/mnt/root/config/flux_user_config.yaml")
|
|
45
|
+
installer_config: Path = Path("/mnt/root/config/flux_config.yaml")
|
|
46
|
+
shaping_policy: Path = Path("/mnt/root/config/shaping_policy.json")
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Unified daemon state model for synchronization between daemon and TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DaemonPhase(str, Enum):
|
|
11
|
+
"""Daemon lifecycle phases (simplified state machine).
|
|
12
|
+
|
|
13
|
+
Phases represent daemon's overall state, NOT UI loading states.
|
|
14
|
+
Use SystemStatus enums to determine specific loading messages.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
INITIALIZING = "initializing"
|
|
18
|
+
RUNNING = "running"
|
|
19
|
+
ERROR = "error"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DatMountStatus(str, Enum):
|
|
23
|
+
"""Data crypt mount status (replaces ad-hoc status_message)."""
|
|
24
|
+
|
|
25
|
+
PENDING = "pending"
|
|
26
|
+
MOUNTING = "mounting"
|
|
27
|
+
DELAYED = "delayed"
|
|
28
|
+
FAILED = "failed"
|
|
29
|
+
COMPLETE = "complete"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConnectivityStatus(str, Enum):
|
|
33
|
+
"""Network connectivity status."""
|
|
34
|
+
|
|
35
|
+
UNKNOWN = "unknown"
|
|
36
|
+
CHECKING = "checking"
|
|
37
|
+
VALIDATED = "validated"
|
|
38
|
+
FAILED = "failed"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class InitializationStatus:
|
|
43
|
+
"""Tracks initialization progress without ad-hoc strings."""
|
|
44
|
+
|
|
45
|
+
completed_milestones: set[str] = field(default_factory=set)
|
|
46
|
+
current_operation: str | None = None
|
|
47
|
+
current_operation_started_at: float | None = None
|
|
48
|
+
blocked_on: list[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
def is_complete(self) -> bool:
|
|
51
|
+
"""Check if all key milestones are complete."""
|
|
52
|
+
key_milestones = {"dat_mounted", "db_state_populated", "connectivity_validated"}
|
|
53
|
+
return key_milestones.issubset(self.completed_milestones)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class SystemStatus:
|
|
58
|
+
"""System readiness status using enums (replaces status_message + fatal_error)."""
|
|
59
|
+
|
|
60
|
+
dat_mount_status: DatMountStatus = DatMountStatus.PENDING
|
|
61
|
+
dat_mount_error: str | None = None
|
|
62
|
+
|
|
63
|
+
connectivity_status: ConnectivityStatus = ConnectivityStatus.UNKNOWN
|
|
64
|
+
connectivity_error: str | None = None
|
|
65
|
+
connectivity_checked_at: float | None = None
|
|
66
|
+
|
|
67
|
+
db_populated: bool = False
|
|
68
|
+
db_error: str | None = None
|
|
69
|
+
|
|
70
|
+
fatal_error: dict[str, str] | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ServiceStates:
|
|
75
|
+
"""Service running states."""
|
|
76
|
+
|
|
77
|
+
fluxd_started: bool = False
|
|
78
|
+
fluxbenchd_started: bool = False
|
|
79
|
+
fluxos_started: bool = False
|
|
80
|
+
syncthing_started: bool = False
|
|
81
|
+
flux_watchdog_started: bool = False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class NetworkInfo:
|
|
86
|
+
"""Network information."""
|
|
87
|
+
|
|
88
|
+
public_ip: str | None = None
|
|
89
|
+
local_ip: str | None = None
|
|
90
|
+
connected: bool = False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class BlockchainInfo:
|
|
95
|
+
"""Blockchain information."""
|
|
96
|
+
|
|
97
|
+
height: int | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class SystemInfo:
|
|
102
|
+
"""System information."""
|
|
103
|
+
|
|
104
|
+
secureboot_enforced: bool = False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class WebserverInfo:
|
|
109
|
+
"""Webserver state."""
|
|
110
|
+
|
|
111
|
+
host: str | None = None
|
|
112
|
+
port: int | None = None
|
|
113
|
+
token: str | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class ConfigInfo:
|
|
118
|
+
"""Configuration state."""
|
|
119
|
+
|
|
120
|
+
installer_config: dict | None = None
|
|
121
|
+
user_config: dict | None = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class DaemonState:
|
|
126
|
+
"""Complete daemon state - single source of truth."""
|
|
127
|
+
|
|
128
|
+
phase: DaemonPhase = DaemonPhase.INITIALIZING
|
|
129
|
+
|
|
130
|
+
install_state: str = "UNKNOWN"
|
|
131
|
+
reconfigure_mode: str | None = None
|
|
132
|
+
|
|
133
|
+
initialization: InitializationStatus = field(default_factory=InitializationStatus)
|
|
134
|
+
|
|
135
|
+
system_status: SystemStatus = field(default_factory=SystemStatus)
|
|
136
|
+
|
|
137
|
+
services: ServiceStates = field(default_factory=ServiceStates)
|
|
138
|
+
|
|
139
|
+
network: NetworkInfo = field(default_factory=NetworkInfo)
|
|
140
|
+
|
|
141
|
+
blockchain: BlockchainInfo = field(default_factory=BlockchainInfo)
|
|
142
|
+
|
|
143
|
+
system: SystemInfo = field(default_factory=SystemInfo)
|
|
144
|
+
|
|
145
|
+
webserver: WebserverInfo = field(default_factory=WebserverInfo)
|
|
146
|
+
|
|
147
|
+
tunnel: dict = field(default_factory=dict)
|
|
148
|
+
|
|
149
|
+
config: ConfigInfo = field(default_factory=ConfigInfo)
|
|
150
|
+
|
|
151
|
+
active_tasks: list[dict] = field(default_factory=list)
|
|
152
|
+
|
|
153
|
+
def to_dict(self) -> dict:
|
|
154
|
+
"""Convert to dictionary for JSON serialization."""
|
|
155
|
+
result = asdict(self)
|
|
156
|
+
result["phase"] = self.phase.value
|
|
157
|
+
result["system_status"]["dat_mount_status"] = (
|
|
158
|
+
self.system_status.dat_mount_status.value
|
|
159
|
+
)
|
|
160
|
+
result["system_status"]["connectivity_status"] = (
|
|
161
|
+
self.system_status.connectivity_status.value
|
|
162
|
+
)
|
|
163
|
+
return result
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Delegate node starting configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
|
|
9
|
+
import pyrage
|
|
10
|
+
from pydantic import ConfigDict, Field, field_validator
|
|
11
|
+
from pydantic.dataclasses import dataclass as py_dataclass
|
|
12
|
+
from pydantic_core import to_jsonable_python
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
WIF_PATTERN = r"^[5KLc9][1-9A-HJ-NP-Za-km-z]{50,51}$"
|
|
16
|
+
COMPRESSED_PUBKEY_PATTERN = r"^(02|03)[0-9a-fA-F]{64}$"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_wif_format(wif: str) -> bool:
|
|
20
|
+
"""Validate WIF private key format.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
wif: WIF format private key string
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True if valid WIF format, False otherwise
|
|
27
|
+
"""
|
|
28
|
+
return bool(re.match(WIF_PATTERN, wif))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def encrypt_delegate_key(wif_key: str, passphrase: str) -> str:
|
|
32
|
+
"""Encrypt a WIF private key with age passphrase encryption.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
wif_key: WIF format private key
|
|
36
|
+
passphrase: Encryption passphrase
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Base64-encoded age-encrypted ciphertext
|
|
40
|
+
"""
|
|
41
|
+
encrypted_bytes = pyrage.passphrase.encrypt(wif_key.encode("utf-8"), passphrase)
|
|
42
|
+
return base64.b64encode(encrypted_bytes).decode("utf-8")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def decrypt_delegate_key(encrypted: str, passphrase: str) -> str:
|
|
46
|
+
"""Decrypt an age-encrypted WIF private key.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
encrypted: Base64-encoded age-encrypted ciphertext
|
|
50
|
+
passphrase: Decryption passphrase
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Decrypted WIF private key
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
binascii.Error: If encrypted data is not valid base64
|
|
57
|
+
pyrage.DecryptError: If passphrase is incorrect
|
|
58
|
+
"""
|
|
59
|
+
encrypted_bytes = base64.b64decode(encrypted)
|
|
60
|
+
decrypted = pyrage.passphrase.decrypt(encrypted_bytes, passphrase)
|
|
61
|
+
return decrypted.decode("utf-8")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@py_dataclass(config=ConfigDict(populate_by_name=True))
|
|
65
|
+
class Delegate:
|
|
66
|
+
"""Delegate node starting configuration."""
|
|
67
|
+
|
|
68
|
+
delegate_private_key_encrypted: str | None = Field(
|
|
69
|
+
default=None, alias="delegatePrivateKeyEncrypted"
|
|
70
|
+
)
|
|
71
|
+
collateral_pubkey: str | None = Field(default=None, alias="collateralPubkey")
|
|
72
|
+
|
|
73
|
+
asdict = asdict
|
|
74
|
+
|
|
75
|
+
@field_validator("collateral_pubkey", mode="after")
|
|
76
|
+
@classmethod
|
|
77
|
+
def validate_collateral_pubkey(cls, value: str | None) -> str | None:
|
|
78
|
+
"""Validate compressed public key format.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
value: Compressed public key (66 hex chars, 02/03 prefix)
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Validated pubkey in lowercase
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If pubkey format is invalid
|
|
88
|
+
"""
|
|
89
|
+
if not value:
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
if not re.match(COMPRESSED_PUBKEY_PATTERN, value):
|
|
93
|
+
raise ValueError("Collateral pubkey must be 66 hex chars with 02/03 prefix")
|
|
94
|
+
|
|
95
|
+
return value.lower()
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def is_configured(self) -> bool:
|
|
99
|
+
"""Check if delegate is fully configured.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
True if both encrypted key and collateral pubkey are set
|
|
103
|
+
"""
|
|
104
|
+
return bool(self.delegate_private_key_encrypted and self.collateral_pubkey)
|
|
105
|
+
|
|
106
|
+
def to_ui_dict(self) -> dict:
|
|
107
|
+
"""Convert to UI dictionary format with camelCase keys.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Dictionary with camelCase keys for UI
|
|
111
|
+
"""
|
|
112
|
+
return to_jsonable_python(self, by_alias=True)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Shared logging configuration for flux_config packages.
|
|
2
|
+
|
|
3
|
+
This module provides a centralized logging configuration function.
|
|
4
|
+
Each application (daemon, TUI, CLI) should call configure_logging() at startup.
|
|
5
|
+
|
|
6
|
+
Best practice: Libraries should NOT configure handlers, only applications should.
|
|
7
|
+
See: https://docs.python-guide.org/writing/logging/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from logging.config import dictConfig
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# Track if logging has been configured to avoid duplicate setup
|
|
19
|
+
_configured = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def configure_logging(
|
|
23
|
+
app_name: str,
|
|
24
|
+
*,
|
|
25
|
+
use_textual: bool = False,
|
|
26
|
+
log_level: int = logging.INFO,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Configure logging for an application.
|
|
29
|
+
|
|
30
|
+
This should be called once at application startup. Each app gets its own
|
|
31
|
+
log directory and file: /var/log/{app_name}/{app_name}.log
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
app_name: Application name for log file (e.g., "flux-configd", "flux-config-tui")
|
|
35
|
+
use_textual: Use TextualHandler for console output (for TUI apps)
|
|
36
|
+
log_level: Logging level (default: INFO)
|
|
37
|
+
"""
|
|
38
|
+
global _configured # noqa: PLW0603
|
|
39
|
+
|
|
40
|
+
if _configured:
|
|
41
|
+
logging.getLogger(__name__).warning(
|
|
42
|
+
f"Logging already configured, ignoring configure_logging({app_name})"
|
|
43
|
+
)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# Determine if we can write to log directory
|
|
47
|
+
log_file = None
|
|
48
|
+
try:
|
|
49
|
+
log_dir = Path(f"/var/log/{app_name}")
|
|
50
|
+
if not log_dir.exists():
|
|
51
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
if os.access(log_dir, os.W_OK):
|
|
53
|
+
log_file = log_dir / f"{app_name}.log"
|
|
54
|
+
except (PermissionError, OSError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
# Build handlers
|
|
58
|
+
handlers: dict = {}
|
|
59
|
+
|
|
60
|
+
# Console handler - TextualHandler for TUI, StreamHandler for others
|
|
61
|
+
if use_textual:
|
|
62
|
+
handlers["console"] = {
|
|
63
|
+
"level": "INFO",
|
|
64
|
+
"formatter": "standard",
|
|
65
|
+
"class": "textual.logging.TextualHandler",
|
|
66
|
+
}
|
|
67
|
+
elif sys.stdout.isatty():
|
|
68
|
+
# Only add console handler if running interactively
|
|
69
|
+
handlers["console"] = {
|
|
70
|
+
"level": "INFO",
|
|
71
|
+
"formatter": "standard",
|
|
72
|
+
"class": "logging.StreamHandler",
|
|
73
|
+
"stream": "ext://sys.stdout",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# File handler (if we can write)
|
|
77
|
+
if log_file:
|
|
78
|
+
handlers["file"] = {
|
|
79
|
+
"level": "DEBUG", # File gets all logs
|
|
80
|
+
"formatter": "detailed",
|
|
81
|
+
"class": "logging.handlers.RotatingFileHandler",
|
|
82
|
+
"filename": str(log_file),
|
|
83
|
+
"mode": "a",
|
|
84
|
+
"maxBytes": 50 * 1024 * 1024, # 50MB
|
|
85
|
+
"backupCount": 5,
|
|
86
|
+
"encoding": "utf-8",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Determine which handlers to use for loggers
|
|
90
|
+
logger_handlers = list(handlers.keys())
|
|
91
|
+
|
|
92
|
+
# Build the config
|
|
93
|
+
config = {
|
|
94
|
+
"version": 1,
|
|
95
|
+
"disable_existing_loggers": False, # Important: don't break existing loggers
|
|
96
|
+
"formatters": {
|
|
97
|
+
"standard": {
|
|
98
|
+
"format": "{asctime} [{levelname}] {name}: {message}",
|
|
99
|
+
"datefmt": "%H:%M:%S",
|
|
100
|
+
"style": "{",
|
|
101
|
+
},
|
|
102
|
+
"detailed": {
|
|
103
|
+
"format": "{asctime} [{levelname}] {name}:{lineno} - {message}",
|
|
104
|
+
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
105
|
+
"style": "{",
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
"handlers": handlers,
|
|
109
|
+
"root": {
|
|
110
|
+
"handlers": logger_handlers,
|
|
111
|
+
"level": log_level,
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# Apply configuration
|
|
116
|
+
dictConfig(config)
|
|
117
|
+
|
|
118
|
+
# Suppress noisy library logs
|
|
119
|
+
logging.getLogger("websockets").setLevel(logging.WARNING)
|
|
120
|
+
logging.getLogger("websockets.server").setLevel(logging.WARNING)
|
|
121
|
+
logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
|
|
122
|
+
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
|
123
|
+
|
|
124
|
+
_configured = True
|