Coverage for integrations / agent_engine / network_provisioner.py: 66.7%

99 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2HART OS Network Provisioner — Agent-driven remote installation via SSH. 

3 

4When a user says "use 192.168.1.50 to install hart" or "provision that network 

5machine", the agent: 

6 1. SSHs into the target (using provided credentials) 

7 2. Runs system checks (OS, RAM, CPU, GPU, disk) 

8 3. Transfers the install bundle 

9 4. Executes installation 

10 5. Registers the new node with the hive 

11 6. Reports back to user with node identity 

12 

13Uses paramiko for SSH operations. 

14""" 

15 

16import io 

17import json 

18import logging 

19import os 

20import re 

21import socket 

22import tarfile 

23import tempfile 

24import time 

25from datetime import datetime 

26from typing import Dict, List, Optional 

27from core.port_registry import get_port 

28 

29logger = logging.getLogger('hevolve_provisioner') 

30 

31# Try import paramiko — graceful fallback if not installed 

32try: 

33 import paramiko 

34 PARAMIKO_AVAILABLE = True 

35except ImportError: 

36 paramiko = None 

37 PARAMIKO_AVAILABLE = False 

38 logger.warning("paramiko not installed — network provisioning disabled. " 

39 "Install with: pip install paramiko") 

40 

41INSTALL_SCRIPT_PATH = os.path.join( 

42 os.path.dirname(__file__), '..', '..', 'deploy', 'linux', 'install.sh') 

43BUNDLE_SCRIPT_PATH = os.path.join( 

44 os.path.dirname(__file__), '..', '..', 'deploy', 'linux', 'build_bundle.sh') 

45 

46# Preflight thresholds 

47MIN_RAM_GB = 4 

48MIN_DISK_GB = 10 

49SUPPORTED_OS = ['ubuntu', 'debian'] 

50 

51 

52_HOSTNAME_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._-]{0,254}$') 

53_USERNAME_RE = re.compile(r'^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,31}$') 

54 

55 

56class NetworkProvisioner: 

57 """SSH-based remote HART OS installation and management.""" 

58 

59 @staticmethod 

60 def _validate_params(target_host: str, ssh_user: str, backend_port: int = 6777): 

61 """Validate provisioning parameters to prevent command injection.""" 

62 if not _HOSTNAME_RE.match(target_host): 

63 raise ValueError(f"Invalid target_host: {target_host!r} — must be FQDN or IPv4") 

64 if not _USERNAME_RE.match(ssh_user): 

65 raise ValueError(f"Invalid ssh_user: {ssh_user!r} — alphanumeric + underscore only") 

66 if not (1 <= int(backend_port) <= 65535): 

67 raise ValueError(f"Invalid backend_port: {backend_port} — must be 1-65535") 

68 

69 @staticmethod 

70 def _get_ssh_client(target_host: str, ssh_user: str = 'root', 

71 ssh_key_path: str = None, 

72 ssh_password: str = None, 

73 timeout: int = 15, 

74 **kwargs) -> 'paramiko.SSHClient': 

75 """Create and connect an SSH client.""" 

76 if not PARAMIKO_AVAILABLE: 

77 raise RuntimeError("paramiko not installed. Run: pip install paramiko") 

78 

79 client = paramiko.SSHClient() 

80 

81 # Load system known_hosts for host key verification 

82 known_hosts_path = os.path.expanduser('~/.ssh/known_hosts') 

83 if os.path.exists(known_hosts_path): 

84 client.load_host_keys(known_hosts_path) 

85 

86 # Strict mode rejects unknown hosts; default adds with warning 

87 strict_host_key = kwargs.pop('strict_host_key', False) 

88 if strict_host_key: 

89 client.set_missing_host_key_policy(paramiko.RejectPolicy()) 

90 logger.info("SSH strict host key: unknown hosts will be REJECTED for %s", target_host) 

91 else: 

92 client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 

93 logger.warning("SSH auto-add: accepting host key for %s (add to known_hosts for production)", 

94 target_host) 

95 

96 connect_kwargs = { 

97 'hostname': target_host, 

98 'username': ssh_user, 

99 'timeout': timeout, 

100 } 

101 

102 if ssh_key_path: 

103 connect_kwargs['key_filename'] = ssh_key_path 

104 elif ssh_password: 

105 connect_kwargs['password'] = ssh_password 

106 else: 

107 # Try default SSH key locations 

108 default_keys = [ 

109 os.path.expanduser('~/.ssh/id_ed25519'), 

110 os.path.expanduser('~/.ssh/id_rsa'), 

111 ] 

112 for key_path in default_keys: 

113 if os.path.exists(key_path): 

114 connect_kwargs['key_filename'] = key_path 

115 break 

116 

117 client.connect(**connect_kwargs) 

118 return client 

119 

120 @staticmethod 

121 def _exec_remote(client, cmd: str, timeout: int = 120) -> Dict: 

122 """Execute command on remote host and return result.""" 

123 stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout) 

124 exit_code = stdout.channel.recv_exit_status() 

125 return { 

126 'stdout': stdout.read().decode('utf-8', errors='replace').strip(), 

127 'stderr': stderr.read().decode('utf-8', errors='replace').strip(), 

128 'exit_code': exit_code, 

129 } 

130 

131 @staticmethod 

132 def preflight_check(target_host: str, ssh_user: str = 'root', 

133 ssh_key_path: str = None, 

134 ssh_password: str = None) -> Dict: 

135 """Run preflight checks on remote machine without installing. 

136 

137 Returns: 

138 Dict with keys: ok (bool), checks (list of check results), 

139 system_info (dict with OS, RAM, disk, CPU, GPU) 

140 """ 

141 checks = [] 

142 system_info = {} 

143 

144 try: 

145 client = NetworkProvisioner._get_ssh_client( 

146 target_host, ssh_user, ssh_key_path, ssh_password) 

147 except Exception as e: 

148 return { 

149 'ok': False, 

150 'checks': [{'name': 'ssh_connect', 'ok': False, 'detail': str(e)}], 

151 'system_info': {}, 

152 } 

153 

154 try: 

155 # SSH connectivity 

156 checks.append({'name': 'ssh_connect', 'ok': True, 'detail': 'Connected'}) 

157 

158 # OS detection 

159 result = NetworkProvisioner._exec_remote(client, 'cat /etc/os-release') 

160 os_info = {} 

161 for line in result['stdout'].split('\n'): 

162 if '=' in line: 

163 key, val = line.split('=', 1) 

164 os_info[key] = val.strip('"') 

165 os_id = os_info.get('ID', 'unknown') 

166 os_version = os_info.get('VERSION_ID', 'unknown') 

167 system_info['os'] = os_info.get('PRETTY_NAME', f'{os_id} {os_version}') 

168 system_info['os_id'] = os_id 

169 

170 os_ok = os_id in SUPPORTED_OS or 'ubuntu' in os_info.get('ID_LIKE', '') 

171 checks.append({ 

172 'name': 'os_supported', 

173 'ok': os_ok, 

174 'detail': system_info['os'], 

175 }) 

176 

177 # RAM check 

178 result = NetworkProvisioner._exec_remote( 

179 client, "grep MemTotal /proc/meminfo | awk '{print $2}'") 

180 ram_kb = int(result['stdout']) if result['stdout'].isdigit() else 0 

181 ram_gb = ram_kb / 1048576 

182 system_info['ram_gb'] = round(ram_gb, 1) 

183 checks.append({ 

184 'name': 'ram_sufficient', 

185 'ok': ram_gb >= MIN_RAM_GB, 

186 'detail': f'{ram_gb:.1f}GB (min {MIN_RAM_GB}GB)', 

187 }) 

188 

189 # Disk check 

190 result = NetworkProvisioner._exec_remote( 

191 client, "df /opt --output=avail | tail -1 | tr -d ' '") 

192 disk_kb = int(result['stdout']) if result['stdout'].isdigit() else 0 

193 disk_gb = disk_kb / 1048576 

194 system_info['disk_gb'] = round(disk_gb, 1) 

195 checks.append({ 

196 'name': 'disk_sufficient', 

197 'ok': disk_gb >= MIN_DISK_GB, 

198 'detail': f'{disk_gb:.1f}GB available (min {MIN_DISK_GB}GB)', 

199 }) 

200 

201 # CPU info 

202 result = NetworkProvisioner._exec_remote(client, 'nproc') 

203 cpu_cores = int(result['stdout']) if result['stdout'].isdigit() else 0 

204 system_info['cpu_cores'] = cpu_cores 

205 checks.append({ 

206 'name': 'cpu_cores', 

207 'ok': cpu_cores >= 2, 

208 'detail': f'{cpu_cores} cores', 

209 }) 

210 

211 # GPU detection 

212 result = NetworkProvisioner._exec_remote( 

213 client, 'nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null || echo "none"') 

214 gpu = result['stdout'].strip() 

215 system_info['gpu'] = gpu if gpu != 'none' else 'none' 

216 

217 # systemd check 

218 result = NetworkProvisioner._exec_remote(client, 'systemctl --version') 

219 has_systemd = result['exit_code'] == 0 

220 checks.append({ 

221 'name': 'systemd_available', 

222 'ok': has_systemd, 

223 'detail': 'present' if has_systemd else 'missing', 

224 }) 

225 

226 # Python check 

227 result = NetworkProvisioner._exec_remote( 

228 client, 'python3.10 --version 2>/dev/null || python3 --version 2>/dev/null || echo "none"') 

229 python_ver = result['stdout'].strip() 

230 system_info['python'] = python_ver 

231 checks.append({ 

232 'name': 'python_available', 

233 'ok': python_ver != 'none', 

234 'detail': python_ver, 

235 }) 

236 

237 all_ok = all(c['ok'] for c in checks) 

238 return {'ok': all_ok, 'checks': checks, 'system_info': system_info} 

239 

240 finally: 

241 client.close() 

242 

243 @staticmethod 

244 def provision_remote(target_host: str, ssh_user: str = 'root', 

245 ssh_key_path: str = None, 

246 ssh_password: str = None, 

247 join_peer: str = None, 

248 backend_port: int = 6777, 

249 no_vision: bool = False, 

250 no_llm: bool = False, 

251 provisioned_by: str = 'system') -> Dict: 

252 """Full remote provisioning pipeline. 

253 

254 Steps: 

255 1. SSH connect 

256 2. Preflight checks 

257 3. Transfer install bundle 

258 4. Execute install.sh 

259 5. Wait for backend to come up 

260 6. Register node with hive 

261 7. Record in ProvisionedNode table 

262 

263 Returns: 

264 Dict with: success (bool), node_id, tier, message, error 

265 """ 

266 if not PARAMIKO_AVAILABLE: 

267 return {'success': False, 'error': 'paramiko not installed'} 

268 

269 # Validate inputs to prevent command injection 

270 NetworkProvisioner._validate_params(target_host, ssh_user, backend_port) 

271 

272 # Step 1: Preflight 

273 logger.info("Provisioning %s@%s — running preflight checks...", 

274 ssh_user, target_host) 

275 preflight = NetworkProvisioner.preflight_check( 

276 target_host, ssh_user, ssh_key_path, ssh_password) 

277 

278 if not preflight['ok']: 

279 failed = [c for c in preflight['checks'] if not c['ok']] 

280 return { 

281 'success': False, 

282 'error': f"Preflight failed: {', '.join(c['name'] for c in failed)}", 

283 'preflight': preflight, 

284 } 

285 

286 try: 

287 client = NetworkProvisioner._get_ssh_client( 

288 target_host, ssh_user, ssh_key_path, ssh_password) 

289 except Exception as e: 

290 return {'success': False, 'error': f'SSH connect failed: {e}'} 

291 

292 try: 

293 # Step 2: Build and transfer bundle 

294 logger.info("Transferring install bundle to %s...", target_host) 

295 

296 # Create a minimal install archive on the fly 

297 sftp = client.open_sftp() 

298 

299 # Create remote temp directory 

300 NetworkProvisioner._exec_remote(client, 'mkdir -p /tmp/hart-install') 

301 

302 # Transfer install script 

303 if os.path.exists(INSTALL_SCRIPT_PATH): 

304 sftp.put(INSTALL_SCRIPT_PATH, '/tmp/hart-install/install.sh') 

305 else: 

306 # Fallback: generate install script path from bundle 

307 return {'success': False, 'error': 'install.sh not found locally'} 

308 

309 # Transfer the application code (tar from repo root) 

310 repo_root = os.path.join(os.path.dirname(__file__), '..', '..') 

311 repo_root = os.path.abspath(repo_root) 

312 

313 # Create tar in memory (exclude large dirs and __pycache__ recursively) 

314 logger.info("Creating transfer archive...") 

315 EXCLUDE_TOP = {'.git', '__pycache__', 'venv310', 'venv', 

316 'tests', '.idea', 'autogen-0.2.37', 'docs', 

317 '.pycharm_plugin', 'dist', 'node_modules'} 

318 EXCLUDE_PATTERNS = {'__pycache__', '.pyc', '.pyo', '*.egg-info'} 

319 

320 def _tar_filter(tarinfo): 

321 """Filter out __pycache__, .pyc, and dev artifacts from archive.""" 

322 parts = tarinfo.name.split('/') 

323 for part in parts: 

324 if part == '__pycache__' or part.endswith('.egg-info'): 

325 return None 

326 if tarinfo.name.endswith(('.pyc', '.pyo')): 

327 return None 

328 return tarinfo 

329 

330 buf = io.BytesIO() 

331 with tarfile.open(fileobj=buf, mode='w:gz') as tar: 

332 for item in os.listdir(repo_root): 

333 if item in EXCLUDE_TOP: 

334 continue 

335 full_path = os.path.join(repo_root, item) 

336 tar.add(full_path, arcname=item, filter=_tar_filter) 

337 buf.seek(0) 

338 

339 # Transfer archive 

340 logger.info("Uploading archive to %s (%d bytes)...", 

341 target_host, buf.getbuffer().nbytes) 

342 sftp.putfo(buf, '/tmp/hart-install/hart-code.tar.gz') 

343 sftp.close() 

344 

345 # Step 3: Extract and install on remote 

346 logger.info("Running installer on %s...", target_host) 

347 

348 # Extract code 

349 NetworkProvisioner._exec_remote( 

350 client, 

351 'cd /tmp/hart-install && mkdir -p code && ' 

352 'tar xzf hart-code.tar.gz -C code', 

353 timeout=60, 

354 ) 

355 

356 # Copy install script to extracted code 

357 NetworkProvisioner._exec_remote( 

358 client, 

359 'mkdir -p /tmp/hart-install/code/deploy/linux && ' 

360 'cp /tmp/hart-install/install.sh /tmp/hart-install/code/deploy/linux/', 

361 ) 

362 

363 # Build install command 

364 install_cmd = 'bash /tmp/hart-install/code/deploy/linux/install.sh' 

365 if join_peer: 

366 install_cmd += f' --join-peer {join_peer}' 

367 if backend_port != 6777: 

368 install_cmd += f' --port {backend_port}' 

369 if no_vision: 

370 install_cmd += ' --no-vision' 

371 if no_llm: 

372 install_cmd += ' --no-llm' 

373 

374 result = NetworkProvisioner._exec_remote( 

375 client, install_cmd, timeout=300) 

376 

377 if result['exit_code'] != 0: 

378 return { 

379 'success': False, 

380 'error': f"Install failed (exit {result['exit_code']})", 

381 'stdout': result['stdout'][-500:], 

382 'stderr': result['stderr'][-500:], 

383 } 

384 

385 # Step 4: Wait for backend 

386 logger.info("Waiting for backend on %s:%d...", target_host, backend_port) 

387 node_up = False 

388 for _ in range(30): 

389 check = NetworkProvisioner._exec_remote( 

390 client, 

391 f'curl -s http://localhost:{backend_port}/status', 

392 timeout=10, 

393 ) 

394 if check['exit_code'] == 0 and check['stdout']: 

395 node_up = True 

396 break 

397 time.sleep(2) 

398 

399 if not node_up: 

400 return { 

401 'success': False, 

402 'error': 'Backend did not start within 60 seconds', 

403 'install_output': result['stdout'][-500:], 

404 } 

405 

406 # Step 5: Get node identity 

407 id_result = NetworkProvisioner._exec_remote( 

408 client, 

409 'cat /var/lib/hart/node_public.key | xxd -p | tr -d "\\n"', 

410 ) 

411 node_id = id_result['stdout'][:64] if id_result['exit_code'] == 0 else 'unknown' 

412 

413 # Step 6: Determine capability tier 

414 tier_result = NetworkProvisioner._exec_remote( 

415 client, 

416 f'curl -s http://localhost:{backend_port}/api/social/dashboard/health', 

417 timeout=10, 

418 ) 

419 tier = 'unknown' 

420 if tier_result['exit_code'] == 0: 

421 try: 

422 health = json.loads(tier_result['stdout']) 

423 tier = health.get('capability_tier', 'unknown') 

424 except (json.JSONDecodeError, KeyError): 

425 pass 

426 

427 # Read actual installed version from remote 

428 ver_result = NetworkProvisioner._exec_remote( 

429 client, 

430 'cat /opt/hart/VERSION 2>/dev/null || echo "1.0.0"', 

431 ) 

432 installed_version = ver_result['stdout'].strip() or '1.0.0' 

433 

434 # Step 7: Record in database 

435 NetworkProvisioner._record_provision( 

436 target_host=target_host, 

437 ssh_user=ssh_user, 

438 node_id=node_id, 

439 capability_tier=tier, 

440 provisioned_by=provisioned_by, 

441 installed_version=installed_version, 

442 ) 

443 

444 # Clean up remote temp files 

445 NetworkProvisioner._exec_remote(client, 'rm -rf /tmp/hart-install') 

446 

447 logger.info("Successfully provisioned %s — node_id=%s tier=%s", 

448 target_host, node_id[:16], tier) 

449 

450 return { 

451 'success': True, 

452 'node_id': node_id, 

453 'tier': tier, 

454 'target_host': target_host, 

455 'backend_url': f'http://{target_host}:{backend_port}', 

456 'system_info': preflight.get('system_info', {}), 

457 'message': f'HART OS installed on {target_host}. ' 

458 f'Node ID: {node_id[:16]}..., Tier: {tier}', 

459 } 

460 

461 except Exception as e: 

462 logger.error("Provisioning failed for %s: %s", target_host, e) 

463 return {'success': False, 'error': str(e)} 

464 

465 finally: 

466 client.close() 

467 

468 @staticmethod 

469 def _record_provision(target_host: str, ssh_user: str, node_id: str, 

470 capability_tier: str, provisioned_by: str, 

471 installed_version: str): 

472 """Record successful provisioning in the database.""" 

473 try: 

474 from integrations.social.models import get_db, ProvisionedNode 

475 db = get_db() 

476 try: 

477 existing = db.query(ProvisionedNode).filter_by( 

478 target_host=target_host).first() 

479 if existing: 

480 existing.node_id = node_id 

481 existing.capability_tier = capability_tier 

482 existing.status = 'active' 

483 existing.installed_version = installed_version 

484 existing.provisioned_at = datetime.utcnow() 

485 existing.last_health_check = datetime.utcnow() 

486 existing.error_message = None 

487 else: 

488 node = ProvisionedNode( 

489 target_host=target_host, 

490 ssh_user=ssh_user, 

491 node_id=node_id, 

492 capability_tier=capability_tier, 

493 status='active', 

494 installed_version=installed_version, 

495 provisioned_at=datetime.utcnow(), 

496 provisioned_by=provisioned_by, 

497 last_health_check=datetime.utcnow(), 

498 ) 

499 db.add(node) 

500 db.commit() 

501 finally: 

502 db.close() 

503 except Exception as e: 

504 logger.warning("Could not record provisioning: %s", e) 

505 

506 @staticmethod 

507 def discover_network_targets(subnet: str = None) -> List[Dict]: 

508 """Scan local network for machines with SSH accessible. 

509 

510 Args: 

511 subnet: CIDR notation (e.g., '192.168.1.0/24'). 

512 Auto-detects if None. 

513 

514 Returns: 

515 List of {ip, hostname, ssh_accessible, port_22_open} 

516 """ 

517 targets = [] 

518 

519 if subnet is None: 

520 # Auto-detect local subnet from default gateway 

521 try: 

522 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 

523 s.connect(("8.8.8.8", 80)) 

524 local_ip = s.getsockname()[0] 

525 s.close() 

526 # Assume /24 subnet 

527 parts = local_ip.split('.') 

528 subnet = f"{parts[0]}.{parts[1]}.{parts[2]}.0/24" 

529 except Exception: 

530 return [] 

531 

532 # Parse subnet 

533 try: 

534 base_parts = subnet.split('/')[0].split('.') 

535 prefix = int(subnet.split('/')[1]) 

536 if prefix != 24: 

537 logger.warning("Only /24 subnets supported for scan") 

538 return targets 

539 except (IndexError, ValueError): 

540 return targets 

541 

542 base = f"{base_parts[0]}.{base_parts[1]}.{base_parts[2]}" 

543 

544 # Scan port 22 on each IP (quick socket connect) 

545 for i in range(1, 255): 

546 ip = f"{base}.{i}" 

547 try: 

548 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 

549 sock.settimeout(0.5) 

550 result = sock.connect_ex((ip, 22)) 

551 sock.close() 

552 

553 if result == 0: 

554 # Try reverse DNS 

555 try: 

556 hostname = socket.gethostbyaddr(ip)[0] 

557 except socket.herror: 

558 hostname = ip 

559 

560 targets.append({ 

561 'ip': ip, 

562 'hostname': hostname, 

563 'ssh_accessible': True, 

564 'port_22_open': True, 

565 }) 

566 except Exception: 

567 continue 

568 

569 return targets 

570 

571 @staticmethod 

572 def check_remote_health(target_host: str, ssh_user: str = 'root', 

573 ssh_key_path: str = None, 

574 ssh_password: str = None) -> Dict: 

575 """Check health of a provisioned HART OS node via SSH.""" 

576 if not PARAMIKO_AVAILABLE: 

577 return {'healthy': False, 'error': 'paramiko not installed'} 

578 

579 try: 

580 client = NetworkProvisioner._get_ssh_client( 

581 target_host, ssh_user, ssh_key_path, ssh_password) 

582 except Exception as e: 

583 return {'healthy': False, 'error': f'SSH failed: {e}'} 

584 

585 try: 

586 health = {'target_host': target_host, 'services': {}} 

587 

588 # Check each service 

589 for svc in ['hart-backend', 'hart-discovery', 'hart-agent-daemon', 

590 'hart-vision', 'hart-llm']: 

591 result = NetworkProvisioner._exec_remote( 

592 client, f'systemctl is-active {svc}.service') 

593 health['services'][svc] = result['stdout'].strip() 

594 

595 # Backend HTTP health 

596 result = NetworkProvisioner._exec_remote( 

597 client, f'curl -s http://localhost:{get_port("backend")}/status') 

598 health['backend_responding'] = result['exit_code'] == 0 

599 

600 # Node ID 

601 result = NetworkProvisioner._exec_remote( 

602 client, 'cat /var/lib/hart/node_public.key | xxd -p | tr -d "\\n"') 

603 health['node_id'] = result['stdout'][:64] if result['exit_code'] == 0 else 'unknown' 

604 

605 # Uptime 

606 result = NetworkProvisioner._exec_remote(client, 'uptime -p') 

607 health['uptime'] = result['stdout'].strip() 

608 

609 health['healthy'] = ( 

610 health['services'].get('hart-backend') == 'active' and 

611 health['backend_responding'] 

612 ) 

613 

614 # Update DB record 

615 try: 

616 from integrations.social.models import get_db, ProvisionedNode 

617 db = get_db() 

618 try: 

619 node = db.query(ProvisionedNode).filter_by( 

620 target_host=target_host).first() 

621 if node: 

622 node.last_health_check = datetime.utcnow() 

623 node.status = 'active' if health['healthy'] else 'offline' 

624 db.commit() 

625 finally: 

626 db.close() 

627 except Exception: 

628 pass 

629 

630 return health 

631 

632 finally: 

633 client.close() 

634 

635 @staticmethod 

636 def update_remote(target_host: str, ssh_user: str = 'root', 

637 ssh_key_path: str = None, 

638 ssh_password: str = None) -> Dict: 

639 """Update HART OS on a remote provisioned node. 

640 

641 Uses rsync-over-SSH to transfer updated code (no git required). 

642 Falls back to full archive transfer if rsync is unavailable. 

643 """ 

644 if not PARAMIKO_AVAILABLE: 

645 return {'success': False, 'error': 'paramiko not installed'} 

646 

647 try: 

648 client = NetworkProvisioner._get_ssh_client( 

649 target_host, ssh_user, ssh_key_path, ssh_password) 

650 except Exception as e: 

651 return {'success': False, 'error': f'SSH failed: {e}'} 

652 

653 try: 

654 # Stop services before update 

655 NetworkProvisioner._exec_remote(client, 'systemctl stop hart.target') 

656 

657 # Build fresh archive and transfer 

658 logger.info("Transferring update to %s...", target_host) 

659 repo_root = os.path.abspath( 

660 os.path.join(os.path.dirname(__file__), '..', '..')) 

661 

662 EXCLUDE_TOP = {'.git', '__pycache__', 'venv310', 'venv', 

663 'tests', '.idea', 'autogen-0.2.37', 'docs', 

664 '.pycharm_plugin', 'dist', 'node_modules'} 

665 

666 def _tar_filter(tarinfo): 

667 parts = tarinfo.name.split('/') 

668 for part in parts: 

669 if part == '__pycache__' or part.endswith('.egg-info'): 

670 return None 

671 if tarinfo.name.endswith(('.pyc', '.pyo')): 

672 return None 

673 return tarinfo 

674 

675 buf = io.BytesIO() 

676 with tarfile.open(fileobj=buf, mode='w:gz') as tar: 

677 for item in os.listdir(repo_root): 

678 if item in EXCLUDE_TOP: 

679 continue 

680 full_path = os.path.join(repo_root, item) 

681 tar.add(full_path, arcname=item, filter=_tar_filter) 

682 buf.seek(0) 

683 

684 # Transfer and extract (preserve config and data) 

685 sftp = client.open_sftp() 

686 sftp.putfo(buf, '/tmp/hart-update.tar.gz') 

687 sftp.close() 

688 

689 NetworkProvisioner._exec_remote( 

690 client, 

691 'mkdir -p /tmp/hart-update-extract && ' 

692 'tar xzf /tmp/hart-update.tar.gz -C /tmp/hart-update-extract && ' 

693 'rsync -a --exclude=.env --exclude="agent_data/*.db" ' 

694 '--exclude="agent_data/*.json" ' 

695 '/tmp/hart-update-extract/ /opt/hart/ && ' 

696 'rm -rf /tmp/hart-update.tar.gz /tmp/hart-update-extract', 

697 timeout=120, 

698 ) 

699 

700 # Update pip dependencies 

701 NetworkProvisioner._exec_remote( 

702 client, 

703 '/opt/hart/venv/bin/pip install -r /opt/hart/requirements.txt -q', 

704 timeout=120, 

705 ) 

706 

707 # Restart services 

708 NetworkProvisioner._exec_remote( 

709 client, 'systemctl daemon-reload && systemctl start hart.target') 

710 

711 # Wait for backend 

712 for _ in range(15): 

713 check = NetworkProvisioner._exec_remote( 

714 client, f'curl -s http://localhost:{get_port("backend")}/status', timeout=5) 

715 if check['exit_code'] == 0: 

716 # Update DB record 

717 ver_result = NetworkProvisioner._exec_remote( 

718 client, 'cat /opt/hart/VERSION 2>/dev/null || echo "unknown"') 

719 try: 

720 from integrations.social.models import get_db, ProvisionedNode 

721 db = get_db() 

722 try: 

723 node = db.query(ProvisionedNode).filter_by( 

724 target_host=target_host).first() 

725 if node: 

726 node.installed_version = ver_result['stdout'].strip() 

727 node.last_health_check = datetime.utcnow() 

728 node.status = 'active' 

729 db.commit() 

730 finally: 

731 db.close() 

732 except Exception: 

733 pass 

734 return {'success': True, 

735 'message': f'Updated and restarted {target_host}'} 

736 time.sleep(2) 

737 

738 return {'success': False, 'error': 'Backend did not restart after update'} 

739 

740 except Exception as e: 

741 # Try to restart services even if update failed 

742 try: 

743 NetworkProvisioner._exec_remote( 

744 client, 'systemctl start hart.target') 

745 except Exception: 

746 pass 

747 return {'success': False, 'error': str(e)} 

748 

749 finally: 

750 client.close() 

751 

752 @staticmethod 

753 def list_provisioned() -> List[Dict]: 

754 """List all provisioned nodes from the database.""" 

755 try: 

756 from integrations.social.models import get_db, ProvisionedNode 

757 db = get_db() 

758 try: 

759 nodes = db.query(ProvisionedNode).all() 

760 return [ 

761 { 

762 'id': n.id, 

763 'target_host': n.target_host, 

764 'ssh_user': n.ssh_user, 

765 'node_id': n.node_id, 

766 'capability_tier': n.capability_tier, 

767 'status': n.status, 

768 'installed_version': n.installed_version, 

769 'provisioned_at': n.provisioned_at.isoformat() if n.provisioned_at else None, 

770 'last_health_check': n.last_health_check.isoformat() if n.last_health_check else None, 

771 'provisioned_by': n.provisioned_by, 

772 } 

773 for n in nodes 

774 ] 

775 finally: 

776 db.close() 

777 except Exception as e: 

778 logger.warning("Could not list provisioned nodes: %s", e) 

779 return []