Merge sphinx-docs (CLI i18n + Sphinx docs) into main
# Conflicts: # .github/workflows/ci.yml # docs/cli.md
This commit is contained in:
@@ -40,6 +40,17 @@ jobs:
|
|||||||
- run: pip install -e ".[dev]"
|
- run: pip install -e ".[dev]"
|
||||||
- run: doit test
|
- run: doit test
|
||||||
|
|
||||||
|
docs:
|
||||||
|
name: Docs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
- run: pip install -e ".[docs]"
|
||||||
|
- run: sphinx-build -b html -W --keep-going docs docs/_build/html
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build (${{ matrix.os }})
|
name: Build (${{ matrix.os }})
|
||||||
needs: [format, typecheck, test]
|
needs: [format, typecheck, test]
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
.PHONY: all client server cli test clean
|
.PHONY: all client server cli test clean docs docs-clean i18n-extract i18n-init i18n-update i18n-compile
|
||||||
|
|
||||||
|
# CLI localization (nexus-cli strings only; daemon/server logs stay in English).
|
||||||
|
LOCALE_DIR := src/nexus_sync/locale
|
||||||
|
POT := $(LOCALE_DIR)/nexus.pot
|
||||||
|
LANG ?= ru
|
||||||
|
# PyInstaller --add-data separator is ':' on Unix, ';' on Windows.
|
||||||
|
LOCALE_DATA := $(LOCALE_DIR):nexus_sync/locale
|
||||||
|
|
||||||
all: client server cli
|
all: client server cli
|
||||||
|
|
||||||
@@ -8,11 +15,35 @@ client:
|
|||||||
server:
|
server:
|
||||||
pyinstaller --onefile src/nexus_sync/server/__main__.py --name nexus-sync-server
|
pyinstaller --onefile src/nexus_sync/server/__main__.py --name nexus-sync-server
|
||||||
|
|
||||||
cli:
|
cli: i18n-compile
|
||||||
pyinstaller --onefile src/nexus_sync/cli/__main__.py --name nexus-cli
|
pyinstaller --onefile src/nexus_sync/cli/__main__.py --name nexus-cli \
|
||||||
|
--add-data "$(LOCALE_DATA)"
|
||||||
|
|
||||||
|
# Rebuild the message template from strings wrapped in _()/gettext()/ngettext().
|
||||||
|
i18n-extract:
|
||||||
|
pybabel extract -F babel.cfg -k _ -o $(POT) src
|
||||||
|
|
||||||
|
# Create a catalog for a new language, e.g. `make i18n-init LANG=de`.
|
||||||
|
i18n-init: i18n-extract
|
||||||
|
pybabel init -i $(POT) -d $(LOCALE_DIR) -D nexus -l $(LANG)
|
||||||
|
|
||||||
|
# Merge new/changed strings into existing catalogs.
|
||||||
|
i18n-update: i18n-extract
|
||||||
|
pybabel update -i $(POT) -d $(LOCALE_DIR) -D nexus
|
||||||
|
|
||||||
|
# Compile .po catalogs to the .mo files bundled with nexus-cli.
|
||||||
|
i18n-compile:
|
||||||
|
pybabel compile -d $(LOCALE_DIR) -D nexus
|
||||||
|
|
||||||
test:
|
test:
|
||||||
pytest
|
pytest
|
||||||
|
|
||||||
|
# Build the Sphinx HTML documentation into docs/_build/html.
|
||||||
|
docs:
|
||||||
|
sphinx-build -b html docs docs/_build/html
|
||||||
|
|
||||||
|
docs-clean:
|
||||||
|
rm -rf docs/_build
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf build dist *.spec
|
rm -rf build dist *.spec docs/_build
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Babel extraction config for CLI localization.
|
||||||
|
# Paths are relative to the extraction root passed to pybabel (the `src` dir).
|
||||||
|
# Only strings wrapped in _()/gettext()/ngettext() are extracted, so logger.*
|
||||||
|
# messages are never picked up.
|
||||||
|
[python: **.py]
|
||||||
+89
-89
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
## Transport
|
## Transport
|
||||||
|
|
||||||
- Протокол: HTTPS.
|
- Protocol: HTTPS.
|
||||||
- Формат тела запроса и ответа: JSON.
|
- Request and response body format: JSON.
|
||||||
- Префикс API: `/api/v1`.
|
- API prefix: `/api/v1`.
|
||||||
- Формат времени: RFC 3339 / ISO 8601, например `2026-05-24T13:20:30Z`.
|
- Time format: RFC 3339 / ISO 8601, e.g. `2026-05-24T13:20:30Z`.
|
||||||
- Авторизация клиента: bearer token в заголовке `Authorization`.
|
- Client authorization: bearer token in the `Authorization` header.
|
||||||
|
|
||||||
Пример заголовков:
|
Example headers:
|
||||||
|
|
||||||
```http
|
```text
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer <client-token>
|
Authorization: Bearer <client-token>
|
||||||
```
|
```
|
||||||
@@ -19,12 +19,12 @@ Authorization: Bearer <client-token>
|
|||||||
|
|
||||||
### `POST /api/v1/client/heartbeat`
|
### `POST /api/v1/client/heartbeat`
|
||||||
|
|
||||||
Клиент вызывает эту ручку на каждом polling tick. Одна и та же ручка
|
The client calls this endpoint on every polling tick. The same endpoint serves
|
||||||
используется для трёх сценариев:
|
three scenarios:
|
||||||
|
|
||||||
- сообщение "я жив";
|
- an "I'm alive" message;
|
||||||
- регулярное обновление состояния;
|
- a regular state update;
|
||||||
- отправка результата последней завершённой команды.
|
- reporting the result of the last finished command.
|
||||||
|
|
||||||
### Request
|
### Request
|
||||||
|
|
||||||
@@ -45,19 +45,19 @@ Authorization: Bearer <client-token>
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Поля:
|
Fields:
|
||||||
|
|
||||||
- `client_id` - стабильный идентификатор, заданный при настройке клиента. Он не
|
- `client_id` - a stable identifier set during client setup. It must not change
|
||||||
должен меняться при каждом рестарте.
|
on every restart.
|
||||||
- `observed_at` - момент, когда клиент подготовил payload.
|
- `observed_at` - the moment the client prepared the payload.
|
||||||
- `client.hostname` - текущий hostname машины.
|
- `client.hostname` - the machine's current hostname.
|
||||||
- `client.platform` - платформа клиента. Желательно использовать названия,
|
- `client.platform` - the client platform. Prefer names close to
|
||||||
близкие к Python/platform: `linux`, `darwin`, `windows`.
|
Python/platform: `linux`, `darwin`, `windows`.
|
||||||
- `client.version` - версия nexus-sync client.
|
- `client.version` - the nexus-sync client version.
|
||||||
- `state` - намеренно маленький объект. Может быть сюда позже можно добавить IP,
|
- `state` - an intentionally small object. IP, disk usage, memory, battery
|
||||||
disk usage, memory, battery status и другие метрики.
|
status and other metrics may be added here later.
|
||||||
- `last_command_result` - `null`, если клиенту нечего нового сообщать о
|
- `last_command_result` - `null` if the client has nothing new to report about
|
||||||
выполнении команды.
|
command execution.
|
||||||
|
|
||||||
### Request with command result
|
### Request with command result
|
||||||
|
|
||||||
@@ -86,17 +86,16 @@ Authorization: Bearer <client-token>
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Поля результата:
|
Result fields:
|
||||||
|
|
||||||
- `command_id` должен совпадать с id команды, которую сервер ранее вернул
|
- `command_id` must match the id of the command the server previously returned
|
||||||
клиенту.
|
to the client.
|
||||||
- `status` принимает одно из значений: `succeeded`, `failed`, `timed_out`,
|
- `status` takes one of: `succeeded`, `failed`, `timed_out`, `rejected`.
|
||||||
`rejected`.
|
- `return_code` - the process exit code, if the process was started. For
|
||||||
- `return_code` - exit code процесса, если процесс был запущен. Для
|
`timed_out` or `rejected` the field may be `null` when there is no final exit
|
||||||
`timed_out` или `rejected` поле может быть `null`, если финального exit code
|
code.
|
||||||
нет.
|
- `stdout` and `stderr` must be size-limited by the client before sending. This
|
||||||
- `stdout` и `stderr` клиент должен ограничивать по размеру перед отправкой.
|
contract does not yet fix a specific byte limit.
|
||||||
Конкретный лимит байт в этом контракте пока не фиксируется.
|
|
||||||
|
|
||||||
### Response without command
|
### Response without command
|
||||||
|
|
||||||
@@ -126,36 +125,37 @@ Authorization: Bearer <client-token>
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Поля команды:
|
Command fields:
|
||||||
|
|
||||||
- `id` генерируется сервером и должен быть уникальным.
|
- `id` is generated by the server and must be unique.
|
||||||
- `kind` описывает тип executor. Пока определён только `exec`.
|
- `kind` describes the executor type. Only `exec` is defined so far.
|
||||||
- `name` - имя command preset.
|
- `name` - the command preset name.
|
||||||
- `args` содержит аргументы, специфичные для конкретного preset.
|
- `args` holds arguments specific to a particular preset.
|
||||||
- `timeout_seconds` - максимальное время выполнения, которое сервер допускает
|
- `timeout_seconds` - the maximum execution time the server allows for this
|
||||||
для этой команды.
|
command.
|
||||||
|
|
||||||
Клиент обязан выполнять только те команды, которые он знает и локально
|
The client must run only commands it knows and locally allows. Unknown or
|
||||||
разрешает. Неизвестные или запрещённые команды нужно возвращать как `rejected`.
|
forbidden commands must be returned as `rejected`.
|
||||||
|
|
||||||
## Server-side API
|
## Server-side API
|
||||||
|
|
||||||
Эти ручки нужны серверной части/админке, чтобы видеть клиентов и ставить им
|
These endpoints are for the server/admin side, to see clients and queue commands
|
||||||
команды в очередь. Клиенты напрямую используют только heartbeat.
|
for them. Clients directly use only the heartbeat.
|
||||||
|
|
||||||
### `GET /api/v1/server/clients`
|
### `GET /api/v1/server/clients`
|
||||||
|
|
||||||
Возвращает список известных клиентов с последними heartbeat-данными и
|
Returns the list of known clients with their latest heartbeat data and
|
||||||
`available_commands`.
|
`available_commands`.
|
||||||
|
|
||||||
### `GET /api/v1/server/clients/{client_id}`
|
### `GET /api/v1/server/clients/{client_id}`
|
||||||
|
|
||||||
Возвращает одного клиента или `404`, если сервер ещё не видел этот `client_id`.
|
Returns a single client, or `404` if the server has not seen this `client_id`
|
||||||
|
yet.
|
||||||
|
|
||||||
### `POST /api/v1/server/clients/{client_id}/commands`
|
### `POST /api/v1/server/clients/{client_id}/commands`
|
||||||
|
|
||||||
Создаёт pending-команду для клиента. Клиент получит её на следующем heartbeat.
|
Creates a pending command for the client. The client receives it on its next
|
||||||
Эта ручка принимает имя команды напрямую.
|
heartbeat. This endpoint accepts the command name directly.
|
||||||
|
|
||||||
Request:
|
Request:
|
||||||
|
|
||||||
@@ -188,66 +188,66 @@ Response:
|
|||||||
|
|
||||||
### `GET /api/v1/server/commands/{command_id}`
|
### `GET /api/v1/server/commands/{command_id}`
|
||||||
|
|
||||||
Возвращает команду и, если клиент уже отчитался, поле `result`.
|
Returns the command and, if the client has already reported, the `result` field.
|
||||||
|
|
||||||
## Command lifecycle
|
## Command lifecycle
|
||||||
|
|
||||||
Жизненный цикл команды:
|
Command lifecycle:
|
||||||
|
|
||||||
1. `pending`: команда создана на сервере и ещё не доставлена клиенту.
|
1. `pending`: the command was created on the server and not yet delivered to the
|
||||||
2. `delivered`: команда была возвращена клиенту в heartbeat response.
|
client.
|
||||||
3. `succeeded`: клиент сообщил об успешном выполнении.
|
2. `delivered`: the command was returned to the client in a heartbeat response.
|
||||||
4. `failed`: клиент сообщил об ошибке выполнения.
|
3. `succeeded`: the client reported successful execution.
|
||||||
5. `timed_out`: клиент сообщил о timeout.
|
4. `failed`: the client reported an execution error.
|
||||||
6. `rejected`: клиент отказался выполнять команду.
|
5. `timed_out`: the client reported a timeout.
|
||||||
|
6. `rejected`: the client refused to run the command.
|
||||||
|
|
||||||
Терминальные статусы: `succeeded`, `failed`, `timed_out`, `rejected`.
|
Terminal statuses: `succeeded`, `failed`, `timed_out`, `rejected`.
|
||||||
|
|
||||||
## HTTP statuses
|
## HTTP statuses
|
||||||
|
|
||||||
Heartbeat endpoint должен использовать такие HTTP-статусы:
|
The heartbeat endpoint should use these HTTP statuses:
|
||||||
|
|
||||||
- `200 OK`: heartbeat принят; тело ответа соответствует контракту выше.
|
- `200 OK`: heartbeat accepted; the response body matches the contract above.
|
||||||
- `400 Bad Request`: некорректный JSON или невалидные значения полей.
|
- `400 Bad Request`: malformed JSON or invalid field values.
|
||||||
- `401 Unauthorized`: bearer token отсутствует или невалиден.
|
- `401 Unauthorized`: the bearer token is missing or invalid.
|
||||||
- `403 Forbidden`: token валиден, но не имеет права действовать как этот
|
- `403 Forbidden`: the token is valid but not allowed to act as this
|
||||||
`client_id`.
|
`client_id`.
|
||||||
- `409 Conflict`: результат ссылается на неизвестную, уже терминальную или
|
- `409 Conflict`: the result references an unknown, already terminal, or
|
||||||
несовместимую команду.
|
incompatible command.
|
||||||
- `429 Too Many Requests`: клиент опрашивает сервер слишком часто.
|
- `429 Too Many Requests`: the client polls the server too often.
|
||||||
- `500 Internal Server Error`: неожиданная ошибка сервера.
|
- `500 Internal Server Error`: an unexpected server error.
|
||||||
|
|
||||||
Состояние выполнения команды выражается полями в JSON, а не HTTP-ошибками.
|
Command execution state is expressed via JSON fields, not HTTP errors. For
|
||||||
Например, команда с exit code `1` всё равно отправляется через успешный
|
example, a command with exit code `1` is still delivered over a successful
|
||||||
heartbeat request.
|
heartbeat request.
|
||||||
|
|
||||||
## Polling rules
|
## Polling rules
|
||||||
|
|
||||||
Ответ сервера содержит `next_poll_after_seconds`.
|
The server response contains `next_poll_after_seconds`.
|
||||||
|
|
||||||
Поведение:
|
Behavior:
|
||||||
|
|
||||||
- обычный polling interval без команды: 60 секунд;
|
- normal polling interval without a command: 60 seconds;
|
||||||
- polling interval после получения команды: 10 секунд;
|
- polling interval after receiving a command: 10 seconds;
|
||||||
- клиентский минимальный interval: 5 секунд;
|
- client minimum interval: 5 seconds;
|
||||||
- клиентский максимальный interval: 300 секунд.
|
- client maximum interval: 300 seconds.
|
||||||
|
|
||||||
Клиент должен воспринимать значение сервера как рекомендацию и зажимать его в
|
The client should treat the server value as a recommendation and clamp it to its
|
||||||
локальные min/max границы.
|
local min/max bounds.
|
||||||
|
|
||||||
## Security constraints
|
## Security constraints
|
||||||
|
|
||||||
Необходимо соблюдать эти ограничения:
|
The following constraints must be respected:
|
||||||
|
|
||||||
- не выполнять произвольные shell-строки, полученные от сервера;
|
- do not run arbitrary shell strings received from the server;
|
||||||
- выполнять только локально известные command presets;
|
- run only locally known command presets;
|
||||||
- требовать bearer token для клиентских endpoint'ов;
|
- require a bearer token for client endpoints;
|
||||||
- включать `command_id` в каждый результат, чтобы не было неоднозначного
|
- include `command_id` in every result to avoid ambiguous matching;
|
||||||
сопоставления;
|
- store command output as logs, not as trusted control data;
|
||||||
- хранить command output как логи, а не как доверенные управляющие данные;
|
- limit the size of command output before sending it to the server;
|
||||||
- ограничивать размер command output перед отправкой на сервер;
|
- run every command with a timeout.
|
||||||
- выполнять каждую команду с timeout.
|
|
||||||
|
|
||||||
Эти ограничения намеренно являются частью контракта, потому что проект
|
These constraints are intentionally part of the contract because the project
|
||||||
занимается удалённым управлением машинами. Дешевле строить первую реализацию
|
deals with remote machine management. It is cheaper to build the first
|
||||||
вокруг них, чем добавлять их задним числом.
|
implementation around them than to add them after the fact.
|
||||||
|
|||||||
+29
-2
@@ -1,4 +1,4 @@
|
|||||||
# nexus-cli
|
# CLI
|
||||||
|
|
||||||
`nexus-cli` is a small non-interactive CLI for the nexus-sync server API.
|
`nexus-cli` is a small non-interactive CLI for the nexus-sync server API.
|
||||||
|
|
||||||
@@ -61,13 +61,40 @@ nexus-cli --json client linux-client
|
|||||||
nexus-cli --json command cmd_123
|
nexus-cli --json command cmd_123
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
CLI output is localized with `gettext` (catalogs managed by Babel). Only
|
||||||
|
user-facing CLI strings are translatable; daemon/server log messages are
|
||||||
|
deliberately left untranslated.
|
||||||
|
|
||||||
|
Select a language with the `NEXUS_SYNC_LANG` env var (falls back to the system
|
||||||
|
locale, then to the source English strings):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXUS_SYNC_LANG=ru nexus-cli command cmd_123
|
||||||
|
```
|
||||||
|
|
||||||
|
Translation sources live in `src/nexus_sync/locale/<lang>/LC_MESSAGES/nexus.po`.
|
||||||
|
Workflow (requires `pip install -e ".[dev]"`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make i18n-extract # rebuild the .pot template from _()-wrapped strings
|
||||||
|
make i18n-update # merge new/changed strings into existing catalogs
|
||||||
|
make i18n-init LANG=de # start a new language
|
||||||
|
# edit the .po file, then:
|
||||||
|
make i18n-compile # build the .mo files shipped with the binary
|
||||||
|
```
|
||||||
|
|
||||||
|
`make cli` compiles catalogs automatically and bundles them into the binary.
|
||||||
|
|
||||||
## Build standalone binary
|
## Build standalone binary
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make cli
|
make cli
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates `dist/nexus-cli` through PyInstaller.
|
This creates `dist/nexus-cli` through PyInstaller (with localization catalogs
|
||||||
|
bundled via `--add-data`).
|
||||||
|
|
||||||
## Build wheel package
|
## Build wheel package
|
||||||
|
|
||||||
|
|||||||
+18
-15
@@ -1,14 +1,17 @@
|
|||||||
# Client
|
# Client
|
||||||
|
|
||||||
Основная суть - кидает на известный по ip/домену сервак свой ключ и базовую информацию о себе: hostname, местное время (что-то ещё?)
|
The core idea - it sends its key and basic info about itself (hostname, local
|
||||||
|
time, anything else?) to a server known by ip/domain.
|
||||||
|
|
||||||
В ответ может получить как простое "ок", так и команду для выполнения
|
In response it can get either a plain "ok" or a command to execute.
|
||||||
|
|
||||||
Запросы на сервер будет кидать с некоторой частотой, постепенно возрастающей, но резко снижающейся при получении команды в ответе (вдруг надо ещё что-то выполнить)
|
It sends requests to the server at a certain frequency, gradually increasing but
|
||||||
|
dropping sharply when a command is received in the response (in case something
|
||||||
|
else needs to be executed).
|
||||||
|
|
||||||
Есть установление нижнего предела на частоту (условная 1/минута)
|
There is a lower bound on the frequency (roughly 1/minute).
|
||||||
|
|
||||||
Пример работы
|
Example flow
|
||||||
|
|
||||||
```
|
```
|
||||||
...
|
...
|
||||||
@@ -25,18 +28,18 @@ client -> server: hello!, i'm $(hostname), uuid= , ts=, stdout= , stderr= , ...
|
|||||||
*waits 23s*
|
*waits 23s*
|
||||||
```
|
```
|
||||||
|
|
||||||
Возможно нужна доп инфа о работе команды (код ошибки как минимум)
|
Additional info about command execution may be needed (at least the error code).
|
||||||
|
|
||||||
+ возможно на клиенте стоит ограничить набор допустимых команд
|
+ the set of allowed commands should probably be limited on the client
|
||||||
+ стоит явно задуматься о шифровании/идентификации сервера
|
+ encryption / server identification should be considered explicitly
|
||||||
|
|
||||||
## Разрешённые команды
|
## Allowed commands
|
||||||
|
|
||||||
Клиент исполняет только команды, описанные в локальном YAML config-файле.
|
The client runs only the commands described in its local YAML config file. Only
|
||||||
Серверу отправляются только `name` и `description`; поле `cmd` остаётся только
|
`name` and `description` are sent to the server; the `cmd` field stays on the
|
||||||
на клиенте и не управляется сервером.
|
client and is not controlled by the server.
|
||||||
|
|
||||||
Пример:
|
Example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
server_url: "http://127.0.0.1:5852"
|
server_url: "http://127.0.0.1:5852"
|
||||||
@@ -52,6 +55,6 @@ allowed_commands:
|
|||||||
logging_level: "INFO"
|
logging_level: "INFO"
|
||||||
```
|
```
|
||||||
|
|
||||||
Файл ищется как `nexus.yml`/`nexus.yaml` в текущей директории, затем в
|
The file is looked up as `nexus.yml`/`nexus.yaml` in the current directory, then
|
||||||
`$XDG_CONFIG_HOME`, затем в `~/.config`, затем как
|
in `$XDG_CONFIG_HOME`, then in `~/.config`, then as
|
||||||
`~/.config/nexus/config.yml`/`.yaml`.
|
`~/.config/nexus/config.yml`/`.yaml`.
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Sphinx configuration for the nexus-sync documentation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Make the package importable for autodoc (sources live under src/).
|
||||||
|
sys.path.insert(0, os.path.abspath("../src"))
|
||||||
|
|
||||||
|
project = "nexus-sync"
|
||||||
|
author = "nexus-sync"
|
||||||
|
copyright = "2026, nexus-sync"
|
||||||
|
release = "0.1.0"
|
||||||
|
|
||||||
|
extensions = [
|
||||||
|
"myst_parser",
|
||||||
|
"sphinx.ext.autodoc",
|
||||||
|
"sphinx.ext.autosummary",
|
||||||
|
"sphinx.ext.napoleon",
|
||||||
|
"sphinx.ext.viewcode",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Pull docstrings even from undocumented members so the API pages are useful
|
||||||
|
# while docstring coverage grows.
|
||||||
|
autosummary_generate = True
|
||||||
|
autodoc_default_options = {
|
||||||
|
"members": True,
|
||||||
|
"undoc-members": True,
|
||||||
|
"show-inheritance": True,
|
||||||
|
}
|
||||||
|
autodoc_typehints = "description"
|
||||||
|
|
||||||
|
# MyST so the existing Markdown guides render as-is.
|
||||||
|
myst_enable_extensions = ["colon_fence", "deflist"]
|
||||||
|
source_suffix = {".md": "markdown", ".rst": "restructuredtext"}
|
||||||
|
|
||||||
|
exclude_patterns = ["_build", ".DS_Store", "Thumbs.db", "archive"]
|
||||||
|
|
||||||
|
html_theme = "furo"
|
||||||
|
html_title = "nexus-sync"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# nexus-sync
|
||||||
|
|
||||||
|
A utility that allows you to manage your computers from a centralized server.
|
||||||
|
|
||||||
|
nexus-sync has three parts: a central **server** (FastAPI), a **client** agent
|
||||||
|
that runs on each managed machine and executes a whitelist of commands, and
|
||||||
|
**nexus-cli**, the operator's command-line tool.
|
||||||
|
|
||||||
|
```{toctree}
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Guide
|
||||||
|
|
||||||
|
api
|
||||||
|
cli
|
||||||
|
client
|
||||||
|
server
|
||||||
|
```
|
||||||
|
|
||||||
|
```{toctree}
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Reference
|
||||||
|
|
||||||
|
reference
|
||||||
|
```
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
Auto-generated from the source docstrings.
|
||||||
|
|
||||||
|
## CLI (`nexus-cli`)
|
||||||
|
|
||||||
|
```{eval-rst}
|
||||||
|
.. automodule:: nexus_sync.cli.__main__
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client
|
||||||
|
|
||||||
|
```{eval-rst}
|
||||||
|
.. automodule:: nexus_sync.client.runtime
|
||||||
|
|
||||||
|
.. automodule:: nexus_sync.client.execute
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server
|
||||||
|
|
||||||
|
```{eval-rst}
|
||||||
|
.. automodule:: nexus_sync.server.app
|
||||||
|
|
||||||
|
.. automodule:: nexus_sync.server.store
|
||||||
|
|
||||||
|
.. automodule:: nexus_sync.server.sqlalchemy_store
|
||||||
|
|
||||||
|
.. automodule:: nexus_sync.server.config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common
|
||||||
|
|
||||||
|
```{eval-rst}
|
||||||
|
.. automodule:: nexus_sync.common.models
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utilities
|
||||||
|
|
||||||
|
```{eval-rst}
|
||||||
|
.. automodule:: nexus_sync.i18n
|
||||||
|
|
||||||
|
.. automodule:: nexus_sync.utils.log_config
|
||||||
|
```
|
||||||
+44
-35
@@ -1,50 +1,59 @@
|
|||||||
# Server
|
# Server
|
||||||
|
|
||||||
TODO:
|
TODO:
|
||||||
- описать формально все API
|
- formally describe the whole API
|
||||||
- добавить сами команды, возможно сделать их в виде пресетов для платформы
|
- add the commands themselves, possibly as per-platform presets
|
||||||
|
|
||||||
## Основная суть
|
## Core idea
|
||||||
|
|
||||||
- API ручки. Доступ чисто по ssl (но это уже зона ответственности nginx)
|
- API endpoints. Access purely over SSL (but that's already nginx's
|
||||||
- клиенты по своему некоторому ключу будут авторизовываться.
|
responsibility).
|
||||||
- проблема ручной настройки (но она тут будто минимальная, не так плохо)
|
- clients authorize with some key of their own.
|
||||||
- когда клиент что-либо присылает, в ответ ему надо кинуть команды на исполнение, если есть
|
- the manual setup problem (but it seems minimal here, not too bad)
|
||||||
- со стороны клиента должен быть настроен trust к серверу
|
- when a client sends anything, the response should include commands to execute,
|
||||||
|
if any.
|
||||||
|
- the client side must be configured to trust the server.
|
||||||
|
|
||||||
Для сервера необходимо несколько настроек
|
The server needs several settings
|
||||||
|
|
||||||
- лимит на кол-во запросов (от одного клиента, условно)
|
- a request rate limit (per single client, roughly)
|
||||||
- лимит на хранение инфо (и хранить ли старое? да, логи)
|
- a limit on stored info (and whether to keep old data? yes, logs)
|
||||||
- лимит на одного юзера
|
- per-user limit
|
||||||
- общий лимит
|
- global limit
|
||||||
- кастомные команды
|
- custom commands
|
||||||
- отслеживание получения/выполнение команды сервером, повторные попытки, лимит попыток
|
- tracking command delivery/execution by the server, retries, attempt limit
|
||||||
|
|
||||||
## Возможности (API ручки)
|
## Capabilities (API endpoints)
|
||||||
|
|
||||||
'Понятия не имею, как описывать эти ручки (точнее, нет желания правильно их описывать)'
|
'No idea how to describe these endpoints (or rather, no desire to describe them
|
||||||
|
properly)'
|
||||||
|
|
||||||
### Ручки для админа
|
### Admin endpoints
|
||||||
|
|
||||||
Все пользователи будут считаться админами для удобства. Остальных нет
|
All users are treated as admins for convenience. There are no others.
|
||||||
|
|
||||||
1. получить список всех клиентов
|
1. get the list of all clients
|
||||||
- с отдельным параметром "только активированные"
|
- with a separate "only activated" parameter
|
||||||
2. получить данные по клиенту
|
2. get data for a client
|
||||||
- сюда включается вся инфа о его показателях, когда был в сети + какие команды на него доступны (да, будем их ограничивать, возможно по модели zero-trust)
|
- this includes all info about its metrics, when it was last online, and which
|
||||||
3. выполнить какую-то команду
|
commands are available for it (yes, we'll restrict them, possibly via a
|
||||||
- тут вопрос в том, будем ли мы ждать ответа от нашего клиента (вряд ли)
|
zero-trust model)
|
||||||
4. получить токен, авторизация
|
3. run some command
|
||||||
5. добавить кастомную команду (для клиента)
|
- the question here is whether we'll wait for a response from our client
|
||||||
- проблема в системах, если хочется универсального добавления. Выход - добавлять только для одного клиента (лучше во всём будет)
|
(probably not)
|
||||||
|
4. obtain a token, authorization
|
||||||
|
5. add a custom command (for a client)
|
||||||
|
- the problem is with systems if you want universal addition. The way out is
|
||||||
|
to add it only for a single client (better all around)
|
||||||
|
|
||||||
### Ручки для клиентов
|
### Client endpoints
|
||||||
|
|
||||||
Не уверен даже, что больше одной нужно будет
|
Not even sure more than one will be needed
|
||||||
|
|
||||||
1. Прислать информацию
|
1. Send information
|
||||||
- просто стучится со своим uuid и кидает, что знает
|
- just knocks with its uuid and sends what it knows
|
||||||
2. Инфо о сервере?
|
2. Info about the server?
|
||||||
- возможно фетчить айпи (чтобы стучаться, если сертификат на месте), другие домены
|
- possibly fetch the ip (to knock on, if the certificate is in place), other
|
||||||
- какие-то особые "правила", если пакет с их установкой потеряется (сам то пакет вряд ли потеряется, мы об этом узнаем сразу)
|
domains
|
||||||
|
- some special "rules" if the package with their installation gets lost (the
|
||||||
|
package itself is unlikely to be lost, we'll know immediately)
|
||||||
|
|||||||
+10
-1
@@ -28,7 +28,13 @@ dev = [
|
|||||||
"doit>=0.36.0",
|
"doit>=0.36.0",
|
||||||
"mypy>=1.0",
|
"mypy>=1.0",
|
||||||
"types-PyYAML>=6.0.12",
|
"types-PyYAML>=6.0.12",
|
||||||
"pre-commit>=4.0"
|
"pre-commit>=4.0",
|
||||||
|
"Babel>=2.14.0"
|
||||||
|
]
|
||||||
|
docs = [
|
||||||
|
"sphinx>=7.0",
|
||||||
|
"myst-parser>=2.0",
|
||||||
|
"furo>=2024.1.29"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -37,6 +43,9 @@ nexus-cli = "nexus_sync.cli.__main__:main"
|
|||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
nexus_sync = ["locale/**/*.mo"]
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = ["py311"]
|
target-version = ["py311"]
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ from collections.abc import Callable
|
|||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from typing import Any, Protocol, Self
|
from typing import Any, Protocol, Self
|
||||||
|
|
||||||
|
from nexus_sync import i18n
|
||||||
|
from nexus_sync.i18n import _
|
||||||
|
|
||||||
DEFAULT_SERVER_URL = "http://127.0.0.1:5852"
|
DEFAULT_SERVER_URL = "http://127.0.0.1:5852"
|
||||||
API_PREFIX = "/api/v1"
|
API_PREFIX = "/api/v1"
|
||||||
JsonObject = dict[str, Any]
|
JsonObject = dict[str, Any]
|
||||||
@@ -53,62 +56,62 @@ def request_json(
|
|||||||
body = response.read()
|
body = response.read()
|
||||||
except urllib.error.HTTPError as error:
|
except urllib.error.HTTPError as error:
|
||||||
detail = error.read().decode(errors="replace")
|
detail = error.read().decode(errors="replace")
|
||||||
raise CLIError(f"HTTP {error.code}: {detail}") from error
|
raise CLIError(_("HTTP {code}: {detail}").format(code=error.code, detail=detail)) from error
|
||||||
except urllib.error.URLError as error:
|
except urllib.error.URLError as error:
|
||||||
raise CLIError(f"request failed: {error.reason}") from error
|
raise CLIError(_("request failed: {error}").format(error=error.reason)) from error
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
raise CLIError(f"request failed: {error}") from error
|
raise CLIError(_("request failed: {error}").format(error=error)) from error
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = json.loads(body.decode())
|
result = json.loads(body.decode())
|
||||||
except json.JSONDecodeError as error:
|
except json.JSONDecodeError as error:
|
||||||
raise CLIError(f"server returned invalid JSON: {error}") from error
|
raise CLIError(_("server returned invalid JSON: {error}").format(error=error)) from error
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
raise CLIError("server returned JSON that is not an object")
|
raise CLIError(_("server returned JSON that is not an object"))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def format_clients(payload: JsonObject) -> str:
|
def format_clients(payload: JsonObject) -> str:
|
||||||
clients = payload.get("clients", [])
|
clients = payload.get("clients", [])
|
||||||
if not isinstance(clients, list) or not clients:
|
if not isinstance(clients, list) or not clients:
|
||||||
return "clients:\n- none"
|
return _("clients:") + "\n- " + _("none")
|
||||||
|
|
||||||
lines = ["clients:"]
|
lines = [_("clients:")]
|
||||||
for item in clients:
|
for item in clients:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
parts = [
|
parts = [
|
||||||
str(item.get("id", "<unknown>")),
|
str(item.get("id", _("<unknown>"))),
|
||||||
str(item.get("platform", "unknown")),
|
str(item.get("platform", _("unknown"))),
|
||||||
str(item.get("hostname", "unknown")),
|
str(item.get("hostname", _("unknown"))),
|
||||||
f"version={item.get('version', 'unknown')}",
|
f"version={item.get('version', _('unknown'))}",
|
||||||
f"last_seen={item.get('last_seen_at', 'unknown')}",
|
f"last_seen={item.get('last_seen_at', _('unknown'))}",
|
||||||
]
|
]
|
||||||
lines.append(f"- {' '.join(parts)}")
|
lines.append(f"- {' '.join(parts)}")
|
||||||
commands = _command_names(item.get("available_commands", []))
|
commands = _command_names(item.get("available_commands", []))
|
||||||
if commands:
|
if commands:
|
||||||
lines.append(f" commands: {', '.join(commands)}")
|
lines.append(f" {_('commands:')} {', '.join(commands)}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def format_client(payload: JsonObject) -> str:
|
def format_client(payload: JsonObject) -> str:
|
||||||
lines = [
|
lines = [
|
||||||
f"id: {payload.get('id', '<unknown>')}",
|
_field_line("id", payload.get("id", _("<unknown>"))),
|
||||||
f"hostname: {payload.get('hostname', 'unknown')}",
|
_field_line("hostname", payload.get("hostname", _("unknown"))),
|
||||||
f"platform: {payload.get('platform', 'unknown')}",
|
_field_line("platform", payload.get("platform", _("unknown"))),
|
||||||
f"version: {payload.get('version', 'unknown')}",
|
_field_line("version", payload.get("version", _("unknown"))),
|
||||||
f"created_at: {payload.get('created_at', 'unknown')}",
|
_field_line("created_at", payload.get("created_at", _("unknown"))),
|
||||||
f"last_seen_at: {payload.get('last_seen_at', 'unknown')}",
|
_field_line("last_seen_at", payload.get("last_seen_at", _("unknown"))),
|
||||||
"available_commands:",
|
_("available_commands:"),
|
||||||
]
|
]
|
||||||
commands = payload.get("available_commands", [])
|
commands = payload.get("available_commands", [])
|
||||||
if not isinstance(commands, list) or not commands:
|
if not isinstance(commands, list) or not commands:
|
||||||
lines.append("- none")
|
lines.append("- " + _("none"))
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
for command in commands:
|
for command in commands:
|
||||||
if isinstance(command, dict):
|
if isinstance(command, dict):
|
||||||
name = command.get("name", "<unknown>")
|
name = command.get("name", _("<unknown>"))
|
||||||
description = command.get("description", "")
|
description = command.get("description", "")
|
||||||
suffix = f" - {description}" if description else ""
|
suffix = f" - {description}" if description else ""
|
||||||
lines.append(f"- {name}{suffix}")
|
lines.append(f"- {name}{suffix}")
|
||||||
@@ -116,7 +119,7 @@ def format_client(payload: JsonObject) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def format_command(payload: JsonObject, *, queued: bool = False) -> str:
|
def format_command(payload: JsonObject, *, queued: bool = False) -> str:
|
||||||
lines = ["queued command:" if queued else "command:"]
|
lines = [_("queued command:") if queued else _("command:")]
|
||||||
for key in (
|
for key in (
|
||||||
"id",
|
"id",
|
||||||
"client_id",
|
"client_id",
|
||||||
@@ -129,43 +132,45 @@ def format_command(payload: JsonObject, *, queued: bool = False) -> str:
|
|||||||
"finished_at",
|
"finished_at",
|
||||||
):
|
):
|
||||||
if key in payload:
|
if key in payload:
|
||||||
lines.append(f"{key}: {payload.get(key)}")
|
lines.append(_field_line(key, payload.get(key)))
|
||||||
|
|
||||||
result = payload.get("result")
|
result = payload.get("result")
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
lines.append("result:")
|
lines.append(_("result:"))
|
||||||
if "status" in result:
|
if "status" in result:
|
||||||
lines.append(f" status: {result.get('status')}")
|
lines.append(" " + _field_line("status", result.get("status")))
|
||||||
if "return_code" in result:
|
if "return_code" in result:
|
||||||
lines.append(f" return_code: {result.get('return_code')}")
|
lines.append(" " + _field_line("return_code", result.get("return_code")))
|
||||||
lines.append(" stdout:")
|
lines.append(" " + _("stdout:"))
|
||||||
lines.extend(_indent_block(str(result.get("stdout", ""))))
|
lines.extend(_indent_block(str(result.get("stdout", ""))))
|
||||||
lines.append(" stderr:")
|
lines.append(" " + _("stderr:"))
|
||||||
lines.extend(_indent_block(str(result.get("stderr", ""))))
|
lines.extend(_indent_block(str(result.get("stderr", ""))))
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(prog="nexus-cli", description="CLI for nexus-sync server API")
|
parser = argparse.ArgumentParser(
|
||||||
parser.add_argument(
|
prog="nexus-cli", description=_("CLI for nexus-sync server API")
|
||||||
"--server-url", "-s", default=DEFAULT_SERVER_URL, help="nexus-sync server URL"
|
|
||||||
)
|
)
|
||||||
parser.add_argument("--list", action="store_true", help="list clients")
|
parser.add_argument(
|
||||||
parser.add_argument("--json", action="store_true", help="print raw JSON response")
|
"--server-url", "-s", default=DEFAULT_SERVER_URL, help=_("nexus-sync server URL")
|
||||||
|
)
|
||||||
|
parser.add_argument("--list", action="store_true", help=_("list clients"))
|
||||||
|
parser.add_argument("--json", action="store_true", help=_("print raw JSON response"))
|
||||||
|
|
||||||
subparsers = parser.add_subparsers(dest="resource")
|
subparsers = parser.add_subparsers(dest="resource")
|
||||||
client = subparsers.add_parser("client", help="show client info or queue a command")
|
client = subparsers.add_parser("client", help=_("show client info or queue a command"))
|
||||||
client.add_argument("id", help="client id")
|
client.add_argument("id", help=_("client id"))
|
||||||
client.add_argument("--run-command", metavar="NAME", help="queue a command for this client")
|
client.add_argument("--run-command", metavar="NAME", help=_("queue a command for this client"))
|
||||||
client.add_argument(
|
client.add_argument(
|
||||||
"--timeout-seconds",
|
"--timeout-seconds",
|
||||||
type=int,
|
type=int,
|
||||||
default=30,
|
default=30,
|
||||||
help="command timeout in seconds for --run-command",
|
help=_("command timeout in seconds for --run-command"),
|
||||||
)
|
)
|
||||||
|
|
||||||
command = subparsers.add_parser("command", help="show command execution info")
|
command = subparsers.add_parser("command", help=_("show command execution info"))
|
||||||
command.add_argument("id", help="command id")
|
command.add_argument("id", help=_("command id"))
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -174,6 +179,7 @@ def main(
|
|||||||
*,
|
*,
|
||||||
requester: Requester = request_json,
|
requester: Requester = request_json,
|
||||||
) -> int:
|
) -> int:
|
||||||
|
i18n.setup()
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
server_url = str(args.server_url).rstrip("/")
|
server_url = str(args.server_url).rstrip("/")
|
||||||
@@ -215,7 +221,7 @@ def main(
|
|||||||
_print_payload(payload, raw_json=args.json, formatter=format_command)
|
_print_payload(payload, raw_json=args.json, formatter=format_command)
|
||||||
return 0
|
return 0
|
||||||
except CLIError as error:
|
except CLIError as error:
|
||||||
print(f"nexus-cli error: {error}", file=sys.stderr)
|
print(_("nexus-cli error: {error}").format(error=error), file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
@@ -234,6 +240,30 @@ def _print_payload(
|
|||||||
print(formatter(payload))
|
print(formatter(payload))
|
||||||
|
|
||||||
|
|
||||||
|
def _field_line(key: str, value: object) -> str:
|
||||||
|
return f"{_field_label(key)}: {value}"
|
||||||
|
|
||||||
|
|
||||||
|
def _field_label(key: str) -> str:
|
||||||
|
labels = {
|
||||||
|
"id": _("id"),
|
||||||
|
"client_id": _("client_id"),
|
||||||
|
"hostname": _("hostname"),
|
||||||
|
"platform": _("platform"),
|
||||||
|
"version": _("version"),
|
||||||
|
"created_at": _("created_at"),
|
||||||
|
"last_seen_at": _("last_seen_at"),
|
||||||
|
"kind": _("kind"),
|
||||||
|
"name": _("name"),
|
||||||
|
"status": _("status"),
|
||||||
|
"timeout_seconds": _("timeout_seconds"),
|
||||||
|
"delivered_at": _("delivered_at"),
|
||||||
|
"finished_at": _("finished_at"),
|
||||||
|
"return_code": _("return_code"),
|
||||||
|
}
|
||||||
|
return labels.get(key, key)
|
||||||
|
|
||||||
|
|
||||||
def _command_names(commands: object) -> list[str]:
|
def _command_names(commands: object) -> list[str]:
|
||||||
if not isinstance(commands, list):
|
if not isinstance(commands, list):
|
||||||
return []
|
return []
|
||||||
@@ -246,7 +276,7 @@ def _command_names(commands: object) -> list[str]:
|
|||||||
|
|
||||||
def _indent_block(value: str) -> list[str]:
|
def _indent_block(value: str) -> list[str]:
|
||||||
if not value:
|
if not value:
|
||||||
return [" <empty>"]
|
return [" " + _("<empty>")]
|
||||||
return [f" {line}" if line else "" for line in value.rstrip("\n").splitlines()]
|
return [f" {line}" if line else "" for line in value.rstrip("\n").splitlines()]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""CLI localization (i18n).
|
||||||
|
|
||||||
|
User-facing CLI strings are wrapped in :func:`_` so they can be translated.
|
||||||
|
Log messages (``logger.*``) are intentionally left unwrapped and always stay
|
||||||
|
in English for grep-ability and operations.
|
||||||
|
|
||||||
|
Translation only becomes active after :func:`setup` is called (done in the CLI
|
||||||
|
entry points). Until then, and whenever no catalog matches the requested
|
||||||
|
language, ``gettext`` falls back to returning the original (English) message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gettext as _gettext
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DOMAIN = "nexus"
|
||||||
|
LANG_ENV = "NEXUS_SYNC_LANG"
|
||||||
|
|
||||||
|
_translation: _gettext.NullTranslations = _gettext.NullTranslations()
|
||||||
|
|
||||||
|
|
||||||
|
def _locale_dir() -> str:
|
||||||
|
"""Locate the compiled message catalogs, both in-source and inside a PyInstaller bundle."""
|
||||||
|
bundled = getattr(sys, "_MEIPASS", None)
|
||||||
|
if bundled is not None:
|
||||||
|
return os.path.join(bundled, "nexus_sync", "locale")
|
||||||
|
return str(Path(__file__).resolve().parent / "locale")
|
||||||
|
|
||||||
|
|
||||||
|
def setup(lang: str | None = None) -> None:
|
||||||
|
"""Activate the message catalog for ``lang``.
|
||||||
|
|
||||||
|
When ``lang`` is ``None`` the ``NEXUS_SYNC_LANG`` env var is consulted, and
|
||||||
|
failing that the system locale (``LANGUAGE``/``LANG``/...) is used. Missing
|
||||||
|
catalogs fall back silently to the original English strings.
|
||||||
|
"""
|
||||||
|
global _translation
|
||||||
|
if lang is None:
|
||||||
|
lang = os.environ.get(LANG_ENV) or None
|
||||||
|
languages = [lang] if lang else None
|
||||||
|
_translation = _gettext.translation(DOMAIN, _locale_dir(), languages=languages, fallback=True)
|
||||||
|
|
||||||
|
|
||||||
|
def gettext(message: str) -> str:
|
||||||
|
return _translation.gettext(message)
|
||||||
|
|
||||||
|
|
||||||
|
def ngettext(singular: str, plural: str, n: int) -> str:
|
||||||
|
return _translation.ngettext(singular, plural, n)
|
||||||
|
|
||||||
|
|
||||||
|
# Conventional alias used to mark translatable strings; recognized by pybabel.
|
||||||
|
_ = gettext
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Russian translations for nexus-sync.
|
||||||
|
# Copyright (C) 2026 ORGANIZATION
|
||||||
|
# This file is distributed under the same license as the nexus-sync project.
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: nexus-sync 0.1.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
|
"POT-Creation-Date: 2026-06-24 22:58+0300\n"
|
||||||
|
"PO-Revision-Date: 2026-06-24 23:01+0300\n"
|
||||||
|
"Last-Translator: nexus-sync\n"
|
||||||
|
"Language: ru\n"
|
||||||
|
"Language-Team: ru <LL@li.org>\n"
|
||||||
|
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
|
||||||
|
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=utf-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Generated-By: Babel 2.18.0\n"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:59
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "HTTP {code}: {detail}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:61 src/nexus_sync/cli/__main__.py:63
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "request failed: {error}"
|
||||||
|
msgstr "запрос не выполнен: {error}"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:69
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "server returned invalid JSON: {error}"
|
||||||
|
msgstr "сервер вернул некорректный JSON: {error}"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:72
|
||||||
|
msgid "server returned JSON that is not an object"
|
||||||
|
msgstr "сервер вернул JSON, который не является объектом"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:79 src/nexus_sync/cli/__main__.py:81
|
||||||
|
msgid "clients:"
|
||||||
|
msgstr "клиенты:"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:79 src/nexus_sync/cli/__main__.py:111
|
||||||
|
msgid "none"
|
||||||
|
msgstr "нет"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:86 src/nexus_sync/cli/__main__.py:101
|
||||||
|
#: src/nexus_sync/cli/__main__.py:116
|
||||||
|
msgid "<unknown>"
|
||||||
|
msgstr "<неизвестно>"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:87 src/nexus_sync/cli/__main__.py:88
|
||||||
|
#: src/nexus_sync/cli/__main__.py:89 src/nexus_sync/cli/__main__.py:90
|
||||||
|
#: src/nexus_sync/cli/__main__.py:102 src/nexus_sync/cli/__main__.py:103
|
||||||
|
#: src/nexus_sync/cli/__main__.py:104 src/nexus_sync/cli/__main__.py:105
|
||||||
|
#: src/nexus_sync/cli/__main__.py:106
|
||||||
|
msgid "unknown"
|
||||||
|
msgstr "неизвестно"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:95
|
||||||
|
msgid "commands:"
|
||||||
|
msgstr "команды:"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:107
|
||||||
|
msgid "available_commands:"
|
||||||
|
msgstr "доступные команды:"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:124
|
||||||
|
msgid "queued command:"
|
||||||
|
msgstr "команда поставлена в очередь:"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:124
|
||||||
|
msgid "command:"
|
||||||
|
msgstr "команда:"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:141
|
||||||
|
msgid "result:"
|
||||||
|
msgstr "результат:"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:146
|
||||||
|
msgid "stdout:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:148
|
||||||
|
msgid "stderr:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:155
|
||||||
|
msgid "CLI for nexus-sync server API"
|
||||||
|
msgstr "CLI для API сервера nexus-sync"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:158
|
||||||
|
msgid "nexus-sync server URL"
|
||||||
|
msgstr "URL сервера nexus-sync"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:160
|
||||||
|
msgid "list clients"
|
||||||
|
msgstr "показать список клиентов"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:161
|
||||||
|
msgid "print raw JSON response"
|
||||||
|
msgstr "вывести необработанный JSON-ответ"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:164
|
||||||
|
msgid "show client info or queue a command"
|
||||||
|
msgstr "показать информацию о клиенте или поставить команду в очередь"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:165
|
||||||
|
msgid "client id"
|
||||||
|
msgstr "идентификатор клиента"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:167
|
||||||
|
msgid "queue a command for this client"
|
||||||
|
msgstr "поставить команду в очередь для этого клиента"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:173
|
||||||
|
msgid "command timeout in seconds for --run-command"
|
||||||
|
msgstr "таймаут команды в секундах для --run-command"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:176
|
||||||
|
msgid "show command execution info"
|
||||||
|
msgstr "показать информацию о выполнении команды"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:177
|
||||||
|
msgid "command id"
|
||||||
|
msgstr "идентификатор команды"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:228
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "nexus-cli error: {error}"
|
||||||
|
msgstr "ошибка nexus-cli: {error}"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:253
|
||||||
|
msgid "id"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:254
|
||||||
|
msgid "client_id"
|
||||||
|
msgstr "id клиента"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:255
|
||||||
|
msgid "hostname"
|
||||||
|
msgstr "имя хоста"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:256
|
||||||
|
msgid "platform"
|
||||||
|
msgstr "платформа"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:257
|
||||||
|
msgid "version"
|
||||||
|
msgstr "версия"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:258
|
||||||
|
msgid "created_at"
|
||||||
|
msgstr "создано"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:259
|
||||||
|
msgid "last_seen_at"
|
||||||
|
msgstr "последняя активность"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:260
|
||||||
|
msgid "kind"
|
||||||
|
msgstr "тип"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:261
|
||||||
|
msgid "name"
|
||||||
|
msgstr "имя"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:262
|
||||||
|
msgid "status"
|
||||||
|
msgstr "статус"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:263
|
||||||
|
msgid "timeout_seconds"
|
||||||
|
msgstr "таймаут (сек)"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:264
|
||||||
|
msgid "delivered_at"
|
||||||
|
msgstr "доставлено"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:265
|
||||||
|
msgid "finished_at"
|
||||||
|
msgstr "завершено"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:266
|
||||||
|
msgid "return_code"
|
||||||
|
msgstr "код возврата"
|
||||||
|
|
||||||
|
#: src/nexus_sync/cli/__main__.py:283
|
||||||
|
msgid "<empty>"
|
||||||
|
msgstr "<пусто>"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _force_source_locale(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Keep CLI output in the source language during tests.
|
||||||
|
|
||||||
|
``nexus-cli`` activates a locale from ``NEXUS_SYNC_LANG`` / the system
|
||||||
|
locale, so assertions on English output would break on a machine whose
|
||||||
|
locale is, e.g., Russian. Pinning to a language with no catalog makes
|
||||||
|
gettext fall back to the source strings regardless of the dev's environment.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("NEXUS_SYNC_LANG", "en")
|
||||||
Reference in New Issue
Block a user