cyclo-manager 0.2.0.dev4__tar.gz → 0.2.0.dev6__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.
Files changed (21) hide show
  1. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/PKG-INFO +1 -1
  2. cyclo_manager-0.2.0.dev6/cyclo_host_agent/routers/update.py +96 -0
  3. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_manager.egg-info/PKG-INFO +1 -1
  4. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_manager_cli/cli.py +84 -17
  5. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/pyproject.toml +1 -1
  6. cyclo_manager-0.2.0.dev4/cyclo_host_agent/routers/update.py +0 -175
  7. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/README.md +0 -0
  8. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_host_agent/__init__.py +0 -0
  9. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_host_agent/main.py +0 -0
  10. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_host_agent/models.py +0 -0
  11. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_host_agent/routers/__init__.py +0 -0
  12. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_host_agent/routers/repos.py +0 -0
  13. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_manager.egg-info/SOURCES.txt +0 -0
  14. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_manager.egg-info/dependency_links.txt +0 -0
  15. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_manager.egg-info/entry_points.txt +0 -0
  16. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_manager.egg-info/requires.txt +0 -0
  17. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_manager.egg-info/top_level.txt +0 -0
  18. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_manager_cli/__init__.py +0 -0
  19. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_manager_cli/config/config.yml +0 -0
  20. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/cyclo_manager_cli/docker/docker-compose.yml +0 -0
  21. {cyclo_manager-0.2.0.dev4 → cyclo_manager-0.2.0.dev6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 0.2.0.dev4
3
+ Version: 0.2.0.dev6
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
@@ -0,0 +1,96 @@
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
+ """System-level endpoints for the host agent."""
20
+
21
+ import asyncio
22
+ import logging
23
+ import shutil
24
+ import time
25
+ import subprocess
26
+
27
+ from fastapi import APIRouter, HTTPException, status
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ router = APIRouter(prefix='/system', tags=['system'])
32
+
33
+ _update_status: dict = {'phase': 'idle', 'output': '', 'error': ''}
34
+
35
+
36
+ async def _run_update(cyclo_exe: str) -> None:
37
+ global _update_status
38
+ try:
39
+ proc = await asyncio.create_subprocess_exec(
40
+ cyclo_exe, 'update',
41
+ stdout=asyncio.subprocess.PIPE,
42
+ stderr=asyncio.subprocess.PIPE,
43
+ )
44
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=600)
45
+ out = (stdout.decode() + stderr.decode()).strip()
46
+ _update_status['output'] = out
47
+ if proc.returncode != 0:
48
+ _update_status['phase'] = 'error'
49
+ _update_status['error'] = f'cyclo_manager update failed (exit {proc.returncode})'
50
+ else:
51
+ _update_status['phase'] = 'done'
52
+ except asyncio.TimeoutError:
53
+ _update_status['phase'] = 'error'
54
+ _update_status['error'] = 'cyclo_manager update timed out'
55
+
56
+
57
+ @router.post('/update')
58
+ async def start_update() -> dict:
59
+ """Delegate update to cyclo_manager update, which handles containers and host agent restart."""
60
+ global _update_status
61
+
62
+ cyclo_exe = shutil.which('cyclo_manager')
63
+ if not cyclo_exe:
64
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
65
+ detail='cyclo_manager command not found')
66
+
67
+ _update_status = {'phase': 'updating', 'output': '', 'error': ''}
68
+ asyncio.create_task(_run_update(cyclo_exe))
69
+ return {'status': 'update started'}
70
+
71
+
72
+ @router.get('/update/status')
73
+ async def get_update_status() -> dict:
74
+ """Return the current update phase and stored outputs."""
75
+ return _update_status
76
+
77
+
78
+ def _delayed_exec(cmd: list[str], delay: float = 2.0) -> None:
79
+ time.sleep(delay)
80
+ subprocess.run(cmd, check=False)
81
+
82
+
83
+ @router.post('/reboot')
84
+ async def reboot_host() -> dict:
85
+ """Reboot the host machine after returning a response."""
86
+ loop = asyncio.get_event_loop()
87
+ loop.run_in_executor(None, _delayed_exec, ['sudo', 'reboot'])
88
+ return {'status': 'rebooting'}
89
+
90
+
91
+ @router.post('/shutdown')
92
+ async def shutdown_host() -> dict:
93
+ """Shut down the host machine after returning a response."""
94
+ loop = asyncio.get_event_loop()
95
+ loop.run_in_executor(None, _delayed_exec, ['sudo', 'poweroff'])
96
+ return {'status': 'shutting_down'}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 0.2.0.dev4
3
+ Version: 0.2.0.dev6
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
@@ -103,6 +103,35 @@ def _setup_socket_dir(user: str) -> bool:
103
103
  return False
104
104
 
105
105
 
106
+ def _install_sudoers(user: str) -> bool:
107
+ """
108
+ Write /etc/sudoers.d/cyclo_manager granting NOPASSWD for udev operations
109
+ needed by container.sh (cp to /etc/udev/rules.d/, udevadm control/trigger).
110
+ """
111
+ sudoers_content = (
112
+ f'{user} ALL=(ALL) NOPASSWD: /usr/bin/cp, '
113
+ f'/usr/bin/udevadm control --reload-rules, '
114
+ f'/usr/bin/udevadm trigger\n'
115
+ )
116
+ sudoers_file = '/etc/sudoers.d/cyclo_manager'
117
+ try:
118
+ subprocess.run(
119
+ ['sudo', 'tee', sudoers_file],
120
+ input=sudoers_content.encode(),
121
+ capture_output=True,
122
+ check=True,
123
+ )
124
+ subprocess.run(
125
+ ['sudo', 'chmod', '440', sudoers_file],
126
+ check=True,
127
+ capture_output=True,
128
+ )
129
+ return True
130
+ except subprocess.CalledProcessError as e:
131
+ print(f'Failed to install sudoers file: {e}', file=sys.stderr)
132
+ return False
133
+
134
+
106
135
  def _ensure_host_agent() -> int:
107
136
  """
108
137
  Install or refresh cyclo_host_agent: systemd unit, socket dir, and service start.
@@ -127,6 +156,9 @@ def _ensure_host_agent() -> int:
127
156
  if not _setup_socket_dir(user):
128
157
  return 1
129
158
 
159
+ if not _install_sudoers(user):
160
+ return 1
161
+
130
162
  service_content = f"""\
131
163
  [Unit]
132
164
  Description=Cyclo Host Agent
@@ -208,8 +240,27 @@ def cmd_up(args: argparse.Namespace) -> int:
208
240
  return 0
209
241
 
210
242
 
243
+ def _teardown_host_agent() -> None:
244
+ """Stop and remove the host agent service, unit file, sudoers, and socket dir."""
245
+ steps = [
246
+ (['sudo', 'systemctl', 'stop', f'{HOST_AGENT_SERVICE}.service'], 'stop service'),
247
+ (['sudo', 'systemctl', 'disable', f'{HOST_AGENT_SERVICE}.service'], 'disable service'),
248
+ (['sudo', 'rm', '-f', f'/etc/systemd/system/{HOST_AGENT_SERVICE}.service'], 'remove unit file'),
249
+ (['sudo', 'systemctl', 'daemon-reload'], 'reload systemd'),
250
+ (['sudo', 'rm', '-f', '/etc/sudoers.d/cyclo_manager'], 'remove sudoers'),
251
+ (['sudo', 'rm', '-rf', HOST_AGENT_SOCKET_DIR], 'remove socket dir'),
252
+ ]
253
+ for cmd, desc in steps:
254
+ try:
255
+ subprocess.run(cmd, check=True, capture_output=True)
256
+ except subprocess.CalledProcessError:
257
+ print(f'Warning: failed to {desc}.', file=sys.stderr)
258
+
259
+
211
260
  def cmd_down(args: argparse.Namespace) -> int:
212
261
  """Stop cyclo_manager server, cyclo_manager_ui, and zenoh daemon (docker compose down)."""
262
+ _teardown_host_agent()
263
+
213
264
  compose_path = _docker_dir() / 'docker-compose.yml'
214
265
  if not compose_path.is_file():
215
266
  print(f'Compose file not found: {compose_path}', file=sys.stderr)
@@ -239,29 +290,32 @@ def cmd_down(args: argparse.Namespace) -> int:
239
290
 
240
291
 
241
292
  def cmd_update(args: argparse.Namespace) -> int:
242
- """Down containers, pip install -U cyclo-manager, then up again."""
243
- cyclo_manager_exe = shutil.which('cyclo_manager')
244
- if not cyclo_manager_exe:
245
- print('cyclo_manager command not found in PATH.', file=sys.stderr)
246
- return 1
293
+ """Bring containers down, pip install -U, bring containers back up, restart host agent."""
247
294
  pip_exe = shutil.which('pip3') or shutil.which('pip')
248
295
  if not pip_exe:
249
296
  print('pip not found; cannot update package.', file=sys.stderr)
250
297
  return 1
251
298
 
252
- print('Stopping containers (cyclo_manager down)...')
299
+ compose_path = _docker_dir() / 'docker-compose.yml'
300
+ if not compose_path.is_file():
301
+ print(f'Compose file not found: {compose_path}', file=sys.stderr)
302
+ return 1
303
+ env = os.environ.copy()
304
+ env['CYCLO_MANAGER_CONFIG_FILE'] = str(_packaged_config_path())
305
+ base = ['docker', 'compose', '-f', str(compose_path)]
306
+
307
+ print('Stopping containers...')
253
308
  try:
254
- subprocess.run([cyclo_manager_exe, 'down'], check=True)
309
+ subprocess.run([*base, 'down'], env=env, check=True)
255
310
  except subprocess.CalledProcessError as e:
256
311
  return e.returncode
312
+ except FileNotFoundError:
313
+ print('Docker not found.', file=sys.stderr)
314
+ return 1
257
315
 
258
316
  print(f'Updating {PYPI_PACKAGE} (pip install -U)...')
259
317
  try:
260
- subprocess.run(
261
- [pip_exe, 'install', '-U', PYPI_PACKAGE],
262
- check=True,
263
- timeout=120,
264
- )
318
+ subprocess.run([pip_exe, 'install', '-U', PYPI_PACKAGE], check=True, timeout=120)
265
319
  except subprocess.CalledProcessError as e:
266
320
  print(f'pip install -U {PYPI_PACKAGE} failed.', file=sys.stderr)
267
321
  return e.returncode
@@ -269,15 +323,28 @@ def cmd_update(args: argparse.Namespace) -> int:
269
323
  print('pip install timed out.', file=sys.stderr)
270
324
  return 1
271
325
 
272
- print('Starting containers (cyclo_manager up)...')
273
- up_args = [cyclo_manager_exe, 'up']
274
- if getattr(args, 'pull', False):
275
- up_args.append('--pull')
326
+ print('Starting containers...')
276
327
  try:
277
- subprocess.run(up_args, check=True)
328
+ if getattr(args, 'pull', False):
329
+ subprocess.run([*base, 'pull'], env=env, check=True)
330
+ subprocess.run([*base, 'up', '-d', *COMPOSE_SERVICES_UP], env=env, check=True)
331
+ subprocess.run(
332
+ [*base, 'create', '--no-recreate', *COMPOSE_SERVICES_CREATE_ONLY],
333
+ env=env,
334
+ check=True,
335
+ )
278
336
  except subprocess.CalledProcessError as e:
279
337
  return e.returncode
280
338
 
339
+ print('Restarting host agent...')
340
+ try:
341
+ subprocess.run(
342
+ ['sudo', 'systemctl', 'restart', f'{HOST_AGENT_SERVICE}.service'],
343
+ check=True,
344
+ )
345
+ except subprocess.CalledProcessError as e:
346
+ print(f'Warning: failed to restart host agent: {e}', file=sys.stderr)
347
+
281
348
  print('cyclo_manager update completed.')
282
349
  return 0
283
350
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cyclo-manager"
7
- version = "0.2.0.dev4"
7
+ version = "0.2.0.dev6"
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"
@@ -1,175 +0,0 @@
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
- """System-level endpoints for the host agent."""
20
-
21
- import asyncio
22
- import logging
23
- import shutil
24
- import subprocess
25
- import time
26
- from typing import Optional
27
-
28
- from fastapi import APIRouter, HTTPException, status
29
-
30
- logger = logging.getLogger(__name__)
31
-
32
- router = APIRouter(prefix='/system', tags=['system'])
33
-
34
- PYPI_PACKAGE = 'cyclo-manager'
35
- HOST_AGENT_SERVICE = 'cyclo_host_agent'
36
-
37
- # Stored result of the last update (survives across the server restart window)
38
- _update_status: dict = {'phase': 'idle', 'install_output': '', 'up_output': '', 'error': ''}
39
-
40
-
41
- def _fmt(cmd: str, out: str) -> str:
42
- return f'$ {cmd}\n{out.strip()}' if out.strip() else f'$ {cmd}'
43
-
44
-
45
- def _restart_self() -> None:
46
- """Restart the host agent after a short delay so new binary takes effect."""
47
- time.sleep(2)
48
- try:
49
- subprocess.run(
50
- ['sudo', 'systemctl', 'restart', f'{HOST_AGENT_SERVICE}.service'],
51
- check=True,
52
- )
53
- except Exception as e:
54
- logger.error('Failed to restart host agent: %s', e)
55
-
56
-
57
- async def _run_install_and_up(cyclo_exe: str, pip_exe: str) -> None:
58
- """Run pip install -U and cyclo up in background; store results in _update_status."""
59
- global _update_status
60
-
61
- # pip install -U
62
- _update_status['phase'] = 'installing'
63
- try:
64
- proc = await asyncio.create_subprocess_exec(
65
- pip_exe, 'install', '-U', PYPI_PACKAGE,
66
- stdout=asyncio.subprocess.PIPE,
67
- stderr=asyncio.subprocess.PIPE,
68
- )
69
- stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
70
- out = (stdout.decode() + stderr.decode()).strip()
71
- _update_status['install_output'] = _fmt(f'pip install -U {PYPI_PACKAGE}', out)
72
- if proc.returncode != 0:
73
- _update_status['phase'] = 'error'
74
- _update_status['error'] = f'pip install failed (exit {proc.returncode})'
75
- return
76
- except asyncio.TimeoutError:
77
- _update_status['phase'] = 'error'
78
- _update_status['error'] = 'pip install timed out'
79
- return
80
-
81
- # cyclo up
82
- _update_status['phase'] = 'starting'
83
- try:
84
- proc = await asyncio.create_subprocess_exec(
85
- cyclo_exe, 'up',
86
- stdout=asyncio.subprocess.PIPE,
87
- stderr=asyncio.subprocess.PIPE,
88
- )
89
- stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
90
- out = (stdout.decode() + stderr.decode()).strip()
91
- _update_status['up_output'] = _fmt('cyclo_manager up', out)
92
- if proc.returncode != 0:
93
- _update_status['phase'] = 'error'
94
- _update_status['error'] = f'cyclo_manager up failed (exit {proc.returncode})'
95
- return
96
- except asyncio.TimeoutError:
97
- _update_status['phase'] = 'error'
98
- _update_status['error'] = 'cyclo_manager up timed out'
99
- return
100
-
101
- _update_status['phase'] = 'done'
102
-
103
- # Restart self so new binary takes effect
104
- loop = asyncio.get_event_loop()
105
- await loop.run_in_executor(None, _restart_self)
106
-
107
-
108
- @router.post('/update')
109
- async def start_update() -> dict:
110
- """
111
- Run cyclo down synchronously, then pip install + cyclo up in background.
112
-
113
- Returns cyclo down output immediately. The server goes down during this call;
114
- the host agent continues the remaining steps and stores results for /system/update/status.
115
- """
116
- global _update_status
117
-
118
- cyclo_exe = shutil.which('cyclo_manager')
119
- pip_exe = shutil.which('pip3') or shutil.which('pip')
120
-
121
- if not cyclo_exe:
122
- raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
123
- detail='cyclo_manager command not found')
124
- if not pip_exe:
125
- raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
126
- detail='pip not found')
127
-
128
- _update_status = {'phase': 'stopping', 'install_output': '', 'up_output': '', 'error': ''}
129
-
130
- # cyclo down (synchronous — server goes down here)
131
- down_output = ''
132
- try:
133
- proc = await asyncio.create_subprocess_exec(
134
- cyclo_exe, 'down',
135
- stdout=asyncio.subprocess.PIPE,
136
- stderr=asyncio.subprocess.PIPE,
137
- )
138
- stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
139
- down_output = _fmt('cyclo_manager down', (stdout.decode() + stderr.decode()))
140
- except asyncio.TimeoutError:
141
- down_output = '$ cyclo_manager down\n(timed out)'
142
- except Exception as e:
143
- down_output = f'$ cyclo_manager down\n(error: {e})'
144
-
145
- # Continue install + up in background
146
- asyncio.create_task(_run_install_and_up(cyclo_exe, pip_exe))
147
-
148
- return {'down_output': down_output}
149
-
150
-
151
- @router.get('/update/status')
152
- async def get_update_status() -> dict:
153
- """Return the current update phase and stored outputs."""
154
- return _update_status
155
-
156
-
157
- def _delayed_exec(cmd: list[str], delay: float = 2.0) -> None:
158
- time.sleep(delay)
159
- subprocess.run(cmd, check=False)
160
-
161
-
162
- @router.post('/reboot')
163
- async def reboot_host() -> dict:
164
- """Reboot the host machine after returning a response."""
165
- loop = asyncio.get_event_loop()
166
- loop.run_in_executor(None, _delayed_exec, ['sudo', 'reboot'])
167
- return {'status': 'rebooting'}
168
-
169
-
170
- @router.post('/shutdown')
171
- async def shutdown_host() -> dict:
172
- """Shut down the host machine after returning a response."""
173
- loop = asyncio.get_event_loop()
174
- loop.run_in_executor(None, _delayed_exec, ['sudo', 'poweroff'])
175
- return {'status': 'shutting_down'}