cyclo-manager 1.1.0.dev2__tar.gz → 1.1.0.dev4__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.
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/PKG-INFO +53 -8
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/README.md +52 -7
- cyclo_manager-1.1.0.dev4/cyclo_host_agent/cpu_usage.py +84 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/main.py +14 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/routers/files.py +47 -25
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/routers/repos.py +59 -36
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/routers/system_stats.py +17 -7
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/PKG-INFO +53 -8
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/SOURCES.txt +3 -1
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager_cli/__init__.py +1 -1
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager_cli/docker/docker-compose.yml +2 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/pyproject.toml +1 -1
- cyclo_manager-1.1.0.dev4/tests/test_cpu_usage.py +169 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/__init__.py +0 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/models.py +0 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/routers/__init__.py +0 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/routers/update.py +0 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/dependency_links.txt +0 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/entry_points.txt +0 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/requires.txt +0 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/top_level.txt +0 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager_cli/cli.py +0 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager_cli/config/config.yml +0 -0
- {cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cyclo-manager
|
|
3
|
-
Version: 1.1.0.
|
|
3
|
+
Version: 1.1.0.dev4
|
|
4
4
|
Summary: cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack.
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -38,6 +38,7 @@ For the full monorepo (API source, UI source, dev compose), see the [repository
|
|
|
38
38
|
- [What `cyclo_manager up` does](#what-cyclo_manager-up-does)
|
|
39
39
|
- [Host agent (`cyclo_host_agent`)](#host-agent-cyclo_host_agent)
|
|
40
40
|
- [Compose services](#compose-services)
|
|
41
|
+
- [Recording storage](#recording-storage)
|
|
41
42
|
- [Configuration](#configuration)
|
|
42
43
|
- [Agent sockets on the host](#agent-sockets-on-the-host)
|
|
43
44
|
- [Environment variables](#environment-variables)
|
|
@@ -134,6 +135,10 @@ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `con
|
|
|
134
135
|
- Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
|
|
135
136
|
- Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
|
|
136
137
|
- Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
|
|
138
|
+
- Provide host CPU/memory/disk statistics and process information. CPU summaries
|
|
139
|
+
share a background sampler's average of the latest three one-second samples.
|
|
140
|
+
- Serve the Files UI's browsing, filename search, upload, text editing, Git diff
|
|
141
|
+
and file/directory management operations under the configured workspace.
|
|
137
142
|
|
|
138
143
|
`cyclo_manager up` installs the `refresh-host-agent` CLI subcommand into sudoers. It is registered as `sys.executable -m cyclo_manager_cli.cli refresh-host-agent` with `SETENV`, so UI-triggered updates can pass the current `PYTHONPATH` to sudo and do not depend on `PATH`. The command is idempotent and refreshes the socket directory, sudoers file, systemd unit, and service state after package upgrades.
|
|
139
144
|
|
|
@@ -145,10 +150,10 @@ Repository scanning and the Files UI use `CYCLO_HOST_AGENT_WORKSPACE` when set.
|
|
|
145
150
|
|
|
146
151
|
Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/docker/docker-compose.yml). All services use **`network_mode: host`**.
|
|
147
152
|
|
|
148
|
-
| Compose service | Container name | `cyclo_manager up` | Image
|
|
153
|
+
| Compose service | Container name | `cyclo_manager up` | Image in this checkout |
|
|
149
154
|
|-----------------|----------------|--------------------|-----------------|
|
|
150
|
-
| `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.
|
|
151
|
-
| `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.
|
|
155
|
+
| `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.1.0` |
|
|
156
|
+
| `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.1.0` |
|
|
152
157
|
| `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
|
|
153
158
|
| `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
|
|
154
159
|
|
|
@@ -156,6 +161,27 @@ Start optional containers from the UI or manually, e.g. `docker start zenoh_daem
|
|
|
156
161
|
|
|
157
162
|
---
|
|
158
163
|
|
|
164
|
+
## Recording storage
|
|
165
|
+
|
|
166
|
+
The packaged stack and dev compose both mount:
|
|
167
|
+
|
|
168
|
+
```yaml
|
|
169
|
+
- ${HOME}/cyclo_manager_ros_bags:/cyclo_manager_ros_bags
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Compose resolves `${HOME}` on the host at invocation time. The API container uses
|
|
173
|
+
`RECORDINGS_DIR=/cyclo_manager_ros_bags`, which is also the server default when the
|
|
174
|
+
variable is absent. The host directory is independent of
|
|
175
|
+
`CYCLO_HOST_AGENT_WORKSPACE`; each recording stores its metadata and MCAP bag under
|
|
176
|
+
its generated ID. Container recreation, `down` and package updates preserve this
|
|
177
|
+
bind-mounted data. Applying a new mount requires container recreation.
|
|
178
|
+
|
|
179
|
+
Recording and playback run in the API container using `rosbag2_py` and the MCAP
|
|
180
|
+
storage plugin included in the manager image. They are not host-agent jobs.
|
|
181
|
+
See [Record & Play](../docs/record-play.md) for capture, repeat playback and stops.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
159
185
|
## Configuration
|
|
160
186
|
|
|
161
187
|
The CLI always mounts the **bundled** config into the API container. There is **no** `-c` / `--config` flag on the pip CLI.
|
|
@@ -168,10 +194,13 @@ The API reads it as `CONFIG_FILE=/app/config.yml` inside the container.
|
|
|
168
194
|
|
|
169
195
|
| Key | Description |
|
|
170
196
|
|-----|-------------|
|
|
171
|
-
| **`supported_robot_containers`** |
|
|
197
|
+
| **`supported_robot_containers`** | Containers eligible for System navigation and server-side motion profile detection (e.g. `ai_worker`, `open_manipulator`). Each must be a key in `sockets` and cannot be `host_agent`. |
|
|
172
198
|
| **`sockets`** | Map of logical name → agent **Unix socket path as seen inside the API container** (under `/agents/...`). Include robot/service containers and `host_agent`. |
|
|
173
199
|
|
|
174
|
-
s6 **service names** are
|
|
200
|
+
s6 **service names** are defined in the manager's System/motion profiles, not in
|
|
201
|
+
this config. Motion detection checks the existing individual status endpoints for
|
|
202
|
+
`ai_worker_bringup` and `open_manipulator_bringup`, then reads `/run/robot_type`
|
|
203
|
+
inside the selected container through Docker. This needs no new s6-agent endpoint.
|
|
175
204
|
|
|
176
205
|
### Example (bundled default)
|
|
177
206
|
|
|
@@ -198,6 +227,7 @@ On the host, sockets typically live under:
|
|
|
198
227
|
```text
|
|
199
228
|
/var/run/robotis/agent_sockets/
|
|
200
229
|
├── ai_worker/s6_agent.sock
|
|
230
|
+
├── open_manipulator/s6_agent.sock
|
|
201
231
|
├── cyclo_intelligence/s6_agent.sock
|
|
202
232
|
└── host/host_agent.sock ← created by cyclo_manager up
|
|
203
233
|
```
|
|
@@ -220,7 +250,11 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
|
|
|
220
250
|
| **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories and used as the Files UI/API root |
|
|
221
251
|
| **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
|
|
222
252
|
| **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
|
|
223
|
-
| **`
|
|
253
|
+
| **`HOME`** | Host environment used by Compose | Selects the host `${HOME}/cyclo_manager_ros_bags` bind-mount directory |
|
|
254
|
+
| **`RECORDINGS_DIR`** | Compose (`/cyclo_manager_ros_bags`) | Server-side recording directory inside the API container; same default without the variable |
|
|
255
|
+
| **`ROS_DOMAIN_ID`** | Manager image shell startup (`30`); not set by CLI | Must match the robot; the manager image configures it in `/root/.bashrc`, which the API startup sources |
|
|
256
|
+
| **`RMW_IMPLEMENTATION`** | Manager image shell startup (`rmw_zenoh_cpp`) | ROS middleware used by the manager; requires a reachable Zenoh router |
|
|
257
|
+
| **`NEXT_PUBLIC_API_URL`** | UI dev/build environment | Browser API address; production bundles embed it at build time, so a compose runtime value does not reconfigure a prebuilt UI |
|
|
224
258
|
|
|
225
259
|
---
|
|
226
260
|
|
|
@@ -234,6 +268,12 @@ With `network_mode: host` and default ports:
|
|
|
234
268
|
| cyclo_manager API | http://127.0.0.1:8081 |
|
|
235
269
|
| OpenAPI (Swagger) | http://127.0.0.1:8081/docs |
|
|
236
270
|
|
|
271
|
+
These loopback URLs apply on the robot host. From another device, use the robot's
|
|
272
|
+
hostname/IP and a browser-reachable API URL. Both compose files contain a UI
|
|
273
|
+
runtime value of `http://127.0.0.1:8081`; it takes effect in development, while a
|
|
274
|
+
production bundle uses its build-time value or hostname fallback. See the
|
|
275
|
+
[UI API address guide](../cyclo_manager_ui/README_UI.md#api-address).
|
|
276
|
+
|
|
237
277
|
---
|
|
238
278
|
|
|
239
279
|
## Custom config and development
|
|
@@ -247,6 +287,11 @@ For development (local API/UI source, custom `config.yml`, hot reload):
|
|
|
247
287
|
docker compose -f docker-compose.dev.yml up
|
|
248
288
|
```
|
|
249
289
|
|
|
290
|
+
The dev compose command starts all defined services and does not install the host
|
|
291
|
+
agent. API/UI source mounts do not update the Python code used by the separately
|
|
292
|
+
installed `cyclo_host_agent.service`; install the updated CLI package and refresh
|
|
293
|
+
that service when testing host-agent changes.
|
|
294
|
+
|
|
250
295
|
See the [root README](../README.md#development).
|
|
251
296
|
|
|
252
297
|
---
|
|
@@ -259,7 +304,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
|
|
|
259
304
|
|---------|---------|
|
|
260
305
|
| **fastapi** | `cyclo_host_agent` HTTP API |
|
|
261
306
|
| **uvicorn** | `cyclo_host_agent` server |
|
|
262
|
-
| **psutil** |
|
|
307
|
+
| **psutil** | Host CPU sampler, memory/disk statistics and process inspection |
|
|
263
308
|
|
|
264
309
|
The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
|
|
265
310
|
|
|
@@ -24,6 +24,7 @@ For the full monorepo (API source, UI source, dev compose), see the [repository
|
|
|
24
24
|
- [What `cyclo_manager up` does](#what-cyclo_manager-up-does)
|
|
25
25
|
- [Host agent (`cyclo_host_agent`)](#host-agent-cyclo_host_agent)
|
|
26
26
|
- [Compose services](#compose-services)
|
|
27
|
+
- [Recording storage](#recording-storage)
|
|
27
28
|
- [Configuration](#configuration)
|
|
28
29
|
- [Agent sockets on the host](#agent-sockets-on-the-host)
|
|
29
30
|
- [Environment variables](#environment-variables)
|
|
@@ -120,6 +121,10 @@ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `con
|
|
|
120
121
|
- Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
|
|
121
122
|
- Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
|
|
122
123
|
- Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
|
|
124
|
+
- Provide host CPU/memory/disk statistics and process information. CPU summaries
|
|
125
|
+
share a background sampler's average of the latest three one-second samples.
|
|
126
|
+
- Serve the Files UI's browsing, filename search, upload, text editing, Git diff
|
|
127
|
+
and file/directory management operations under the configured workspace.
|
|
123
128
|
|
|
124
129
|
`cyclo_manager up` installs the `refresh-host-agent` CLI subcommand into sudoers. It is registered as `sys.executable -m cyclo_manager_cli.cli refresh-host-agent` with `SETENV`, so UI-triggered updates can pass the current `PYTHONPATH` to sudo and do not depend on `PATH`. The command is idempotent and refreshes the socket directory, sudoers file, systemd unit, and service state after package upgrades.
|
|
125
130
|
|
|
@@ -131,10 +136,10 @@ Repository scanning and the Files UI use `CYCLO_HOST_AGENT_WORKSPACE` when set.
|
|
|
131
136
|
|
|
132
137
|
Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/docker/docker-compose.yml). All services use **`network_mode: host`**.
|
|
133
138
|
|
|
134
|
-
| Compose service | Container name | `cyclo_manager up` | Image
|
|
139
|
+
| Compose service | Container name | `cyclo_manager up` | Image in this checkout |
|
|
135
140
|
|-----------------|----------------|--------------------|-----------------|
|
|
136
|
-
| `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.
|
|
137
|
-
| `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.
|
|
141
|
+
| `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.1.0` |
|
|
142
|
+
| `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.1.0` |
|
|
138
143
|
| `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
|
|
139
144
|
| `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
|
|
140
145
|
|
|
@@ -142,6 +147,27 @@ Start optional containers from the UI or manually, e.g. `docker start zenoh_daem
|
|
|
142
147
|
|
|
143
148
|
---
|
|
144
149
|
|
|
150
|
+
## Recording storage
|
|
151
|
+
|
|
152
|
+
The packaged stack and dev compose both mount:
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
- ${HOME}/cyclo_manager_ros_bags:/cyclo_manager_ros_bags
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Compose resolves `${HOME}` on the host at invocation time. The API container uses
|
|
159
|
+
`RECORDINGS_DIR=/cyclo_manager_ros_bags`, which is also the server default when the
|
|
160
|
+
variable is absent. The host directory is independent of
|
|
161
|
+
`CYCLO_HOST_AGENT_WORKSPACE`; each recording stores its metadata and MCAP bag under
|
|
162
|
+
its generated ID. Container recreation, `down` and package updates preserve this
|
|
163
|
+
bind-mounted data. Applying a new mount requires container recreation.
|
|
164
|
+
|
|
165
|
+
Recording and playback run in the API container using `rosbag2_py` and the MCAP
|
|
166
|
+
storage plugin included in the manager image. They are not host-agent jobs.
|
|
167
|
+
See [Record & Play](../docs/record-play.md) for capture, repeat playback and stops.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
145
171
|
## Configuration
|
|
146
172
|
|
|
147
173
|
The CLI always mounts the **bundled** config into the API container. There is **no** `-c` / `--config` flag on the pip CLI.
|
|
@@ -154,10 +180,13 @@ The API reads it as `CONFIG_FILE=/app/config.yml` inside the container.
|
|
|
154
180
|
|
|
155
181
|
| Key | Description |
|
|
156
182
|
|-----|-------------|
|
|
157
|
-
| **`supported_robot_containers`** |
|
|
183
|
+
| **`supported_robot_containers`** | Containers eligible for System navigation and server-side motion profile detection (e.g. `ai_worker`, `open_manipulator`). Each must be a key in `sockets` and cannot be `host_agent`. |
|
|
158
184
|
| **`sockets`** | Map of logical name → agent **Unix socket path as seen inside the API container** (under `/agents/...`). Include robot/service containers and `host_agent`. |
|
|
159
185
|
|
|
160
|
-
s6 **service names** are
|
|
186
|
+
s6 **service names** are defined in the manager's System/motion profiles, not in
|
|
187
|
+
this config. Motion detection checks the existing individual status endpoints for
|
|
188
|
+
`ai_worker_bringup` and `open_manipulator_bringup`, then reads `/run/robot_type`
|
|
189
|
+
inside the selected container through Docker. This needs no new s6-agent endpoint.
|
|
161
190
|
|
|
162
191
|
### Example (bundled default)
|
|
163
192
|
|
|
@@ -184,6 +213,7 @@ On the host, sockets typically live under:
|
|
|
184
213
|
```text
|
|
185
214
|
/var/run/robotis/agent_sockets/
|
|
186
215
|
├── ai_worker/s6_agent.sock
|
|
216
|
+
├── open_manipulator/s6_agent.sock
|
|
187
217
|
├── cyclo_intelligence/s6_agent.sock
|
|
188
218
|
└── host/host_agent.sock ← created by cyclo_manager up
|
|
189
219
|
```
|
|
@@ -206,7 +236,11 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
|
|
|
206
236
|
| **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories and used as the Files UI/API root |
|
|
207
237
|
| **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
|
|
208
238
|
| **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
|
|
209
|
-
| **`
|
|
239
|
+
| **`HOME`** | Host environment used by Compose | Selects the host `${HOME}/cyclo_manager_ros_bags` bind-mount directory |
|
|
240
|
+
| **`RECORDINGS_DIR`** | Compose (`/cyclo_manager_ros_bags`) | Server-side recording directory inside the API container; same default without the variable |
|
|
241
|
+
| **`ROS_DOMAIN_ID`** | Manager image shell startup (`30`); not set by CLI | Must match the robot; the manager image configures it in `/root/.bashrc`, which the API startup sources |
|
|
242
|
+
| **`RMW_IMPLEMENTATION`** | Manager image shell startup (`rmw_zenoh_cpp`) | ROS middleware used by the manager; requires a reachable Zenoh router |
|
|
243
|
+
| **`NEXT_PUBLIC_API_URL`** | UI dev/build environment | Browser API address; production bundles embed it at build time, so a compose runtime value does not reconfigure a prebuilt UI |
|
|
210
244
|
|
|
211
245
|
---
|
|
212
246
|
|
|
@@ -220,6 +254,12 @@ With `network_mode: host` and default ports:
|
|
|
220
254
|
| cyclo_manager API | http://127.0.0.1:8081 |
|
|
221
255
|
| OpenAPI (Swagger) | http://127.0.0.1:8081/docs |
|
|
222
256
|
|
|
257
|
+
These loopback URLs apply on the robot host. From another device, use the robot's
|
|
258
|
+
hostname/IP and a browser-reachable API URL. Both compose files contain a UI
|
|
259
|
+
runtime value of `http://127.0.0.1:8081`; it takes effect in development, while a
|
|
260
|
+
production bundle uses its build-time value or hostname fallback. See the
|
|
261
|
+
[UI API address guide](../cyclo_manager_ui/README_UI.md#api-address).
|
|
262
|
+
|
|
223
263
|
---
|
|
224
264
|
|
|
225
265
|
## Custom config and development
|
|
@@ -233,6 +273,11 @@ For development (local API/UI source, custom `config.yml`, hot reload):
|
|
|
233
273
|
docker compose -f docker-compose.dev.yml up
|
|
234
274
|
```
|
|
235
275
|
|
|
276
|
+
The dev compose command starts all defined services and does not install the host
|
|
277
|
+
agent. API/UI source mounts do not update the Python code used by the separately
|
|
278
|
+
installed `cyclo_host_agent.service`; install the updated CLI package and refresh
|
|
279
|
+
that service when testing host-agent changes.
|
|
280
|
+
|
|
236
281
|
See the [root README](../README.md#development).
|
|
237
282
|
|
|
238
283
|
---
|
|
@@ -245,7 +290,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
|
|
|
245
290
|
|---------|---------|
|
|
246
291
|
| **fastapi** | `cyclo_host_agent` HTTP API |
|
|
247
292
|
| **uvicorn** | `cyclo_host_agent` server |
|
|
248
|
-
| **psutil** |
|
|
293
|
+
| **psutil** | Host CPU sampler, memory/disk statistics and process inspection |
|
|
249
294
|
|
|
250
295
|
The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
|
|
251
296
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 ROBOTIS CO., LTD.
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
#
|
|
17
|
+
# Author: Hyungyu Kim
|
|
18
|
+
|
|
19
|
+
"""Continuous host CPU sampling, independent of HTTP requests."""
|
|
20
|
+
|
|
21
|
+
from collections import deque
|
|
22
|
+
import logging
|
|
23
|
+
import threading
|
|
24
|
+
|
|
25
|
+
import psutil
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
CPU_SAMPLE_INTERVAL_SECONDS = 1.0
|
|
30
|
+
CPU_AVERAGE_SAMPLES = 3
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CpuUsageSampler:
|
|
34
|
+
"""Keep the mean of the latest three consecutive one-second CPU samples."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, interval: float = CPU_SAMPLE_INTERVAL_SECONDS) -> None:
|
|
37
|
+
"""Initialize the sampler without starting a background thread."""
|
|
38
|
+
self._interval = interval
|
|
39
|
+
self._samples: deque[float] = deque(maxlen=CPU_AVERAGE_SAMPLES)
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
self._stop = threading.Event()
|
|
42
|
+
self._thread: threading.Thread | None = None
|
|
43
|
+
|
|
44
|
+
def start(self) -> None:
|
|
45
|
+
"""Start one sampling thread and discard samples from any previous run."""
|
|
46
|
+
if self._thread is not None and self._thread.is_alive():
|
|
47
|
+
return
|
|
48
|
+
with self._lock:
|
|
49
|
+
self._samples.clear()
|
|
50
|
+
self._stop.clear()
|
|
51
|
+
self._thread = threading.Thread(
|
|
52
|
+
target=self._run, name='host-cpu-sampler', daemon=True,
|
|
53
|
+
)
|
|
54
|
+
self._thread.start()
|
|
55
|
+
|
|
56
|
+
def stop(self) -> None:
|
|
57
|
+
"""Wake and join the sampler on application shutdown."""
|
|
58
|
+
self._stop.set()
|
|
59
|
+
if self._thread is not None:
|
|
60
|
+
self._thread.join()
|
|
61
|
+
self._thread = None
|
|
62
|
+
|
|
63
|
+
def average(self) -> float | None:
|
|
64
|
+
"""Return a cached average, or None before the first complete interval."""
|
|
65
|
+
with self._lock:
|
|
66
|
+
if not self._samples:
|
|
67
|
+
return None
|
|
68
|
+
return round(sum(self._samples) / len(self._samples), 1)
|
|
69
|
+
|
|
70
|
+
def _run(self) -> None:
|
|
71
|
+
while not self._stop.is_set():
|
|
72
|
+
try:
|
|
73
|
+
# Prime and read on the same thread. The initial value is not a sample.
|
|
74
|
+
psutil.cpu_percent(interval=None)
|
|
75
|
+
while not self._stop.wait(self._interval):
|
|
76
|
+
sample = psutil.cpu_percent(interval=None)
|
|
77
|
+
with self._lock:
|
|
78
|
+
self._samples.append(float(sample))
|
|
79
|
+
except Exception:
|
|
80
|
+
# Re-prime after a failure instead of mixing stale measurement intervals.
|
|
81
|
+
with self._lock:
|
|
82
|
+
self._samples.clear()
|
|
83
|
+
logger.exception('Failed to sample host CPU usage')
|
|
84
|
+
self._stop.wait(self._interval)
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
"""Cyclo host agent: FastAPI server on Unix Domain Socket for host-level operations."""
|
|
20
20
|
|
|
21
|
+
import asyncio
|
|
22
|
+
from contextlib import asynccontextmanager
|
|
21
23
|
import logging
|
|
22
24
|
from pathlib import Path
|
|
23
25
|
|
|
@@ -34,10 +36,22 @@ logger = logging.getLogger(__name__)
|
|
|
34
36
|
|
|
35
37
|
SOCKET_PATH = '/var/run/robotis/agent_sockets/host/host_agent.sock'
|
|
36
38
|
|
|
39
|
+
|
|
40
|
+
@asynccontextmanager
|
|
41
|
+
async def lifespan(app: FastAPI):
|
|
42
|
+
"""Run the shared CPU sampler for the lifetime of the host agent."""
|
|
43
|
+
system_stats.cpu_sampler.start()
|
|
44
|
+
try:
|
|
45
|
+
yield
|
|
46
|
+
finally:
|
|
47
|
+
await asyncio.to_thread(system_stats.cpu_sampler.stop)
|
|
48
|
+
|
|
49
|
+
|
|
37
50
|
app = FastAPI(
|
|
38
51
|
title='cyclo_host_agent',
|
|
39
52
|
description='Host agent for Cyclo Manager: repo management.',
|
|
40
53
|
version=__version__,
|
|
54
|
+
lifespan=lifespan,
|
|
41
55
|
)
|
|
42
56
|
|
|
43
57
|
app.include_router(repos.router)
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
"""Host file browser and editor endpoints."""
|
|
20
20
|
|
|
21
|
+
import asyncio
|
|
21
22
|
import os
|
|
22
23
|
import shutil
|
|
23
24
|
import subprocess
|
|
@@ -71,6 +72,21 @@ def _safe_path(relative_path: str = '') -> tuple[Path, str]:
|
|
|
71
72
|
return target, rel
|
|
72
73
|
|
|
73
74
|
|
|
75
|
+
def _safe_nofollow_path(relative_path: str = '') -> tuple[Path, str]:
|
|
76
|
+
raw_path = Path(relative_path)
|
|
77
|
+
if raw_path.is_absolute() or '..' in raw_path.parts:
|
|
78
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
|
|
79
|
+
|
|
80
|
+
lexical_target = FILE_ROOT_PATH / raw_path
|
|
81
|
+
parent = lexical_target.parent.resolve()
|
|
82
|
+
if parent != FILE_ROOT_PATH and FILE_ROOT_PATH not in parent.parents:
|
|
83
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
|
|
84
|
+
|
|
85
|
+
target = parent / lexical_target.name
|
|
86
|
+
rel = '' if target == FILE_ROOT_PATH else target.relative_to(FILE_ROOT_PATH).as_posix()
|
|
87
|
+
return target, rel
|
|
88
|
+
|
|
89
|
+
|
|
74
90
|
def _safe_filename(filename: str) -> str:
|
|
75
91
|
name = Path(filename).name
|
|
76
92
|
if name in {'', '.', '..'} or name != filename or '/' in filename or '\\' in filename:
|
|
@@ -80,7 +96,8 @@ def _safe_filename(filename: str) -> str:
|
|
|
80
96
|
|
|
81
97
|
def _is_binary(path: Path) -> bool:
|
|
82
98
|
try:
|
|
83
|
-
|
|
99
|
+
with path.open('rb') as file:
|
|
100
|
+
return b'\x00' in file.read(8192)
|
|
84
101
|
except OSError:
|
|
85
102
|
return True
|
|
86
103
|
|
|
@@ -262,7 +279,7 @@ def _git_head_content(repo_root: Path, repo_path: str) -> str:
|
|
|
262
279
|
|
|
263
280
|
|
|
264
281
|
@router.get('/tree', response_model=FileTreeResponse)
|
|
265
|
-
|
|
282
|
+
def list_directory(
|
|
266
283
|
path: str = '',
|
|
267
284
|
show_hidden: bool = Query(False),
|
|
268
285
|
) -> FileTreeResponse:
|
|
@@ -286,7 +303,7 @@ async def list_directory(
|
|
|
286
303
|
|
|
287
304
|
|
|
288
305
|
@router.get('/search', response_model=FileSearchResponse)
|
|
289
|
-
|
|
306
|
+
def search_files(
|
|
290
307
|
query: str,
|
|
291
308
|
path: str = '',
|
|
292
309
|
show_hidden: bool = Query(False),
|
|
@@ -340,7 +357,7 @@ async def search_files(
|
|
|
340
357
|
|
|
341
358
|
|
|
342
359
|
@router.get('/read', response_model=FileReadResponse)
|
|
343
|
-
|
|
360
|
+
def read_file(
|
|
344
361
|
path: str,
|
|
345
362
|
) -> FileReadResponse:
|
|
346
363
|
"""Read a UTF-8 text file under the configured file root."""
|
|
@@ -366,7 +383,7 @@ async def read_file(
|
|
|
366
383
|
|
|
367
384
|
|
|
368
385
|
@router.get('/diff', response_model=FileDiffResponse)
|
|
369
|
-
|
|
386
|
+
def diff_file(
|
|
370
387
|
path: str,
|
|
371
388
|
) -> FileDiffResponse:
|
|
372
389
|
"""Return original and current file content for a git diff view."""
|
|
@@ -397,7 +414,7 @@ async def diff_file(
|
|
|
397
414
|
|
|
398
415
|
|
|
399
416
|
@router.post('/write', response_model=FileOperationResponse)
|
|
400
|
-
|
|
417
|
+
def write_file(req: FileWriteRequest) -> FileOperationResponse:
|
|
401
418
|
"""Write a UTF-8 text file under the configured file root."""
|
|
402
419
|
target, rel = _safe_path(req.path)
|
|
403
420
|
if target.exists() and not target.is_file():
|
|
@@ -417,10 +434,10 @@ async def write_file(req: FileWriteRequest) -> FileOperationResponse:
|
|
|
417
434
|
|
|
418
435
|
|
|
419
436
|
@router.post('/create', response_model=FileOperationResponse)
|
|
420
|
-
|
|
437
|
+
def create_path(req: FileCreateRequest) -> FileOperationResponse:
|
|
421
438
|
"""Create a file or folder under the configured file root."""
|
|
422
|
-
target, rel =
|
|
423
|
-
if target.exists():
|
|
439
|
+
target, rel = _safe_nofollow_path(req.path)
|
|
440
|
+
if target.exists() or target.is_symlink():
|
|
424
441
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Path already exists')
|
|
425
442
|
try:
|
|
426
443
|
if req.type == 'directory':
|
|
@@ -440,17 +457,17 @@ async def create_path(req: FileCreateRequest) -> FileOperationResponse:
|
|
|
440
457
|
|
|
441
458
|
|
|
442
459
|
@router.post('/rename', response_model=FileOperationResponse)
|
|
443
|
-
|
|
460
|
+
def rename_path(req: FileRenameRequest) -> FileOperationResponse:
|
|
444
461
|
"""Rename a file or folder under the configured file root."""
|
|
445
|
-
target, _ =
|
|
446
|
-
if not target.exists():
|
|
462
|
+
target, _ = _safe_nofollow_path(req.path)
|
|
463
|
+
if not target.exists() and not target.is_symlink():
|
|
447
464
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
|
|
448
465
|
if '/' in req.new_name or '\\' in req.new_name or req.new_name in {'', '.', '..'}:
|
|
449
466
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid name')
|
|
450
|
-
destination =
|
|
467
|
+
destination = target.parent / req.new_name
|
|
451
468
|
if destination != FILE_ROOT_PATH and FILE_ROOT_PATH not in destination.parents:
|
|
452
469
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
|
|
453
|
-
if destination.exists():
|
|
470
|
+
if destination.exists() or destination.is_symlink():
|
|
454
471
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Destination already exists')
|
|
455
472
|
try:
|
|
456
473
|
target.rename(destination)
|
|
@@ -473,34 +490,37 @@ async def upload_file(
|
|
|
473
490
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a directory')
|
|
474
491
|
|
|
475
492
|
safe_name = _safe_filename(filename)
|
|
476
|
-
destination =
|
|
493
|
+
destination = target_dir / safe_name
|
|
477
494
|
if destination != FILE_ROOT_PATH and FILE_ROOT_PATH not in destination.parents:
|
|
478
495
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
|
|
479
496
|
if destination.exists() and destination.is_dir():
|
|
480
497
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='A folder with that name already exists')
|
|
481
|
-
overwritten = destination.exists()
|
|
498
|
+
overwritten = destination.exists() or destination.is_symlink()
|
|
482
499
|
if overwritten and not overwrite:
|
|
483
500
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='File already exists')
|
|
484
501
|
|
|
485
502
|
temp_path = target_dir / f'.{safe_name}.{uuid.uuid4().hex}.uploading'
|
|
486
503
|
size_bytes = 0
|
|
487
504
|
try:
|
|
488
|
-
|
|
505
|
+
output = await asyncio.to_thread(temp_path.open, 'wb')
|
|
506
|
+
try:
|
|
489
507
|
async for chunk in request.stream():
|
|
490
508
|
size_bytes += len(chunk)
|
|
491
509
|
if size_bytes > MAX_UPLOAD_BYTES:
|
|
492
510
|
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='File is too large to upload')
|
|
493
|
-
output.write
|
|
494
|
-
|
|
511
|
+
await asyncio.to_thread(output.write, chunk)
|
|
512
|
+
finally:
|
|
513
|
+
await asyncio.to_thread(output.close)
|
|
514
|
+
await asyncio.to_thread(os.replace, temp_path, destination)
|
|
495
515
|
except HTTPException:
|
|
496
516
|
try:
|
|
497
|
-
temp_path.unlink
|
|
517
|
+
await asyncio.to_thread(temp_path.unlink, missing_ok=True)
|
|
498
518
|
except OSError:
|
|
499
519
|
pass
|
|
500
520
|
raise
|
|
501
521
|
except OSError as exc:
|
|
502
522
|
try:
|
|
503
|
-
temp_path.unlink
|
|
523
|
+
await asyncio.to_thread(temp_path.unlink, missing_ok=True)
|
|
504
524
|
except OSError:
|
|
505
525
|
pass
|
|
506
526
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
@@ -517,18 +537,20 @@ async def upload_file(
|
|
|
517
537
|
|
|
518
538
|
|
|
519
539
|
@router.delete('', response_model=FileOperationResponse)
|
|
520
|
-
|
|
540
|
+
def delete_path(
|
|
521
541
|
path: str,
|
|
522
542
|
recursive: bool = Query(False),
|
|
523
543
|
) -> FileOperationResponse:
|
|
524
544
|
"""Delete a file or folder under the configured file root."""
|
|
525
|
-
target, rel =
|
|
545
|
+
target, rel = _safe_nofollow_path(path)
|
|
526
546
|
if target == FILE_ROOT_PATH:
|
|
527
547
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Cannot delete file root')
|
|
528
|
-
if not target.exists():
|
|
548
|
+
if not target.exists() and not target.is_symlink():
|
|
529
549
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
|
|
530
550
|
try:
|
|
531
|
-
if target.
|
|
551
|
+
if target.is_symlink():
|
|
552
|
+
target.unlink()
|
|
553
|
+
elif target.is_dir():
|
|
532
554
|
if recursive:
|
|
533
555
|
shutil.rmtree(target)
|
|
534
556
|
else:
|
|
@@ -343,6 +343,7 @@ async def _update_reset(repo_path: Path, preserve_files: list[str]) -> UpdateRes
|
|
|
343
343
|
# ── container helper ───────────────────────────────────────────────────────────
|
|
344
344
|
|
|
345
345
|
_container_start_status_by_repo: dict[str, dict] = {}
|
|
346
|
+
_container_start_tasks: set[asyncio.Task[None]] = set()
|
|
346
347
|
|
|
347
348
|
|
|
348
349
|
def _prune_container_start_jobs() -> None:
|
|
@@ -424,6 +425,20 @@ async def _read_container_job_stream(stream: asyncio.StreamReader, job: dict) ->
|
|
|
424
425
|
_update_container_job_from_line(job, line)
|
|
425
426
|
|
|
426
427
|
|
|
428
|
+
async def _stop_container_start_process(
|
|
429
|
+
proc: asyncio.subprocess.Process | None,
|
|
430
|
+
readers: list[asyncio.Task],
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Stop a container-start subprocess and consume its reader tasks."""
|
|
433
|
+
if proc is not None and proc.returncode is None:
|
|
434
|
+
try:
|
|
435
|
+
proc.kill()
|
|
436
|
+
except ProcessLookupError:
|
|
437
|
+
pass
|
|
438
|
+
await proc.wait()
|
|
439
|
+
await asyncio.gather(*readers, return_exceptions=True)
|
|
440
|
+
|
|
441
|
+
|
|
427
442
|
async def _run_container_sh(repo_path: Path, action: str) -> tuple[bool, str]:
|
|
428
443
|
script = repo_path / 'docker' / 'container.sh'
|
|
429
444
|
if not script.exists():
|
|
@@ -448,52 +463,58 @@ async def _run_container_start(repo_path: Path, name: str) -> None:
|
|
|
448
463
|
job = _container_start_status_by_repo.get(name)
|
|
449
464
|
if job is None:
|
|
450
465
|
return
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
_set_container_job_status(
|
|
454
|
-
job,
|
|
455
|
-
running=False,
|
|
456
|
-
success=False,
|
|
457
|
-
error=f'container.sh not found at {script}',
|
|
458
|
-
)
|
|
459
|
-
return
|
|
460
|
-
|
|
461
|
-
env = os.environ.copy()
|
|
462
|
-
env['COMPOSE_PROGRESS'] = 'plain'
|
|
463
|
-
env['COMPOSE_ANSI'] = 'never'
|
|
464
|
-
proc = await asyncio.create_subprocess_exec(
|
|
465
|
-
'bash', str(script), 'start',
|
|
466
|
-
cwd=str(repo_path / 'docker'),
|
|
467
|
-
env=env,
|
|
468
|
-
stdin=asyncio.subprocess.PIPE,
|
|
469
|
-
stdout=asyncio.subprocess.PIPE,
|
|
470
|
-
stderr=asyncio.subprocess.PIPE,
|
|
471
|
-
)
|
|
472
|
-
if proc.stdin is not None:
|
|
473
|
-
proc.stdin.write(b'y\n')
|
|
474
|
-
await proc.stdin.drain()
|
|
475
|
-
proc.stdin.close()
|
|
476
|
-
|
|
477
|
-
readers = [
|
|
478
|
-
asyncio.create_task(_read_container_job_stream(proc.stdout, job)),
|
|
479
|
-
asyncio.create_task(_read_container_job_stream(proc.stderr, job)),
|
|
480
|
-
]
|
|
466
|
+
proc: asyncio.subprocess.Process | None = None
|
|
467
|
+
readers: list[asyncio.Task] = []
|
|
481
468
|
try:
|
|
469
|
+
script = repo_path / 'docker' / 'container.sh'
|
|
470
|
+
if not script.exists():
|
|
471
|
+
raise FileNotFoundError(f'container.sh not found at {script}')
|
|
472
|
+
|
|
473
|
+
env = os.environ.copy()
|
|
474
|
+
env['COMPOSE_PROGRESS'] = 'plain'
|
|
475
|
+
env['COMPOSE_ANSI'] = 'never'
|
|
476
|
+
proc = await asyncio.create_subprocess_exec(
|
|
477
|
+
'bash', str(script), 'start',
|
|
478
|
+
cwd=str(repo_path / 'docker'),
|
|
479
|
+
env=env,
|
|
480
|
+
stdin=asyncio.subprocess.PIPE,
|
|
481
|
+
stdout=asyncio.subprocess.PIPE,
|
|
482
|
+
stderr=asyncio.subprocess.PIPE,
|
|
483
|
+
)
|
|
484
|
+
if proc.stdin is not None:
|
|
485
|
+
proc.stdin.write(b'y\n')
|
|
486
|
+
await proc.stdin.drain()
|
|
487
|
+
proc.stdin.close()
|
|
488
|
+
|
|
489
|
+
readers = [
|
|
490
|
+
asyncio.create_task(_read_container_job_stream(proc.stdout, job)),
|
|
491
|
+
asyncio.create_task(_read_container_job_stream(proc.stderr, job)),
|
|
492
|
+
]
|
|
482
493
|
await asyncio.wait_for(proc.wait(), timeout=CONTAINER_SCRIPT_TIMEOUT)
|
|
483
494
|
await asyncio.gather(*readers)
|
|
484
495
|
except asyncio.TimeoutError:
|
|
485
|
-
|
|
486
|
-
await proc
|
|
487
|
-
|
|
488
|
-
|
|
496
|
+
error = 'Timeout waiting for container.sh'
|
|
497
|
+
await _stop_container_start_process(proc, readers)
|
|
498
|
+
_append_container_job_output(job, error)
|
|
499
|
+
_set_container_job_status(job, running=False, success=False, error=error)
|
|
500
|
+
return
|
|
501
|
+
except asyncio.CancelledError:
|
|
502
|
+
await _stop_container_start_process(proc, readers)
|
|
489
503
|
_set_container_job_status(
|
|
490
504
|
job,
|
|
491
505
|
running=False,
|
|
492
506
|
success=False,
|
|
493
|
-
error='
|
|
507
|
+
error='Container start operation was cancelled',
|
|
494
508
|
)
|
|
509
|
+
raise
|
|
510
|
+
except Exception as exc:
|
|
511
|
+
await _stop_container_start_process(proc, readers)
|
|
512
|
+
error = str(exc) or type(exc).__name__
|
|
513
|
+
_append_container_job_output(job, error)
|
|
514
|
+
_set_container_job_status(job, running=False, success=False, error=error)
|
|
495
515
|
return
|
|
496
516
|
|
|
517
|
+
assert proc is not None
|
|
497
518
|
if proc.returncode == 0:
|
|
498
519
|
_set_container_job_status(
|
|
499
520
|
job,
|
|
@@ -569,7 +590,9 @@ async def start_repo_container(name: str) -> ContainerStartStatusResponse:
|
|
|
569
590
|
return _build_container_start_status_response(existing)
|
|
570
591
|
|
|
571
592
|
job = _new_container_start_status(name)
|
|
572
|
-
asyncio.create_task(_run_container_start(repo_path, name))
|
|
593
|
+
task = asyncio.create_task(_run_container_start(repo_path, name))
|
|
594
|
+
_container_start_tasks.add(task)
|
|
595
|
+
task.add_done_callback(_container_start_tasks.discard)
|
|
573
596
|
return _build_container_start_status_response(job)
|
|
574
597
|
|
|
575
598
|
|
{cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_host_agent/routers/system_stats.py
RENAMED
|
@@ -21,21 +21,30 @@
|
|
|
21
21
|
import time
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
|
|
24
|
+
from cyclo_host_agent.cpu_usage import CpuUsageSampler
|
|
24
25
|
from cyclo_host_agent.models import (
|
|
25
26
|
HostProcessesResponse,
|
|
26
27
|
HostProcessInfo,
|
|
27
28
|
HostSystemStatsResponse,
|
|
28
29
|
)
|
|
29
|
-
from fastapi import APIRouter, Query
|
|
30
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
30
31
|
import psutil
|
|
31
32
|
|
|
32
33
|
router = APIRouter(prefix='/system', tags=['system'])
|
|
34
|
+
cpu_sampler = CpuUsageSampler()
|
|
33
35
|
|
|
34
36
|
PROCESS_SAMPLE_INTERVAL_SECONDS = 0.2
|
|
35
37
|
PROCESS_DEFAULT_LIMIT = 80
|
|
36
38
|
EXTRA_STORAGE_MOUNT_PATHS = ('/mnt/ssd', '/data')
|
|
37
39
|
|
|
38
40
|
|
|
41
|
+
def _cpu_average() -> float:
|
|
42
|
+
value = cpu_sampler.average()
|
|
43
|
+
if value is None:
|
|
44
|
+
raise HTTPException(status_code=503, detail='CPU measurement is warming up')
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
39
48
|
def _temperature() -> float | None:
|
|
40
49
|
try:
|
|
41
50
|
sensors = psutil.sensors_temperatures()
|
|
@@ -84,7 +93,8 @@ def _sample_processes(limit: int = PROCESS_DEFAULT_LIMIT) -> HostProcessesRespon
|
|
|
84
93
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
85
94
|
continue
|
|
86
95
|
|
|
87
|
-
|
|
96
|
+
# Process usage keeps its short measurement window; total CPU uses the shared average.
|
|
97
|
+
time.sleep(PROCESS_SAMPLE_INTERVAL_SECONDS)
|
|
88
98
|
mem = psutil.virtual_memory()
|
|
89
99
|
|
|
90
100
|
processes: list[HostProcessInfo] = []
|
|
@@ -111,7 +121,7 @@ def _sample_processes(limit: int = PROCESS_DEFAULT_LIMIT) -> HostProcessesRespon
|
|
|
111
121
|
|
|
112
122
|
processes.sort(key=lambda item: item.cpu_percent, reverse=True)
|
|
113
123
|
return HostProcessesResponse(
|
|
114
|
-
cpu_percent=
|
|
124
|
+
cpu_percent=_cpu_average(),
|
|
115
125
|
memory_used_mb=int(mem.used // (1024 * 1024)),
|
|
116
126
|
memory_total_mb=int(mem.total // (1024 * 1024)),
|
|
117
127
|
processes=processes[:max(1, min(limit, 500))],
|
|
@@ -119,9 +129,9 @@ def _sample_processes(limit: int = PROCESS_DEFAULT_LIMIT) -> HostProcessesRespon
|
|
|
119
129
|
|
|
120
130
|
|
|
121
131
|
@router.get('/status', response_model=HostSystemStatsResponse)
|
|
122
|
-
|
|
123
|
-
"""Return host CPU
|
|
124
|
-
cpu_percent =
|
|
132
|
+
def get_system_stats() -> HostSystemStatsResponse:
|
|
133
|
+
"""Return host stats with CPU averaged over the latest three one-second samples."""
|
|
134
|
+
cpu_percent = _cpu_average()
|
|
125
135
|
mem = psutil.virtual_memory()
|
|
126
136
|
disk = psutil.disk_usage('/')
|
|
127
137
|
extra_storage_path, extra_storage_disk = _extra_storage_disk_usage()
|
|
@@ -149,7 +159,7 @@ async def get_system_stats() -> HostSystemStatsResponse:
|
|
|
149
159
|
|
|
150
160
|
|
|
151
161
|
@router.get('/processes', response_model=HostProcessesResponse)
|
|
152
|
-
|
|
162
|
+
def get_system_processes(
|
|
153
163
|
limit: int = Query(PROCESS_DEFAULT_LIMIT, ge=1, le=500),
|
|
154
164
|
) -> HostProcessesResponse:
|
|
155
165
|
"""Return host process CPU/memory usage sorted by CPU usage."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cyclo-manager
|
|
3
|
-
Version: 1.1.0.
|
|
3
|
+
Version: 1.1.0.dev4
|
|
4
4
|
Summary: cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack.
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -38,6 +38,7 @@ For the full monorepo (API source, UI source, dev compose), see the [repository
|
|
|
38
38
|
- [What `cyclo_manager up` does](#what-cyclo_manager-up-does)
|
|
39
39
|
- [Host agent (`cyclo_host_agent`)](#host-agent-cyclo_host_agent)
|
|
40
40
|
- [Compose services](#compose-services)
|
|
41
|
+
- [Recording storage](#recording-storage)
|
|
41
42
|
- [Configuration](#configuration)
|
|
42
43
|
- [Agent sockets on the host](#agent-sockets-on-the-host)
|
|
43
44
|
- [Environment variables](#environment-variables)
|
|
@@ -134,6 +135,10 @@ The API container reaches it at `/agents/host/host_agent.sock` (see bundled `con
|
|
|
134
135
|
- Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
|
|
135
136
|
- Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
|
|
136
137
|
- Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
|
|
138
|
+
- Provide host CPU/memory/disk statistics and process information. CPU summaries
|
|
139
|
+
share a background sampler's average of the latest three one-second samples.
|
|
140
|
+
- Serve the Files UI's browsing, filename search, upload, text editing, Git diff
|
|
141
|
+
and file/directory management operations under the configured workspace.
|
|
137
142
|
|
|
138
143
|
`cyclo_manager up` installs the `refresh-host-agent` CLI subcommand into sudoers. It is registered as `sys.executable -m cyclo_manager_cli.cli refresh-host-agent` with `SETENV`, so UI-triggered updates can pass the current `PYTHONPATH` to sudo and do not depend on `PATH`. The command is idempotent and refreshes the socket directory, sudoers file, systemd unit, and service state after package upgrades.
|
|
139
144
|
|
|
@@ -145,10 +150,10 @@ Repository scanning and the Files UI use `CYCLO_HOST_AGENT_WORKSPACE` when set.
|
|
|
145
150
|
|
|
146
151
|
Defined in [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/docker/docker-compose.yml). All services use **`network_mode: host`**.
|
|
147
152
|
|
|
148
|
-
| Compose service | Container name | `cyclo_manager up` | Image
|
|
153
|
+
| Compose service | Container name | `cyclo_manager up` | Image in this checkout |
|
|
149
154
|
|-----------------|----------------|--------------------|-----------------|
|
|
150
|
-
| `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.
|
|
151
|
-
| `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.
|
|
155
|
+
| `cyclo_manager` | `cyclo_manager` | **Started** | `robotis/cyclo-manager:1.1.0` |
|
|
156
|
+
| `ui` | `cyclo_manager_ui` | **Started** | `robotis/cyclo-manager-ui:1.1.0` |
|
|
152
157
|
| `rmw_zenoh` | `zenoh_daemon` | **Created only** | `robotis/zenoh-daemon:latest` |
|
|
153
158
|
| `novnc-server` | `novnc-server` | **Created only** | `robotis/novnc-server:latest` |
|
|
154
159
|
|
|
@@ -156,6 +161,27 @@ Start optional containers from the UI or manually, e.g. `docker start zenoh_daem
|
|
|
156
161
|
|
|
157
162
|
---
|
|
158
163
|
|
|
164
|
+
## Recording storage
|
|
165
|
+
|
|
166
|
+
The packaged stack and dev compose both mount:
|
|
167
|
+
|
|
168
|
+
```yaml
|
|
169
|
+
- ${HOME}/cyclo_manager_ros_bags:/cyclo_manager_ros_bags
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Compose resolves `${HOME}` on the host at invocation time. The API container uses
|
|
173
|
+
`RECORDINGS_DIR=/cyclo_manager_ros_bags`, which is also the server default when the
|
|
174
|
+
variable is absent. The host directory is independent of
|
|
175
|
+
`CYCLO_HOST_AGENT_WORKSPACE`; each recording stores its metadata and MCAP bag under
|
|
176
|
+
its generated ID. Container recreation, `down` and package updates preserve this
|
|
177
|
+
bind-mounted data. Applying a new mount requires container recreation.
|
|
178
|
+
|
|
179
|
+
Recording and playback run in the API container using `rosbag2_py` and the MCAP
|
|
180
|
+
storage plugin included in the manager image. They are not host-agent jobs.
|
|
181
|
+
See [Record & Play](../docs/record-play.md) for capture, repeat playback and stops.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
159
185
|
## Configuration
|
|
160
186
|
|
|
161
187
|
The CLI always mounts the **bundled** config into the API container. There is **no** `-c` / `--config` flag on the pip CLI.
|
|
@@ -168,10 +194,13 @@ The API reads it as `CONFIG_FILE=/app/config.yml` inside the container.
|
|
|
168
194
|
|
|
169
195
|
| Key | Description |
|
|
170
196
|
|-----|-------------|
|
|
171
|
-
| **`supported_robot_containers`** |
|
|
197
|
+
| **`supported_robot_containers`** | Containers eligible for System navigation and server-side motion profile detection (e.g. `ai_worker`, `open_manipulator`). Each must be a key in `sockets` and cannot be `host_agent`. |
|
|
172
198
|
| **`sockets`** | Map of logical name → agent **Unix socket path as seen inside the API container** (under `/agents/...`). Include robot/service containers and `host_agent`. |
|
|
173
199
|
|
|
174
|
-
s6 **service names** are
|
|
200
|
+
s6 **service names** are defined in the manager's System/motion profiles, not in
|
|
201
|
+
this config. Motion detection checks the existing individual status endpoints for
|
|
202
|
+
`ai_worker_bringup` and `open_manipulator_bringup`, then reads `/run/robot_type`
|
|
203
|
+
inside the selected container through Docker. This needs no new s6-agent endpoint.
|
|
175
204
|
|
|
176
205
|
### Example (bundled default)
|
|
177
206
|
|
|
@@ -198,6 +227,7 @@ On the host, sockets typically live under:
|
|
|
198
227
|
```text
|
|
199
228
|
/var/run/robotis/agent_sockets/
|
|
200
229
|
├── ai_worker/s6_agent.sock
|
|
230
|
+
├── open_manipulator/s6_agent.sock
|
|
201
231
|
├── cyclo_intelligence/s6_agent.sock
|
|
202
232
|
└── host/host_agent.sock ← created by cyclo_manager up
|
|
203
233
|
```
|
|
@@ -220,7 +250,11 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
|
|
|
220
250
|
| **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories and used as the Files UI/API root |
|
|
221
251
|
| **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
|
|
222
252
|
| **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
|
|
223
|
-
| **`
|
|
253
|
+
| **`HOME`** | Host environment used by Compose | Selects the host `${HOME}/cyclo_manager_ros_bags` bind-mount directory |
|
|
254
|
+
| **`RECORDINGS_DIR`** | Compose (`/cyclo_manager_ros_bags`) | Server-side recording directory inside the API container; same default without the variable |
|
|
255
|
+
| **`ROS_DOMAIN_ID`** | Manager image shell startup (`30`); not set by CLI | Must match the robot; the manager image configures it in `/root/.bashrc`, which the API startup sources |
|
|
256
|
+
| **`RMW_IMPLEMENTATION`** | Manager image shell startup (`rmw_zenoh_cpp`) | ROS middleware used by the manager; requires a reachable Zenoh router |
|
|
257
|
+
| **`NEXT_PUBLIC_API_URL`** | UI dev/build environment | Browser API address; production bundles embed it at build time, so a compose runtime value does not reconfigure a prebuilt UI |
|
|
224
258
|
|
|
225
259
|
---
|
|
226
260
|
|
|
@@ -234,6 +268,12 @@ With `network_mode: host` and default ports:
|
|
|
234
268
|
| cyclo_manager API | http://127.0.0.1:8081 |
|
|
235
269
|
| OpenAPI (Swagger) | http://127.0.0.1:8081/docs |
|
|
236
270
|
|
|
271
|
+
These loopback URLs apply on the robot host. From another device, use the robot's
|
|
272
|
+
hostname/IP and a browser-reachable API URL. Both compose files contain a UI
|
|
273
|
+
runtime value of `http://127.0.0.1:8081`; it takes effect in development, while a
|
|
274
|
+
production bundle uses its build-time value or hostname fallback. See the
|
|
275
|
+
[UI API address guide](../cyclo_manager_ui/README_UI.md#api-address).
|
|
276
|
+
|
|
237
277
|
---
|
|
238
278
|
|
|
239
279
|
## Custom config and development
|
|
@@ -247,6 +287,11 @@ For development (local API/UI source, custom `config.yml`, hot reload):
|
|
|
247
287
|
docker compose -f docker-compose.dev.yml up
|
|
248
288
|
```
|
|
249
289
|
|
|
290
|
+
The dev compose command starts all defined services and does not install the host
|
|
291
|
+
agent. API/UI source mounts do not update the Python code used by the separately
|
|
292
|
+
installed `cyclo_host_agent.service`; install the updated CLI package and refresh
|
|
293
|
+
that service when testing host-agent changes.
|
|
294
|
+
|
|
250
295
|
See the [root README](../README.md#development).
|
|
251
296
|
|
|
252
297
|
---
|
|
@@ -259,7 +304,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
|
|
|
259
304
|
|---------|---------|
|
|
260
305
|
| **fastapi** | `cyclo_host_agent` HTTP API |
|
|
261
306
|
| **uvicorn** | `cyclo_host_agent` server |
|
|
262
|
-
| **psutil** |
|
|
307
|
+
| **psutil** | Host CPU sampler, memory/disk statistics and process inspection |
|
|
263
308
|
|
|
264
309
|
The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
|
|
265
310
|
|
|
@@ -2,6 +2,7 @@ README.md
|
|
|
2
2
|
pyproject.toml
|
|
3
3
|
setup.cfg
|
|
4
4
|
cyclo_host_agent/__init__.py
|
|
5
|
+
cyclo_host_agent/cpu_usage.py
|
|
5
6
|
cyclo_host_agent/main.py
|
|
6
7
|
cyclo_host_agent/models.py
|
|
7
8
|
cyclo_host_agent/routers/__init__.py
|
|
@@ -18,4 +19,5 @@ cyclo_manager.egg-info/top_level.txt
|
|
|
18
19
|
cyclo_manager_cli/__init__.py
|
|
19
20
|
cyclo_manager_cli/cli.py
|
|
20
21
|
cyclo_manager_cli/config/config.yml
|
|
21
|
-
cyclo_manager_cli/docker/docker-compose.yml
|
|
22
|
+
cyclo_manager_cli/docker/docker-compose.yml
|
|
23
|
+
tests/test_cpu_usage.py
|
{cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager_cli/docker/docker-compose.yml
RENAMED
|
@@ -21,8 +21,10 @@ services:
|
|
|
21
21
|
- ${CYCLO_MANAGER_CONFIG_FILE}:/app/config.yml # For hot-reload on config changes
|
|
22
22
|
- /var/run/docker.sock:/var/run/docker.sock # For Docker API access
|
|
23
23
|
- /:/host_root:ro # For host disk usage and OS info
|
|
24
|
+
- ${HOME}/cyclo_manager_ros_bags:/cyclo_manager_ros_bags
|
|
24
25
|
environment:
|
|
25
26
|
- CONFIG_FILE=/app/config.yml
|
|
27
|
+
- RECORDINGS_DIR=/cyclo_manager_ros_bags
|
|
26
28
|
- HOST_HOSTNAME=${HOSTNAME}
|
|
27
29
|
|
|
28
30
|
ui:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cyclo-manager"
|
|
7
|
-
version = "1.1.0.
|
|
7
|
+
version = "1.1.0.dev4"
|
|
8
8
|
description = "cyclo_manager CLI: pip-installable launcher for cyclo_manager server and UI containers. Run 'cyclo_manager up' to start Docker stack."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 ROBOTIS CO., LTD.
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
#
|
|
17
|
+
# Author: Hyungyu Kim
|
|
18
|
+
|
|
19
|
+
"""Regression tests for continuous CPU sampling and shared API summaries."""
|
|
20
|
+
|
|
21
|
+
from types import SimpleNamespace
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import unittest
|
|
25
|
+
from unittest.mock import Mock, call, patch
|
|
26
|
+
|
|
27
|
+
from cyclo_host_agent.cpu_usage import CpuUsageSampler
|
|
28
|
+
from cyclo_host_agent.main import app, lifespan
|
|
29
|
+
from cyclo_host_agent.routers import system_stats
|
|
30
|
+
from fastapi import HTTPException
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CpuUsageTests(unittest.TestCase):
|
|
34
|
+
"""Check measurement windows, startup behavior, and sampling ownership."""
|
|
35
|
+
|
|
36
|
+
def test_rolling_window_discards_prime_and_oldest_sample(self):
|
|
37
|
+
"""Average available samples at startup and only the last three afterward."""
|
|
38
|
+
sampler = CpuUsageSampler()
|
|
39
|
+
observed = []
|
|
40
|
+
|
|
41
|
+
def next_interval(interval):
|
|
42
|
+
self.assertEqual(interval, 1.0)
|
|
43
|
+
observed.append(sampler.average())
|
|
44
|
+
if len(observed) == 5:
|
|
45
|
+
sampler._stop.set()
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
with patch('cyclo_host_agent.cpu_usage.psutil.cpu_percent',
|
|
50
|
+
side_effect=[99.0, 10.0, 20.0, 60.0, 90.0]) as cpu:
|
|
51
|
+
with patch.object(sampler._stop, 'wait', side_effect=next_interval):
|
|
52
|
+
sampler._run()
|
|
53
|
+
self.assertEqual(observed, [None, 10.0, 15.0, 30.0, 56.7])
|
|
54
|
+
self.assertEqual(cpu.call_args_list, [call(interval=None)] * 5)
|
|
55
|
+
|
|
56
|
+
def test_background_sampling_is_independent_of_readers(self):
|
|
57
|
+
"""Prime and sample on one thread, even without API requests."""
|
|
58
|
+
sampler = CpuUsageSampler(interval=0.01)
|
|
59
|
+
thread_ids = []
|
|
60
|
+
|
|
61
|
+
def cpu_percent(interval):
|
|
62
|
+
thread_ids.append(threading.get_ident())
|
|
63
|
+
return 30.0
|
|
64
|
+
|
|
65
|
+
with patch('cyclo_host_agent.cpu_usage.psutil.cpu_percent',
|
|
66
|
+
side_effect=cpu_percent):
|
|
67
|
+
sampler.start()
|
|
68
|
+
worker = sampler._thread
|
|
69
|
+
try:
|
|
70
|
+
sampler.start()
|
|
71
|
+
self.assertIs(sampler._thread, worker)
|
|
72
|
+
deadline = time.monotonic() + 2
|
|
73
|
+
while sampler.average() is None and time.monotonic() < deadline:
|
|
74
|
+
time.sleep(0.005)
|
|
75
|
+
self.assertEqual(sampler.average(), 30.0)
|
|
76
|
+
finally:
|
|
77
|
+
sampler.stop()
|
|
78
|
+
self.assertFalse(worker.is_alive())
|
|
79
|
+
count = len(thread_ids)
|
|
80
|
+
for _ in range(50):
|
|
81
|
+
self.assertEqual(sampler.average(), 30.0)
|
|
82
|
+
self.assertEqual(len(thread_ids), count)
|
|
83
|
+
self.assertGreaterEqual(count, 2)
|
|
84
|
+
self.assertEqual(len(set(thread_ids)), 1)
|
|
85
|
+
self.assertNotEqual(thread_ids[0], threading.get_ident())
|
|
86
|
+
sampler._interval = 1.0
|
|
87
|
+
sampler.start()
|
|
88
|
+
try:
|
|
89
|
+
self.assertIsNone(sampler.average())
|
|
90
|
+
finally:
|
|
91
|
+
sampler.stop()
|
|
92
|
+
|
|
93
|
+
def test_warmup_does_not_report_a_fake_zero(self):
|
|
94
|
+
"""Return a retryable response before a complete sample exists."""
|
|
95
|
+
with patch.object(system_stats, 'cpu_sampler', CpuUsageSampler()):
|
|
96
|
+
with self.assertRaises(HTTPException) as result:
|
|
97
|
+
system_stats.get_system_stats()
|
|
98
|
+
self.assertEqual(result.exception.status_code, 503)
|
|
99
|
+
|
|
100
|
+
def test_sampling_failure_clears_history_and_primes_again(self):
|
|
101
|
+
"""Do not keep old CPU values when a measurement fails."""
|
|
102
|
+
sampler = CpuUsageSampler()
|
|
103
|
+
observed = []
|
|
104
|
+
|
|
105
|
+
def next_interval(interval):
|
|
106
|
+
observed.append(sampler.average())
|
|
107
|
+
if len(observed) == 5:
|
|
108
|
+
sampler._stop.set()
|
|
109
|
+
return True
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
with patch('cyclo_host_agent.cpu_usage.psutil.cpu_percent',
|
|
113
|
+
side_effect=[99.0, 10.0, OSError('CPU read failed'), 99.0, 60.0]), \
|
|
114
|
+
patch('cyclo_host_agent.cpu_usage.logger.exception') as log, \
|
|
115
|
+
patch.object(sampler._stop, 'wait', side_effect=next_interval):
|
|
116
|
+
sampler._run()
|
|
117
|
+
self.assertEqual(observed, [None, 10.0, None, None, 60.0])
|
|
118
|
+
log.assert_called_once()
|
|
119
|
+
|
|
120
|
+
def test_status_and_process_summary_read_the_same_average(self):
|
|
121
|
+
"""Total CPU is shared while process rows retain their own measurements."""
|
|
122
|
+
process = Mock()
|
|
123
|
+
process.as_dict.return_value = {
|
|
124
|
+
'pid': 42, 'username': 'robot', 'cpu_percent': 80.0,
|
|
125
|
+
'memory_percent': 1.0, 'memory_info': SimpleNamespace(rss=1024),
|
|
126
|
+
'cmdline': ['robot-node'], 'name': 'robot-node',
|
|
127
|
+
}
|
|
128
|
+
with patch.object(system_stats.cpu_sampler, 'average', return_value=12.3), \
|
|
129
|
+
patch.object(system_stats.psutil, 'cpu_percent',
|
|
130
|
+
side_effect=AssertionError('API must not resample total CPU')), \
|
|
131
|
+
patch.object(system_stats.psutil, 'virtual_memory',
|
|
132
|
+
return_value=SimpleNamespace(used=1024**3, total=8*1024**3)), \
|
|
133
|
+
patch.object(system_stats.psutil, 'disk_usage',
|
|
134
|
+
return_value=SimpleNamespace(used=10*1024**3, total=100*1024**3)), \
|
|
135
|
+
patch.object(system_stats, '_extra_storage_disk_usage', return_value=(None, None)), \
|
|
136
|
+
patch.object(system_stats, '_temperature', return_value=None), \
|
|
137
|
+
patch.object(system_stats.psutil, 'process_iter', return_value=[process]), \
|
|
138
|
+
patch.object(system_stats.psutil, 'cpu_count', return_value=4), \
|
|
139
|
+
patch.object(system_stats.time, 'sleep') as sleep:
|
|
140
|
+
status = system_stats.get_system_stats()
|
|
141
|
+
processes = system_stats._sample_processes()
|
|
142
|
+
self.assertEqual(status.cpu_percent, 12.3)
|
|
143
|
+
self.assertEqual(processes.cpu_percent, status.cpu_percent)
|
|
144
|
+
self.assertEqual(processes.processes[0].cpu_percent, 20.0)
|
|
145
|
+
process.cpu_percent.assert_called_once_with(None)
|
|
146
|
+
sleep.assert_called_once_with(0.2)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class CpuLifespanTests(unittest.IsolatedAsyncioTestCase):
|
|
150
|
+
"""Check sampler cleanup when the API exits."""
|
|
151
|
+
|
|
152
|
+
async def test_lifespan_stops_sampler_even_on_failure(self):
|
|
153
|
+
"""Stop the background worker on both normal and exceptional shutdown."""
|
|
154
|
+
sampler = Mock()
|
|
155
|
+
with patch.object(system_stats, 'cpu_sampler', sampler):
|
|
156
|
+
async with lifespan(app):
|
|
157
|
+
sampler.start.assert_called_once_with()
|
|
158
|
+
sampler.stop.assert_not_called()
|
|
159
|
+
sampler.stop.assert_called_once_with()
|
|
160
|
+
sampler.reset_mock()
|
|
161
|
+
with self.assertRaises(RuntimeError):
|
|
162
|
+
async with lifespan(app):
|
|
163
|
+
raise RuntimeError('shutdown')
|
|
164
|
+
sampler.start.assert_called_once_with()
|
|
165
|
+
sampler.stop.assert_called_once_with()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == '__main__':
|
|
169
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{cyclo_manager-1.1.0.dev2 → cyclo_manager-1.1.0.dev4}/cyclo_manager.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|