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
« 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.
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
13Uses paramiko for SSH operations.
14"""
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
29logger = logging.getLogger('hevolve_provisioner')
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")
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')
46# Preflight thresholds
47MIN_RAM_GB = 4
48MIN_DISK_GB = 10
49SUPPORTED_OS = ['ubuntu', 'debian']
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}$')
56class NetworkProvisioner:
57 """SSH-based remote HART OS installation and management."""
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")
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")
79 client = paramiko.SSHClient()
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)
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)
96 connect_kwargs = {
97 'hostname': target_host,
98 'username': ssh_user,
99 'timeout': timeout,
100 }
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
117 client.connect(**connect_kwargs)
118 return client
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 }
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.
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 = {}
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 }
154 try:
155 # SSH connectivity
156 checks.append({'name': 'ssh_connect', 'ok': True, 'detail': 'Connected'})
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
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 })
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 })
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 })
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 })
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'
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 })
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 })
237 all_ok = all(c['ok'] for c in checks)
238 return {'ok': all_ok, 'checks': checks, 'system_info': system_info}
240 finally:
241 client.close()
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.
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
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'}
269 # Validate inputs to prevent command injection
270 NetworkProvisioner._validate_params(target_host, ssh_user, backend_port)
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)
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 }
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}'}
292 try:
293 # Step 2: Build and transfer bundle
294 logger.info("Transferring install bundle to %s...", target_host)
296 # Create a minimal install archive on the fly
297 sftp = client.open_sftp()
299 # Create remote temp directory
300 NetworkProvisioner._exec_remote(client, 'mkdir -p /tmp/hart-install')
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'}
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)
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'}
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
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)
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()
345 # Step 3: Extract and install on remote
346 logger.info("Running installer on %s...", target_host)
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 )
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 )
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'
374 result = NetworkProvisioner._exec_remote(
375 client, install_cmd, timeout=300)
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 }
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)
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 }
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'
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
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'
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 )
444 # Clean up remote temp files
445 NetworkProvisioner._exec_remote(client, 'rm -rf /tmp/hart-install')
447 logger.info("Successfully provisioned %s — node_id=%s tier=%s",
448 target_host, node_id[:16], tier)
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 }
461 except Exception as e:
462 logger.error("Provisioning failed for %s: %s", target_host, e)
463 return {'success': False, 'error': str(e)}
465 finally:
466 client.close()
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)
506 @staticmethod
507 def discover_network_targets(subnet: str = None) -> List[Dict]:
508 """Scan local network for machines with SSH accessible.
510 Args:
511 subnet: CIDR notation (e.g., '192.168.1.0/24').
512 Auto-detects if None.
514 Returns:
515 List of {ip, hostname, ssh_accessible, port_22_open}
516 """
517 targets = []
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 []
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
542 base = f"{base_parts[0]}.{base_parts[1]}.{base_parts[2]}"
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()
553 if result == 0:
554 # Try reverse DNS
555 try:
556 hostname = socket.gethostbyaddr(ip)[0]
557 except socket.herror:
558 hostname = ip
560 targets.append({
561 'ip': ip,
562 'hostname': hostname,
563 'ssh_accessible': True,
564 'port_22_open': True,
565 })
566 except Exception:
567 continue
569 return targets
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'}
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}'}
585 try:
586 health = {'target_host': target_host, 'services': {}}
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()
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
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'
605 # Uptime
606 result = NetworkProvisioner._exec_remote(client, 'uptime -p')
607 health['uptime'] = result['stdout'].strip()
609 health['healthy'] = (
610 health['services'].get('hart-backend') == 'active' and
611 health['backend_responding']
612 )
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
630 return health
632 finally:
633 client.close()
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.
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'}
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}'}
653 try:
654 # Stop services before update
655 NetworkProvisioner._exec_remote(client, 'systemctl stop hart.target')
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__), '..', '..'))
662 EXCLUDE_TOP = {'.git', '__pycache__', 'venv310', 'venv',
663 'tests', '.idea', 'autogen-0.2.37', 'docs',
664 '.pycharm_plugin', 'dist', 'node_modules'}
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
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)
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()
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 )
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 )
707 # Restart services
708 NetworkProvisioner._exec_remote(
709 client, 'systemctl daemon-reload && systemctl start hart.target')
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)
738 return {'success': False, 'error': 'Backend did not restart after update'}
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)}
749 finally:
750 client.close()
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 []