add cli for server-side
This commit is contained in:
@@ -55,6 +55,7 @@ jobs:
|
|||||||
- run: pip install -e ".[dev]"
|
- run: pip install -e ".[dev]"
|
||||||
- run: pyinstaller --onefile src/nexus_sync/client/__main__.py --name nexus-sync-client
|
- run: pyinstaller --onefile src/nexus_sync/client/__main__.py --name nexus-sync-client
|
||||||
- run: pyinstaller --onefile src/nexus_sync/server/__main__.py --name nexus-sync-server
|
- run: pyinstaller --onefile src/nexus_sync/server/__main__.py --name nexus-sync-server
|
||||||
|
- run: pyinstaller --onefile src/nexus_sync/cli/__main__.py --name nexus-cli
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: nexus-sync-${{ matrix.os }}
|
name: nexus-sync-${{ matrix.os }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.PHONY: all client server test clean
|
.PHONY: all client server cli test clean
|
||||||
|
|
||||||
all: client server
|
all: client server cli
|
||||||
|
|
||||||
client:
|
client:
|
||||||
pyinstaller --onefile src/nexus_sync/client/__main__.py --name nexus-sync-client
|
pyinstaller --onefile src/nexus_sync/client/__main__.py --name nexus-sync-client
|
||||||
@@ -8,6 +8,9 @@ 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:
|
||||||
|
pyinstaller --onefile src/nexus_sync/cli/__main__.py --name nexus-cli
|
||||||
|
|
||||||
test:
|
test:
|
||||||
pytest
|
pytest
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ stays local to the client config.
|
|||||||
- API contract: [docs/api.md](docs/api.md)
|
- API contract: [docs/api.md](docs/api.md)
|
||||||
- Client behavior notes: [docs/client.md](docs/client.md)
|
- Client behavior notes: [docs/client.md](docs/client.md)
|
||||||
- Server behavior notes: [docs/server.md](docs/server.md)
|
- Server behavior notes: [docs/server.md](docs/server.md)
|
||||||
|
- CLI usage: [docs/cli.md](docs/cli.md)
|
||||||
|
|
||||||
## Server API
|
## Server API
|
||||||
|
|
||||||
@@ -84,10 +85,34 @@ Queue command example:
|
|||||||
Clients receive queued commands on their next heartbeat and report results in a
|
Clients receive queued commands on their next heartbeat and report results in a
|
||||||
later heartbeat.
|
later heartbeat.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
Install the package in editable mode to use the server API CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
nexus-cli --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Common commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nexus-cli --list
|
||||||
|
nexus-cli --server-url http://127.0.0.1:5852 --list
|
||||||
|
nexus-cli client linux-client
|
||||||
|
nexus-cli client linux-client --run-command hostname
|
||||||
|
nexus-cli client linux-client --run-command hostname --timeout-seconds 30
|
||||||
|
nexus-cli command cmd_123
|
||||||
|
nexus-cli --json command cmd_123
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI uses only server-side API handlers. Queued commands are delivered to the
|
||||||
|
client on its next heartbeat.
|
||||||
|
|
||||||
### To build
|
### To build
|
||||||
|
|
||||||
```
|
```
|
||||||
make [client|server]
|
make [client|server|cli]
|
||||||
```
|
```
|
||||||
|
|
||||||
## systemd templates
|
## systemd templates
|
||||||
@@ -143,7 +168,7 @@ sudo systemctl enable --now nexus-sync-client.timer
|
|||||||
Install the client YAML config separately, for example:
|
Install the client YAML config separately, for example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo install -Dm600 template/linux.yaml /root/.config/nexus/config.yaml
|
sudo install -Dm600 template/linux-config.yaml /root/.config/nexus/config.yaml
|
||||||
sudo editor /root/.config/nexus/config.yaml
|
sudo editor /root/.config/nexus/config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
# nexus-cli
|
||||||
|
|
||||||
|
`nexus-cli` is a small non-interactive CLI for the nexus-sync server API.
|
||||||
|
|
||||||
|
## Install for development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
nexus-cli --help
|
||||||
|
```
|
||||||
|
|
||||||
|
The default server URL is `http://127.0.0.1:5852`. Override it with
|
||||||
|
`--server-url` or `-s`.
|
||||||
|
|
||||||
|
## List clients
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nexus-cli --list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Show client info
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nexus-cli client linux-client
|
||||||
|
```
|
||||||
|
|
||||||
|
The output includes basic client metadata and advertised `available_commands`.
|
||||||
|
|
||||||
|
## Queue a command for a client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nexus-cli client linux-client --run-command hostname
|
||||||
|
nexus-cli client linux-client --run-command hostname --timeout-seconds 30
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "hostname",
|
||||||
|
"args": {},
|
||||||
|
"timeout_seconds": 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Show command execution info
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nexus-cli command cmd_123
|
||||||
|
```
|
||||||
|
|
||||||
|
If the client has reported a result, stdout and stderr are printed.
|
||||||
|
|
||||||
|
## JSON output
|
||||||
|
|
||||||
|
Use `--json` for machine-readable output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nexus-cli --json --list
|
||||||
|
nexus-cli --json client linux-client
|
||||||
|
nexus-cli --json command cmd_123
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build standalone binary
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make cli
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `dist/nexus-cli` through PyInstaller.
|
||||||
@@ -28,6 +28,9 @@ dev = [
|
|||||||
"pre-commit>=4.0"
|
"pre-commit>=4.0"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
nexus-cli = "nexus_sync.cli.__main__:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections.abc import Callable
|
||||||
|
from types import TracebackType
|
||||||
|
from typing import Any, Protocol, Self
|
||||||
|
|
||||||
|
DEFAULT_SERVER_URL = "http://127.0.0.1:5852"
|
||||||
|
API_PREFIX = "/api/v1"
|
||||||
|
JsonObject = dict[str, Any]
|
||||||
|
Requester = Callable[[str, str, JsonObject | None], JsonObject]
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPResponse(Protocol):
|
||||||
|
def __enter__(self) -> Self: ...
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc: BaseException | None,
|
||||||
|
traceback: TracebackType | None,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
def read(self) -> bytes: ...
|
||||||
|
|
||||||
|
|
||||||
|
Opener = Callable[[urllib.request.Request], HTTPResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class CLIError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
payload: JsonObject | None = None,
|
||||||
|
*,
|
||||||
|
opener: Opener = urllib.request.urlopen,
|
||||||
|
) -> JsonObject:
|
||||||
|
data = None
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with opener(request) as response:
|
||||||
|
body = response.read()
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
detail = error.read().decode(errors="replace")
|
||||||
|
raise CLIError(f"HTTP {error.code}: {detail}") from error
|
||||||
|
except urllib.error.URLError as error:
|
||||||
|
raise CLIError(f"request failed: {error.reason}") from error
|
||||||
|
except OSError as error:
|
||||||
|
raise CLIError(f"request failed: {error}") from error
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = json.loads(body.decode())
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise CLIError(f"server returned invalid JSON: {error}") from error
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise CLIError("server returned JSON that is not an object")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def format_clients(payload: JsonObject) -> str:
|
||||||
|
clients = payload.get("clients", [])
|
||||||
|
if not isinstance(clients, list) or not clients:
|
||||||
|
return "clients:\n- none"
|
||||||
|
|
||||||
|
lines = ["clients:"]
|
||||||
|
for item in clients:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
parts = [
|
||||||
|
str(item.get("id", "<unknown>")),
|
||||||
|
str(item.get("platform", "unknown")),
|
||||||
|
str(item.get("hostname", "unknown")),
|
||||||
|
f"version={item.get('version', 'unknown')}",
|
||||||
|
f"last_seen={item.get('last_seen_at', 'unknown')}",
|
||||||
|
]
|
||||||
|
lines.append(f"- {' '.join(parts)}")
|
||||||
|
commands = _command_names(item.get("available_commands", []))
|
||||||
|
if commands:
|
||||||
|
lines.append(f" commands: {', '.join(commands)}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_client(payload: JsonObject) -> str:
|
||||||
|
lines = [
|
||||||
|
f"id: {payload.get('id', '<unknown>')}",
|
||||||
|
f"hostname: {payload.get('hostname', 'unknown')}",
|
||||||
|
f"platform: {payload.get('platform', 'unknown')}",
|
||||||
|
f"version: {payload.get('version', 'unknown')}",
|
||||||
|
f"created_at: {payload.get('created_at', 'unknown')}",
|
||||||
|
f"last_seen_at: {payload.get('last_seen_at', 'unknown')}",
|
||||||
|
"available_commands:",
|
||||||
|
]
|
||||||
|
commands = payload.get("available_commands", [])
|
||||||
|
if not isinstance(commands, list) or not commands:
|
||||||
|
lines.append("- none")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
for command in commands:
|
||||||
|
if isinstance(command, dict):
|
||||||
|
name = command.get("name", "<unknown>")
|
||||||
|
description = command.get("description", "")
|
||||||
|
suffix = f" - {description}" if description else ""
|
||||||
|
lines.append(f"- {name}{suffix}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_command(payload: JsonObject, *, queued: bool = False) -> str:
|
||||||
|
lines = ["queued command:" if queued else "command:"]
|
||||||
|
for key in (
|
||||||
|
"id",
|
||||||
|
"client_id",
|
||||||
|
"kind",
|
||||||
|
"name",
|
||||||
|
"status",
|
||||||
|
"timeout_seconds",
|
||||||
|
"created_at",
|
||||||
|
"delivered_at",
|
||||||
|
"finished_at",
|
||||||
|
):
|
||||||
|
if key in payload:
|
||||||
|
lines.append(f"{key}: {payload.get(key)}")
|
||||||
|
|
||||||
|
result = payload.get("result")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
lines.append("result:")
|
||||||
|
if "status" in result:
|
||||||
|
lines.append(f" status: {result.get('status')}")
|
||||||
|
if "return_code" in result:
|
||||||
|
lines.append(f" return_code: {result.get('return_code')}")
|
||||||
|
lines.append(" stdout:")
|
||||||
|
lines.extend(_indent_block(str(result.get("stdout", ""))))
|
||||||
|
lines.append(" stderr:")
|
||||||
|
lines.extend(_indent_block(str(result.get("stderr", ""))))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(prog="nexus-cli", description="CLI for nexus-sync server API")
|
||||||
|
parser.add_argument(
|
||||||
|
"--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")
|
||||||
|
client = subparsers.add_parser("client", help="show client info or queue a command")
|
||||||
|
client.add_argument("id", help="client id")
|
||||||
|
client.add_argument("--run-command", metavar="NAME", help="queue a command for this client")
|
||||||
|
client.add_argument(
|
||||||
|
"--timeout-seconds",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="command timeout in seconds for --run-command",
|
||||||
|
)
|
||||||
|
|
||||||
|
command = subparsers.add_parser("command", help="show command execution info")
|
||||||
|
command.add_argument("id", help="command id")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
argv: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
requester: Requester = request_json,
|
||||||
|
) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
server_url = str(args.server_url).rstrip("/")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.list:
|
||||||
|
payload = requester("GET", f"{server_url}{API_PREFIX}/server/clients", None)
|
||||||
|
_print_payload(payload, raw_json=args.json, formatter=format_clients)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.resource == "client":
|
||||||
|
client_id = urllib.parse.quote(str(args.id), safe="")
|
||||||
|
if args.run_command:
|
||||||
|
payload = requester(
|
||||||
|
"POST",
|
||||||
|
f"{server_url}{API_PREFIX}/server/clients/{client_id}/commands",
|
||||||
|
{
|
||||||
|
"name": args.run_command,
|
||||||
|
"args": {},
|
||||||
|
"timeout_seconds": args.timeout_seconds,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_print_payload(
|
||||||
|
payload,
|
||||||
|
raw_json=args.json,
|
||||||
|
formatter=lambda value: format_command(value, queued=True),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
payload = requester("GET", f"{server_url}{API_PREFIX}/server/clients/{client_id}", None)
|
||||||
|
_print_payload(payload, raw_json=args.json, formatter=format_client)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.resource == "command":
|
||||||
|
command_id = urllib.parse.quote(str(args.id), safe="")
|
||||||
|
payload = requester(
|
||||||
|
"GET", f"{server_url}{API_PREFIX}/server/commands/{command_id}", None
|
||||||
|
)
|
||||||
|
_print_payload(payload, raw_json=args.json, formatter=format_command)
|
||||||
|
return 0
|
||||||
|
except CLIError as error:
|
||||||
|
print(f"nexus-cli error: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
parser.print_help()
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def _print_payload(
|
||||||
|
payload: JsonObject,
|
||||||
|
*,
|
||||||
|
raw_json: bool,
|
||||||
|
formatter: Callable[[JsonObject], str],
|
||||||
|
) -> None:
|
||||||
|
if raw_json:
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
||||||
|
return
|
||||||
|
print(formatter(payload))
|
||||||
|
|
||||||
|
|
||||||
|
def _command_names(commands: object) -> list[str]:
|
||||||
|
if not isinstance(commands, list):
|
||||||
|
return []
|
||||||
|
names = []
|
||||||
|
for command in commands:
|
||||||
|
if isinstance(command, dict) and command.get("name"):
|
||||||
|
names.append(str(command["name"]))
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _indent_block(value: str) -> list[str]:
|
||||||
|
if not value:
|
||||||
|
return [" <empty>"]
|
||||||
|
return [f" {line}" if line else "" for line in value.rstrip("\n").splitlines()]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nexus_sync.cli.__main__ import (
|
||||||
|
DEFAULT_SERVER_URL,
|
||||||
|
format_client,
|
||||||
|
format_clients,
|
||||||
|
format_command,
|
||||||
|
main,
|
||||||
|
request_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
def __init__(self, payload: dict[str, Any]) -> None:
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def __enter__(self) -> "_Response":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read(self) -> bytes:
|
||||||
|
return json.dumps(self.payload).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_json_sends_get_request() -> None:
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def fake_opener(request: urllib.request.Request) -> _Response:
|
||||||
|
captured["url"] = request.full_url
|
||||||
|
captured["method"] = request.get_method()
|
||||||
|
captured["data"] = request.data
|
||||||
|
return _Response({"clients": []})
|
||||||
|
|
||||||
|
result = request_json("GET", "http://server.test/api/v1/server/clients", opener=fake_opener)
|
||||||
|
|
||||||
|
assert result == {"clients": []}
|
||||||
|
assert captured == {
|
||||||
|
"url": "http://server.test/api/v1/server/clients",
|
||||||
|
"method": "GET",
|
||||||
|
"data": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_json_sends_post_json_request() -> None:
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def fake_opener(request: urllib.request.Request) -> _Response:
|
||||||
|
captured["url"] = request.full_url
|
||||||
|
captured["method"] = request.get_method()
|
||||||
|
captured["content_type"] = request.get_header("Content-type")
|
||||||
|
captured["payload"] = json.loads((request.data or b"").decode())
|
||||||
|
return _Response({"id": "cmd_123", "status": "pending"})
|
||||||
|
|
||||||
|
result = request_json(
|
||||||
|
"POST",
|
||||||
|
"http://server.test/api/v1/server/clients/linux-client/commands",
|
||||||
|
payload={"name": "hostname", "args": {}, "timeout_seconds": 30},
|
||||||
|
opener=fake_opener,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"id": "cmd_123", "status": "pending"}
|
||||||
|
assert captured == {
|
||||||
|
"url": "http://server.test/api/v1/server/clients/linux-client/commands",
|
||||||
|
"method": "POST",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"payload": {"name": "hostname", "args": {}, "timeout_seconds": 30},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_lists_clients_as_text(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
calls: list[tuple[str, str, dict[str, Any] | None]] = []
|
||||||
|
|
||||||
|
def fake_request(
|
||||||
|
method: str, url: str, payload: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
calls.append((method, url, payload))
|
||||||
|
return {
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"id": "linux-client",
|
||||||
|
"hostname": "box",
|
||||||
|
"platform": "linux",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"last_seen_at": "2026-06-24T12:00:00Z",
|
||||||
|
"available_commands": [{"name": "hostname", "description": "Return hostname"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
exit_code = main(["--server-url", "http://server.test", "--list"], requester=fake_request)
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert calls == [("GET", "http://server.test/api/v1/server/clients", None)]
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "clients:" in output
|
||||||
|
assert "linux-client" in output
|
||||||
|
assert "box" in output
|
||||||
|
assert "hostname" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_shows_client_as_json(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
def fake_request(
|
||||||
|
method: str, url: str, payload: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
assert method == "GET"
|
||||||
|
assert url == f"{DEFAULT_SERVER_URL}/api/v1/server/clients/linux-client"
|
||||||
|
assert payload is None
|
||||||
|
return {"id": "linux-client", "hostname": "box", "available_commands": []}
|
||||||
|
|
||||||
|
exit_code = main(["--json", "client", "linux-client"], requester=fake_request)
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert json.loads(capsys.readouterr().out) == {
|
||||||
|
"id": "linux-client",
|
||||||
|
"hostname": "box",
|
||||||
|
"available_commands": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_queues_client_command(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
calls: list[tuple[str, str, dict[str, Any] | None]] = []
|
||||||
|
|
||||||
|
def fake_request(
|
||||||
|
method: str, url: str, payload: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
calls.append((method, url, payload))
|
||||||
|
return {
|
||||||
|
"id": "cmd_123",
|
||||||
|
"client_id": "linux-client",
|
||||||
|
"name": "hostname",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-06-24T12:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--server-url",
|
||||||
|
"http://server.test/",
|
||||||
|
"client",
|
||||||
|
"linux-client",
|
||||||
|
"--run-command",
|
||||||
|
"hostname",
|
||||||
|
"--timeout-seconds",
|
||||||
|
"5",
|
||||||
|
],
|
||||||
|
requester=fake_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert calls == [
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"http://server.test/api/v1/server/clients/linux-client/commands",
|
||||||
|
{"name": "hostname", "args": {}, "timeout_seconds": 5},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "queued command:" in output
|
||||||
|
assert "cmd_123" in output
|
||||||
|
assert "pending" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_shows_command_result(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
def fake_request(
|
||||||
|
method: str, url: str, payload: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
assert method == "GET"
|
||||||
|
assert url == "http://server.test/api/v1/server/commands/cmd_123"
|
||||||
|
assert payload is None
|
||||||
|
return {
|
||||||
|
"id": "cmd_123",
|
||||||
|
"client_id": "linux-client",
|
||||||
|
"name": "hostname",
|
||||||
|
"status": "succeeded",
|
||||||
|
"result": {"return_code": 0, "stdout": "box\n", "stderr": ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
exit_code = main(["-s", "http://server.test", "command", "cmd_123"], requester=fake_request)
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "cmd_123" in output
|
||||||
|
assert "succeeded" in output
|
||||||
|
assert "stdout:" in output
|
||||||
|
assert "box" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_formatters_include_core_fields() -> None:
|
||||||
|
clients_text = format_clients(
|
||||||
|
{
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"id": "linux-client",
|
||||||
|
"hostname": "box",
|
||||||
|
"platform": "linux",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"last_seen_at": "now",
|
||||||
|
"available_commands": [{"name": "hostname", "description": "Return hostname"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
client_text = format_client(
|
||||||
|
{
|
||||||
|
"id": "linux-client",
|
||||||
|
"hostname": "box",
|
||||||
|
"platform": "linux",
|
||||||
|
"available_commands": [{"name": "hostname", "description": "Return hostname"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
command_text = format_command(
|
||||||
|
{
|
||||||
|
"id": "cmd_123",
|
||||||
|
"client_id": "linux-client",
|
||||||
|
"name": "hostname",
|
||||||
|
"status": "succeeded",
|
||||||
|
"result": {"return_code": 0, "stdout": "box\n", "stderr": ""},
|
||||||
|
},
|
||||||
|
queued=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "linux-client" in clients_text
|
||||||
|
assert "hostname" in client_text
|
||||||
|
assert "queued command:" in command_text
|
||||||
|
assert "box" in command_text
|
||||||
Reference in New Issue
Block a user