remote-cmd-manager 2.8.0__tar.gz → 2.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {remote_cmd_manager-2.8.0/remote_cmd_manager.egg-info → remote_cmd_manager-2.9.0}/PKG-INFO +18 -22
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/README.md +17 -21
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/__init__.py +11 -1
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/_version.py +1 -1
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/cli/main.py +291 -33
- remote_cmd_manager-2.9.0/remote_cmd/core/recipe.py +259 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/repository/json_host_repository.py +37 -0
- remote_cmd_manager-2.9.0/remote_cmd/repository/recipe_store.py +43 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/repository/sqlite_host_repository.py +225 -34
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/async_batch_executor.py +7 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/batch_executor.py +24 -4
- remote_cmd_manager-2.9.0/remote_cmd/service/recipe_service.py +113 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0/remote_cmd_manager.egg-info}/PKG-INFO +18 -22
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd_manager.egg-info/SOURCES.txt +6 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_async_batch_executor.py +22 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_batch_executor.py +46 -2
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_cli_profile.py +28 -0
- remote_cmd_manager-2.9.0/tests/test_cli_recipe.py +175 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_profile_merge.py +8 -4
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_profile_service.py +21 -0
- remote_cmd_manager-2.9.0/tests/test_recipe.py +231 -0
- remote_cmd_manager-2.9.0/tests/test_recipe_service.py +134 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_release_metadata.py +12 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_sqlite_repository.py +181 -2
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/LICENSE +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/MANIFEST.in +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/config.example.yaml +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/examples/basic_usage.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/examples/deploy_script.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/examples/nginx_batch_update.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/examples/system_health_check.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/pyproject.toml +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/api/__init__.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/api/host_manager.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/cli/__init__.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/cli/formatters/__init__.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/cli/formatters/base.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/cli/formatters/json.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/cli/formatters/table.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/__init__.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/async_connection_pool.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/async_ssh_client.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/budget.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/host.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/host_manager.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/pool_policy.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/profile.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/ssh_client.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/core/sync_connection_pool.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/repository/__init__.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/repository/host_repository.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/repository/profile_store.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/__init__.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/_host_runner.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/_pool_policy.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/_types.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/credential_provider.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/host_service.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/profile_service.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/retry_policy.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/ssh_service.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/storage_factory.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/service/task_runner.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/utils/__init__.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/utils/config.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/utils/credential_guard.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/utils/crypto.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/utils/exceptions.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd/utils/logging_utils.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd_manager.egg-info/requires.txt +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/remote_cmd_manager.egg-info/top_level.txt +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/requirements.txt +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/setup.cfg +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/__init__.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/conftest.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/integration/conftest.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/integration/test_ssh_connection.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/performance/__init__.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/performance/conftest.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/performance/test_benchmarks.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/performance/test_paramiko_threads.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_async_ssh_client.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_cli.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_config.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_connection_budget.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_credential_provider.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_crypto.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_exceptions.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_formatters.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_host.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_host_manager.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_host_service.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_keyring_provider.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_logging_utils.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_output_policy.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_package_import.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_pool_policy.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_profile.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_repository.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_retry_policy.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_ssh_client.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_ssh_service.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_storage_factory.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_sync_connection_pool.py +0 -0
- {remote_cmd_manager-2.8.0 → remote_cmd_manager-2.9.0}/tests/test_task_runner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remote_cmd_manager
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.9.0
|
|
4
4
|
Summary: Cross-platform SSH remote server management tool (Linux/macOS/Windows) — lightweight, zero-configuration CLI and Python API for managing servers over SSH. Supports host CRUD, batch commands, file transfer, credential encryption, async execution, and more.
|
|
5
5
|
Author-email: Vae-Scrooge <vaescrooge@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -89,37 +89,32 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
|
|
|
89
89
|
|
|
90
90
|
---
|
|
91
91
|
|
|
92
|
-
## v2.
|
|
92
|
+
## v2.9.0 Release Highlights
|
|
93
93
|
|
|
94
|
-
v2.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
[full migration notes](./CHANGELOG.md).
|
|
94
|
+
v2.9.0 adds the **secure Recipe engine** and makes **SQLite profile deletion
|
|
95
|
+
atomic**. Public APIs remain backward compatible; existing stores upgrade in
|
|
96
|
+
place. See the [full migration notes](./CHANGELOG.md).
|
|
98
97
|
|
|
99
|
-
###
|
|
98
|
+
### Secure Recipes
|
|
100
99
|
|
|
101
|
-
- New `
|
|
102
|
-
-
|
|
103
|
-
-
|
|
104
|
-
- `ProfileService` CRUD + `remote-cmd profile add/list/show/remove`, with deletion protection for profiles still referenced by hosts.
|
|
100
|
+
- New `Recipe` / `RecipeVariable` (exported from `remote_cmd`): parameterized command templates with **typed variables** — `shell_arg` values are `shlex.quote`d, `env` values are exported as remote environment variables. Raw string interpolation is intentionally unsupported.
|
|
101
|
+
- Placeholders must be declared; undeclared placeholders fail at creation, and missing/unknown values fail before any connection is made. Rendering is single-pass, so `{{ ... }}` inside values stays literal.
|
|
102
|
+
- Recipes persist through the optional `RecipeStore` capability (JSON `recipes` section / SQLite `recipes` table) and run via `remote-cmd recipe run ...` or `RecipeService` + the batch executors.
|
|
105
103
|
|
|
106
|
-
###
|
|
104
|
+
### Atomic Profile Deletion
|
|
107
105
|
|
|
108
|
-
-
|
|
109
|
-
- JSON uses a stable schema with sorted result keys; table output is plain aligned text. Machine formats suppress the progress bar/header so stdout stays parseable.
|
|
110
|
-
- The formatter layer lives in `remote_cmd/cli/formatters/` and never touches the execution kernels.
|
|
106
|
+
- SQLite `hosts.profile` now carries `FOREIGN KEY ... ON DELETE RESTRICT` (`DB_VERSION` 2 → 3); existing databases are rebuilt automatically on open with data preserved. The service-level reference check stays as a friendly fast path.
|
|
111
107
|
|
|
112
108
|
### Try It
|
|
113
109
|
|
|
114
110
|
```bash
|
|
115
|
-
remote-cmd
|
|
116
|
-
remote-cmd
|
|
117
|
-
remote-cmd batch-run web-01 web-02 "uptime" --format json | jq .
|
|
111
|
+
remote-cmd recipe add deploy --command 'deploy {{ package }}' -V package=app
|
|
112
|
+
remote-cmd recipe run deploy web-01 web-02 -V package=api-2026.09 --format json | jq .
|
|
118
113
|
```
|
|
119
114
|
|
|
120
115
|
## Table of Contents
|
|
121
116
|
|
|
122
|
-
- [v2.
|
|
117
|
+
- [v2.9.0 Release Highlights](#v290-release-highlights)
|
|
123
118
|
- [Why Remote CMD?](#why-remote-cmd)
|
|
124
119
|
- [Quick Start](#quick-start)
|
|
125
120
|
- [Use Cases](#use-cases)
|
|
@@ -267,6 +262,7 @@ with SSHClient(config) as client:
|
|
|
267
262
|
| **File Transfer** | Upload/download via SFTP (`remote-cmd upload/download`) |
|
|
268
263
|
| **Host Management** | CRUD with pluggable JSON or **SQLite** persistence |
|
|
269
264
|
| **Connection Profiles** | Reusable username/port/key/tag defaults referenced by hosts (`ProfileService`, `--profile`); never stores credentials |
|
|
265
|
+
| **Secure Recipes** | Typed command templates (`shell_arg` auto-quoted / `env` exported), stored in the repository and run across hosts; no raw interpolation |
|
|
270
266
|
| **Output Formats** | `--format rich\|json\|table` for `run` / `batch-run` (stable JSON schema, parseable stdout) |
|
|
271
267
|
| **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`); profile tags participate in filtering |
|
|
272
268
|
| **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
|
|
@@ -345,9 +341,9 @@ are simply not exported.
|
|
|
345
341
|
- [x] Async SSH operations (parallel execution) — v1.1.0
|
|
346
342
|
- [x] Pluggable storage backends (JSON + SQLite) — v1.2.x
|
|
347
343
|
- [x] Chainable credential providers + at-rest encryption — v1.2.x
|
|
348
|
-
- [
|
|
349
|
-
- [
|
|
350
|
-
- [
|
|
344
|
+
- [x] Configuration profiles (AWS, GCP, custom) — v2.8.0
|
|
345
|
+
- [x] Output formatting (JSON, table) — v2.8.0
|
|
346
|
+
- [x] Templated command recipes (typed variables, shell-quoted) — v2.9.0
|
|
351
347
|
|
|
352
348
|
Good first issues are labelled `good first issue` in the
|
|
353
349
|
[issue tracker](https://github.com/Vae-Scrooge/remote-cmd/issues) — contributions welcome.
|
|
@@ -36,37 +36,32 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
|
|
|
36
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
39
|
-
## v2.
|
|
39
|
+
## v2.9.0 Release Highlights
|
|
40
40
|
|
|
41
|
-
v2.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
[full migration notes](./CHANGELOG.md).
|
|
41
|
+
v2.9.0 adds the **secure Recipe engine** and makes **SQLite profile deletion
|
|
42
|
+
atomic**. Public APIs remain backward compatible; existing stores upgrade in
|
|
43
|
+
place. See the [full migration notes](./CHANGELOG.md).
|
|
45
44
|
|
|
46
|
-
###
|
|
45
|
+
### Secure Recipes
|
|
47
46
|
|
|
48
|
-
- New `
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
- `ProfileService` CRUD + `remote-cmd profile add/list/show/remove`, with deletion protection for profiles still referenced by hosts.
|
|
47
|
+
- New `Recipe` / `RecipeVariable` (exported from `remote_cmd`): parameterized command templates with **typed variables** — `shell_arg` values are `shlex.quote`d, `env` values are exported as remote environment variables. Raw string interpolation is intentionally unsupported.
|
|
48
|
+
- Placeholders must be declared; undeclared placeholders fail at creation, and missing/unknown values fail before any connection is made. Rendering is single-pass, so `{{ ... }}` inside values stays literal.
|
|
49
|
+
- Recipes persist through the optional `RecipeStore` capability (JSON `recipes` section / SQLite `recipes` table) and run via `remote-cmd recipe run ...` or `RecipeService` + the batch executors.
|
|
52
50
|
|
|
53
|
-
###
|
|
51
|
+
### Atomic Profile Deletion
|
|
54
52
|
|
|
55
|
-
-
|
|
56
|
-
- JSON uses a stable schema with sorted result keys; table output is plain aligned text. Machine formats suppress the progress bar/header so stdout stays parseable.
|
|
57
|
-
- The formatter layer lives in `remote_cmd/cli/formatters/` and never touches the execution kernels.
|
|
53
|
+
- SQLite `hosts.profile` now carries `FOREIGN KEY ... ON DELETE RESTRICT` (`DB_VERSION` 2 → 3); existing databases are rebuilt automatically on open with data preserved. The service-level reference check stays as a friendly fast path.
|
|
58
54
|
|
|
59
55
|
### Try It
|
|
60
56
|
|
|
61
57
|
```bash
|
|
62
|
-
remote-cmd
|
|
63
|
-
remote-cmd
|
|
64
|
-
remote-cmd batch-run web-01 web-02 "uptime" --format json | jq .
|
|
58
|
+
remote-cmd recipe add deploy --command 'deploy {{ package }}' -V package=app
|
|
59
|
+
remote-cmd recipe run deploy web-01 web-02 -V package=api-2026.09 --format json | jq .
|
|
65
60
|
```
|
|
66
61
|
|
|
67
62
|
## Table of Contents
|
|
68
63
|
|
|
69
|
-
- [v2.
|
|
64
|
+
- [v2.9.0 Release Highlights](#v290-release-highlights)
|
|
70
65
|
- [Why Remote CMD?](#why-remote-cmd)
|
|
71
66
|
- [Quick Start](#quick-start)
|
|
72
67
|
- [Use Cases](#use-cases)
|
|
@@ -214,6 +209,7 @@ with SSHClient(config) as client:
|
|
|
214
209
|
| **File Transfer** | Upload/download via SFTP (`remote-cmd upload/download`) |
|
|
215
210
|
| **Host Management** | CRUD with pluggable JSON or **SQLite** persistence |
|
|
216
211
|
| **Connection Profiles** | Reusable username/port/key/tag defaults referenced by hosts (`ProfileService`, `--profile`); never stores credentials |
|
|
212
|
+
| **Secure Recipes** | Typed command templates (`shell_arg` auto-quoted / `env` exported), stored in the repository and run across hosts; no raw interpolation |
|
|
217
213
|
| **Output Formats** | `--format rich\|json\|table` for `run` / `batch-run` (stable JSON schema, parseable stdout) |
|
|
218
214
|
| **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`); profile tags participate in filtering |
|
|
219
215
|
| **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
|
|
@@ -292,9 +288,9 @@ are simply not exported.
|
|
|
292
288
|
- [x] Async SSH operations (parallel execution) — v1.1.0
|
|
293
289
|
- [x] Pluggable storage backends (JSON + SQLite) — v1.2.x
|
|
294
290
|
- [x] Chainable credential providers + at-rest encryption — v1.2.x
|
|
295
|
-
- [
|
|
296
|
-
- [
|
|
297
|
-
- [
|
|
291
|
+
- [x] Configuration profiles (AWS, GCP, custom) — v2.8.0
|
|
292
|
+
- [x] Output formatting (JSON, table) — v2.8.0
|
|
293
|
+
- [x] Templated command recipes (typed variables, shell-quoted) — v2.9.0
|
|
298
294
|
|
|
299
295
|
Good first issues are labelled `good first issue` in the
|
|
300
296
|
[issue tracker](https://github.com/Vae-Scrooge/remote-cmd/issues) — contributions welcome.
|
|
@@ -48,7 +48,7 @@ Remote CMD - SSH 远程服务器管理工具
|
|
|
48
48
|
- 文档: 参见 docs/ 目录
|
|
49
49
|
|
|
50
50
|
Author: Vae-Scrooge
|
|
51
|
-
Version: 2.
|
|
51
|
+
Version: 2.9.0(单一真相源见 remote_cmd._version)
|
|
52
52
|
License: MIT
|
|
53
53
|
"""
|
|
54
54
|
|
|
@@ -79,6 +79,7 @@ from remote_cmd.api.host_manager import HostManager
|
|
|
79
79
|
from remote_cmd.core.budget import ConnectionBudget
|
|
80
80
|
from remote_cmd.core.host import Host
|
|
81
81
|
from remote_cmd.core.profile import HostProfile
|
|
82
|
+
from remote_cmd.core.recipe import Recipe, RecipeVariable, RenderedRecipe
|
|
82
83
|
from remote_cmd.core.ssh_client import SSHClient
|
|
83
84
|
from remote_cmd.core.sync_connection_pool import SyncConnectionPool
|
|
84
85
|
|
|
@@ -107,6 +108,7 @@ from remote_cmd.service.batch_executor import (
|
|
|
107
108
|
)
|
|
108
109
|
from remote_cmd.service.credential_provider import KeyringCredentialProvider
|
|
109
110
|
from remote_cmd.service.profile_service import ProfileService
|
|
111
|
+
from remote_cmd.service.recipe_service import RecipeService
|
|
110
112
|
from remote_cmd.service.task_runner import Task, TaskRunner, TaskStatus
|
|
111
113
|
from remote_cmd.utils.crypto import CredentialEncryption
|
|
112
114
|
from remote_cmd.utils.exceptions import BudgetTimeoutError, PlaintextCredentialWarning
|
|
@@ -127,12 +129,16 @@ if _HAS_ASYNC:
|
|
|
127
129
|
"AsyncBatchExecutor",
|
|
128
130
|
"Host",
|
|
129
131
|
"HostProfile",
|
|
132
|
+
"Recipe",
|
|
133
|
+
"RecipeVariable",
|
|
134
|
+
"RenderedRecipe",
|
|
130
135
|
"HostManager",
|
|
131
136
|
# 新架构导出
|
|
132
137
|
"HostRepository",
|
|
133
138
|
"JsonHostRepository",
|
|
134
139
|
"HostService",
|
|
135
140
|
"ProfileService",
|
|
141
|
+
"RecipeService",
|
|
136
142
|
"SSHService",
|
|
137
143
|
"CredentialProvider",
|
|
138
144
|
"EnvCredentialProvider",
|
|
@@ -166,12 +172,16 @@ else:
|
|
|
166
172
|
"SSHClient",
|
|
167
173
|
"Host",
|
|
168
174
|
"HostProfile",
|
|
175
|
+
"Recipe",
|
|
176
|
+
"RecipeVariable",
|
|
177
|
+
"RenderedRecipe",
|
|
169
178
|
"HostManager",
|
|
170
179
|
# 新架构导出
|
|
171
180
|
"HostRepository",
|
|
172
181
|
"JsonHostRepository",
|
|
173
182
|
"HostService",
|
|
174
183
|
"ProfileService",
|
|
184
|
+
"RecipeService",
|
|
175
185
|
"SSHService",
|
|
176
186
|
"CredentialProvider",
|
|
177
187
|
"EnvCredentialProvider",
|
|
@@ -30,7 +30,9 @@ from remote_cmd.cli.formatters import format_batch_result, format_single_result
|
|
|
30
30
|
from remote_cmd.cli.formatters.base import FORMAT_CHOICES
|
|
31
31
|
from remote_cmd.core.host import Host
|
|
32
32
|
from remote_cmd.core.profile import HostProfile
|
|
33
|
+
from remote_cmd.core.recipe import Recipe, RecipeVariable
|
|
33
34
|
from remote_cmd.repository.profile_store import ProfileStore
|
|
35
|
+
from remote_cmd.repository.recipe_store import RecipeStore
|
|
34
36
|
from remote_cmd.service.batch_executor import BatchExecutor
|
|
35
37
|
from remote_cmd.service.credential_provider import (
|
|
36
38
|
ChainCredentialProvider,
|
|
@@ -39,6 +41,7 @@ from remote_cmd.service.credential_provider import (
|
|
|
39
41
|
)
|
|
40
42
|
from remote_cmd.service.host_service import HostService
|
|
41
43
|
from remote_cmd.service.profile_service import ProfileService
|
|
44
|
+
from remote_cmd.service.recipe_service import RecipeService
|
|
42
45
|
from remote_cmd.service.storage_factory import build_repository
|
|
43
46
|
from remote_cmd.utils.config import get_default_config_path, load_config
|
|
44
47
|
from remote_cmd.utils.crypto import CredentialEncryption
|
|
@@ -47,7 +50,7 @@ from remote_cmd.utils.exceptions import ValidationError
|
|
|
47
50
|
|
|
48
51
|
def _build_service(
|
|
49
52
|
config_file: str, storage_backend: Optional[str] = None
|
|
50
|
-
) -> tuple[HostService, ProfileService]:
|
|
53
|
+
) -> tuple[HostService, ProfileService, RecipeService]:
|
|
51
54
|
"""Build a HostService and ProfileService from a config file.
|
|
52
55
|
|
|
53
56
|
Credential chain order: env var -> encrypted file storage.
|
|
@@ -75,10 +78,13 @@ def _build_service(
|
|
|
75
78
|
]
|
|
76
79
|
)
|
|
77
80
|
service = HostService(repository=repo, credential_provider=cred_provider)
|
|
78
|
-
# 内置仓库均实现 ProfileStore(v2.8);
|
|
81
|
+
# 内置仓库均实现 ProfileStore(v2.8)/ RecipeStore(v2.9);
|
|
82
|
+
# assert 同时用于类型收窄
|
|
79
83
|
assert isinstance(repo, ProfileStore)
|
|
84
|
+
assert isinstance(repo, RecipeStore)
|
|
80
85
|
profile_service = ProfileService(store=repo, host_repository=repo)
|
|
81
|
-
|
|
86
|
+
recipe_service = RecipeService(store=repo)
|
|
87
|
+
return service, profile_service, recipe_service
|
|
82
88
|
|
|
83
89
|
|
|
84
90
|
@click.group()
|
|
@@ -129,9 +135,10 @@ def cli(
|
|
|
129
135
|
# (扩展名推断覆盖 .json/.db/.sqlite 常见场景)
|
|
130
136
|
hosts_file = hosts_file_override or ctx.obj["config"].get("hosts_file", "hosts.json")
|
|
131
137
|
storage_backend = ctx.obj["config"].get("storage_backend")
|
|
132
|
-
service, profile_service = _build_service(hosts_file, storage_backend)
|
|
138
|
+
service, profile_service, recipe_service = _build_service(hosts_file, storage_backend)
|
|
133
139
|
ctx.obj["service"] = service
|
|
134
140
|
ctx.obj["profile_service"] = profile_service
|
|
141
|
+
ctx.obj["recipe_service"] = recipe_service
|
|
135
142
|
|
|
136
143
|
if verbose:
|
|
137
144
|
click.echo(f"Using config file: {config_path}")
|
|
@@ -478,6 +485,233 @@ def profile_remove(ctx: click.Context, name: str, force: bool) -> None:
|
|
|
478
485
|
ctx.exit(1)
|
|
479
486
|
|
|
480
487
|
|
|
488
|
+
@cli.group()
|
|
489
|
+
def recipe() -> None:
|
|
490
|
+
"""
|
|
491
|
+
Recipe management and safe execution (v2.9)
|
|
492
|
+
|
|
493
|
+
Recipes are parameterized command templates with **typed** variables:
|
|
494
|
+
``shell_arg`` values are ``shlex.quote``-escaped before substitution and
|
|
495
|
+
``env`` values are exported as remote environment variables. Raw string
|
|
496
|
+
interpolation is intentionally not supported.
|
|
497
|
+
|
|
498
|
+
Commands:
|
|
499
|
+
add Create a recipe
|
|
500
|
+
list List recipes
|
|
501
|
+
show Show recipe details
|
|
502
|
+
remove Remove a recipe
|
|
503
|
+
run Render and execute a recipe across hosts
|
|
504
|
+
"""
|
|
505
|
+
pass
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
@recipe.command("add")
|
|
509
|
+
@click.argument("name", required=True)
|
|
510
|
+
@click.option(
|
|
511
|
+
"--command",
|
|
512
|
+
"-c",
|
|
513
|
+
"command_template",
|
|
514
|
+
required=True,
|
|
515
|
+
help="Command template containing {{ var }} placeholders",
|
|
516
|
+
)
|
|
517
|
+
@click.option(
|
|
518
|
+
"--var",
|
|
519
|
+
"-V",
|
|
520
|
+
"shell_vars",
|
|
521
|
+
multiple=True,
|
|
522
|
+
help="shell_arg variable declaration: NAME or NAME=DEFAULT (repeatable)",
|
|
523
|
+
)
|
|
524
|
+
@click.option(
|
|
525
|
+
"--env",
|
|
526
|
+
"-E",
|
|
527
|
+
"env_vars",
|
|
528
|
+
multiple=True,
|
|
529
|
+
help="env variable declaration: NAME or NAME=DEFAULT (repeatable)",
|
|
530
|
+
)
|
|
531
|
+
@click.option("--description", "-d", default="", help="Description")
|
|
532
|
+
@click.option("--tag", "-t", multiple=True, help="Tag (may be repeated)")
|
|
533
|
+
@click.pass_context
|
|
534
|
+
def recipe_add(
|
|
535
|
+
ctx: click.Context,
|
|
536
|
+
name: str,
|
|
537
|
+
command_template: str,
|
|
538
|
+
shell_vars: tuple,
|
|
539
|
+
env_vars: tuple,
|
|
540
|
+
description: str,
|
|
541
|
+
tag: tuple,
|
|
542
|
+
) -> None:
|
|
543
|
+
"""Create a recipe (typed variables; no raw interpolation)."""
|
|
544
|
+
recipes: RecipeService = ctx.obj["recipe_service"]
|
|
545
|
+
try:
|
|
546
|
+
shell_variables = _parse_var_declarations(shell_vars, "shell_arg")
|
|
547
|
+
env_variables = _parse_var_declarations(env_vars, "env")
|
|
548
|
+
|
|
549
|
+
# 同名变量不得跨类型声明(-V TOKEN -E TOKEN):静默覆盖会改变
|
|
550
|
+
# 变量的安全语义(内联转义 vs 环境导出),必须显式拒绝。
|
|
551
|
+
overlap = shell_variables.keys() & env_variables.keys()
|
|
552
|
+
if overlap:
|
|
553
|
+
raise ValidationError(
|
|
554
|
+
"variable(s) declared as both shell_arg and env: " + ", ".join(sorted(overlap))
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
variables = {**shell_variables, **env_variables}
|
|
558
|
+
recipes.add_recipe(
|
|
559
|
+
Recipe(
|
|
560
|
+
name=name,
|
|
561
|
+
command=command_template,
|
|
562
|
+
variables=variables,
|
|
563
|
+
description=description,
|
|
564
|
+
tags=list(tag),
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
click.echo(f"✓ Recipe '{name}' added successfully")
|
|
568
|
+
except (Exit, click.Abort):
|
|
569
|
+
raise
|
|
570
|
+
except (ValueError, ValidationError) as e:
|
|
571
|
+
click.echo(f"✗ Error: {e}", err=True)
|
|
572
|
+
ctx.exit(1)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@recipe.command("list")
|
|
576
|
+
@click.pass_context
|
|
577
|
+
def recipe_list(ctx: click.Context) -> None:
|
|
578
|
+
"""List all recipes."""
|
|
579
|
+
recipes: RecipeService = ctx.obj["recipe_service"]
|
|
580
|
+
items = recipes.list_recipes()
|
|
581
|
+
if not items:
|
|
582
|
+
click.echo("No recipes.")
|
|
583
|
+
return
|
|
584
|
+
click.echo(f"{'NAME':<24} {'VARIABLES':<32} TAGS")
|
|
585
|
+
for r in items:
|
|
586
|
+
var_desc = ", ".join(
|
|
587
|
+
f"{v.name}:{v.type}" for v in r.variables.values()
|
|
588
|
+
)
|
|
589
|
+
click.echo(f"{r.name:<24} {var_desc:<32} {','.join(r.tags)}")
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@recipe.command("show")
|
|
593
|
+
@click.argument("name", required=True)
|
|
594
|
+
@click.pass_context
|
|
595
|
+
def recipe_show(ctx: click.Context, name: str) -> None:
|
|
596
|
+
"""Show one recipe's details."""
|
|
597
|
+
recipes: RecipeService = ctx.obj["recipe_service"]
|
|
598
|
+
try:
|
|
599
|
+
r = recipes.get_recipe(name)
|
|
600
|
+
except KeyError:
|
|
601
|
+
click.echo(f"✗ Error: recipe '{name}' not found", err=True)
|
|
602
|
+
ctx.exit(1)
|
|
603
|
+
click.echo(f"Name: {r.name}")
|
|
604
|
+
click.echo(f"Command: {r.command}")
|
|
605
|
+
click.echo(f"Description: {r.description or '-'}")
|
|
606
|
+
click.echo(f"Tags: {', '.join(r.tags) if r.tags else '-'}")
|
|
607
|
+
if r.variables:
|
|
608
|
+
click.echo("Variables:")
|
|
609
|
+
for v in r.variables.values():
|
|
610
|
+
default = f", default={v.default!r}" if v.default is not None else ""
|
|
611
|
+
required = "required" if v.required and v.default is None else "optional"
|
|
612
|
+
click.echo(f" - {v.name} ({v.type}, {required}{default})")
|
|
613
|
+
else:
|
|
614
|
+
click.echo("Variables: -")
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@recipe.command("remove")
|
|
618
|
+
@click.argument("name", required=True)
|
|
619
|
+
@click.confirmation_option(prompt="Are you sure you want to remove this recipe?")
|
|
620
|
+
@click.pass_context
|
|
621
|
+
def recipe_remove(ctx: click.Context, name: str) -> None:
|
|
622
|
+
"""Remove a recipe."""
|
|
623
|
+
recipes: RecipeService = ctx.obj["recipe_service"]
|
|
624
|
+
try:
|
|
625
|
+
recipes.remove_recipe(name)
|
|
626
|
+
click.echo(f"✓ Recipe '{name}' removed")
|
|
627
|
+
except KeyError:
|
|
628
|
+
click.echo(f"✗ Error: recipe '{name}' not found", err=True)
|
|
629
|
+
ctx.exit(1)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
@recipe.command("run")
|
|
633
|
+
@click.argument("name", required=True)
|
|
634
|
+
@click.argument("host_names", nargs=-1, required=True)
|
|
635
|
+
@click.option(
|
|
636
|
+
"--var",
|
|
637
|
+
"-V",
|
|
638
|
+
"var_values",
|
|
639
|
+
multiple=True,
|
|
640
|
+
help="Variable value NAME=VALUE (repeatable; type comes from the recipe)",
|
|
641
|
+
)
|
|
642
|
+
@click.option("--concurrency", "-C", default=10, help="Max concurrency (default: 10)")
|
|
643
|
+
@click.option("--timeout", "-T", default=30, help="Command timeout in seconds (default: 30)")
|
|
644
|
+
@click.option("--retry", "-r", default=0, help="Failure retry count (default: 0)")
|
|
645
|
+
@click.option("--async", "use_async", is_flag=True, help="Use async execution engine")
|
|
646
|
+
@click.option("--show-failures", is_flag=True, help="Show only failed hosts")
|
|
647
|
+
@click.option(
|
|
648
|
+
"--format",
|
|
649
|
+
"output_format",
|
|
650
|
+
type=click.Choice(FORMAT_CHOICES),
|
|
651
|
+
default="rich",
|
|
652
|
+
help="Output format: rich (default) or json/table for scripting",
|
|
653
|
+
)
|
|
654
|
+
@click.pass_context
|
|
655
|
+
def recipe_run(
|
|
656
|
+
ctx: click.Context,
|
|
657
|
+
name: str,
|
|
658
|
+
host_names: tuple,
|
|
659
|
+
var_values: tuple,
|
|
660
|
+
concurrency: int,
|
|
661
|
+
timeout: int,
|
|
662
|
+
retry: int,
|
|
663
|
+
use_async: bool,
|
|
664
|
+
show_failures: bool,
|
|
665
|
+
output_format: str,
|
|
666
|
+
) -> None:
|
|
667
|
+
"""Render a recipe safely and run it across hosts.
|
|
668
|
+
|
|
669
|
+
Rendering is type-aware: shell_arg values are shell-quoted, env values
|
|
670
|
+
are exported as environment variables. Missing required variables and
|
|
671
|
+
unknown variables fail before any connection is made.
|
|
672
|
+
"""
|
|
673
|
+
recipes: RecipeService = ctx.obj["recipe_service"]
|
|
674
|
+
service: HostService = ctx.obj["service"]
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
values = _parse_var_values(var_values)
|
|
678
|
+
rendered = recipes.render(name, values)
|
|
679
|
+
except KeyError:
|
|
680
|
+
click.echo(f"✗ Error: recipe '{name}' not found", err=True)
|
|
681
|
+
ctx.exit(1)
|
|
682
|
+
except (ValueError, ValidationError) as e:
|
|
683
|
+
click.echo(f"✗ Error: {e}", err=True)
|
|
684
|
+
ctx.exit(1)
|
|
685
|
+
|
|
686
|
+
if output_format == "rich":
|
|
687
|
+
click.echo(
|
|
688
|
+
f"Recipe '{name}' on {len(host_names)} hosts, "
|
|
689
|
+
f"command={rendered.command!r}"
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
executor = BatchExecutor(
|
|
693
|
+
host_service=service,
|
|
694
|
+
max_concurrency=concurrency,
|
|
695
|
+
command_timeout=timeout,
|
|
696
|
+
use_async=use_async,
|
|
697
|
+
)
|
|
698
|
+
result = executor.execute(
|
|
699
|
+
host_names=list(host_names),
|
|
700
|
+
command=rendered.command,
|
|
701
|
+
retry_count=retry,
|
|
702
|
+
retry_delay=1.0,
|
|
703
|
+
environment=rendered.environment or None,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
if output_format != "rich":
|
|
707
|
+
click.echo(format_batch_result(result, output_format, show_failures=show_failures))
|
|
708
|
+
else:
|
|
709
|
+
_echo_batch_rich(result, show_failures)
|
|
710
|
+
|
|
711
|
+
if result.failed > 0:
|
|
712
|
+
ctx.exit(1)
|
|
713
|
+
|
|
714
|
+
|
|
481
715
|
@cli.command()
|
|
482
716
|
@click.argument("host_name", required=True)
|
|
483
717
|
@click.argument("command", required=True)
|
|
@@ -590,6 +824,58 @@ def download(ctx: click.Context, host_name: str, local_path: str, remote_path: s
|
|
|
590
824
|
ctx.exit(1)
|
|
591
825
|
|
|
592
826
|
|
|
827
|
+
def _echo_batch_rich(result: Any, show_failures: bool) -> None:
|
|
828
|
+
"""rich(默认)批量结果渲染:摘要 + 失败/成功主机列表。"""
|
|
829
|
+
click.echo()
|
|
830
|
+
click.echo("=" * 50)
|
|
831
|
+
click.echo(" Batch result summary")
|
|
832
|
+
click.echo("=" * 50)
|
|
833
|
+
click.echo(f" Total: {result.total}")
|
|
834
|
+
click.echo(f" Succeeded: {result.success}")
|
|
835
|
+
click.echo(f" Failed: {result.failed}")
|
|
836
|
+
click.echo(f" Duration: {result.duration:.1f}s")
|
|
837
|
+
click.echo(f" Success: {result.success_rate:.1%}")
|
|
838
|
+
click.echo("=" * 50)
|
|
839
|
+
|
|
840
|
+
if result.failed_hosts:
|
|
841
|
+
click.echo()
|
|
842
|
+
click.echo(click.style("Failed hosts:", fg="red"))
|
|
843
|
+
for host in result.failed_hosts:
|
|
844
|
+
host_result = result.results[host]
|
|
845
|
+
error_msg = host_result.error or f"exit_code={host_result.exit_code}"
|
|
846
|
+
click.echo(click.style(f" ✗ {host}: {error_msg}", fg="red"))
|
|
847
|
+
|
|
848
|
+
if not show_failures and result.success_hosts:
|
|
849
|
+
click.echo()
|
|
850
|
+
click.echo(click.style("Successful hosts:", fg="green"))
|
|
851
|
+
for host in result.success_hosts:
|
|
852
|
+
click.echo(click.style(f" ✓ {host}", fg="green"))
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _parse_var_declarations(items: tuple, var_type: str) -> dict:
|
|
856
|
+
"""解析 CLI 变量声明(NAME 或 NAME=DEFAULT)。"""
|
|
857
|
+
variables: dict = {}
|
|
858
|
+
for item in items:
|
|
859
|
+
name, sep, default = item.partition("=")
|
|
860
|
+
if not name:
|
|
861
|
+
raise ValidationError(f"invalid variable declaration: {item!r}")
|
|
862
|
+
variables[name] = RecipeVariable(
|
|
863
|
+
name=name, type=var_type, default=default if sep else None
|
|
864
|
+
)
|
|
865
|
+
return variables
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _parse_var_values(items: tuple) -> dict:
|
|
869
|
+
"""解析 CLI 变量取值(必须 NAME=VALUE)。"""
|
|
870
|
+
values: dict = {}
|
|
871
|
+
for item in items:
|
|
872
|
+
name, sep, value = item.partition("=")
|
|
873
|
+
if not sep or not name:
|
|
874
|
+
raise ValidationError(f"expected NAME=VALUE, got: {item!r}")
|
|
875
|
+
values[name] = value
|
|
876
|
+
return values
|
|
877
|
+
|
|
878
|
+
|
|
593
879
|
@cli.command()
|
|
594
880
|
@click.argument("host_names", nargs=-1, required=True)
|
|
595
881
|
@click.argument("command", required=True)
|
|
@@ -683,35 +969,7 @@ def batch_run(
|
|
|
683
969
|
progress_callback=progress,
|
|
684
970
|
)
|
|
685
971
|
|
|
686
|
-
|
|
687
|
-
click.echo("=" * 50)
|
|
688
|
-
click.echo(" Batch result summary")
|
|
689
|
-
click.echo("=" * 50)
|
|
690
|
-
click.echo(f" Total: {result.total}")
|
|
691
|
-
click.echo(f" Succeeded: {result.success}")
|
|
692
|
-
click.echo(f" Failed: {result.failed}")
|
|
693
|
-
click.echo(f" Duration: {result.duration:.1f}s")
|
|
694
|
-
click.echo(f" Success: {result.success_rate:.1%}")
|
|
695
|
-
click.echo("=" * 50)
|
|
696
|
-
|
|
697
|
-
if result.failed_hosts:
|
|
698
|
-
click.echo()
|
|
699
|
-
click.echo(click.style("Failed hosts:", fg="red"))
|
|
700
|
-
for host in result.failed_hosts:
|
|
701
|
-
host_result = result.results[host]
|
|
702
|
-
error_msg = host_result.error or f"exit_code={host_result.exit_code}"
|
|
703
|
-
click.echo(
|
|
704
|
-
click.style(
|
|
705
|
-
f" ✗ {host}: {error_msg}",
|
|
706
|
-
fg="red",
|
|
707
|
-
)
|
|
708
|
-
)
|
|
709
|
-
|
|
710
|
-
if not show_failures and result.success_hosts:
|
|
711
|
-
click.echo()
|
|
712
|
-
click.echo(click.style("Successful hosts:", fg="green"))
|
|
713
|
-
for host in result.success_hosts:
|
|
714
|
-
click.echo(click.style(f" ✓ {host}", fg="green"))
|
|
972
|
+
_echo_batch_rich(result, show_failures)
|
|
715
973
|
|
|
716
974
|
if result.failed > 0:
|
|
717
975
|
ctx.exit(1)
|