cyclo-manager 1.0.0.dev10__tar.gz → 1.1.0.dev1__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.0.0.dev10 → cyclo_manager-1.1.0.dev1}/PKG-INFO +17 -9
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/README.md +16 -8
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_host_agent/main.py +3 -1
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_host_agent/models.py +106 -0
- cyclo_manager-1.1.0.dev1/cyclo_host_agent/routers/files.py +216 -0
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_host_agent/routers/repos.py +178 -4
- cyclo_manager-1.1.0.dev1/cyclo_host_agent/routers/system_stats.py +156 -0
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager.egg-info/PKG-INFO +17 -9
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager.egg-info/SOURCES.txt +2 -0
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager_cli/docker/docker-compose.yml +2 -2
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/pyproject.toml +1 -1
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_host_agent/__init__.py +0 -0
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_host_agent/routers/__init__.py +0 -0
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_host_agent/routers/update.py +0 -0
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager.egg-info/dependency_links.txt +0 -0
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager.egg-info/entry_points.txt +0 -0
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager.egg-info/requires.txt +0 -0
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager.egg-info/top_level.txt +0 -0
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager_cli/__init__.py +0 -0
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager_cli/cli.py +0 -0
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager_cli/config/config.yml +0 -0
- {cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cyclo-manager
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0.dev1
|
|
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
|
|
@@ -18,7 +18,7 @@ Requires-Dist: ruff; extra == "dev"
|
|
|
18
18
|
**Console commands:** `cyclo_manager` (underscore), `cyclo-manager` (hyphen)
|
|
19
19
|
**Host agent:** `cyclo_host_agent` (installed by the package, run via systemd)
|
|
20
20
|
|
|
21
|
-
This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It:
|
|
21
|
+
This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It is intended for production robots that run the pre-built Docker images. It:
|
|
22
22
|
|
|
23
23
|
1. Runs **`docker compose`** against a **bundled** [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/docker/docker-compose.yml) to start the **API** and **web UI** containers.
|
|
24
24
|
2. Installs and maintains **`cyclo_host_agent`** as a **systemd** service for host-level operations (git repo updates, `cyclo_manager` package update from the UI).
|
|
@@ -50,11 +50,12 @@ For the full monorepo (API source, UI source, dev compose), see the [repository
|
|
|
50
50
|
|
|
51
51
|
## Prerequisites
|
|
52
52
|
|
|
53
|
-
- **Docker** with **Compose v2** (`docker compose
|
|
53
|
+
- **Docker** with **Compose v2** (`docker compose`; the legacy `docker-compose` binary alone is not enough)
|
|
54
54
|
- **Python 3.10+**
|
|
55
|
+
- **systemd**: required for the managed `cyclo_host_agent.service`
|
|
55
56
|
- **`sudo`** — required on normal-user hosts to install the host agent systemd unit, socket directory permissions, and sudoers rules
|
|
56
57
|
- For **`cyclo_manager update`:** **`pip`** or **`pip3`** on `PATH`
|
|
57
|
-
- **Root shell handling** — root
|
|
58
|
+
- **Root shell handling** — `cyclo_manager up` rejects direct root execution when a normal login user exists. On root-only devices, the host agent is installed as root.
|
|
58
59
|
- **Agent sockets** on the host under `/var/run/robotis/agent_sockets/` (robot containers and host agent). The bundled Compose file bind-mounts this tree into the API container as `/agents/`.
|
|
59
60
|
|
|
60
61
|
---
|
|
@@ -94,6 +95,8 @@ python3 -m cyclo_manager_cli.cli up
|
|
|
94
95
|
| `cyclo_manager update --no-pull` | Same as `update`, but skip image pull (use locally cached images). |
|
|
95
96
|
| `cyclo_manager --help` | Subcommand overview |
|
|
96
97
|
|
|
98
|
+
The CLI also has an internal `refresh-host-agent` subcommand. It is used by the sudoers rule and should normally not be invoked by users directly.
|
|
99
|
+
|
|
97
100
|
**`down` vs `update`:** `down` removes the host agent entirely. `update` only stops Docker containers, upgrades the pip package, restarts the stack, and refreshes the host agent — it does **not** call `cyclo_manager down`.
|
|
98
101
|
|
|
99
102
|
---
|
|
@@ -120,18 +123,21 @@ The pip package installs a small **FastAPI** server that listens on a **Unix dom
|
|
|
120
123
|
|------|------|
|
|
121
124
|
| Socket | `/var/run/robotis/agent_sockets/host/host_agent.sock` |
|
|
122
125
|
| systemd unit | `cyclo_host_agent.service` |
|
|
123
|
-
| sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts; `refresh-host-agent` CLI subcommand for
|
|
126
|
+
| sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts; `refresh-host-agent` CLI subcommand for package-update refresh; skipped on root-only devices) |
|
|
124
127
|
|
|
125
128
|
The API container reaches it at `/agents/host/host_agent.sock` (see bundled `config.yml`).
|
|
126
129
|
|
|
127
130
|
**Responsibilities:**
|
|
128
131
|
|
|
129
|
-
- List
|
|
130
|
-
-
|
|
132
|
+
- List managed **ROBOTIS-GIT** git repositories in the configured workspace.
|
|
133
|
+
- Check update availability from remote tags and local `package.xml` versions.
|
|
134
|
+
- Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
|
|
135
|
+
- Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
|
|
136
|
+
- Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
|
|
131
137
|
|
|
132
138
|
`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.
|
|
133
139
|
|
|
134
|
-
Repository scanning uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically.
|
|
140
|
+
Repository scanning uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically. Only repositories whose `origin` remote belongs to `ROBOTIS-GIT` are returned by the repo list/update endpoints.
|
|
135
141
|
|
|
136
142
|
---
|
|
137
143
|
|
|
@@ -211,6 +217,8 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
|
|
|
211
217
|
| Variable | Set by | Purpose |
|
|
212
218
|
|----------|--------|---------|
|
|
213
219
|
| **`CYCLO_MANAGER_CONFIG_FILE`** | CLI (`up`, `down`, `update`) | Absolute path to bundled `config.yml` on the host; mounted into the API container |
|
|
220
|
+
| **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories |
|
|
221
|
+
| **`CYCLO_HOST_AGENT_FILE_ROOT`** | User (optional) | Root directory for the Files UI/API. Defaults to the host agent service user's home (`$HOME`, e.g. `/root` when running as root) |
|
|
214
222
|
| **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
|
|
215
223
|
| **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
|
|
216
224
|
| **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
|
|
@@ -252,7 +260,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
|
|
|
252
260
|
|---------|---------|
|
|
253
261
|
| **fastapi** | `cyclo_host_agent` HTTP API |
|
|
254
262
|
| **uvicorn** | `cyclo_host_agent` server |
|
|
255
|
-
| **psutil** |
|
|
263
|
+
| **psutil** | Declared package dependency for host/system utility support |
|
|
256
264
|
|
|
257
265
|
The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
|
|
258
266
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
**Console commands:** `cyclo_manager` (underscore), `cyclo-manager` (hyphen)
|
|
5
5
|
**Host agent:** `cyclo_host_agent` (installed by the package, run via systemd)
|
|
6
6
|
|
|
7
|
-
This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It:
|
|
7
|
+
This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It is intended for production robots that run the pre-built Docker images. It:
|
|
8
8
|
|
|
9
9
|
1. Runs **`docker compose`** against a **bundled** [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/docker/docker-compose.yml) to start the **API** and **web UI** containers.
|
|
10
10
|
2. Installs and maintains **`cyclo_host_agent`** as a **systemd** service for host-level operations (git repo updates, `cyclo_manager` package update from the UI).
|
|
@@ -36,11 +36,12 @@ For the full monorepo (API source, UI source, dev compose), see the [repository
|
|
|
36
36
|
|
|
37
37
|
## Prerequisites
|
|
38
38
|
|
|
39
|
-
- **Docker** with **Compose v2** (`docker compose
|
|
39
|
+
- **Docker** with **Compose v2** (`docker compose`; the legacy `docker-compose` binary alone is not enough)
|
|
40
40
|
- **Python 3.10+**
|
|
41
|
+
- **systemd**: required for the managed `cyclo_host_agent.service`
|
|
41
42
|
- **`sudo`** — required on normal-user hosts to install the host agent systemd unit, socket directory permissions, and sudoers rules
|
|
42
43
|
- For **`cyclo_manager update`:** **`pip`** or **`pip3`** on `PATH`
|
|
43
|
-
- **Root shell handling** — root
|
|
44
|
+
- **Root shell handling** — `cyclo_manager up` rejects direct root execution when a normal login user exists. On root-only devices, the host agent is installed as root.
|
|
44
45
|
- **Agent sockets** on the host under `/var/run/robotis/agent_sockets/` (robot containers and host agent). The bundled Compose file bind-mounts this tree into the API container as `/agents/`.
|
|
45
46
|
|
|
46
47
|
---
|
|
@@ -80,6 +81,8 @@ python3 -m cyclo_manager_cli.cli up
|
|
|
80
81
|
| `cyclo_manager update --no-pull` | Same as `update`, but skip image pull (use locally cached images). |
|
|
81
82
|
| `cyclo_manager --help` | Subcommand overview |
|
|
82
83
|
|
|
84
|
+
The CLI also has an internal `refresh-host-agent` subcommand. It is used by the sudoers rule and should normally not be invoked by users directly.
|
|
85
|
+
|
|
83
86
|
**`down` vs `update`:** `down` removes the host agent entirely. `update` only stops Docker containers, upgrades the pip package, restarts the stack, and refreshes the host agent — it does **not** call `cyclo_manager down`.
|
|
84
87
|
|
|
85
88
|
---
|
|
@@ -106,18 +109,21 @@ The pip package installs a small **FastAPI** server that listens on a **Unix dom
|
|
|
106
109
|
|------|------|
|
|
107
110
|
| Socket | `/var/run/robotis/agent_sockets/host/host_agent.sock` |
|
|
108
111
|
| systemd unit | `cyclo_host_agent.service` |
|
|
109
|
-
| sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts; `refresh-host-agent` CLI subcommand for
|
|
112
|
+
| sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts; `refresh-host-agent` CLI subcommand for package-update refresh; skipped on root-only devices) |
|
|
110
113
|
|
|
111
114
|
The API container reaches it at `/agents/host/host_agent.sock` (see bundled `config.yml`).
|
|
112
115
|
|
|
113
116
|
**Responsibilities:**
|
|
114
117
|
|
|
115
|
-
- List
|
|
116
|
-
-
|
|
118
|
+
- List managed **ROBOTIS-GIT** git repositories in the configured workspace.
|
|
119
|
+
- Check update availability from remote tags and local `package.xml` versions.
|
|
120
|
+
- Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
|
|
121
|
+
- Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
|
|
122
|
+
- Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
|
|
117
123
|
|
|
118
124
|
`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.
|
|
119
125
|
|
|
120
|
-
Repository scanning uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically.
|
|
126
|
+
Repository scanning uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically. Only repositories whose `origin` remote belongs to `ROBOTIS-GIT` are returned by the repo list/update endpoints.
|
|
121
127
|
|
|
122
128
|
---
|
|
123
129
|
|
|
@@ -197,6 +203,8 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
|
|
|
197
203
|
| Variable | Set by | Purpose |
|
|
198
204
|
|----------|--------|---------|
|
|
199
205
|
| **`CYCLO_MANAGER_CONFIG_FILE`** | CLI (`up`, `down`, `update`) | Absolute path to bundled `config.yml` on the host; mounted into the API container |
|
|
206
|
+
| **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories |
|
|
207
|
+
| **`CYCLO_HOST_AGENT_FILE_ROOT`** | User (optional) | Root directory for the Files UI/API. Defaults to the host agent service user's home (`$HOME`, e.g. `/root` when running as root) |
|
|
200
208
|
| **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
|
|
201
209
|
| **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
|
|
202
210
|
| **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
|
|
@@ -238,7 +246,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
|
|
|
238
246
|
|---------|---------|
|
|
239
247
|
| **fastapi** | `cyclo_host_agent` HTTP API |
|
|
240
248
|
| **uvicorn** | `cyclo_host_agent` server |
|
|
241
|
-
| **psutil** |
|
|
249
|
+
| **psutil** | Declared package dependency for host/system utility support |
|
|
242
250
|
|
|
243
251
|
The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
|
|
244
252
|
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import logging
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
|
|
24
|
-
from cyclo_host_agent.routers import repos, update
|
|
24
|
+
from cyclo_host_agent.routers import files, repos, system_stats, update
|
|
25
25
|
from cyclo_manager_cli import __version__
|
|
26
26
|
from fastapi import FastAPI
|
|
27
27
|
import uvicorn
|
|
@@ -41,6 +41,8 @@ app = FastAPI(
|
|
|
41
41
|
)
|
|
42
42
|
|
|
43
43
|
app.include_router(repos.router)
|
|
44
|
+
app.include_router(files.router)
|
|
45
|
+
app.include_router(system_stats.router)
|
|
44
46
|
app.include_router(update.router)
|
|
45
47
|
|
|
46
48
|
|
|
@@ -103,3 +103,109 @@ class ContainerScriptResponse(BaseModel):
|
|
|
103
103
|
action: str
|
|
104
104
|
success: bool
|
|
105
105
|
output: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ContainerStartStatusResponse(BaseModel):
|
|
109
|
+
"""Current output/status for a container start operation."""
|
|
110
|
+
|
|
111
|
+
running: bool
|
|
112
|
+
output: str
|
|
113
|
+
success: bool | None = None
|
|
114
|
+
error: str = ''
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class HostSystemStatsResponse(BaseModel):
|
|
118
|
+
"""Host CPU/memory/disk/uptime/temperature status."""
|
|
119
|
+
|
|
120
|
+
cpu_percent: float
|
|
121
|
+
memory_used_mb: int
|
|
122
|
+
memory_total_mb: int
|
|
123
|
+
disk_used_gb: float
|
|
124
|
+
disk_total_gb: float
|
|
125
|
+
ssd_used_gb: float | None = None
|
|
126
|
+
ssd_total_gb: float | None = None
|
|
127
|
+
ssd_mount_path: str | None = None
|
|
128
|
+
uptime_seconds: int
|
|
129
|
+
temperature_celsius: float | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class HostProcessInfo(BaseModel):
|
|
133
|
+
"""Single host process CPU/memory usage sample."""
|
|
134
|
+
|
|
135
|
+
pid: int
|
|
136
|
+
user: str
|
|
137
|
+
cpu_percent: float
|
|
138
|
+
memory_percent: float
|
|
139
|
+
rss_kb: int | None = None
|
|
140
|
+
command: str
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class HostProcessesResponse(BaseModel):
|
|
144
|
+
"""Host process list with aggregate resource summary."""
|
|
145
|
+
|
|
146
|
+
cpu_percent: float
|
|
147
|
+
memory_used_mb: int
|
|
148
|
+
memory_total_mb: int
|
|
149
|
+
processes: list[HostProcessInfo]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class FileTreeEntry(BaseModel):
|
|
153
|
+
"""Single file browser entry."""
|
|
154
|
+
|
|
155
|
+
name: str
|
|
156
|
+
path: str
|
|
157
|
+
type: str
|
|
158
|
+
size: int | None = None
|
|
159
|
+
modified: float | None = None
|
|
160
|
+
readonly: bool = False
|
|
161
|
+
hidden: bool = False
|
|
162
|
+
symlink: bool = False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class FileTreeResponse(BaseModel):
|
|
166
|
+
"""Response for GET /files/tree."""
|
|
167
|
+
|
|
168
|
+
root_path: str
|
|
169
|
+
path: str
|
|
170
|
+
entries: list[FileTreeEntry]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class FileReadResponse(BaseModel):
|
|
174
|
+
"""Response for GET /files/read."""
|
|
175
|
+
|
|
176
|
+
path: str
|
|
177
|
+
content: str
|
|
178
|
+
size: int
|
|
179
|
+
modified: float
|
|
180
|
+
readonly: bool = False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class FileWriteRequest(BaseModel):
|
|
184
|
+
"""Request body for POST /files/write."""
|
|
185
|
+
|
|
186
|
+
path: str
|
|
187
|
+
content: str
|
|
188
|
+
expected_modified: float | None = None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class FileCreateRequest(BaseModel):
|
|
192
|
+
"""Request body for POST /files/create."""
|
|
193
|
+
|
|
194
|
+
path: str
|
|
195
|
+
type: str
|
|
196
|
+
content: str = ''
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class FileRenameRequest(BaseModel):
|
|
200
|
+
"""Request body for POST /files/rename."""
|
|
201
|
+
|
|
202
|
+
path: str
|
|
203
|
+
new_name: str
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class FileOperationResponse(BaseModel):
|
|
207
|
+
"""Generic file operation response."""
|
|
208
|
+
|
|
209
|
+
path: str
|
|
210
|
+
success: bool
|
|
211
|
+
message: str
|
|
@@ -0,0 +1,216 @@
|
|
|
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
|
+
"""Host file browser and editor endpoints."""
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from cyclo_host_agent.models import (
|
|
26
|
+
FileCreateRequest,
|
|
27
|
+
FileOperationResponse,
|
|
28
|
+
FileReadResponse,
|
|
29
|
+
FileRenameRequest,
|
|
30
|
+
FileTreeEntry,
|
|
31
|
+
FileTreeResponse,
|
|
32
|
+
FileWriteRequest,
|
|
33
|
+
)
|
|
34
|
+
from fastapi import APIRouter, HTTPException, Query, status
|
|
35
|
+
|
|
36
|
+
router = APIRouter(prefix='/files', tags=['files'])
|
|
37
|
+
|
|
38
|
+
MAX_READ_BYTES = 2 * 1024 * 1024
|
|
39
|
+
MAX_WRITE_BYTES = 4 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_file_root() -> Path:
|
|
43
|
+
configured_root = os.environ.get('CYCLO_HOST_AGENT_FILE_ROOT')
|
|
44
|
+
if configured_root:
|
|
45
|
+
return Path(configured_root).expanduser().resolve()
|
|
46
|
+
sudo_user = os.environ.get('SUDO_USER')
|
|
47
|
+
if sudo_user:
|
|
48
|
+
return Path(f'/home/{sudo_user}').resolve()
|
|
49
|
+
return Path.home().resolve()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
FILE_ROOT_PATH = _resolve_file_root()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _safe_path(relative_path: str = '') -> tuple[Path, str]:
|
|
56
|
+
target = (FILE_ROOT_PATH / relative_path).resolve()
|
|
57
|
+
if target != FILE_ROOT_PATH and FILE_ROOT_PATH not in target.parents:
|
|
58
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
|
|
59
|
+
rel = '' if target == FILE_ROOT_PATH else target.relative_to(FILE_ROOT_PATH).as_posix()
|
|
60
|
+
return target, rel
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_binary(path: Path) -> bool:
|
|
64
|
+
try:
|
|
65
|
+
return b'\x00' in path.read_bytes()[:8192]
|
|
66
|
+
except OSError:
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _entry(path: Path) -> FileTreeEntry:
|
|
71
|
+
stat = path.lstat()
|
|
72
|
+
return FileTreeEntry(
|
|
73
|
+
name=path.name,
|
|
74
|
+
path='' if path == FILE_ROOT_PATH else path.relative_to(FILE_ROOT_PATH).as_posix(),
|
|
75
|
+
type='directory' if path.is_dir() else 'file',
|
|
76
|
+
size=None if path.is_dir() else stat.st_size,
|
|
77
|
+
modified=stat.st_mtime,
|
|
78
|
+
readonly=not os.access(path, os.W_OK),
|
|
79
|
+
hidden=path.name.startswith('.'),
|
|
80
|
+
symlink=path.is_symlink(),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@router.get('/tree', response_model=FileTreeResponse)
|
|
85
|
+
async def list_directory(
|
|
86
|
+
path: str = '',
|
|
87
|
+
show_hidden: bool = Query(False),
|
|
88
|
+
) -> FileTreeResponse:
|
|
89
|
+
"""Return one directory level under the configured file root."""
|
|
90
|
+
target, rel = _safe_path(path)
|
|
91
|
+
if not target.is_dir():
|
|
92
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a directory')
|
|
93
|
+
|
|
94
|
+
entries: list[FileTreeEntry] = []
|
|
95
|
+
for child in sorted(target.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
|
|
96
|
+
if not show_hidden and child.name.startswith('.'):
|
|
97
|
+
continue
|
|
98
|
+
try:
|
|
99
|
+
entries.append(_entry(child))
|
|
100
|
+
except OSError:
|
|
101
|
+
continue
|
|
102
|
+
return FileTreeResponse(root_path=str(FILE_ROOT_PATH), path=rel, entries=entries)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@router.get('/read', response_model=FileReadResponse)
|
|
106
|
+
async def read_file(
|
|
107
|
+
path: str,
|
|
108
|
+
) -> FileReadResponse:
|
|
109
|
+
"""Read a UTF-8 text file under the configured file root."""
|
|
110
|
+
target, rel = _safe_path(path)
|
|
111
|
+
if not target.is_file():
|
|
112
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a file')
|
|
113
|
+
stat = target.stat()
|
|
114
|
+
if stat.st_size > MAX_READ_BYTES:
|
|
115
|
+
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='File is too large to edit')
|
|
116
|
+
if _is_binary(target):
|
|
117
|
+
raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail='Binary files are not editable')
|
|
118
|
+
try:
|
|
119
|
+
content = target.read_text(encoding='utf-8', errors='replace')
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
122
|
+
return FileReadResponse(
|
|
123
|
+
path=rel,
|
|
124
|
+
content=content,
|
|
125
|
+
size=stat.st_size,
|
|
126
|
+
modified=stat.st_mtime,
|
|
127
|
+
readonly=not os.access(target, os.W_OK),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@router.post('/write', response_model=FileOperationResponse)
|
|
132
|
+
async def write_file(req: FileWriteRequest) -> FileOperationResponse:
|
|
133
|
+
"""Write a UTF-8 text file under the configured file root."""
|
|
134
|
+
target, rel = _safe_path(req.path)
|
|
135
|
+
if target.exists() and not target.is_file():
|
|
136
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path is not a file')
|
|
137
|
+
if len(req.content.encode('utf-8')) > MAX_WRITE_BYTES:
|
|
138
|
+
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='Content is too large')
|
|
139
|
+
if req.expected_modified is not None and target.exists():
|
|
140
|
+
current_modified = target.stat().st_mtime
|
|
141
|
+
if abs(current_modified - req.expected_modified) > 0.001:
|
|
142
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='File changed on disk')
|
|
143
|
+
try:
|
|
144
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
target.write_text(req.content, encoding='utf-8')
|
|
146
|
+
except OSError as exc:
|
|
147
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
148
|
+
return FileOperationResponse(path=rel, success=True, message='Saved')
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@router.post('/create', response_model=FileOperationResponse)
|
|
152
|
+
async def create_path(req: FileCreateRequest) -> FileOperationResponse:
|
|
153
|
+
"""Create a file or folder under the configured file root."""
|
|
154
|
+
target, rel = _safe_path(req.path)
|
|
155
|
+
if target.exists():
|
|
156
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Path already exists')
|
|
157
|
+
try:
|
|
158
|
+
if req.type == 'directory':
|
|
159
|
+
target.mkdir(parents=True)
|
|
160
|
+
message = 'Folder created'
|
|
161
|
+
elif req.type == 'file':
|
|
162
|
+
if len(req.content.encode('utf-8')) > MAX_WRITE_BYTES:
|
|
163
|
+
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail='Content is too large')
|
|
164
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
target.write_text(req.content, encoding='utf-8')
|
|
166
|
+
message = 'File created'
|
|
167
|
+
else:
|
|
168
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Type must be file or directory')
|
|
169
|
+
except OSError as exc:
|
|
170
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
171
|
+
return FileOperationResponse(path=rel, success=True, message=message)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@router.post('/rename', response_model=FileOperationResponse)
|
|
175
|
+
async def rename_path(req: FileRenameRequest) -> FileOperationResponse:
|
|
176
|
+
"""Rename a file or folder under the configured file root."""
|
|
177
|
+
target, _ = _safe_path(req.path)
|
|
178
|
+
if not target.exists():
|
|
179
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
|
|
180
|
+
if '/' in req.new_name or '\\' in req.new_name or req.new_name in {'', '.', '..'}:
|
|
181
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid name')
|
|
182
|
+
destination = (target.parent / req.new_name).resolve()
|
|
183
|
+
if destination != FILE_ROOT_PATH and FILE_ROOT_PATH not in destination.parents:
|
|
184
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Path escapes file root')
|
|
185
|
+
if destination.exists():
|
|
186
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Destination already exists')
|
|
187
|
+
try:
|
|
188
|
+
target.rename(destination)
|
|
189
|
+
except OSError as exc:
|
|
190
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
191
|
+
rel = '' if destination == FILE_ROOT_PATH else destination.relative_to(FILE_ROOT_PATH).as_posix()
|
|
192
|
+
return FileOperationResponse(path=rel, success=True, message='Renamed')
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@router.delete('', response_model=FileOperationResponse)
|
|
196
|
+
async def delete_path(
|
|
197
|
+
path: str,
|
|
198
|
+
recursive: bool = Query(False),
|
|
199
|
+
) -> FileOperationResponse:
|
|
200
|
+
"""Delete a file or folder under the configured file root."""
|
|
201
|
+
target, rel = _safe_path(path)
|
|
202
|
+
if target == FILE_ROOT_PATH:
|
|
203
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Cannot delete file root')
|
|
204
|
+
if not target.exists():
|
|
205
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Path not found')
|
|
206
|
+
try:
|
|
207
|
+
if target.is_dir():
|
|
208
|
+
if recursive:
|
|
209
|
+
shutil.rmtree(target)
|
|
210
|
+
else:
|
|
211
|
+
target.rmdir()
|
|
212
|
+
else:
|
|
213
|
+
target.unlink()
|
|
214
|
+
except OSError as exc:
|
|
215
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
|
216
|
+
return FileOperationResponse(path=rel, success=True, message='Deleted')
|
|
@@ -22,9 +22,11 @@ import asyncio
|
|
|
22
22
|
import os
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
import re
|
|
25
|
+
import time
|
|
25
26
|
from typing import Optional
|
|
26
27
|
|
|
27
28
|
from cyclo_host_agent.models import (
|
|
29
|
+
ContainerStartStatusResponse,
|
|
28
30
|
ContainerScriptResponse,
|
|
29
31
|
FileChange,
|
|
30
32
|
RepoBranchCheckResponse,
|
|
@@ -42,6 +44,9 @@ router = APIRouter(prefix='/repos', tags=['repos'])
|
|
|
42
44
|
|
|
43
45
|
GIT_TIMEOUT = 120.0
|
|
44
46
|
GIT_REMOTE_TIMEOUT = 5.0
|
|
47
|
+
CONTAINER_SCRIPT_TIMEOUT = 600.0
|
|
48
|
+
CONTAINER_JOB_RETENTION_SECONDS = 3600.0
|
|
49
|
+
CONTAINER_JOB_OUTPUT_LIMIT = 40000
|
|
45
50
|
|
|
46
51
|
|
|
47
52
|
def _resolve_workspace() -> Path:
|
|
@@ -337,6 +342,88 @@ async def _update_reset(repo_path: Path, preserve_files: list[str]) -> UpdateRes
|
|
|
337
342
|
|
|
338
343
|
# ── container helper ───────────────────────────────────────────────────────────
|
|
339
344
|
|
|
345
|
+
_container_start_status_by_repo: dict[str, dict] = {}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _prune_container_start_jobs() -> None:
|
|
349
|
+
now = time.time()
|
|
350
|
+
stale = [
|
|
351
|
+
name
|
|
352
|
+
for name, status_entry in _container_start_status_by_repo.items()
|
|
353
|
+
if now - float(status_entry.get('updated_at') or 0) > CONTAINER_JOB_RETENTION_SECONDS
|
|
354
|
+
]
|
|
355
|
+
for name in stale:
|
|
356
|
+
_container_start_status_by_repo.pop(name, None)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _new_container_start_status(name: str) -> dict:
|
|
360
|
+
_prune_container_start_jobs()
|
|
361
|
+
now = time.time()
|
|
362
|
+
status_entry = {
|
|
363
|
+
'name': name,
|
|
364
|
+
'running': True,
|
|
365
|
+
'output': '',
|
|
366
|
+
'success': None,
|
|
367
|
+
'error': '',
|
|
368
|
+
'started_at': now,
|
|
369
|
+
'updated_at': now,
|
|
370
|
+
}
|
|
371
|
+
_container_start_status_by_repo[name] = status_entry
|
|
372
|
+
return status_entry
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _append_container_job_output(job: dict, line: str) -> None:
|
|
376
|
+
if not line:
|
|
377
|
+
return
|
|
378
|
+
output = f"{job.get('output') or ''}{line}\n"
|
|
379
|
+
if len(output) > CONTAINER_JOB_OUTPUT_LIMIT:
|
|
380
|
+
output = output[-CONTAINER_JOB_OUTPUT_LIMIT:]
|
|
381
|
+
job['output'] = output
|
|
382
|
+
job['updated_at'] = time.time()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _set_container_job_status(
|
|
386
|
+
job: dict,
|
|
387
|
+
*,
|
|
388
|
+
running: Optional[bool] = None,
|
|
389
|
+
success: Optional[bool] = None,
|
|
390
|
+
error: Optional[str] = None,
|
|
391
|
+
) -> None:
|
|
392
|
+
if running is not None:
|
|
393
|
+
job['running'] = running
|
|
394
|
+
if success is not None:
|
|
395
|
+
job['success'] = success
|
|
396
|
+
if error is not None:
|
|
397
|
+
job['error'] = error
|
|
398
|
+
job['updated_at'] = time.time()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _update_container_job_from_line(job: dict, line: str) -> None:
|
|
402
|
+
line = line.strip()
|
|
403
|
+
if not line:
|
|
404
|
+
return
|
|
405
|
+
_append_container_job_output(job, line)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _build_container_start_status_response(job: dict) -> ContainerStartStatusResponse:
|
|
409
|
+
return ContainerStartStatusResponse(
|
|
410
|
+
running=bool(job.get('running')),
|
|
411
|
+
output=job.get('output') or '',
|
|
412
|
+
success=job.get('success'),
|
|
413
|
+
error=job.get('error') or '',
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
async def _read_container_job_stream(stream: asyncio.StreamReader, job: dict) -> None:
|
|
418
|
+
while True:
|
|
419
|
+
raw = await stream.readline()
|
|
420
|
+
if not raw:
|
|
421
|
+
break
|
|
422
|
+
text = raw.decode(errors='replace').replace('\r', '\n')
|
|
423
|
+
for line in text.splitlines():
|
|
424
|
+
_update_container_job_from_line(job, line)
|
|
425
|
+
|
|
426
|
+
|
|
340
427
|
async def _run_container_sh(repo_path: Path, action: str) -> tuple[bool, str]:
|
|
341
428
|
script = repo_path / 'docker' / 'container.sh'
|
|
342
429
|
if not script.exists():
|
|
@@ -357,6 +444,71 @@ async def _run_container_sh(repo_path: Path, action: str) -> tuple[bool, str]:
|
|
|
357
444
|
return proc.returncode == 0, (stdout.decode() + stderr.decode()).strip()
|
|
358
445
|
|
|
359
446
|
|
|
447
|
+
async def _run_container_start(repo_path: Path, name: str) -> None:
|
|
448
|
+
job = _container_start_status_by_repo.get(name)
|
|
449
|
+
if job is None:
|
|
450
|
+
return
|
|
451
|
+
script = repo_path / 'docker' / 'container.sh'
|
|
452
|
+
if not script.exists():
|
|
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
|
+
]
|
|
481
|
+
try:
|
|
482
|
+
await asyncio.wait_for(proc.wait(), timeout=CONTAINER_SCRIPT_TIMEOUT)
|
|
483
|
+
await asyncio.gather(*readers)
|
|
484
|
+
except asyncio.TimeoutError:
|
|
485
|
+
proc.kill()
|
|
486
|
+
await proc.wait()
|
|
487
|
+
await asyncio.gather(*readers, return_exceptions=True)
|
|
488
|
+
_append_container_job_output(job, 'Timeout waiting for container.sh')
|
|
489
|
+
_set_container_job_status(
|
|
490
|
+
job,
|
|
491
|
+
running=False,
|
|
492
|
+
success=False,
|
|
493
|
+
error='Timeout waiting for container.sh',
|
|
494
|
+
)
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
if proc.returncode == 0:
|
|
498
|
+
_set_container_job_status(
|
|
499
|
+
job,
|
|
500
|
+
running=False,
|
|
501
|
+
success=True,
|
|
502
|
+
)
|
|
503
|
+
else:
|
|
504
|
+
_set_container_job_status(
|
|
505
|
+
job,
|
|
506
|
+
running=False,
|
|
507
|
+
success=False,
|
|
508
|
+
error=f'container.sh exited with code {proc.returncode}',
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
360
512
|
# ── endpoints ──────────────────────────────────────────────────────────────────
|
|
361
513
|
|
|
362
514
|
@router.get('/updates', response_model=RepoUpdatesResponse)
|
|
@@ -409,11 +561,33 @@ async def stop_repo_container(name: str) -> ContainerScriptResponse:
|
|
|
409
561
|
return ContainerScriptResponse(name=name, action='stop', success=success, output=output)
|
|
410
562
|
|
|
411
563
|
|
|
412
|
-
@router.post('/{name}/container/start', response_model=
|
|
413
|
-
async def start_repo_container(name: str) ->
|
|
564
|
+
@router.post('/{name}/container/start', response_model=ContainerStartStatusResponse)
|
|
565
|
+
async def start_repo_container(name: str) -> ContainerStartStatusResponse:
|
|
414
566
|
repo_path = _get_repo(name)
|
|
415
|
-
|
|
416
|
-
|
|
567
|
+
existing = _container_start_status_by_repo.get(name)
|
|
568
|
+
if existing and existing.get('running'):
|
|
569
|
+
return _build_container_start_status_response(existing)
|
|
570
|
+
|
|
571
|
+
job = _new_container_start_status(name)
|
|
572
|
+
asyncio.create_task(_run_container_start(repo_path, name))
|
|
573
|
+
return _build_container_start_status_response(job)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
@router.get(
|
|
577
|
+
'/{name}/container/start/status',
|
|
578
|
+
response_model=ContainerStartStatusResponse,
|
|
579
|
+
)
|
|
580
|
+
async def get_start_repo_container_status(
|
|
581
|
+
name: str,
|
|
582
|
+
) -> ContainerStartStatusResponse:
|
|
583
|
+
_get_repo(name)
|
|
584
|
+
job = _container_start_status_by_repo.get(name)
|
|
585
|
+
if job is None:
|
|
586
|
+
raise HTTPException(
|
|
587
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
588
|
+
detail=f"Container start status for '{name}' not found",
|
|
589
|
+
)
|
|
590
|
+
return _build_container_start_status_response(job)
|
|
417
591
|
|
|
418
592
|
|
|
419
593
|
@router.post('/{name}/update', response_model=UpdateResponse)
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
"""Host system status and process endpoints."""
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from cyclo_host_agent.models import (
|
|
25
|
+
HostProcessesResponse,
|
|
26
|
+
HostProcessInfo,
|
|
27
|
+
HostSystemStatsResponse,
|
|
28
|
+
)
|
|
29
|
+
from fastapi import APIRouter, Query
|
|
30
|
+
import psutil
|
|
31
|
+
|
|
32
|
+
router = APIRouter(prefix='/system', tags=['system'])
|
|
33
|
+
|
|
34
|
+
PROCESS_SAMPLE_INTERVAL_SECONDS = 0.2
|
|
35
|
+
PROCESS_DEFAULT_LIMIT = 80
|
|
36
|
+
EXTRA_STORAGE_MOUNT_PATHS = ('/mnt/ssd', '/data')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _temperature() -> float | None:
|
|
40
|
+
try:
|
|
41
|
+
sensors = psutil.sensors_temperatures()
|
|
42
|
+
if not sensors:
|
|
43
|
+
return None
|
|
44
|
+
for readings in sensors.values():
|
|
45
|
+
if readings:
|
|
46
|
+
return round(float(readings[0].current), 1)
|
|
47
|
+
except (AttributeError, Exception):
|
|
48
|
+
pass
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _mounted_disk_usage(path: str):
|
|
53
|
+
path_obj = Path(path)
|
|
54
|
+
if not path_obj.is_mount():
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
return psutil.disk_usage(path)
|
|
58
|
+
except OSError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extra_storage_disk_usage():
|
|
63
|
+
for mount_path in EXTRA_STORAGE_MOUNT_PATHS:
|
|
64
|
+
usage = _mounted_disk_usage(mount_path)
|
|
65
|
+
if usage:
|
|
66
|
+
return mount_path, usage
|
|
67
|
+
return None, None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _command_for_process(process: psutil.Process, info: dict) -> str:
|
|
71
|
+
cmdline = info.get('cmdline') or []
|
|
72
|
+
if cmdline:
|
|
73
|
+
return ' '.join(str(part) for part in cmdline)
|
|
74
|
+
return str(info.get('name') or process.pid)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _sample_processes(limit: int = PROCESS_DEFAULT_LIMIT) -> HostProcessesResponse:
|
|
78
|
+
tracked: list[psutil.Process] = []
|
|
79
|
+
cpu_count = psutil.cpu_count() or 1
|
|
80
|
+
for process in psutil.process_iter(['pid']):
|
|
81
|
+
try:
|
|
82
|
+
process.cpu_percent(None)
|
|
83
|
+
tracked.append(process)
|
|
84
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
overall_cpu_percent = psutil.cpu_percent(interval=PROCESS_SAMPLE_INTERVAL_SECONDS)
|
|
88
|
+
mem = psutil.virtual_memory()
|
|
89
|
+
|
|
90
|
+
processes: list[HostProcessInfo] = []
|
|
91
|
+
for process in tracked:
|
|
92
|
+
try:
|
|
93
|
+
info = process.as_dict(
|
|
94
|
+
attrs=['pid', 'username', 'cpu_percent', 'memory_percent', 'memory_info', 'cmdline', 'name']
|
|
95
|
+
)
|
|
96
|
+
memory_info = info.get('memory_info')
|
|
97
|
+
rss_kb = int(memory_info.rss // 1024) if memory_info else None
|
|
98
|
+
process_cpu_percent = float(info.get('cpu_percent') or 0.0) / cpu_count
|
|
99
|
+
processes.append(
|
|
100
|
+
HostProcessInfo(
|
|
101
|
+
pid=int(info['pid']),
|
|
102
|
+
user=str(info.get('username') or ''),
|
|
103
|
+
cpu_percent=round(process_cpu_percent, 1),
|
|
104
|
+
memory_percent=round(float(info.get('memory_percent') or 0.0), 1),
|
|
105
|
+
rss_kb=rss_kb,
|
|
106
|
+
command=_command_for_process(process, info),
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
processes.sort(key=lambda item: item.cpu_percent, reverse=True)
|
|
113
|
+
return HostProcessesResponse(
|
|
114
|
+
cpu_percent=round(float(overall_cpu_percent), 1),
|
|
115
|
+
memory_used_mb=int(mem.used // (1024 * 1024)),
|
|
116
|
+
memory_total_mb=int(mem.total // (1024 * 1024)),
|
|
117
|
+
processes=processes[:max(1, min(limit, 500))],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.get('/status', response_model=HostSystemStatsResponse)
|
|
122
|
+
async def get_system_stats() -> HostSystemStatsResponse:
|
|
123
|
+
"""Return host CPU, memory, disk, and uptime stats."""
|
|
124
|
+
cpu_percent = psutil.cpu_percent(interval=PROCESS_SAMPLE_INTERVAL_SECONDS)
|
|
125
|
+
mem = psutil.virtual_memory()
|
|
126
|
+
disk = psutil.disk_usage('/')
|
|
127
|
+
extra_storage_path, extra_storage_disk = _extra_storage_disk_usage()
|
|
128
|
+
uptime_seconds = int(time.time() - psutil.boot_time())
|
|
129
|
+
return HostSystemStatsResponse(
|
|
130
|
+
cpu_percent=round(float(cpu_percent), 1),
|
|
131
|
+
memory_used_mb=int(mem.used // (1024 * 1024)),
|
|
132
|
+
memory_total_mb=int(mem.total // (1024 * 1024)),
|
|
133
|
+
disk_used_gb=round(float(disk.used / (1024 ** 3)), 1),
|
|
134
|
+
disk_total_gb=round(float(disk.total / (1024 ** 3)), 1),
|
|
135
|
+
ssd_used_gb=(
|
|
136
|
+
round(float(extra_storage_disk.used / (1024 ** 3)), 1)
|
|
137
|
+
if extra_storage_disk
|
|
138
|
+
else None
|
|
139
|
+
),
|
|
140
|
+
ssd_total_gb=(
|
|
141
|
+
round(float(extra_storage_disk.total / (1024 ** 3)), 1)
|
|
142
|
+
if extra_storage_disk
|
|
143
|
+
else None
|
|
144
|
+
),
|
|
145
|
+
ssd_mount_path=extra_storage_path,
|
|
146
|
+
uptime_seconds=uptime_seconds,
|
|
147
|
+
temperature_celsius=_temperature(),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@router.get('/processes', response_model=HostProcessesResponse)
|
|
152
|
+
async def get_system_processes(
|
|
153
|
+
limit: int = Query(PROCESS_DEFAULT_LIMIT, ge=1, le=500),
|
|
154
|
+
) -> HostProcessesResponse:
|
|
155
|
+
"""Return host process CPU/memory usage sorted by CPU usage."""
|
|
156
|
+
return _sample_processes(limit)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cyclo-manager
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0.dev1
|
|
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
|
|
@@ -18,7 +18,7 @@ Requires-Dist: ruff; extra == "dev"
|
|
|
18
18
|
**Console commands:** `cyclo_manager` (underscore), `cyclo-manager` (hyphen)
|
|
19
19
|
**Host agent:** `cyclo_host_agent` (installed by the package, run via systemd)
|
|
20
20
|
|
|
21
|
-
This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It:
|
|
21
|
+
This package is the **pip-installable launcher** for the cyclo_manager stack on a robot host. It is intended for production robots that run the pre-built Docker images. It:
|
|
22
22
|
|
|
23
23
|
1. Runs **`docker compose`** against a **bundled** [`cyclo_manager_cli/docker/docker-compose.yml`](cyclo_manager_cli/docker/docker-compose.yml) to start the **API** and **web UI** containers.
|
|
24
24
|
2. Installs and maintains **`cyclo_host_agent`** as a **systemd** service for host-level operations (git repo updates, `cyclo_manager` package update from the UI).
|
|
@@ -50,11 +50,12 @@ For the full monorepo (API source, UI source, dev compose), see the [repository
|
|
|
50
50
|
|
|
51
51
|
## Prerequisites
|
|
52
52
|
|
|
53
|
-
- **Docker** with **Compose v2** (`docker compose
|
|
53
|
+
- **Docker** with **Compose v2** (`docker compose`; the legacy `docker-compose` binary alone is not enough)
|
|
54
54
|
- **Python 3.10+**
|
|
55
|
+
- **systemd**: required for the managed `cyclo_host_agent.service`
|
|
55
56
|
- **`sudo`** — required on normal-user hosts to install the host agent systemd unit, socket directory permissions, and sudoers rules
|
|
56
57
|
- For **`cyclo_manager update`:** **`pip`** or **`pip3`** on `PATH`
|
|
57
|
-
- **Root shell handling** — root
|
|
58
|
+
- **Root shell handling** — `cyclo_manager up` rejects direct root execution when a normal login user exists. On root-only devices, the host agent is installed as root.
|
|
58
59
|
- **Agent sockets** on the host under `/var/run/robotis/agent_sockets/` (robot containers and host agent). The bundled Compose file bind-mounts this tree into the API container as `/agents/`.
|
|
59
60
|
|
|
60
61
|
---
|
|
@@ -94,6 +95,8 @@ python3 -m cyclo_manager_cli.cli up
|
|
|
94
95
|
| `cyclo_manager update --no-pull` | Same as `update`, but skip image pull (use locally cached images). |
|
|
95
96
|
| `cyclo_manager --help` | Subcommand overview |
|
|
96
97
|
|
|
98
|
+
The CLI also has an internal `refresh-host-agent` subcommand. It is used by the sudoers rule and should normally not be invoked by users directly.
|
|
99
|
+
|
|
97
100
|
**`down` vs `update`:** `down` removes the host agent entirely. `update` only stops Docker containers, upgrades the pip package, restarts the stack, and refreshes the host agent — it does **not** call `cyclo_manager down`.
|
|
98
101
|
|
|
99
102
|
---
|
|
@@ -120,18 +123,21 @@ The pip package installs a small **FastAPI** server that listens on a **Unix dom
|
|
|
120
123
|
|------|------|
|
|
121
124
|
| Socket | `/var/run/robotis/agent_sockets/host/host_agent.sock` |
|
|
122
125
|
| systemd unit | `cyclo_host_agent.service` |
|
|
123
|
-
| sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts; `refresh-host-agent` CLI subcommand for
|
|
126
|
+
| sudoers | `/etc/sudoers.d/cyclo_manager` (`cp`, `udevadm` for container setup scripts; `refresh-host-agent` CLI subcommand for package-update refresh; skipped on root-only devices) |
|
|
124
127
|
|
|
125
128
|
The API container reaches it at `/agents/host/host_agent.sock` (see bundled `config.yml`).
|
|
126
129
|
|
|
127
130
|
**Responsibilities:**
|
|
128
131
|
|
|
129
|
-
- List
|
|
130
|
-
-
|
|
132
|
+
- List managed **ROBOTIS-GIT** git repositories in the configured workspace.
|
|
133
|
+
- Check update availability from remote tags and local `package.xml` versions.
|
|
134
|
+
- Update repos on allowed branches (`main`, `jazzy`) using stash or reset workflows.
|
|
135
|
+
- Stop/start a repo's `docker/container.sh` helper during an update when requested by the UI.
|
|
136
|
+
- Run Cyclo Manager package updates from the UI by delegating to `cyclo_manager update`.
|
|
131
137
|
|
|
132
138
|
`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.
|
|
133
139
|
|
|
134
|
-
Repository scanning uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically.
|
|
140
|
+
Repository scanning uses `CYCLO_HOST_AGENT_WORKSPACE` when set. Otherwise it uses the service user's home directory, except root-only devices with `/data/docker`, where `/data/docker` is used automatically. Only repositories whose `origin` remote belongs to `ROBOTIS-GIT` are returned by the repo list/update endpoints.
|
|
135
141
|
|
|
136
142
|
---
|
|
137
143
|
|
|
@@ -211,6 +217,8 @@ So `/agents/ai_worker/s6_agent.sock` in config corresponds to the host path abov
|
|
|
211
217
|
| Variable | Set by | Purpose |
|
|
212
218
|
|----------|--------|---------|
|
|
213
219
|
| **`CYCLO_MANAGER_CONFIG_FILE`** | CLI (`up`, `down`, `update`) | Absolute path to bundled `config.yml` on the host; mounted into the API container |
|
|
220
|
+
| **`CYCLO_HOST_AGENT_WORKSPACE`** | User or CLI-generated systemd unit | Workspace scanned by `cyclo_host_agent` for managed git repositories |
|
|
221
|
+
| **`CYCLO_HOST_AGENT_FILE_ROOT`** | User (optional) | Root directory for the Files UI/API. Defaults to the host agent service user's home (`$HOME`, e.g. `/root` when running as root) |
|
|
214
222
|
| **`HOSTNAME`** | CLI (default: machine hostname) | Passed to API as `HOST_HOSTNAME` |
|
|
215
223
|
| **`CONFIG_FILE`** | Compose (`/app/config.yml`) | Path inside the API container |
|
|
216
224
|
| **`ROS_DOMAIN_ID`** | **Not** set by CLI | Set inside robot containers (e.g. `~/.bashrc`) so DDS matches your fleet |
|
|
@@ -252,7 +260,7 @@ Python dependencies declared in [`pyproject.toml`](pyproject.toml):
|
|
|
252
260
|
|---------|---------|
|
|
253
261
|
| **fastapi** | `cyclo_host_agent` HTTP API |
|
|
254
262
|
| **uvicorn** | `cyclo_host_agent` server |
|
|
255
|
-
| **psutil** |
|
|
263
|
+
| **psutil** | Declared package dependency for host/system utility support |
|
|
256
264
|
|
|
257
265
|
The **`cyclo_manager`** CLI itself uses only the Python standard library plus **`subprocess`** calls to **`docker compose`**, **`pip`**, **`systemctl`**, and **`sudo`**.
|
|
258
266
|
|
|
@@ -5,7 +5,9 @@ cyclo_host_agent/__init__.py
|
|
|
5
5
|
cyclo_host_agent/main.py
|
|
6
6
|
cyclo_host_agent/models.py
|
|
7
7
|
cyclo_host_agent/routers/__init__.py
|
|
8
|
+
cyclo_host_agent/routers/files.py
|
|
8
9
|
cyclo_host_agent/routers/repos.py
|
|
10
|
+
cyclo_host_agent/routers/system_stats.py
|
|
9
11
|
cyclo_host_agent/routers/update.py
|
|
10
12
|
cyclo_manager.egg-info/PKG-INFO
|
|
11
13
|
cyclo_manager.egg-info/SOURCES.txt
|
{cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager_cli/docker/docker-compose.yml
RENAMED
|
@@ -11,7 +11,7 @@ services:
|
|
|
11
11
|
|
|
12
12
|
cyclo_manager:
|
|
13
13
|
container_name: cyclo_manager
|
|
14
|
-
image: robotis/cyclo-manager:1.
|
|
14
|
+
image: robotis/cyclo-manager:1.1.0
|
|
15
15
|
restart: always
|
|
16
16
|
network_mode: host
|
|
17
17
|
volumes:
|
|
@@ -27,7 +27,7 @@ services:
|
|
|
27
27
|
|
|
28
28
|
ui:
|
|
29
29
|
container_name: cyclo_manager_ui
|
|
30
|
-
image: robotis/cyclo-manager-ui:1.
|
|
30
|
+
image: robotis/cyclo-manager-ui:1.1.0
|
|
31
31
|
restart: always
|
|
32
32
|
network_mode: host
|
|
33
33
|
environment:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cyclo-manager"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.1.0.dev1"
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/cyclo_manager.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{cyclo_manager-1.0.0.dev10 → cyclo_manager-1.1.0.dev1}/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
|
|
File without changes
|