add yml config file, client now does not depends on .env
This commit is contained in:
+3
-31
@@ -1,5 +1,5 @@
|
||||
# nexus-sync environment example
|
||||
# Copy this file to .env and replace example secrets before running.
|
||||
# nexus-sync server environment example
|
||||
# Copy this file to .env and replace example secrets before running the server.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Server configuration
|
||||
@@ -15,33 +15,5 @@
|
||||
# better to set this explicitly.
|
||||
NEXUS_SYNC_CLIENT_TOKENS="dev-client:dev-token"
|
||||
|
||||
# Logging level for both server and client: DEBUG, INFO, WARNING, ERROR, CRITICAL.
|
||||
# Logging level for the server: DEBUG, INFO, WARNING, ERROR, CRITICAL.
|
||||
NEXUS_SYNC_LOG_LEVEL="INFO"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Client configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Base URL of the nexus-sync server.
|
||||
NEXUS_SYNC_SERVER_URL="http://127.0.0.1:8000"
|
||||
|
||||
# Stable ID of this machine/client. Must match one client_id from
|
||||
# NEXUS_SYNC_CLIENT_TOKENS on the server.
|
||||
NEXUS_SYNC_CLIENT_ID="dev-client"
|
||||
|
||||
# Bearer token for this client. Must match the token paired with
|
||||
# NEXUS_SYNC_CLIENT_ID on the server.
|
||||
NEXUS_SYNC_CLIENT_TOKEN="dev-token"
|
||||
|
||||
# Commands this client is allowed to execute when the server requests them.
|
||||
# Supported presets currently implemented:
|
||||
# - hostname
|
||||
# - network_interfaces
|
||||
#
|
||||
# Safer explicit allowlist:
|
||||
NEXUS_SYNC_ALLOWED_COMMANDS="hostname,network_interfaces"
|
||||
|
||||
# Alternative: allow every locally registered preset. This still does NOT allow
|
||||
# arbitrary shell strings from the server. Uncomment instead of the line above if
|
||||
# desired.
|
||||
# NEXUS_SYNC_ALLOWED_COMMANDS="full_access"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
a utility that allows you to manage your computers.
|
||||
|
||||
*Main Idea*
|
||||
_Main Idea_
|
||||
server can send to every client "what to do". Centralized control for all clients.
|
||||
|
||||
## Development
|
||||
@@ -11,6 +11,42 @@ server can send to every client "what to do". Centralized control for all client
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
## Client config
|
||||
|
||||
The client reads YAML config files. The server still use
|
||||
`.env` for server-side settings such as `NEXUS_SYNC_CLIENT_TOKENS`.
|
||||
|
||||
Client config is searched in this order:
|
||||
|
||||
1. `$(pwd)/nexus.yml`
|
||||
2. `$(pwd)/nexus.yaml`
|
||||
3. `$XDG_CONFIG_HOME/nexus.yml`
|
||||
4. `$XDG_CONFIG_HOME/nexus.yaml`
|
||||
5. `~/.config/nexus.yml`
|
||||
6. `~/.config/nexus.yaml`
|
||||
7. `~/.config/nexus/config.yml`
|
||||
8. `~/.config/nexus/config.yaml`
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
server_url: "http://127.0.0.1:5852"
|
||||
client_id: "linux-client"
|
||||
client_token: "change-me-client-token"
|
||||
allowed_commands:
|
||||
- name: hostname
|
||||
description: "Get the hostname of the client machine"
|
||||
cmd: "hostname"
|
||||
- name: network_interfaces
|
||||
description: "Get network interface information"
|
||||
cmd: "ip addr show"
|
||||
logging_level: "INFO"
|
||||
```
|
||||
|
||||
Template examples are available in `template/` for Linux, Windows, and Darwin.
|
||||
The client reports only command `name` and `description` to the server; `cmd`
|
||||
stays local to the client config.
|
||||
|
||||
## Current design
|
||||
|
||||
- API contract: [docs/api.md](docs/api.md)
|
||||
@@ -23,7 +59,6 @@ pip install -e .[dev]
|
||||
make [client|server]
|
||||
```
|
||||
|
||||
|
||||
### To test
|
||||
|
||||
```
|
||||
|
||||
+19
-8
@@ -32,15 +32,26 @@ client -> server: hello!, i'm $(hostname), uuid= , ts=, stdout= , stderr= , ...
|
||||
|
||||
## Разрешённые команды
|
||||
|
||||
Клиент исполняет только локально разрешённые command presets. Базовая
|
||||
настройка задаётся переменной окружения `NEXUS_SYNC_ALLOWED_COMMANDS`.
|
||||
Клиент исполняет только команды, описанные в локальном YAML config-файле.
|
||||
Серверу отправляются только `name` и `description`; поле `cmd` остаётся только
|
||||
на клиенте и не управляется сервером.
|
||||
|
||||
Примеры:
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
NEXUS_SYNC_ALLOWED_COMMANDS=hostname,network_interfaces
|
||||
NEXUS_SYNC_ALLOWED_COMMANDS=full_access
|
||||
```yaml
|
||||
server_url: "http://127.0.0.1:5852"
|
||||
client_id: "linux-client"
|
||||
client_token: "change-me-client-token"
|
||||
allowed_commands:
|
||||
- name: hostname
|
||||
description: "Get the hostname of the client machine"
|
||||
cmd: "hostname"
|
||||
- name: network_interfaces
|
||||
description: "Get network interface information"
|
||||
cmd: "ip addr show"
|
||||
logging_level: "INFO"
|
||||
```
|
||||
|
||||
`full_access` означает доступ ко всем локально зарегистрированным presets. Это
|
||||
не разрешает выполнение произвольных shell-строк от сервера.
|
||||
Файл ищется как `nexus.yml`/`nexus.yaml` в текущей директории, затем в
|
||||
`$XDG_CONFIG_HOME`, затем в `~/.config`, затем как
|
||||
`~/.config/nexus/config.yml`/`.yaml`.
|
||||
|
||||
@@ -12,6 +12,7 @@ requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"pydantic>=2.10.0",
|
||||
"PyYAML>=6.0.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"uvicorn[standard]>=0.34.0",
|
||||
]
|
||||
@@ -23,6 +24,7 @@ dev = [
|
||||
"black>=24.0",
|
||||
"pyinstaller>=6.0",
|
||||
"mypy>=1.0",
|
||||
"types-PyYAML>=6.0.12",
|
||||
"pre-commit>=4.0"
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# nexus-sync client config template for macOS/Darwin
|
||||
server_url: "http://127.0.0.1:5852"
|
||||
client_id: "darwin-client"
|
||||
client_token: "change-me-client-token"
|
||||
allowed_commands:
|
||||
- name: hostname
|
||||
description: "Get the hostname of the client machine"
|
||||
cmd: "hostname"
|
||||
- name: network_interfaces
|
||||
description: "Get network interface information"
|
||||
cmd: "ifconfig"
|
||||
logging_level: "INFO"
|
||||
@@ -0,0 +1,12 @@
|
||||
# nexus-sync client config template for Linux
|
||||
server_url: "http://127.0.0.1:5852"
|
||||
client_id: "linux-client"
|
||||
client_token: "change-me-client-token"
|
||||
allowed_commands:
|
||||
- name: hostname
|
||||
description: "Get the hostname of the client machine"
|
||||
cmd: "hostname"
|
||||
- name: network_interfaces
|
||||
description: "Get network interface information"
|
||||
cmd: "ip addr show"
|
||||
logging_level: "INFO"
|
||||
@@ -0,0 +1,12 @@
|
||||
# nexus-sync client config template for Windows
|
||||
server_url: "http://127.0.0.1:5852"
|
||||
client_id: "windows-client"
|
||||
client_token: "change-me-client-token"
|
||||
allowed_commands:
|
||||
- name: hostname
|
||||
description: "Get the hostname of the client machine"
|
||||
cmd: "hostname"
|
||||
- name: network_interfaces
|
||||
description: "Get network interface information"
|
||||
cmd: "ipconfig /all"
|
||||
logging_level: "INFO"
|
||||
@@ -1,42 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from nexus_sync.client.config import COMMAND_ACCESS_ENV, load_command_access_policy
|
||||
from nexus_sync.client.execute import CommandAccessPolicy
|
||||
|
||||
|
||||
def test_load_command_access_policy_defaults_to_deny_all() -> None:
|
||||
policy = load_command_access_policy({})
|
||||
|
||||
assert not policy.full_access
|
||||
assert not policy.allows("hostname")
|
||||
|
||||
|
||||
def test_load_command_access_policy_uses_default_when_env_is_missing() -> None:
|
||||
default = CommandAccessPolicy.allow(["hostname"])
|
||||
|
||||
policy = load_command_access_policy({}, default=default)
|
||||
|
||||
assert policy == default
|
||||
|
||||
|
||||
def test_load_command_access_policy_supports_command_allowlist() -> None:
|
||||
policy = load_command_access_policy(
|
||||
{COMMAND_ACCESS_ENV: "hostname, network_interfaces"},
|
||||
)
|
||||
|
||||
assert policy.allows("hostname")
|
||||
assert policy.allows("network_interfaces")
|
||||
assert not policy.allows("unknown")
|
||||
|
||||
|
||||
def test_load_command_access_policy_supports_full_access() -> None:
|
||||
policy = load_command_access_policy({COMMAND_ACCESS_ENV: "full_access"})
|
||||
|
||||
assert policy.full_access
|
||||
assert policy.allows("hostname")
|
||||
assert policy.allows("future_registered_preset")
|
||||
|
||||
|
||||
def test_load_command_access_policy_rejects_mixed_full_access() -> None:
|
||||
with pytest.raises(ValueError, match="cannot be mixed"):
|
||||
load_command_access_policy({COMMAND_ACCESS_ENV: "hostname,full_access"})
|
||||
@@ -7,13 +7,11 @@ import pytest
|
||||
|
||||
from nexus_sync.client.execute import CommandAccessPolicy
|
||||
from nexus_sync.client.runtime import (
|
||||
CLIENT_ID_ENV,
|
||||
CLIENT_TOKEN_ENV,
|
||||
SERVER_URL_ENV,
|
||||
ClientConfig,
|
||||
ClientConfigError,
|
||||
HeartbeatError,
|
||||
build_heartbeat_request,
|
||||
find_client_config_path,
|
||||
list_available_commands,
|
||||
load_client_config,
|
||||
main,
|
||||
@@ -39,34 +37,77 @@ def _config(policy: CommandAccessPolicy | None = None) -> ClientConfig:
|
||||
)
|
||||
|
||||
|
||||
def test_load_client_config_reads_required_env_and_normalizes_server_url() -> None:
|
||||
config = load_client_config(
|
||||
{
|
||||
SERVER_URL_ENV: "https://nexus.example.test/",
|
||||
CLIENT_ID_ENV: "macbook-pro-01",
|
||||
CLIENT_TOKEN_ENV: "client-token",
|
||||
"NEXUS_SYNC_ALLOWED_COMMANDS": "hostname",
|
||||
}
|
||||
def test_find_client_config_path_prefers_current_directory(tmp_path) -> None:
|
||||
cwd_config = tmp_path / "nexus.yaml"
|
||||
cwd_config.write_text("client_id: current\n")
|
||||
xdg_config = tmp_path / "xdg" / "nexus.yml"
|
||||
xdg_config.parent.mkdir()
|
||||
xdg_config.write_text("client_id: xdg\n")
|
||||
|
||||
path = find_client_config_path(
|
||||
env={"XDG_CONFIG_HOME": str(xdg_config.parent)},
|
||||
cwd=tmp_path,
|
||||
home=tmp_path / "home",
|
||||
)
|
||||
|
||||
assert path == cwd_config
|
||||
|
||||
|
||||
def test_find_client_config_path_checks_xdg_and_home_locations(tmp_path) -> None:
|
||||
home = tmp_path / "home"
|
||||
nested_config = home / ".config" / "nexus" / "config.yml"
|
||||
nested_config.parent.mkdir(parents=True)
|
||||
nested_config.write_text("client_id: nested\n")
|
||||
|
||||
path = find_client_config_path(env={}, cwd=tmp_path, home=home)
|
||||
|
||||
assert path == nested_config
|
||||
|
||||
|
||||
def test_load_client_config_reads_yaml_file_and_normalizes_server_url(tmp_path) -> None:
|
||||
config_path = tmp_path / "nexus.yml"
|
||||
config_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
'server_url: "https://nexus.example.test/"',
|
||||
'client_id: "macbook-pro-01"',
|
||||
'client_token: "client-token"',
|
||||
"allowed_commands:",
|
||||
" - name: hostname",
|
||||
' description: "Configured hostname"',
|
||||
' cmd: "hostname"',
|
||||
" - name: network_interfaces",
|
||||
' description: "Configured interfaces"',
|
||||
' cmd: "ip addr show"',
|
||||
'logging_level: "INFO"',
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
config = load_client_config(config_path=config_path)
|
||||
|
||||
assert config.server_url == "https://nexus.example.test"
|
||||
assert config.client_id == "macbook-pro-01"
|
||||
assert config.token == "client-token"
|
||||
assert config.command_access_policy.allows("hostname")
|
||||
assert not config.command_access_policy.allows("network_interfaces")
|
||||
assert config.command_access_policy.allows("network_interfaces")
|
||||
assert config.command_descriptions["hostname"] == "Configured hostname"
|
||||
assert config.command_presets["network_interfaces"]({}) == ["ip", "addr", "show"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing_name", [SERVER_URL_ENV, CLIENT_ID_ENV, CLIENT_TOKEN_ENV])
|
||||
def test_load_client_config_requires_env_values(missing_name: str) -> None:
|
||||
env = {
|
||||
SERVER_URL_ENV: "https://nexus.example.test",
|
||||
CLIENT_ID_ENV: "macbook-pro-01",
|
||||
CLIENT_TOKEN_ENV: "client-token",
|
||||
@pytest.mark.parametrize("missing_name", ["server_url", "client_id", "client_token"])
|
||||
def test_load_client_config_requires_yaml_values(tmp_path, missing_name: str) -> None:
|
||||
values = {
|
||||
"server_url": '"https://nexus.example.test"',
|
||||
"client_id": '"macbook-pro-01"',
|
||||
"client_token": '"client-token"',
|
||||
}
|
||||
del env[missing_name]
|
||||
del values[missing_name]
|
||||
config_path = tmp_path / "nexus.yml"
|
||||
config_path.write_text("\n".join(f"{key}: {value}" for key, value in values.items()))
|
||||
|
||||
with pytest.raises(ClientConfigError, match=missing_name):
|
||||
load_client_config(env)
|
||||
load_client_config(config_path=config_path)
|
||||
|
||||
|
||||
def test_build_heartbeat_request_contains_client_state(monkeypatch) -> None:
|
||||
@@ -270,6 +311,7 @@ def test_run_once_executes_command_with_configured_access_policy() -> None:
|
||||
def fake_executor(command: Command, **kwargs) -> CommandResult:
|
||||
seen["command"] = command
|
||||
seen["access_policy"] = kwargs["access_policy"]
|
||||
seen["presets"] = kwargs["presets"]
|
||||
return CommandResult(
|
||||
command_id=command.id,
|
||||
status=CommandResultStatus.SUCCEEDED,
|
||||
@@ -283,6 +325,7 @@ def test_run_once_executes_command_with_configured_access_policy() -> None:
|
||||
assert result.status == CommandResultStatus.SUCCEEDED
|
||||
assert seen["command"].name == "hostname"
|
||||
assert seen["access_policy"] == policy
|
||||
assert "hostname" in seen["presets"]
|
||||
|
||||
|
||||
def test_run_once_sends_previous_command_result() -> None:
|
||||
@@ -313,22 +356,29 @@ def test_run_once_sends_previous_command_result() -> None:
|
||||
assert seen["last_command_result"] == previous_result
|
||||
|
||||
|
||||
def test_main_returns_non_zero_for_missing_config(monkeypatch, caplog) -> None:
|
||||
monkeypatch.delenv(SERVER_URL_ENV, raising=False)
|
||||
monkeypatch.delenv(CLIENT_ID_ENV, raising=False)
|
||||
monkeypatch.delenv(CLIENT_TOKEN_ENV, raising=False)
|
||||
def test_main_returns_non_zero_for_missing_config(monkeypatch, caplog, tmp_path) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
||||
|
||||
exit_code = main([])
|
||||
|
||||
assert exit_code == 1
|
||||
assert SERVER_URL_ENV in caplog.text
|
||||
assert "client config file not found" in caplog.text
|
||||
|
||||
|
||||
def test_main_logs_success_without_command(monkeypatch, caplog) -> None:
|
||||
def test_main_logs_success_without_command(monkeypatch, caplog, tmp_path) -> None:
|
||||
caplog.set_level("INFO")
|
||||
monkeypatch.setenv(SERVER_URL_ENV, "https://nexus.example.test")
|
||||
monkeypatch.setenv(CLIENT_ID_ENV, "macbook-pro-01")
|
||||
monkeypatch.setenv(CLIENT_TOKEN_ENV, "client-token")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "nexus.yml").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
'server_url: "https://nexus.example.test"',
|
||||
'client_id: "macbook-pro-01"',
|
||||
'client_token: "client-token"',
|
||||
"allowed_commands: []",
|
||||
]
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr("nexus_sync.client.runtime.run_once", lambda _config: None)
|
||||
|
||||
exit_code = main([])
|
||||
@@ -337,11 +387,19 @@ def test_main_logs_success_without_command(monkeypatch, caplog) -> None:
|
||||
assert "heartbeat accepted; no command" in caplog.text
|
||||
|
||||
|
||||
def test_main_logs_command_result(monkeypatch, caplog) -> None:
|
||||
def test_main_logs_command_result(monkeypatch, caplog, tmp_path) -> None:
|
||||
caplog.set_level("INFO")
|
||||
monkeypatch.setenv(SERVER_URL_ENV, "https://nexus.example.test")
|
||||
monkeypatch.setenv(CLIENT_ID_ENV, "macbook-pro-01")
|
||||
monkeypatch.setenv(CLIENT_TOKEN_ENV, "client-token")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "nexus.yml").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
'server_url: "https://nexus.example.test"',
|
||||
'client_id: "macbook-pro-01"',
|
||||
'client_token: "client-token"',
|
||||
"allowed_commands: []",
|
||||
]
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nexus_sync.client.runtime.run_once",
|
||||
lambda _config: CommandResult(
|
||||
|
||||
Reference in New Issue
Block a user