cyclo-manager 0.2.0.dev2__tar.gz → 0.2.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.
Files changed (20) hide show
  1. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/PKG-INFO +1 -1
  2. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_host_agent/routers/repos.py +44 -56
  3. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_host_agent/routers/update.py +56 -18
  4. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_manager.egg-info/PKG-INFO +1 -1
  5. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_manager_cli/cli.py +65 -33
  6. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/pyproject.toml +1 -1
  7. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/README.md +0 -0
  8. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_host_agent/__init__.py +0 -0
  9. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_host_agent/main.py +0 -0
  10. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_host_agent/models.py +0 -0
  11. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_host_agent/routers/__init__.py +0 -0
  12. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_manager.egg-info/SOURCES.txt +0 -0
  13. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_manager.egg-info/dependency_links.txt +0 -0
  14. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_manager.egg-info/entry_points.txt +0 -0
  15. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_manager.egg-info/requires.txt +0 -0
  16. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_manager.egg-info/top_level.txt +0 -0
  17. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_manager_cli/__init__.py +0 -0
  18. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_manager_cli/config/config.yml +0 -0
  19. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/cyclo_manager_cli/docker/docker-compose.yml +0 -0
  20. {cyclo_manager-0.2.0.dev2 → cyclo_manager-0.2.0.dev4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 0.2.0.dev2
3
+ Version: 0.2.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
@@ -19,11 +19,8 @@
19
19
  """Repository management endpoints."""
20
20
 
21
21
  import asyncio
22
- import json
23
22
  import os
24
23
  import re
25
- import subprocess
26
- import urllib.request
27
24
  from pathlib import Path
28
25
  from typing import Optional
29
26
 
@@ -87,23 +84,20 @@ def _fmt_cmd(cmd: str, output: str) -> str:
87
84
 
88
85
  # ── git helpers ────────────────────────────────────────────────────────────────
89
86
 
90
- def _git_sync(args: list[str], cwd: Optional[Path] = None) -> tuple[int, str, str]:
91
- try:
92
- result = subprocess.run(
93
- ['git', *args],
94
- cwd=str(cwd) if cwd else None,
95
- capture_output=True,
96
- timeout=GIT_TIMEOUT,
97
- )
98
- return result.returncode, result.stdout.decode(), result.stderr.decode()
99
- except subprocess.TimeoutExpired:
100
- return 1, '', 'git timed out'
101
- except Exception as e:
102
- return 1, '', str(e)
103
-
104
-
105
87
  async def _git(args: list[str], cwd: Optional[Path] = None) -> tuple[int, str, str]:
106
- return await asyncio.to_thread(_git_sync, args, cwd)
88
+ proc = await asyncio.create_subprocess_exec(
89
+ 'git', *args,
90
+ cwd=str(cwd) if cwd else None,
91
+ stdout=asyncio.subprocess.PIPE,
92
+ stderr=asyncio.subprocess.PIPE,
93
+ )
94
+ try:
95
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=GIT_TIMEOUT)
96
+ except asyncio.TimeoutError:
97
+ proc.kill()
98
+ await proc.wait()
99
+ raise
100
+ return proc.returncode, stdout.decode(), stderr.decode()
107
101
 
108
102
 
109
103
  async def _repo_info(repo_path: Path) -> RepoInfo:
@@ -137,21 +131,14 @@ def _is_newer(latest: str, current: str) -> bool:
137
131
  return _parse_version(latest) > _parse_version(current)
138
132
 
139
133
 
140
- def _parse_github_slug(remote_url: str) -> Optional[str]:
141
- https_match = re.match(r'https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$', remote_url)
134
+ def _is_managed_remote(remote_url: str) -> bool:
135
+ https_match = re.match(r'https?://github\.com/([^/]+)/', remote_url)
142
136
  if https_match:
143
- return https_match.group(1)
144
- ssh_match = re.match(r'git@github\.com:([^/]+/[^/]+?)(?:\.git)?$', remote_url)
137
+ return https_match.group(1).lower() == MANAGED_GITHUB_ORG.lower()
138
+ ssh_match = re.match(r'git@github\.com:([^/]+)/', remote_url)
145
139
  if ssh_match:
146
- return ssh_match.group(1)
147
- return None
148
-
149
-
150
- def _is_managed_remote(remote_url: str) -> bool:
151
- slug = _parse_github_slug(remote_url)
152
- if not slug:
153
- return False
154
- return slug.split('/')[0].lower() == MANAGED_GITHUB_ORG.lower()
140
+ return ssh_match.group(1).lower() == MANAGED_GITHUB_ORG.lower()
141
+ return False
155
142
 
156
143
 
157
144
  def _read_package_xml_version(repo_path: Path) -> Optional[str]:
@@ -168,15 +155,18 @@ def _read_package_xml_version(repo_path: Path) -> Optional[str]:
168
155
  return None
169
156
 
170
157
 
171
- def _fetch_github_latest(slug: str) -> Optional[str]:
172
- url = f'https://api.github.com/repos/{slug}/releases/latest'
173
- req = urllib.request.Request(url, headers={'User-Agent': 'cyclo-manager/1.0'})
174
- try:
175
- with urllib.request.urlopen(req, timeout=10) as resp:
176
- data = json.loads(resp.read().decode())
177
- return (data.get('tag_name') or '').strip() or None
178
- except Exception:
158
+ async def _fetch_latest_tag(remote_url: str) -> Optional[str]:
159
+ rc, out, _ = await _git(['ls-remote', '--tags', remote_url])
160
+ if rc != 0:
161
+ return None
162
+ tags = []
163
+ for line in out.splitlines():
164
+ if 'refs/tags/' not in line or '^{}' in line:
165
+ continue
166
+ tags.append(line.split('refs/tags/')[-1].strip())
167
+ if not tags:
179
168
  return None
169
+ return max(tags, key=_parse_version)
180
170
 
181
171
 
182
172
  async def _repo_update_status(repo_path: Path) -> Optional[RepoUpdateStatus]:
@@ -186,12 +176,11 @@ async def _repo_update_status(repo_path: Path) -> Optional[RepoUpdateStatus]:
186
176
  if not remote_url or not _is_managed_remote(remote_url):
187
177
  return None
188
178
 
189
- slug = _parse_github_slug(remote_url)
190
179
  current = _read_package_xml_version(repo_path)
191
180
  if not current:
192
181
  return RepoUpdateStatus(name=repo_path.name, has_update=False)
193
182
 
194
- latest = await asyncio.to_thread(_fetch_github_latest, slug)
183
+ latest = await _fetch_latest_tag(remote_url)
195
184
  if not latest:
196
185
  return RepoUpdateStatus(name=repo_path.name, current_version=current, has_update=False)
197
186
 
@@ -283,25 +272,24 @@ async def _update_reset(repo_path: Path, preserve_files: list[str]) -> UpdateRes
283
272
 
284
273
  # ── container helper ───────────────────────────────────────────────────────────
285
274
 
286
- def _run_container_sh_sync(repo_path: Path, action: str) -> tuple[bool, str]:
275
+ async def _run_container_sh(repo_path: Path, action: str) -> tuple[bool, str]:
287
276
  script = repo_path / 'docker' / 'container.sh'
288
277
  if not script.exists():
289
278
  return False, f'container.sh not found at {script}'
279
+ proc = await asyncio.create_subprocess_exec(
280
+ 'bash', str(script), action,
281
+ cwd=str(repo_path / 'docker'),
282
+ stdin=asyncio.subprocess.PIPE,
283
+ stdout=asyncio.subprocess.PIPE,
284
+ stderr=asyncio.subprocess.PIPE,
285
+ )
290
286
  try:
291
- result = subprocess.run(
292
- ['bash', str(script), action],
293
- cwd=str(repo_path / 'docker'),
294
- input=b'y\n',
295
- capture_output=True,
296
- timeout=300.0,
297
- )
298
- return result.returncode == 0, (result.stdout.decode() + result.stderr.decode()).strip()
299
- except subprocess.TimeoutExpired:
287
+ stdout, stderr = await asyncio.wait_for(proc.communicate(input=b'y\n'), timeout=300.0)
288
+ except asyncio.TimeoutError:
289
+ proc.kill()
290
+ await proc.wait()
300
291
  return False, 'Timeout waiting for container.sh'
301
-
302
-
303
- async def _run_container_sh(repo_path: Path, action: str) -> tuple[bool, str]:
304
- return await asyncio.to_thread(_run_container_sh_sync, repo_path, action)
292
+ return proc.returncode == 0, (stdout.decode() + stderr.decode()).strip()
305
293
 
306
294
 
307
295
  # ── endpoints ──────────────────────────────────────────────────────────────────
@@ -42,15 +42,16 @@ def _fmt(cmd: str, out: str) -> str:
42
42
  return f'$ {cmd}\n{out.strip()}' if out.strip() else f'$ {cmd}'
43
43
 
44
44
 
45
-
46
- def _run_cmd(args: list[str], timeout: float) -> tuple[int, str]:
45
+ def _restart_self() -> None:
46
+ """Restart the host agent after a short delay so new binary takes effect."""
47
+ time.sleep(2)
47
48
  try:
48
- result = subprocess.run(args, capture_output=True, timeout=timeout)
49
- return result.returncode, (result.stdout.decode() + result.stderr.decode()).strip()
50
- except subprocess.TimeoutExpired:
51
- return 1, 'timed out'
49
+ subprocess.run(
50
+ ['sudo', 'systemctl', 'restart', f'{HOST_AGENT_SERVICE}.service'],
51
+ check=True,
52
+ )
52
53
  except Exception as e:
53
- return 1, str(e)
54
+ logger.error('Failed to restart host agent: %s', e)
54
55
 
55
56
 
56
57
  async def _run_install_and_up(cyclo_exe: str, pip_exe: str) -> None:
@@ -59,24 +60,50 @@ async def _run_install_and_up(cyclo_exe: str, pip_exe: str) -> None:
59
60
 
60
61
  # pip install -U
61
62
  _update_status['phase'] = 'installing'
62
- rc, out = await asyncio.to_thread(_run_cmd, [pip_exe, 'install', '-U', PYPI_PACKAGE], 120)
63
- _update_status['install_output'] = _fmt(f'pip install -U {PYPI_PACKAGE}', out)
64
- if rc != 0:
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:
65
77
  _update_status['phase'] = 'error'
66
- _update_status['error'] = f'pip install failed (exit {rc})'
78
+ _update_status['error'] = 'pip install timed out'
67
79
  return
68
80
 
69
81
  # cyclo up
70
82
  _update_status['phase'] = 'starting'
71
- rc, out = await asyncio.to_thread(_run_cmd, [cyclo_exe, 'up'], 300)
72
- _update_status['up_output'] = _fmt('cyclo_manager up', out)
73
- if rc != 0:
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:
74
97
  _update_status['phase'] = 'error'
75
- _update_status['error'] = f'cyclo_manager up failed (exit {rc})'
98
+ _update_status['error'] = 'cyclo_manager up timed out'
76
99
  return
77
100
 
78
101
  _update_status['phase'] = 'done'
79
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
+
80
107
 
81
108
  @router.post('/update')
82
109
  async def start_update() -> dict:
@@ -100,9 +127,20 @@ async def start_update() -> dict:
100
127
 
101
128
  _update_status = {'phase': 'stopping', 'install_output': '', 'up_output': '', 'error': ''}
102
129
 
103
- # cyclo down
104
- rc, out = await asyncio.to_thread(_run_cmd, [cyclo_exe, 'down'], 30)
105
- down_output = _fmt('cyclo_manager down', out)
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})'
106
144
 
107
145
  # Continue install + up in background
108
146
  asyncio.create_task(_run_install_and_up(cyclo_exe, pip_exe))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyclo-manager
3
- Version: 0.2.0.dev2
3
+ Version: 0.2.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
@@ -33,6 +33,7 @@ import sys
33
33
  PYPI_PACKAGE = 'cyclo-manager'
34
34
  HOST_AGENT_SERVICE = 'cyclo_host_agent'
35
35
  HOST_AGENT_SOCKET = '/var/run/robotis/agent_sockets/host/host_agent.sock'
36
+ HOST_AGENT_SOCKET_DIR = '/var/run/robotis/agent_sockets/host'
36
37
 
37
38
  # `cyclo_manager up` starts these immediately.
38
39
  COMPOSE_SERVICES_UP = ('cyclo_manager', 'ui')
@@ -53,24 +54,67 @@ def _packaged_config_path() -> Path:
53
54
  return _config_dir() / 'config.yml'
54
55
 
55
56
 
56
- def _check_host_agent() -> bool:
57
- """Return True if the cyclo_host_agent service is already active."""
58
- result = subprocess.run(
59
- ['systemctl', 'is-active', f'{HOST_AGENT_SERVICE}.service'],
60
- capture_output=True,
61
- text=True,
62
- )
63
- return result.returncode == 0
57
+ def _resolve_service_user() -> tuple[str, Path] | None:
58
+ """
59
+ Return (username, home) for the host agent systemd service.
64
60
 
61
+ Uses SUDO_USER when cyclo_manager up is invoked via sudo so the agent runs
62
+ as the login user (required for git repo scans under ~/...).
63
+ """
64
+ sudo_user = os.environ.get('SUDO_USER')
65
+ if sudo_user:
66
+ return sudo_user, Path(f'/home/{sudo_user}')
67
+ user = os.environ.get('USER') or os.getlogin()
68
+ if user == 'root':
69
+ print(
70
+ 'Error: cyclo_manager up must run as a normal user (e.g. robotis), not root.',
71
+ file=sys.stderr,
72
+ )
73
+ return None
74
+ return user, Path.home()
75
+
76
+
77
+ def _setup_socket_dir(user: str) -> bool:
78
+ """
79
+ Ensure the host agent UDS directory exists and is owned by the service user.
80
+
81
+ Only host/ is chowned so other agent sockets under agent_sockets/ are untouched.
82
+ Removes a stale socket file (e.g. left by a previous root-owned service).
83
+ """
84
+ try:
85
+ subprocess.run(
86
+ ['sudo', 'mkdir', '-p', HOST_AGENT_SOCKET_DIR],
87
+ check=True,
88
+ capture_output=True,
89
+ )
90
+ subprocess.run(
91
+ ['sudo', 'chown', f'{user}:{user}', HOST_AGENT_SOCKET_DIR],
92
+ check=True,
93
+ capture_output=True,
94
+ )
95
+ subprocess.run(
96
+ ['sudo', 'rm', '-f', HOST_AGENT_SOCKET],
97
+ check=True,
98
+ capture_output=True,
99
+ )
100
+ return True
101
+ except subprocess.CalledProcessError as e:
102
+ print(f'Failed to set up host agent socket directory: {e}', file=sys.stderr)
103
+ return False
65
104
 
66
105
 
67
- def _create_host_agent() -> int:
106
+ def _ensure_host_agent() -> int:
68
107
  """
69
- Write cyclo_host_agent systemd service unit file and enable it.
108
+ Install or refresh cyclo_host_agent: systemd unit, socket dir, and service start.
70
109
 
71
- Runs as a persistent service (Restart=always); systemd starts it at boot
72
- and restarts it automatically if it exits unexpectedly.
110
+ Idempotent safe to run on every `cyclo_manager up` (fixes upgraded units and
111
+ socket ownership after switching away from a root-owned service).
73
112
  """
113
+ account = _resolve_service_user()
114
+ if account is None:
115
+ return 1
116
+
117
+ user, user_home = account
74
118
  agent_exe = shutil.which('cyclo_host_agent')
75
119
  if not agent_exe:
76
120
  print(
@@ -80,8 +124,9 @@ def _create_host_agent() -> int:
80
124
  )
81
125
  return 1
82
126
 
83
- current_user = os.environ.get('SUDO_USER') or os.environ.get('USER') or os.getlogin()
84
- user_home = Path(f'/home/{current_user}') if current_user else Path.home()
127
+ if not _setup_socket_dir(user):
128
+ return 1
129
+
85
130
  service_content = f"""\
86
131
  [Unit]
87
132
  Description=Cyclo Host Agent
@@ -89,7 +134,8 @@ After=network.target
89
134
 
90
135
  [Service]
91
136
  Type=simple
92
- User={current_user}
137
+ User={user}
138
+ Group={user}
93
139
  ExecStart={agent_exe}
94
140
  Environment=HOME={user_home}
95
141
  Restart=always
@@ -110,7 +156,8 @@ WantedBy=multi-user.target
110
156
  check=True,
111
157
  )
112
158
  subprocess.run(['sudo', 'systemctl', 'daemon-reload'], check=True)
113
- subprocess.run(['sudo', 'systemctl', 'enable', '--now', f'{HOST_AGENT_SERVICE}.service'], check=True)
159
+ subprocess.run(['sudo', 'systemctl', 'enable', f'{HOST_AGENT_SERVICE}.service'], check=True)
160
+ subprocess.run(['sudo', 'systemctl', 'restart', f'{HOST_AGENT_SERVICE}.service'], check=True)
114
161
  print('Host agent service installed and started.')
115
162
  return 0
116
163
  except subprocess.CalledProcessError as e:
@@ -123,14 +170,8 @@ WantedBy=multi-user.target
123
170
 
124
171
  def cmd_up(args: argparse.Namespace) -> int:
125
172
  """Start API + UI; create zenoh and noVNC containers without starting them."""
126
- if _check_host_agent():
127
- try:
128
- subprocess.run(['sudo', 'systemctl', 'restart', f'{HOST_AGENT_SERVICE}.service'], check=True)
129
- except (subprocess.CalledProcessError, FileNotFoundError):
130
- print('Warning: Failed to restart host agent service.', file=sys.stderr)
131
- else:
132
- if _create_host_agent() != 0:
133
- print('Warning: Failed to install host agent service.')
173
+ if _ensure_host_agent() != 0:
174
+ print('Warning: Failed to install host agent service.', file=sys.stderr)
134
175
 
135
176
  config_path = _packaged_config_path()
136
177
  if not config_path.is_file():
@@ -237,15 +278,6 @@ def cmd_update(args: argparse.Namespace) -> int:
237
278
  except subprocess.CalledProcessError as e:
238
279
  return e.returncode
239
280
 
240
- print('Restarting host agent...')
241
- try:
242
- subprocess.run(
243
- ['sudo', 'systemctl', 'restart', f'{HOST_AGENT_SERVICE}.service'],
244
- check=True,
245
- )
246
- except (subprocess.CalledProcessError, FileNotFoundError):
247
- print('Warning: Failed to restart host agent service.', file=sys.stderr)
248
-
249
281
  print('cyclo_manager update completed.')
250
282
  return 0
251
283
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cyclo-manager"
7
- version = "0.2.0.dev2"
7
+ version = "0.2.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"