golem-vm-provider 0.1.54__py3-none-any.whl → 0.1.56__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: golem-vm-provider
3
- Version: 0.1.54
3
+ Version: 0.1.56
4
4
  Summary: VM on Golem Provider Node - Run your own provider node to offer VMs on the Golem Network
5
5
  Keywords: golem,vm,provider,cloud,decentralized
6
6
  Author: Phillip Jensen
@@ -61,6 +61,69 @@ pip install golem-vm-provider
61
61
  golem-provider start --network testnet
62
62
  ```
63
63
 
64
+ Verify your environment and connectivity anytime:
65
+
66
+ ```bash
67
+ golem-provider status
68
+ ```
69
+ This checks Multipass availability, local/external port reachability, and whether an update is available on PyPI.
70
+
71
+ ### Status Command (TTY and JSON)
72
+
73
+ Use `golem-provider status` to quickly assess health.
74
+
75
+ TTY output highlights
76
+
77
+ ```
78
+ Overall Error | Issues detected | Healthy
79
+
80
+ Multipass ✅ OK | ❌ Missing
81
+
82
+ Provider Port 0.0.0.0:7466
83
+ Local ✅ service is listening | ❌ port unavailable
84
+ External ✅ reachable | ❌ unreachable — <reason>
85
+
86
+ SSH Ports <start>-<end> — OK | limited — N issue(s) | blocked
87
+ Usable free <count> # free AND externally reachable
88
+ In use <count>
89
+ Issues e.g. "100 not reachable externally" or "3 unreachable, 1 not listening"
90
+ ```
91
+
92
+ Severity rules
93
+
94
+ - Overall is Error when any critical prerequisite fails:
95
+ - Provider API port not externally reachable (or external check fails).
96
+ - No externally reachable SSH ports in the configured range.
97
+ - Multipass missing or provider local port not ready.
98
+ - Otherwise it shows Issues detected or Healthy.
99
+
100
+ Machine‑readable JSON
101
+
102
+ ```bash
103
+ golem-provider status --json
104
+ ```
105
+
106
+ Key fields:
107
+
108
+ - `overall.status`: "healthy" | "issues" | "error"
109
+ - `overall.issues`: list of concise issue strings
110
+ - `ports.provider`:
111
+ - `port`: int, `host`: string
112
+ - `status`: "reachable" | "unreachable" (external check failures are treated as "unreachable")
113
+ - `ports.ssh`:
114
+ - `range`: [start, end)
115
+ - `status`: "ok" | "limited" | "blocked"
116
+ - `usable_free`: integer — free AND externally reachable
117
+ - `in_use`: integer
118
+ - `issues`: `{ unreachable: int, not_listening: int }`
119
+ - `ports`: array of per‑port summaries:
120
+ - `{ port: int, status: "reachable" | "unreachable" | "unknown", listening: bool }`
121
+
122
+ Notes
123
+
124
+ - The concept of "free" in JSON is replaced by `usable_free` (free + externally reachable) to avoid misleading counts when ports are blocked.
125
+ - When the external checker is unavailable, per‑port `status` is `"unknown"` and `listening` still reflects local state.
126
+
64
127
  3) Set pricing in USD (GLM rates auto‑compute):
65
128
 
66
129
  ```bash
@@ -510,6 +573,7 @@ sequenceDiagram
510
573
  participant DS as Discovery Service
511
574
 
512
575
  P->>RT: Initialize
576
+ P->>RT: Sync with existing VMs
513
577
  RT->>AD: Register Callback
514
578
  loop Every 4 minutes
515
579
  AD->>RT: Get Resources
@@ -519,6 +583,8 @@ sequenceDiagram
519
583
  end
520
584
  ```
521
585
 
586
+ On startup, the provider syncs the resource tracker with all VMs currently running on the host (via Multipass). This ensures advertisements reflect already-allocated CPU, RAM, and storage after restarts, preventing false “outdated advertisement” updates when existing VMs are consuming resources. The sync is based on actual VMs present, independent of any still-open payment streams.
587
+
522
588
  ### Monitoring
523
589
 
524
590
  The provider includes comprehensive logging:
@@ -589,13 +655,16 @@ The provider includes real-time port verification status:
589
655
 
590
656
  Example status output:
591
657
 
592
- ```bash
593
- 🌟 Port Verification Status
594
- ==========================
595
- [✅] Provider Port {provider_port}: External ✓ | Internal ✓
596
- [] VM Access Ports: 3 ports available ({start_port}-{start_port+2})
597
- [] Overall Status: Provider Ready
598
- └─ Can handle up to {n} concurrent VMs
658
+ ```
659
+ Overall Healthy
660
+
661
+ Provider Port {host}:{provider_port}
662
+ Local service is listening
663
+ External reachable
664
+
665
+ SSH Ports {start_port}-{end_port_minus_one} — OK
666
+ Usable free {usable_free}
667
+ In use {in_use}
599
668
  ```
600
669
 
601
670
  ### Resource Allocation Issues
@@ -13,15 +13,15 @@ provider/discovery/multi_advertiser.py,sha256=_J79wA1-XQ4GsLzt9KrKpWigGSGBqtut7D
13
13
  provider/discovery/resource_monitor.py,sha256=AmiEc7yBGEGXCunQ-QKmVgosDX3gOhK1Y58LJZXrwAs,949
14
14
  provider/discovery/resource_tracker.py,sha256=MP7IXd3aIMsjB4xz5Oj9zFDTEnvrnw-Cyxpl33xcJcc,6006
15
15
  provider/discovery/service.py,sha256=vX_mVSxvn3arnb2cKDM_SeJp1ZgPdImP2aUubeXgdRg,915
16
- provider/main.py,sha256=_j92g56B-d8CE09Ugv0fqWVMi5jw_iuTrysxSw7845A,32309
17
- provider/network/port_verifier.py,sha256=3l6WNwBHydggJRFYkAsuBp1eCxaU619kjWuM-zSVj2o,13267
16
+ provider/main.py,sha256=ozyvQc-1oNLznwr09gYdbGBkYncw-1ejdOdNhxOPcTo,52788
17
+ provider/network/port_verifier.py,sha256=mlSzr9Z-W5Z5mL3EYg4zemgGoi8Z5ebNoeFgLGRaoH4,13253
18
18
  provider/payments/blockchain_service.py,sha256=4GrzDKwCSUVoENqjD4RLyJ0qwBOJKMyVk5Li-XNsyTc,3567
19
- provider/payments/monitor.py,sha256=Rw17zYsxZre0zU6R0oeRNvVIzMdXLsgoUvSPHpJy6I0,4488
19
+ provider/payments/monitor.py,sha256=seo8vE622IdbcRE3x69IpvHn2mel_tlMNGt_DxOIoww,5386
20
20
  provider/payments/stream_map.py,sha256=qk6Y8hS72DplAifZ0ZMWPHBAyc_3IWIQyWUBuCU3_To,1191
21
21
  provider/security/ethereum.py,sha256=EwPZj4JR8OEpto6LhKjuuT3Z9pBX6P7-UQaqJtqFkYQ,1242
22
22
  provider/security/faucet.py,sha256=8T4lW1fVQgUk8EQILgbrr9UUosw9e7eA40tlZ2_KCPQ,4368
23
23
  provider/security/l2_faucet.py,sha256=yRV4xdPBgU8-LDTLqtuAijfgIoe2kYxvXqJLxFd-BVI,2662
24
- provider/service.py,sha256=IIjeSM9T4r616nBRnxCUum_sgbyRusMMcja3yQd8zQI,3383
24
+ provider/service.py,sha256=hlQn0woppsYFHZDMEgq-40cOjmiPWruiWLy_dQvaCRU,6859
25
25
  provider/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
26
  provider/utils/ascii_art.py,sha256=ykBFsztk57GIiz1NJ-EII5UvN74iECqQL4h9VmiW6Z8,3161
27
27
  provider/utils/logging.py,sha256=VV3oTYSRT8hUejtXLuua1M6kCHmIJgPspIkzsUVhYW0,1920
@@ -39,7 +39,7 @@ provider/vm/port_manager.py,sha256=iYSwjTjD_ziOhG8aI7juKHw1OwwRUTJQyQoRUNQvz9w,1
39
39
  provider/vm/provider.py,sha256=A7QN89EJjcSS40_SmKeinG1Jp_NGffJaLse-XdKciAs,1164
40
40
  provider/vm/proxy_manager.py,sha256=n4NTsyz2rtrvjtf_ceKBk-g2q_mzqPwruB1q7UlQVBc,14928
41
41
  provider/vm/service.py,sha256=Ki4SGNIZUq3XmaPMwAOoNzdZzKQsmFXid374wgjFPes,4636
42
- golem_vm_provider-0.1.54.dist-info/METADATA,sha256=u7WDC-uHV2F5NJ1ayjL7Vlz2s1yXDRFrOIolk_7V1EM,18452
43
- golem_vm_provider-0.1.54.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
44
- golem_vm_provider-0.1.54.dist-info/entry_points.txt,sha256=5Jiie1dIXygmxmDW66bKKxQpmBLJ7leSKRrb8bkQALw,52
45
- golem_vm_provider-0.1.54.dist-info/RECORD,,
42
+ golem_vm_provider-0.1.56.dist-info/METADATA,sha256=D83fVjgeQlLJN2XzW_XGGBWK0g0PAe0nOp87hzjgLjU,20932
43
+ golem_vm_provider-0.1.56.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
44
+ golem_vm_provider-0.1.56.dist-info/entry_points.txt,sha256=5Jiie1dIXygmxmDW66bKKxQpmBLJ7leSKRrb8bkQALw,52
45
+ golem_vm_provider-0.1.56.dist-info/RECORD,,
provider/main.py CHANGED
@@ -143,6 +143,540 @@ def main():
143
143
  return
144
144
 
145
145
 
146
+ def _get_installed_version(pkg_name: str) -> str:
147
+ try:
148
+ return metadata.version(pkg_name)
149
+ except Exception:
150
+ return "unknown"
151
+
152
+
153
+ def _get_latest_version_from_pypi(pkg_name: str) -> Optional[str]:
154
+ # Avoid network in pytest runs
155
+ if os.environ.get("PYTEST_CURRENT_TEST"):
156
+ return None
157
+ try:
158
+ import json as _json
159
+ from urllib.request import urlopen
160
+ with urlopen(f"https://pypi.org/pypi/{pkg_name}/json", timeout=5) as resp:
161
+ data = _json.loads(resp.read().decode("utf-8"))
162
+ return data.get("info", {}).get("version")
163
+ except Exception:
164
+ return None
165
+
166
+
167
+ @cli.command("status")
168
+ def status(json_out: bool = typer.Option(False, "--json", help="Output machine-readable JSON")):
169
+ """Show provider environment status and update info (pretty or JSON)."""
170
+ from .utils.logging import logger as _logger
171
+ from rich.console import Console
172
+ from rich.table import Table
173
+ from rich.panel import Panel
174
+ from rich import box
175
+
176
+ # Temporarily quiet info logs during checks for cleaner UI
177
+ prev_level = _logger.level
178
+ try:
179
+ _logger.setLevel("WARNING")
180
+ except Exception:
181
+ pass
182
+
183
+ # Silence port_verifier warnings during status checks for clean UI
184
+ import logging as _logging
185
+ try:
186
+ _pv_logger = _logging.getLogger("provider.network.port_verifier")
187
+ _prev_pv_level = _pv_logger.level
188
+ _pv_logger.setLevel(_logging.CRITICAL)
189
+ except Exception:
190
+ _pv_logger = None
191
+ _prev_pv_level = None
192
+ # Also quiet config auto-detection logs (e.g., multipass path) for clean JSON/TTY
193
+ try:
194
+ _cfg_logger = _logging.getLogger("provider.config")
195
+ _prev_cfg_level = _cfg_logger.level
196
+ _cfg_logger.setLevel(_logging.WARNING)
197
+ except Exception:
198
+ _cfg_logger = None
199
+ _prev_cfg_level = None
200
+
201
+ # Defer config-heavy imports until after log levels are adjusted
202
+ from .config import settings as _settings
203
+ from .network.port_verifier import PortVerifier
204
+
205
+ # Versions
206
+ pkg = "golem-vm-provider"
207
+ current = _get_installed_version(pkg)
208
+ latest = _get_latest_version_from_pypi(pkg)
209
+ update_available = bool(latest and current != latest)
210
+
211
+ # Environment
212
+ env = os.environ.get("GOLEM_PROVIDER_ENVIRONMENT", _settings.ENVIRONMENT)
213
+ net = getattr(_settings, "NETWORK", None)
214
+ dev_mode = env == "development" or bool(getattr(_settings, "DEV_MODE", False))
215
+
216
+ # Multipass
217
+ mp = {"ok": False, "path": None, "version": None, "error": None}
218
+ try:
219
+ mp_path = _settings.MULTIPASS_BINARY_PATH
220
+ mp["path"] = mp_path or None
221
+ if mp_path:
222
+ import subprocess
223
+ r = subprocess.run([mp_path, "version"], capture_output=True, text=True, timeout=5)
224
+ if r.returncode == 0:
225
+ mp["ok"] = True
226
+ mp["version"] = (r.stdout or r.stderr).strip()
227
+ else:
228
+ mp["error"] = (r.stderr or r.stdout or "failed").strip()
229
+ else:
230
+ mp["error"] = "not configured"
231
+ except Exception as e:
232
+ mp["ok"] = False
233
+ mp["error"] = str(e)
234
+
235
+ # Provider port (local)
236
+ port = int(_settings.PORT)
237
+ host = getattr(_settings, "HOST", "0.0.0.0")
238
+ local = {"ok": False, "detail": None}
239
+ try:
240
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
241
+ s.settimeout(1)
242
+ local_conn = s.connect_ex(("127.0.0.1", port)) == 0
243
+ s.close()
244
+ if local_conn:
245
+ local["ok"] = True
246
+ local["detail"] = "service is listening"
247
+ else:
248
+ # Check that we can bind (port free)
249
+ if asyncio.run(verify_provider_port(port)):
250
+ local["ok"] = True
251
+ local["detail"] = "port is free (bindable)"
252
+ else:
253
+ local["ok"] = False
254
+ local["detail"] = "port unavailable"
255
+ except Exception as e:
256
+ local["ok"] = False
257
+ local["detail"] = str(e)
258
+
259
+ # Always use shared external port-checker for public reachability
260
+ servers = ["http://195.201.39.101:9000"]
261
+
262
+ external = {"status": "unknown", "verified_by": None, "error": None}
263
+ try:
264
+ verifier = PortVerifier(servers, discovery_port=port)
265
+ results = asyncio.run(verifier.verify_external_access({port}))
266
+ r = results.get(port)
267
+ if r and r.accessible:
268
+ external["status"] = "reachable"
269
+ external["verified_by"] = r.verified_by
270
+ elif r:
271
+ external["status"] = "unreachable"
272
+ external["error"] = r.error
273
+ else:
274
+ external["status"] = "not_verified"
275
+ except Exception as e:
276
+ external["status"] = "check_failed"
277
+ external["error"] = str(e).splitlines()[0]
278
+
279
+ # Base data structure
280
+ data = {
281
+ "version": {
282
+ "installed": current,
283
+ "latest": latest,
284
+ "update_available": update_available,
285
+ },
286
+ "environment": {
287
+ "environment": env,
288
+ "network": net,
289
+ "dev_mode": dev_mode,
290
+ },
291
+ "multipass": mp,
292
+ "ports": {
293
+ "provider": {
294
+ "port": port,
295
+ "host": host,
296
+ "local_ok": local["ok"],
297
+ "local_detail": local["detail"],
298
+ "external": external,
299
+ }
300
+ },
301
+ }
302
+
303
+ # SSH port usage summary from state file + external reachability for full range
304
+ try:
305
+ from pathlib import Path as _Path
306
+ import json as _json
307
+ state_path = _Path(_settings.PROXY_STATE_DIR) / "ports.json"
308
+ ports_in_use = []
309
+ if state_path.exists():
310
+ with open(state_path, "r") as fh:
311
+ st = _json.load(fh)
312
+ for _req_name, pinfo in (st.get("proxies", {}) or {}).items():
313
+ prt = pinfo.get("port")
314
+ if isinstance(prt, int):
315
+ ports_in_use.append(prt)
316
+ start = int(getattr(_settings, "PORT_RANGE_START", 50800))
317
+ end = int(getattr(_settings, "PORT_RANGE_END", 50900))
318
+ total = max(0, end - start)
319
+ used = sorted(set(prt for prt in ports_in_use if start <= prt < end))
320
+ # Check if used ports are actually listening
321
+ used_listening = []
322
+ used_not_listening = []
323
+ for prt in used:
324
+ try:
325
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
326
+ s.settimeout(0.5)
327
+ ok = s.connect_ex(("127.0.0.1", prt)) == 0
328
+ s.close()
329
+ (used_listening if ok else used_not_listening).append(prt)
330
+ except Exception:
331
+ used_not_listening.append(prt)
332
+ free_count = total - len(used)
333
+ # External reachability across entire range
334
+ external_ok: set[int] = set()
335
+ ext_results_map: dict[int, bool] = {}
336
+ external_batch_ok = False
337
+ external_batch_error: str | None = None
338
+ try:
339
+ verifier_all = PortVerifier(servers, discovery_port=port)
340
+ _ext_results = asyncio.run(verifier_all.verify_external_access(set(range(start, end))))
341
+ for prt, res in _ext_results.items():
342
+ p = int(prt)
343
+ ok = bool(getattr(res, "accessible", False))
344
+ ext_results_map[p] = ok
345
+ if ok:
346
+ external_ok.add(p)
347
+ external_batch_ok = True
348
+ except Exception:
349
+ # Leave external_ok empty on failure
350
+ external_batch_ok = False
351
+ try:
352
+ external_batch_error = str(_) # type: ignore[name-defined]
353
+ except Exception:
354
+ external_batch_error = None
355
+
356
+ firewall_issues = [p for p in used_listening if p not in external_ok]
357
+
358
+ # Build per-port details for JSON consumers
359
+ details = []
360
+ for p in range(start, end):
361
+ details.append({
362
+ "port": p,
363
+ "in_use": p in used,
364
+ "local_listening": p in used_listening,
365
+ "external_reachable": bool(ext_results_map.get(p, False)) if external_batch_ok else False,
366
+ })
367
+
368
+ # Compute number of free ports that are actually externally reachable (usable)
369
+ usable_free_count = None
370
+ try:
371
+ if external_batch_ok:
372
+ usable_free_count = len([
373
+ p for p in range(start, end)
374
+ if (p not in used) and bool(ext_results_map.get(p, False))
375
+ ])
376
+ except Exception:
377
+ usable_free_count = None
378
+
379
+ # Legacy detailed metrics (retained under ssh_legacy)
380
+ _ssh_legacy = {
381
+ "range": [start, end],
382
+ "total": total,
383
+ "in_use": used,
384
+ "listening_ok": used_listening,
385
+ "listening_issues": used_not_listening,
386
+ "free_count": free_count,
387
+ "external_reachable_count": len([p for p in range(start, end) if p in external_ok]),
388
+ "firewall_issues_count": len(firewall_issues),
389
+ "external_checked": external_batch_ok,
390
+ "external_error": external_batch_error,
391
+ "usable_free_count": usable_free_count,
392
+ "details": details,
393
+ }
394
+
395
+ # Concise status fields for programmatic checks (mirrors TTY)
396
+ _ext_reach = int(len(external_ok))
397
+ _issues_count = len(used_not_listening) + len(firewall_issues)
398
+ if (not external_batch_ok) or _ext_reach == 0:
399
+ ssh_status = "blocked"
400
+ elif _issues_count > 0:
401
+ ssh_status = "limited"
402
+ else:
403
+ ssh_status = "ok"
404
+
405
+ # Usable free: default to 0 when external check failed
406
+ if usable_free_count is None:
407
+ usable_free_out = 0
408
+ else:
409
+ usable_free_out = int(usable_free_count)
410
+
411
+ # Issues breakdown matching TTY wording
412
+ unreachable_count = total if (not external_batch_ok or _ext_reach == 0) else len(firewall_issues)
413
+ not_listening_count = len(used_not_listening)
414
+
415
+ # Build minimal per-port status list for JSON consumers
416
+ # Consistent definition:
417
+ # - status: reachable | unreachable | unknown (unknown only if external check failed)
418
+ # - listening: true | false
419
+ ports_detail: list[dict] = []
420
+ for p in range(start, end):
421
+ listening = p in used_listening
422
+ if external_batch_ok:
423
+ status_val = "reachable" if bool(ext_results_map.get(p, False)) else "unreachable"
424
+ else:
425
+ status_val = "unknown"
426
+ ports_detail.append({
427
+ "port": p,
428
+ "status": status_val,
429
+ "listening": bool(listening),
430
+ })
431
+
432
+ data["ports"]["ssh"] = {
433
+ "range": [start, end],
434
+ "status": ssh_status,
435
+ "usable_free": usable_free_out,
436
+ "in_use": len(used),
437
+ "issues": {
438
+ "unreachable": int(unreachable_count),
439
+ "not_listening": int(not_listening_count),
440
+ },
441
+ "ports": ports_detail,
442
+ }
443
+
444
+ except Exception:
445
+ # Non-fatal; omit ssh summary if state not available
446
+ pass
447
+
448
+ # Provider concise status: reachable | unreachable (treat check failures as unreachable)
449
+ prov_status = external.get("status")
450
+ provider_status = "reachable" if prov_status == "reachable" else "unreachable"
451
+ data["ports"]["provider"]["status"] = provider_status
452
+
453
+ # Compute overall and issues for JSON output (mirrors condensed model)
454
+ json_issues = []
455
+ json_ssh_blocked = False
456
+ json_critical_no_ssh = False
457
+ # Multipass
458
+ if not mp["ok"]:
459
+ json_issues.append("Multipass not available")
460
+ # Provider local port
461
+ if not local["ok"]:
462
+ json_issues.append(f"Provider port {port} not ready")
463
+ # SSH ports
464
+ if data["ports"].get("ssh"):
465
+ _ssh = data["ports"]["ssh"]
466
+ _status = str(_ssh.get("status") or "blocked").lower()
467
+ _issues = _ssh.get("issues") or {}
468
+ _not_listening = int(_issues.get("not_listening", 0) or 0)
469
+ _unreachable = int(_issues.get("unreachable", 0) or 0)
470
+ if _status == "blocked":
471
+ json_ssh_blocked = True
472
+ json_critical_no_ssh = True
473
+ json_issues.append("No externally reachable SSH ports")
474
+ else:
475
+ if _unreachable > 0:
476
+ json_issues.append(f"{_unreachable} SSH port(s) unreachable externally")
477
+ if _not_listening > 0:
478
+ json_issues.append(f"{_not_listening} SSH port(s) not listening")
479
+ # Provider external
480
+ json_critical_provider_external = False
481
+ if external.get("status") in ("unreachable", "check_failed"):
482
+ json_issues.append("Provider API port not reachable externally")
483
+ json_critical_provider_external = True
484
+
485
+ if json_critical_no_ssh or (not local["ok"]) or (not mp["ok"]) or json_critical_provider_external:
486
+ overall_status = "error"
487
+ else:
488
+ overall_status = "healthy" if (not json_issues and not json_ssh_blocked) else "issues"
489
+
490
+ data["overall"] = {"status": overall_status, "issues": json_issues}
491
+
492
+ if json_out:
493
+ import json as _json
494
+ print(_json.dumps(data, indent=2))
495
+ # Restore logger level
496
+ try:
497
+ _logger.setLevel(prev_level)
498
+ except Exception:
499
+ pass
500
+ if _pv_logger and _prev_pv_level is not None:
501
+ try:
502
+ _pv_logger.setLevel(_prev_pv_level)
503
+ except Exception:
504
+ pass
505
+ if _cfg_logger and _prev_cfg_level is not None:
506
+ try:
507
+ _cfg_logger.setLevel(_prev_cfg_level)
508
+ except Exception:
509
+ pass
510
+ return
511
+
512
+ console = Console()
513
+
514
+ # Overall status
515
+ issues = []
516
+ if not mp["ok"]:
517
+ issues.append("Multipass not available")
518
+ if not local["ok"]:
519
+ issues.append(f"Provider port {port} not ready")
520
+ ssh_blocked = False
521
+ critical_no_ssh = False
522
+ if data["ports"].get("ssh"):
523
+ ssh = data["ports"]["ssh"]
524
+ if ssh.get("listening_issues"):
525
+ issues.append(f"{len(ssh['listening_issues'])} SSH port(s) not listening")
526
+ if ssh.get("free_count", 0) == 0:
527
+ issues.append("No free SSH ports available")
528
+ if ssh.get("external_checked"):
529
+ if int(ssh.get("external_reachable_count", 0) or 0) == 0:
530
+ ssh_blocked = True
531
+ critical_no_ssh = True # No externally reachable SSH ports is critical
532
+ issues.append("No externally reachable SSH ports")
533
+ else:
534
+ # If we couldn't check, mark as issue but not critical
535
+ issues.append("SSH external reachability check failed")
536
+ critical_provider_external = False
537
+ if external["status"] in ("unreachable", "check_failed"):
538
+ issues.append("Provider API port not reachable externally")
539
+ critical_provider_external = True
540
+
541
+ # Severity: Error when critical conditions are met; else Issues/Healthy
542
+ if critical_no_ssh or (not local["ok"]) or (not mp["ok"]) or critical_provider_external:
543
+ overall = "Error"
544
+ else:
545
+ overall = "Healthy" if (not issues and not ssh_blocked) else "Issues detected"
546
+
547
+ # Build a single compact table
548
+ tbl = Table(box=box.SIMPLE_HEAVY, show_header=False, pad_edge=False)
549
+ tbl.add_column("Item", style="bold")
550
+ tbl.add_column("Value")
551
+
552
+ # Header
553
+ if overall == "Healthy":
554
+ overall_txt = "[green]Healthy[/green]"
555
+ elif overall == "Error":
556
+ overall_txt = "[red]Error[/red]"
557
+ else:
558
+ overall_txt = f"[yellow]{overall}[/yellow]"
559
+ tbl.add_row("Overall", overall_txt)
560
+ tbl.add_row("", "")
561
+
562
+ # Versions
563
+ tbl.add_row("Versions", "")
564
+ ver_inst = data["version"]["installed"] or "unknown"
565
+ ver_latest = data["version"]["latest"] or "unknown"
566
+ upd = data["version"]["update_available"]
567
+ tbl.add_row(" Installed", f"[white]{ver_inst}[/white]")
568
+ if upd:
569
+ tbl.add_row(" Latest", f"[bold bright_yellow]{ver_latest}[/bold bright_yellow] [grey62](pip install -U golem-vm-provider)[/grey62]")
570
+ tbl.add_row(" Update", "[bold bright_yellow]⬆️ yes[/bold bright_yellow]")
571
+ else:
572
+ tbl.add_row(" Latest", f"[cyan]{ver_latest}[/cyan]")
573
+ tbl.add_row(" Update", "[green]no[/green]")
574
+ tbl.add_row("", "")
575
+
576
+ # Environment
577
+ tbl.add_row("Environment", "")
578
+ tbl.add_row(" Environment", env + (" (dev)" if dev_mode else ""))
579
+ tbl.add_row(" Network", net or "-")
580
+ tbl.add_row("", "")
581
+
582
+ # Multipass
583
+ mp_ver = (mp.get("version") or mp.get("error") or "-").replace("\n", ", ")
584
+ tbl.add_row("Multipass", "")
585
+ tbl.add_row(" Status", "✅ OK" if mp["ok"] else "❌ Missing")
586
+ tbl.add_row(" Path", mp.get("path") or "-")
587
+ tbl.add_row(" Version", mp_ver)
588
+ tbl.add_row("", "")
589
+
590
+ # Provider port
591
+ tbl.add_row("Provider Port", f"{host}:{port}")
592
+ tbl.add_row(" Local", ("✅ " if local["ok"] else "❌ ") + (local["detail"] or ""))
593
+ # External reachability is foundational; treat unreachable and check failures the same
594
+ _ext = external.get("status") or "unknown"
595
+ _err = external.get("error")
596
+ if _ext == "reachable":
597
+ ext_row = "✅ reachable"
598
+ elif _ext in ("unreachable", "check_failed"):
599
+ ext_row = "❌ unreachable" + (f" — {_err}" if _err else "")
600
+ elif _ext == "not_verified":
601
+ ext_row = "⚠️ not verified"
602
+ else:
603
+ ext_row = "⚠️ " + _ext + (f" — {_err}" if _err else "")
604
+ tbl.add_row(" External", ext_row)
605
+
606
+ # SSH ports (condensed, actionable)
607
+ if data["ports"].get("ssh"):
608
+ ssh = data["ports"]["ssh"]
609
+ r0, r1 = ssh['range'][0], ssh['range'][1]-1
610
+ tbl.add_row("", "")
611
+ status_val = str(ssh.get("status") or "blocked").lower()
612
+ issues_obj = ssh.get("issues") or {}
613
+ unreachable_issues = int(issues_obj.get("unreachable", 0) or 0)
614
+ not_listening_issues = int(issues_obj.get("not_listening", 0) or 0)
615
+ in_use = int(ssh.get("in_use", 0) or 0)
616
+ usable_free = ssh.get("usable_free")
617
+
618
+ # Determine clear status
619
+ if status_val == "ok":
620
+ status_txt = "[green]OK[/green]"
621
+ elif status_val == "limited":
622
+ status_txt = f"[yellow]limited — {not_listening_issues + (unreachable_issues or 0)} issue(s)[/yellow]"
623
+ else:
624
+ status_txt = "[red]blocked[/red]"
625
+
626
+ tbl.add_row("SSH Ports", f"{r0}-{r1} — {status_txt}")
627
+
628
+ # Provide only the most relevant numbers
629
+ # Usable free = free and externally reachable; avoid misleading "Free" when blocked
630
+ tbl.add_row(" Usable free", str(int(usable_free or 0)))
631
+ tbl.add_row(" In use", str(in_use))
632
+ if status_val == "blocked":
633
+ # Show total not reachable externally
634
+ total_ports = (r1 - r0 + 1)
635
+ cnt = unreachable_issues if unreachable_issues else total_ports
636
+ tbl.add_row(" Issues", f"{cnt} not reachable externally")
637
+ elif (not_listening_issues or unreachable_issues):
638
+ parts = []
639
+ if unreachable_issues:
640
+ parts.append(f"{unreachable_issues} unreachable (listening but blocked)")
641
+ if not_listening_issues:
642
+ parts.append(f"{not_listening_issues} not listening")
643
+ tbl.add_row(" Issues", ", ".join(parts))
644
+
645
+ # Issues / Tips combined at bottom
646
+ # Only show Notes when there are issues
647
+ if issues:
648
+ tbl.add_row("", "")
649
+ tbl.add_row("Issues", "\n".join(f"• {t}" for t in issues))
650
+
651
+ console.print(Panel(tbl, title="Provider Status"))
652
+
653
+ # Tips
654
+ tips = []
655
+ if update_available:
656
+ tips.append("Upgrade with: pip install -U golem-vm-provider")
657
+ if not mp["ok"]:
658
+ tips.append("Install Multipass and/or set GOLEM_PROVIDER_MULTIPASS_BINARY_PATH")
659
+ if external["status"] != "reachable":
660
+ tips.append("Ensure at least one port-check server is online (see above)")
661
+ # Tips are included in the single panel under Notes
662
+
663
+ # Restore logger level
664
+ try:
665
+ _logger.setLevel(prev_level)
666
+ except Exception:
667
+ pass
668
+ if _pv_logger and _prev_pv_level is not None:
669
+ try:
670
+ _pv_logger.setLevel(_prev_pv_level)
671
+ except Exception:
672
+ pass
673
+ if _cfg_logger and _prev_cfg_level is not None:
674
+ try:
675
+ _cfg_logger.setLevel(_prev_cfg_level)
676
+ except Exception:
677
+ pass
678
+
679
+
146
680
  @wallet_app.command("faucet-l2")
147
681
  def wallet_faucet_l2():
148
682
  """Request L2 faucet funds for the provider's payment address (native ETH)."""
@@ -126,8 +126,8 @@ class PortVerifier:
126
126
  Returns:
127
127
  Dictionary mapping ports to their verification results
128
128
  """
129
- results = {}
130
- attempts = []
129
+ results: Dict[int, PortVerificationResult] = {}
130
+ attempts: List[ServerAttempt] = []
131
131
 
132
132
  # Try each server
133
133
  for server in self.port_check_servers:
@@ -146,27 +146,29 @@ class PortVerifier:
146
146
 
147
147
  if response.status == 200:
148
148
  data = await response.json()
149
- if data["success"]:
150
- # Convert server results to PortVerificationResult objects
151
- for port_str, result in data["results"].items():
152
- port = int(port_str)
153
- if port not in results or not results[port].accessible:
154
- # Only update if we haven't found a successful verification yet
155
- results[port] = PortVerificationResult(
156
- port=port,
157
- accessible=result["accessible"],
158
- error=result.get("error"),
159
- verified_by=server if result["accessible"] else None,
160
- attempts=[] # Will be filled at the end
161
- )
162
- attempts.append(ServerAttempt(server=server, success=True))
163
- logger.info(f"Port verification completed using {server}")
164
- else:
165
- attempts.append(ServerAttempt(
166
- server=server,
167
- success=False,
168
- error=f"Server {server} returned unsuccessful response"
169
- ))
149
+ # Treat a 200 response as a successful attempt regardless of overall success flag.
150
+ # The 'success' field in the checker indicates if any port was reachable, not server health.
151
+ raw_results = data.get("results", {}) or {}
152
+ for port_key, result in raw_results.items():
153
+ try:
154
+ port = int(port_key)
155
+ except Exception:
156
+ # Some implementations might already use ints
157
+ port = int(result.get("port", 0)) if isinstance(result, dict) else 0
158
+ if not port:
159
+ continue
160
+ accessible = bool(result.get("accessible"))
161
+ err = result.get("error")
162
+ if port not in results or (accessible and not results[port].accessible):
163
+ results[port] = PortVerificationResult(
164
+ port=port,
165
+ accessible=accessible,
166
+ error=err,
167
+ verified_by=server if accessible else None,
168
+ attempts=[],
169
+ )
170
+ attempts.append(ServerAttempt(server=server, success=True))
171
+ logger.info(f"Port verification completed using {server}")
170
172
  else:
171
173
  attempts.append(ServerAttempt(
172
174
  server=server,
@@ -198,7 +200,7 @@ class PortVerifier:
198
200
  ))
199
201
  logger.warning(error_msg)
200
202
 
201
- # If no servers were successful, fail verification
203
+ # If no servers responded successfully, fail verification
202
204
  if not any(attempt.success for attempt in attempts):
203
205
  error_msg = (
204
206
  "Failed to connect to any port check servers. Please ensure:\n"
@@ -209,19 +211,15 @@ class PortVerifier:
209
211
  logger.error(error_msg)
210
212
  raise RuntimeError(error_msg)
211
213
 
212
- # If no successful verifications but servers were reachable, mark ports as inaccessible
213
- if not any(result.accessible for result in results.values()):
214
- error_msg = "No ports were verified as accessible"
215
- logger.error(error_msg)
216
- results = {
217
- port: PortVerificationResult(
214
+ # Ensure all requested ports are present in results; default to inaccessible
215
+ for port in ports:
216
+ if port not in results:
217
+ results[port] = PortVerificationResult(
218
218
  port=port,
219
219
  accessible=False,
220
- error=error_msg,
221
- attempts=[] # Will be filled below
220
+ error=None,
221
+ attempts=[],
222
222
  )
223
- for port in ports
224
- }
225
223
 
226
224
  # Add attempts to all results
227
225
  for result in results.values():
@@ -56,21 +56,41 @@ class StreamMonitor:
56
56
  logger.warning(f"stream {stream_id} lookup failed: {e}")
57
57
  continue
58
58
  # Stop VM if remaining runway < threshold
59
- remaining = max(s["stopTime"] - now, 0)
59
+ remaining = max(int(s["stopTime"]) - int(now), 0)
60
60
  logger.debug(
61
61
  f"stream {stream_id} for VM {vm_id}: start={s['startTime']} stop={s['stopTime']} "
62
62
  f"rate={s['ratePerSecond']} withdrawn={s['withdrawn']} halted={s['halted']} remaining={remaining}s"
63
63
  )
64
- if self._get("STREAM_MONITOR_ENABLED", False) and remaining < int(self._get("STREAM_MIN_REMAINING_SECONDS", 0)):
65
- logger.info(f"Stopping VM {vm_id} due to low stream runway ({remaining}s)")
64
+ # If stream is force-halted, delete immediately to free all resources
65
+ if bool(s.get("halted")):
66
+ logger.info(
67
+ f"Deleting VM {vm_id} due to halted stream (id={stream_id}, now={now})"
68
+ )
69
+ try:
70
+ await self.vm_service.delete_vm(vm_id)
71
+ except Exception as e:
72
+ logger.warning(f"delete_vm failed for {vm_id}: {e}")
73
+ try:
74
+ await self.stream_map.remove(vm_id)
75
+ except Exception as e:
76
+ logger.debug(f"failed to remove vm {vm_id} from stream map: {e}")
77
+ continue
78
+
79
+ # Only stop a VM when runway is completely empty
80
+ if remaining == 0:
81
+ logger.info(
82
+ f"Stopping VM {vm_id} as stream runway is exhausted (id={stream_id}, now={now}, stop={s.get('stopTime')})"
83
+ )
66
84
  try:
67
85
  await self.vm_service.stop_vm(vm_id)
68
86
  except Exception as e:
69
87
  logger.warning(f"stop_vm failed for {vm_id}: {e}")
70
- else:
71
- logger.debug(
72
- f"VM {vm_id} stream {stream_id} healthy (remaining={remaining}s, threshold={self._get('STREAM_MIN_REMAINING_SECONDS', 0)}s)"
73
- )
88
+ continue
89
+
90
+ # Otherwise, do not stop; just log health and consider withdrawals
91
+ logger.debug(
92
+ f"VM {vm_id} stream {stream_id} healthy (remaining={remaining}s)"
93
+ )
74
94
  # Withdraw if enough vested and configured
75
95
  if self._get("STREAM_WITHDRAW_ENABLED", False) and self.client:
76
96
  vested = max(min(now, s["stopTime"]) - s["startTime"], 0) * s["ratePerSecond"]
provider/service.py CHANGED
@@ -37,6 +37,68 @@ class ProviderService:
37
37
  # Initialize services
38
38
  await self.port_manager.initialize()
39
39
  await self.vm_service.provider.initialize()
40
+
41
+ # Before starting advertisement, sync allocated resources with existing VMs
42
+ try:
43
+ vm_resources = await self.vm_service.get_all_vms_resources()
44
+ await self.vm_service.resource_tracker.sync_with_multipass(vm_resources)
45
+ except Exception as e:
46
+ logger.warning(f"Failed to sync resources with existing VMs: {e}")
47
+
48
+ # Cross-check running VMs against payment streams. If a VM has no
49
+ # active stream, it is no longer rented: terminate it and free resources.
50
+ try:
51
+ # Only perform checks if payments are configured
52
+ if settings.STREAM_PAYMENT_ADDRESS and not settings.STREAM_PAYMENT_ADDRESS.lower().endswith("0000000000000000000000000000000000000000") and settings.POLYGON_RPC_URL:
53
+ stream_map = app.container.stream_map()
54
+ reader = app.container.stream_reader()
55
+
56
+ # Use the most recent view of VMs from the previous sync
57
+ vm_ids = list(vm_resources.keys()) if 'vm_resources' in locals() else []
58
+ for vm_id in vm_ids:
59
+ try:
60
+ stream_id = await stream_map.get(vm_id)
61
+ except Exception:
62
+ stream_id = None
63
+
64
+ if stream_id is None:
65
+ reason = "no stream mapped"
66
+ should_terminate = True
67
+ else:
68
+ try:
69
+ ok, msg = reader.verify_stream(int(stream_id), settings.PROVIDER_ID)
70
+ should_terminate = not ok
71
+ reason = msg if not ok else "ok"
72
+ except Exception as e:
73
+ # If verification cannot be performed, be conservative and keep the VM
74
+ logger.warning(f"Stream verification error for VM {vm_id} (stream {stream_id}): {e}")
75
+ should_terminate = False
76
+ reason = f"verification error: {e}"
77
+
78
+ if should_terminate:
79
+ logger.info(
80
+ f"Deleting VM {vm_id}: inactive stream (stream_id={stream_id}, reason={reason})"
81
+ )
82
+ try:
83
+ await self.vm_service.delete_vm(vm_id)
84
+ except Exception as e:
85
+ logger.warning(f"Failed to delete VM {vm_id}: {e}")
86
+ try:
87
+ await stream_map.remove(vm_id)
88
+ except Exception:
89
+ pass
90
+
91
+ # Re-sync after any terminations to ensure ads reflect capacity
92
+ try:
93
+ vm_resources = await self.vm_service.get_all_vms_resources()
94
+ await self.vm_service.resource_tracker.sync_with_multipass(vm_resources)
95
+ except Exception as e:
96
+ logger.warning(f"Post-termination resource sync failed: {e}")
97
+ else:
98
+ logger.info("Payments not configured; skipping startup stream checks")
99
+ except Exception as e:
100
+ logger.warning(f"Failed to reconcile VMs with payment streams: {e}")
101
+
40
102
  await self.advertisement_service.start()
41
103
  # Start pricing auto-updater; trigger re-advertise after updates
42
104
  async def _on_price_updated(platform: str, glm_usd):