add yml config file, client now does not depends on .env

This commit is contained in:
2026-06-24 04:42:00 +03:00
parent 25ae509d52
commit fe2db1b6e9
13 changed files with 325 additions and 167 deletions
-8
View File
@@ -1,17 +1,9 @@
from nexus_sync.client.config import (
COMMAND_ACCESS_ENV,
FULL_ACCESS_VALUE,
load_command_access_policy,
)
from nexus_sync.client.execute import (
CommandAccessPolicy,
execute_command,
)
__all__ = [
"COMMAND_ACCESS_ENV",
"CommandAccessPolicy",
"FULL_ACCESS_VALUE",
"execute_command",
"load_command_access_policy",
]
-25
View File
@@ -1,25 +0,0 @@
import os
from collections.abc import Mapping
from nexus_sync.client.execute import CommandAccessPolicy
COMMAND_ACCESS_ENV = "NEXUS_SYNC_ALLOWED_COMMANDS"
FULL_ACCESS_VALUE = "full_access"
def load_command_access_policy(
env: Mapping[str, str] = os.environ,
*,
default: CommandAccessPolicy | None = None,
) -> CommandAccessPolicy:
raw_value = env.get(COMMAND_ACCESS_ENV)
if raw_value is None or not raw_value.strip():
return default or CommandAccessPolicy.deny_all()
command_names = [item.strip() for item in raw_value.split(",") if item.strip()]
if len(command_names) == 1 and command_names[0].lower() == FULL_ACCESS_VALUE:
return CommandAccessPolicy.allow_all()
if any(command_name.lower() == FULL_ACCESS_VALUE for command_name in command_names):
raise ValueError(f"{FULL_ACCESS_VALUE} cannot be mixed with explicit command names")
return CommandAccessPolicy.allow(command_names)
+136 -17
View File
@@ -2,21 +2,25 @@ import json
import logging
import os
import platform
import shlex
import socket
import urllib.error
import urllib.request
from dataclasses import dataclass
from collections.abc import Mapping as MappingABC
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from types import TracebackType
from typing import Callable, Mapping, Protocol, Self
import yaml # type: ignore[import-untyped]
from pydantic import ValidationError
from nexus_sync.client.config import load_command_access_policy
from nexus_sync.client.execute import (
DEFAULT_PRESET_DESCRIPTIONS,
DEFAULT_PRESETS,
CommandAccessPolicy,
PresetBuilder,
execute_command,
)
from nexus_sync.common import (
@@ -29,9 +33,6 @@ from nexus_sync.common import (
HeartbeatResponse,
)
SERVER_URL_ENV = "NEXUS_SYNC_SERVER_URL"
CLIENT_ID_ENV = "NEXUS_SYNC_CLIENT_ID"
CLIENT_TOKEN_ENV = "NEXUS_SYNC_CLIENT_TOKEN"
CLIENT_VERSION = "0.1.0"
HEARTBEAT_PATH = "/api/v1/client/heartbeat"
logger = logging.getLogger(__name__)
@@ -67,17 +68,75 @@ class ClientConfig:
client_id: str
token: str
command_access_policy: CommandAccessPolicy
command_presets: Mapping[str, PresetBuilder] = field(default_factory=lambda: DEFAULT_PRESETS)
command_descriptions: Mapping[str, str] = field(
default_factory=lambda: DEFAULT_PRESET_DESCRIPTIONS
)
logging_level: str = "INFO"
def load_client_config(env: Mapping[str, str] = os.environ) -> ClientConfig:
server_url = _required_env(env, SERVER_URL_ENV).rstrip("/")
client_id = _required_env(env, CLIENT_ID_ENV)
token = _required_env(env, CLIENT_TOKEN_ENV)
def find_client_config_path(
*,
env: Mapping[str, str] = os.environ,
cwd: Path | None = None,
home: Path | None = None,
) -> Path | None:
cwd = cwd or Path.cwd()
home = home or Path.home()
candidates = [
cwd / "nexus.yml",
cwd / "nexus.yaml",
]
xdg_config_home = env.get("XDG_CONFIG_HOME")
if xdg_config_home and xdg_config_home.strip():
xdg_dir = Path(xdg_config_home).expanduser()
candidates.extend([xdg_dir / "nexus.yml", xdg_dir / "nexus.yaml"])
candidates.extend(
[
home / ".config" / "nexus.yml",
home / ".config" / "nexus.yaml",
home / ".config" / "nexus" / "config.yml",
home / ".config" / "nexus" / "config.yaml",
]
)
return next((path for path in candidates if path.is_file()), None)
def load_client_config(
env: Mapping[str, str] = os.environ,
*,
config_path: Path | str | None = None,
) -> ClientConfig:
path = Path(config_path) if config_path is not None else find_client_config_path(env=env)
if path is None:
raise ClientConfigError("client config file not found")
try:
raw_config = yaml.safe_load(path.read_text())
except OSError as error:
raise ClientConfigError(f"failed to read client config file {path}: {error}") from error
except yaml.YAMLError as error:
raise ClientConfigError(f"failed to parse client config file {path}: {error}") from error
if not isinstance(raw_config, MappingABC):
raise ClientConfigError("client config file must contain a YAML mapping")
server_url = _required_config_string(raw_config, "server_url").rstrip("/")
client_id = _required_config_string(raw_config, "client_id")
token = _required_config_string(raw_config, "client_token")
commands = _load_configured_commands(raw_config.get("allowed_commands", []))
return ClientConfig(
server_url=server_url,
client_id=client_id,
token=token,
command_access_policy=load_command_access_policy(env),
command_access_policy=CommandAccessPolicy.allow(commands.presets),
command_presets=commands.presets,
command_descriptions=commands.descriptions,
logging_level=str(raw_config.get("logging_level", "INFO")).strip() or "INFO",
)
@@ -99,20 +158,27 @@ def build_heartbeat_request(
local_time=datetime.now().astimezone(),
uptime_seconds=None,
),
available_commands=list_available_commands(config.command_access_policy),
available_commands=list_available_commands(
config.command_access_policy,
presets=config.command_presets,
descriptions=config.command_descriptions,
),
last_command_result=last_command_result,
)
def list_available_commands(
access_policy: CommandAccessPolicy,
*,
presets: Mapping[str, PresetBuilder] = DEFAULT_PRESETS,
descriptions: Mapping[str, str] = DEFAULT_PRESET_DESCRIPTIONS,
) -> list[ClientCommandCapability]:
return [
ClientCommandCapability(
name=name,
description=DEFAULT_PRESET_DESCRIPTIONS.get(name, ""),
description=descriptions.get(name, ""),
)
for name in sorted(DEFAULT_PRESETS)
for name in sorted(presets)
if access_policy.allows(name)
]
@@ -171,6 +237,7 @@ def run_once(
return executor(
response.command,
access_policy=config.command_access_policy,
presets=config.command_presets,
)
@@ -198,10 +265,62 @@ def main(argv: list[str] | None = None) -> int:
return 0
def _required_env(env: Mapping[str, str], name: str) -> str:
value = env.get(name)
if value is None or not value.strip():
raise ClientConfigError(f"{name} is required")
@dataclass(frozen=True)
class _ConfiguredCommands:
presets: dict[str, PresetBuilder]
descriptions: dict[str, str]
def _load_configured_commands(raw_commands: object) -> _ConfiguredCommands:
if raw_commands is None:
raw_commands = []
if not isinstance(raw_commands, list):
raise ClientConfigError("allowed_commands must be a list")
presets: dict[str, PresetBuilder] = {}
descriptions: dict[str, str] = {}
for index, raw_command in enumerate(raw_commands):
if not isinstance(raw_command, MappingABC):
raise ClientConfigError(f"allowed_commands[{index}] must be a mapping")
name = _required_config_string(raw_command, "name", prefix=f"allowed_commands[{index}]")
description = _required_config_string(
raw_command,
"description",
prefix=f"allowed_commands[{index}]",
)
command_line = _required_config_string(
raw_command, "cmd", prefix=f"allowed_commands[{index}]"
)
argv = shlex.split(command_line, posix=os.name != "nt")
if not argv:
raise ClientConfigError(f"allowed_commands[{index}].cmd must not be empty")
if name in presets:
raise ClientConfigError(f"duplicate allowed command name: {name}")
presets[name] = _static_preset(argv)
descriptions[name] = description
return _ConfiguredCommands(presets=presets, descriptions=descriptions)
def _static_preset(argv: list[str]) -> PresetBuilder:
def build(args: Mapping[str, object]) -> list[str]:
if args:
raise ValueError("configured commands do not accept arguments")
return list(argv)
return build
def _required_config_string(
config: MappingABC[object, object],
name: str,
*,
prefix: str | None = None,
) -> str:
value = config.get(name)
display_name = f"{prefix}.{name}" if prefix else name
if not isinstance(value, str) or not value.strip():
raise ClientConfigError(f"{display_name} is required")
return value.strip()
+1 -1
View File
@@ -6,7 +6,7 @@ from nexus_sync.utils import configure_logging
def main() -> None:
configure_logging()
uvicorn.run(app, host="0.0.0.0", port=8000)
uvicorn.run(app, host="0.0.0.0", port=5852)
if __name__ == "__main__":