golem-vm-provider 0.1.55__py3-none-any.whl → 0.1.57__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.55
3
+ Version: 0.1.57
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
@@ -592,13 +655,16 @@ The provider includes real-time port verification status:
592
655
 
593
656
  Example status output:
594
657
 
595
- ```bash
596
- 🌟 Port Verification Status
597
- ==========================
598
- [✅] Provider Port {provider_port}: External ✓ | Internal ✓
599
- [] VM Access Ports: 3 ports available ({start_port}-{start_port+2})
600
- [] Overall Status: Provider Ready
601
- └─ 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}
602
668
  ```
603
669
 
604
670
  ### Resource Allocation Issues
@@ -1,9 +1,9 @@
1
1
  provider/__init__.py,sha256=HO1fkPpZqPO3z8O8-eVIyx8xXSMIVuTR_b1YF0RtXOg,45
2
2
  provider/api/__init__.py,sha256=ssX1ugDqEPt8Fn04IymgmG-Ev8PiXLsCSaiZVvHQnec,344
3
3
  provider/api/models.py,sha256=CmfgXqSH3m0HLqY6JvUFI-2IrdGf3EhNKtZ5kbIAX-U,4304
4
- provider/api/routes.py,sha256=RaOhdUZLJVmCHFWHyhYF9kdBmsFSe5rThIYsW6meMrQ,13194
5
- provider/config.py,sha256=IDeAYQ4z8oaT5HcG9jFQhSZrLlLU6wMTGDTbSxK6FSc,28901
6
- provider/container.py,sha256=81x5LiA-qjYN1Uh_JdOxqvuIXiNDr9X3OXNN0VqYFCI,3681
4
+ provider/api/routes.py,sha256=tH6_msflEgx4O6nMku_Lgg4OW-JonqXHv89NibDFc94,13678
5
+ provider/config.py,sha256=nQzYBujgn-Z7Rqh6q0eOsTpk6R9-V-YF1OysmPpSH0Q,28993
6
+ provider/container.py,sha256=xN1a9qClciGomppCBnEGuPPNzGQkYIWlw1lzexrjptM,3726
7
7
  provider/data/deployments/l2.json,sha256=XTNN2C5LkBfp4YbDKdUKfWMdp1fKnfv8D3TgcwVWxtQ,249
8
8
  provider/discovery/__init__.py,sha256=Y6o8RxGevBpuQS3k32y-zSVbP6HBXG3veBl9ElVPKaU,349
9
9
  provider/discovery/advertiser.py,sha256=o-LiDl1j0lXMUU0-zPe3qerjpoD2360EA60Y_V_VeBc,6571
@@ -13,8 +13,8 @@ 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=2FicpbL8113Gvw3qQzhVHdpYixrNgIYbqwYI0nJaqRI,55746
17
+ provider/network/port_verifier.py,sha256=mlSzr9Z-W5Z5mL3EYg4zemgGoi8Z5ebNoeFgLGRaoH4,13253
18
18
  provider/payments/blockchain_service.py,sha256=4GrzDKwCSUVoENqjD4RLyJ0qwBOJKMyVk5Li-XNsyTc,3567
19
19
  provider/payments/monitor.py,sha256=seo8vE622IdbcRE3x69IpvHn2mel_tlMNGt_DxOIoww,5386
20
20
  provider/payments/stream_map.py,sha256=qk6Y8hS72DplAifZ0ZMWPHBAyc_3IWIQyWUBuCU3_To,1191
@@ -24,9 +24,9 @@ provider/security/l2_faucet.py,sha256=yRV4xdPBgU8-LDTLqtuAijfgIoe2kYxvXqJLxFd-BV
24
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
- provider/utils/logging.py,sha256=VV3oTYSRT8hUejtXLuua1M6kCHmIJgPspIkzsUVhYW0,1920
27
+ provider/utils/logging.py,sha256=1Br806ohJyYpDIw1i8NsNpg8Xc-8-rUYwKBU4LFomLk,2623
28
28
  provider/utils/port_display.py,sha256=u1HWQFA2kPbsM-TnsQfL6Hr4KmjIZWZfsjoxarHpbW0,11981
29
- provider/utils/pricing.py,sha256=uTgiBJ04LuVPKkzwMTVTb6Jb7m_Z3o5XLsoMh49ixqY,6315
29
+ provider/utils/pricing.py,sha256=eDEjt0s7REyTR-7b_3D_a_yPCnQ4req2KvtemYrE2Kw,6673
30
30
  provider/utils/retry.py,sha256=GvBjpr0DpTOgw28M2hI0yt17dpYLRwrxUUqVxWHQPtM,3148
31
31
  provider/utils/setup.py,sha256=Z5dLuBQkb5vdoQsu1HJZwXmu9NWsiBYJ7Vq9-C-_tY8,2932
32
32
  provider/vm/__init__.py,sha256=LJL504QGbqZvBbMN3G9ixMgAwvOWAKW37zUm_EiaW9M,508
@@ -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.55.dist-info/METADATA,sha256=_GZ2hyX-aeTtK--VOiSE4tZfAnQwANw21tO63EvOskY,18877
43
- golem_vm_provider-0.1.55.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
44
- golem_vm_provider-0.1.55.dist-info/entry_points.txt,sha256=5Jiie1dIXygmxmDW66bKKxQpmBLJ7leSKRrb8bkQALw,52
45
- golem_vm_provider-0.1.55.dist-info/RECORD,,
42
+ golem_vm_provider-0.1.57.dist-info/METADATA,sha256=qsWTEj2YWwwpv_NTw4f6cFqFulMm5NZvMUP2iAK8-B8,20932
43
+ golem_vm_provider-0.1.57.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
44
+ golem_vm_provider-0.1.57.dist-info/entry_points.txt,sha256=5Jiie1dIXygmxmDW66bKKxQpmBLJ7leSKRrb8bkQALw,52
45
+ golem_vm_provider-0.1.57.dist-info/RECORD,,
provider/api/routes.py CHANGED
@@ -7,8 +7,7 @@ from fastapi import APIRouter, HTTPException, Request
7
7
  from dependency_injector.wiring import inject, Provide
8
8
  from fastapi import APIRouter, HTTPException, Depends
9
9
 
10
- from ..config import Settings
11
- from ..config import Settings as _Cfg
10
+ from typing import TYPE_CHECKING, Any
12
11
  from ..container import Container
13
12
  from ..utils.logging import setup_logger
14
13
  from ..utils.ascii_art import vm_creation_animation, vm_status_change
@@ -27,7 +26,7 @@ router = APIRouter()
27
26
  async def create_vm(
28
27
  request: CreateVMRequest,
29
28
  vm_service: VMService = Depends(Provide[Container.vm_service]),
30
- settings: Settings = Depends(Provide[Container.config]),
29
+ settings: Any = Depends(Provide[Container.config]),
31
30
  stream_map = Depends(Provide[Container.stream_map]),
32
31
  ) -> VMInfo:
33
32
  """Create a new VM."""
@@ -39,11 +38,12 @@ async def create_vm(
39
38
  # If payments are enabled, require a valid stream before starting
40
39
  # Determine if we should enforce gating
41
40
  enforce = False
42
- spa = settings["STREAM_PAYMENT_ADDRESS"]
41
+ spa = (settings.get("STREAM_PAYMENT_ADDRESS") if isinstance(settings, dict) else getattr(settings, "STREAM_PAYMENT_ADDRESS", None))
43
42
  if spa and spa != "0x0000000000000000000000000000000000000000":
44
43
  if os.environ.get("PYTEST_CURRENT_TEST"):
45
44
  # In pytest, skip gating only when using default deployment address
46
45
  try:
46
+ from ..config import Settings as _Cfg # type: ignore
47
47
  default_spa, _ = _Cfg._load_l2_deployment() # type: ignore[attr-defined]
48
48
  except Exception:
49
49
  default_spa = None
@@ -54,8 +54,10 @@ async def create_vm(
54
54
  if enforce:
55
55
  if request.stream_id is None:
56
56
  raise HTTPException(status_code=400, detail="stream_id required when payments are enabled")
57
- reader = StreamPaymentReader(settings["POLYGON_RPC_URL"], settings["STREAM_PAYMENT_ADDRESS"])
58
- ok, reason = reader.verify_stream(int(request.stream_id), settings["PROVIDER_ID"])
57
+ rpc_url = settings.get("POLYGON_RPC_URL") if isinstance(settings, dict) else getattr(settings, "POLYGON_RPC_URL", None)
58
+ reader = StreamPaymentReader(rpc_url, spa)
59
+ expected_recipient = settings.get("PROVIDER_ID") if isinstance(settings, dict) else getattr(settings, "PROVIDER_ID", None)
60
+ ok, reason = reader.verify_stream(int(request.stream_id), expected_recipient)
59
61
  try:
60
62
  s = reader.get_stream(int(request.stream_id))
61
63
  now = int(reader.web3.eth.get_block("latest")["timestamp"]) # type: ignore[attr-defined]
@@ -73,7 +75,7 @@ async def create_vm(
73
75
  # Create VM config
74
76
  config = VMConfig(
75
77
  name=request.name,
76
- image=request.image or settings["DEFAULT_VM_IMAGE"],
78
+ image=request.image or (settings.get("DEFAULT_VM_IMAGE") if isinstance(settings, dict) else getattr(settings, "DEFAULT_VM_IMAGE", "")),
77
79
  resources=resources,
78
80
  ssh_key=request.ssh_key
79
81
  )
@@ -143,7 +145,7 @@ async def get_vm_status(
143
145
  async def get_vm_access(
144
146
  requestor_name: str,
145
147
  vm_service: VMService = Depends(Provide[Container.vm_service]),
146
- settings: Settings = Depends(Provide[Container.config]),
148
+ settings: Any = Depends(Provide[Container.config]),
147
149
  ) -> VMAccessInfo:
148
150
  """Get VM access information."""
149
151
  try:
@@ -156,7 +158,7 @@ async def get_vm_access(
156
158
  raise HTTPException(404, "VM mapping not found")
157
159
 
158
160
  return VMAccessInfo(
159
- ssh_host=settings["PUBLIC_IP"] or "localhost",
161
+ ssh_host=((settings.get("PUBLIC_IP") if isinstance(settings, dict) else getattr(settings, "PUBLIC_IP", None)) or "localhost"),
160
162
  ssh_port=vm.ssh_port,
161
163
  vm_id=requestor_name,
162
164
  multipass_name=multipass_name
@@ -222,7 +224,7 @@ async def delete_vm(
222
224
  raise HTTPException(status_code=500, detail="An unexpected error occurred")
223
225
  @router.get("/provider/info", response_model=ProviderInfoResponse)
224
226
  @inject
225
- async def provider_info(settings: Settings = Depends(Provide[Container.config])) -> ProviderInfoResponse:
227
+ async def provider_info(settings: Any = Depends(Provide[Container.config])) -> ProviderInfoResponse:
226
228
  return ProviderInfoResponse(
227
229
  provider_id=settings["PROVIDER_ID"],
228
230
  stream_payment_address=settings["STREAM_PAYMENT_ADDRESS"],
@@ -234,7 +236,7 @@ async def provider_info(settings: Settings = Depends(Provide[Container.config]))
234
236
  @inject
235
237
  async def get_vm_stream_status(
236
238
  requestor_name: str,
237
- settings: Settings = Depends(Provide[Container.config]),
239
+ settings: Any = Depends(Provide[Container.config]),
238
240
  stream_map = Depends(Provide[Container.stream_map]),
239
241
  ) -> StreamStatus:
240
242
  """Return on-chain stream status for a VM (if mapped)."""
@@ -266,7 +268,7 @@ async def get_vm_stream_status(
266
268
  @router.get("/payments/streams", response_model=List[StreamStatus])
267
269
  @inject
268
270
  async def list_stream_statuses(
269
- settings: Settings = Depends(Provide[Container.config]),
271
+ settings: Any = Depends(Provide[Container.config]),
270
272
  stream_map = Depends(Provide[Container.stream_map]),
271
273
  ) -> List[StreamStatus]:
272
274
  """List stream status for all mapped VMs."""
provider/config.py CHANGED
@@ -38,7 +38,8 @@ def ensure_config() -> None:
38
38
  created = True
39
39
 
40
40
  if created:
41
- print("Using default settings run with --help to customize")
41
+ # Inform the user, but write to stderr so JSON outputs on stdout remain clean
42
+ logger.info("Using default settings – run with --help to customize")
42
43
 
43
44
 
44
45
  if not os.environ.get("GOLEM_PROVIDER_SKIP_BOOTSTRAP") and not os.environ.get("PYTEST_CURRENT_TEST"):
provider/container.py CHANGED
@@ -2,7 +2,6 @@ import os
2
2
  from dependency_injector import containers, providers
3
3
  from pathlib import Path
4
4
 
5
- from .config import settings
6
5
  from .discovery.resource_tracker import ResourceTracker
7
6
  from .discovery.golem_base_advertiser import GolemBaseAdvertiser
8
7
  from .discovery.advertiser import DiscoveryServerAdvertiser
@@ -49,12 +48,12 @@ class Container(containers.DeclarativeContainer):
49
48
 
50
49
  vm_name_mapper = providers.Singleton(
51
50
  VMNameMapper,
52
- db_path=Path(settings.VM_DATA_DIR) / "vm_names.json",
51
+ db_path=providers.Callable(lambda base: Path(base) / "vm_names.json", config.VM_DATA_DIR),
53
52
  )
54
53
 
55
54
  stream_map = providers.Singleton(
56
55
  StreamMap,
57
- storage_path=Path(settings.VM_DATA_DIR) / "streams.json",
56
+ storage_path=providers.Callable(lambda base: Path(base) / "streams.json", config.VM_DATA_DIR),
58
57
  )
59
58
 
60
59
  port_manager = providers.Singleton(
provider/main.py CHANGED
@@ -1,32 +1,43 @@
1
1
  import asyncio
2
2
  import os
3
+ import sys as _sys
3
4
  import socket
4
5
  from fastapi import FastAPI
5
6
  from fastapi.middleware.cors import CORSMiddleware
6
7
  from typing import Optional
7
8
 
8
- from .config import settings, ensure_config
9
9
  from .utils.logging import setup_logger
10
- from .utils.ascii_art import startup_animation
11
- from .discovery.resource_tracker import ResourceTracker
12
- from .discovery.advertiser import DiscoveryServerAdvertiser
10
+
11
+
12
+ # If the invocation includes --json, mute logs as early as possible
13
+ if "--json" in _sys.argv:
14
+ os.environ["GOLEM_SILENCE_LOGS"] = "1"
15
+
16
+ # Defer heavy local imports (may import config) until after we decide on silence
13
17
  from .container import Container
14
18
  from .service import ProviderService
15
19
 
16
-
17
20
  logger = setup_logger(__name__)
18
21
 
19
22
  app = FastAPI(title="VM on Golem Provider")
20
23
  container = Container()
21
- # Load configuration using a dict to avoid version-specific adapters
22
- try:
23
- container.config.from_dict(settings.model_dump())
24
- except Exception:
25
- # Fallback for environments without pydantic v2 model_dump
26
- container.config.from_pydantic(settings)
27
24
  app.container = container
28
25
  container.wire(modules=[".api.routes"])
29
26
 
27
+ # Minimal safe defaults so DI providers that rely on config have paths before runtime
28
+ try:
29
+ from pathlib import Path as _Path
30
+ container.config.from_dict({
31
+ "VM_DATA_DIR": str(_Path.home() / ".golem" / "provider" / "vms"),
32
+ "PROXY_STATE_DIR": str(_Path.home() / ".golem" / "provider" / "proxy"),
33
+ "PORT_RANGE_START": 50800,
34
+ "PORT_RANGE_END": 50900,
35
+ "PORT": 7466,
36
+ "SKIP_PORT_VERIFICATION": True,
37
+ })
38
+ except Exception:
39
+ pass
40
+
30
41
  from .vm.models import VMNotFoundError
31
42
  from fastapi import Request
32
43
  from fastapi.responses import JSONResponse
@@ -57,6 +68,13 @@ app.add_middleware(
57
68
  @app.on_event("startup")
58
69
  async def startup_event():
59
70
  """Handle application startup."""
71
+ # Load configuration into container lazily at runtime
72
+ from .config import settings as _settings
73
+ try:
74
+ container.config.from_dict(_settings.model_dump())
75
+ except Exception:
76
+ # Fallback for environments without pydantic v2 model_dump
77
+ container.config.from_pydantic(_settings)
60
78
  provider_service = container.provider_service()
61
79
  await provider_service.setup(app)
62
80
 
@@ -136,13 +154,581 @@ config_app = typer.Typer(help="Configure stream monitoring and withdrawals")
136
154
  cli.add_typer(config_app, name="config")
137
155
 
138
156
  @cli.callback()
139
- def main():
157
+ def main(ctx: typer.Context):
140
158
  """VM on Golem Provider CLI"""
141
- ensure_config()
142
159
  # No-op callback to initialize config; avoid custom --version flag to keep help stable
143
160
  return
144
161
 
145
162
 
163
+ def _get_installed_version(pkg_name: str) -> str:
164
+ try:
165
+ return metadata.version(pkg_name)
166
+ except Exception:
167
+ return "unknown"
168
+
169
+
170
+ def _get_latest_version_from_pypi(pkg_name: str) -> Optional[str]:
171
+ # Avoid network in pytest runs
172
+ if os.environ.get("PYTEST_CURRENT_TEST"):
173
+ return None
174
+ try:
175
+ import json as _json
176
+ from urllib.request import urlopen
177
+ with urlopen(f"https://pypi.org/pypi/{pkg_name}/json", timeout=5) as resp:
178
+ data = _json.loads(resp.read().decode("utf-8"))
179
+ return data.get("info", {}).get("version")
180
+ except Exception:
181
+ return None
182
+
183
+
184
+ @cli.command("status")
185
+ def status(json_out: bool = typer.Option(False, "--json", help="Output machine-readable JSON")):
186
+ """Show provider environment status and update info (pretty or JSON)."""
187
+ from .utils.logging import logger as _logger
188
+ from rich.console import Console
189
+ from rich.table import Table
190
+ from rich.panel import Panel
191
+ from rich import box
192
+
193
+ # For JSON, set a process-wide mute env that setup_logger() respects
194
+ import os as _os
195
+ if json_out:
196
+ _os.environ["GOLEM_SILENCE_LOGS"] = "1"
197
+
198
+ # Temporarily quiet logs; when --json, suppress near everything
199
+ prev_level = _logger.level
200
+ import logging as _logging
201
+ _root_logger = _logging.getLogger()
202
+ _prev_root_level = _root_logger.level
203
+ try:
204
+ _logger.setLevel("WARNING")
205
+ if json_out:
206
+ _root_logger.setLevel(_logging.CRITICAL)
207
+ except Exception:
208
+ pass
209
+
210
+ # Silence port_verifier warnings during status checks for clean UI
211
+ try:
212
+ _pv_logger = _logging.getLogger("provider.network.port_verifier")
213
+ _prev_pv_level = _pv_logger.level
214
+ _pv_logger.setLevel(_logging.CRITICAL)
215
+ except Exception:
216
+ _pv_logger = None
217
+ _prev_pv_level = None
218
+ # Also quiet config auto-detection logs (e.g., multipass path) for clean JSON/TTY
219
+ try:
220
+ _cfg_logger = _logging.getLogger("provider.config")
221
+ _prev_cfg_level = _cfg_logger.level
222
+ _cfg_logger.setLevel(_logging.WARNING)
223
+ except Exception:
224
+ _cfg_logger = None
225
+ _prev_cfg_level = None
226
+
227
+ # Defer config-heavy imports until after log levels are adjusted
228
+ from .config import settings as _settings
229
+ from .network.port_verifier import PortVerifier
230
+
231
+ # Versions
232
+ pkg = "golem-vm-provider"
233
+ current = _get_installed_version(pkg)
234
+ latest = _get_latest_version_from_pypi(pkg)
235
+ update_available = bool(latest and current != latest)
236
+
237
+ # Environment
238
+ env = os.environ.get("GOLEM_PROVIDER_ENVIRONMENT", _settings.ENVIRONMENT)
239
+ net = getattr(_settings, "NETWORK", None)
240
+ dev_mode = env == "development" or bool(getattr(_settings, "DEV_MODE", False))
241
+
242
+ # Multipass
243
+ mp = {"ok": False, "path": None, "version": None, "error": None}
244
+ try:
245
+ mp_path = _settings.MULTIPASS_BINARY_PATH
246
+ mp["path"] = mp_path or None
247
+ if mp_path:
248
+ import subprocess
249
+ r = subprocess.run([mp_path, "version"], capture_output=True, text=True, timeout=5)
250
+ if r.returncode == 0:
251
+ mp["ok"] = True
252
+ mp["version"] = (r.stdout or r.stderr).strip()
253
+ else:
254
+ mp["error"] = (r.stderr or r.stdout or "failed").strip()
255
+ else:
256
+ mp["error"] = "not configured"
257
+ except Exception as e:
258
+ mp["ok"] = False
259
+ mp["error"] = str(e)
260
+
261
+ # Provider port (local)
262
+ port = int(_settings.PORT)
263
+ host = getattr(_settings, "HOST", "0.0.0.0")
264
+ local = {"ok": False, "detail": None}
265
+ try:
266
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
267
+ s.settimeout(1)
268
+ local_conn = s.connect_ex(("127.0.0.1", port)) == 0
269
+ s.close()
270
+ if local_conn:
271
+ local["ok"] = True
272
+ local["detail"] = "service is listening"
273
+ else:
274
+ # Check that we can bind (port free)
275
+ if asyncio.run(verify_provider_port(port)):
276
+ local["ok"] = True
277
+ local["detail"] = "port is free (bindable)"
278
+ else:
279
+ local["ok"] = False
280
+ local["detail"] = "port unavailable"
281
+ except Exception as e:
282
+ local["ok"] = False
283
+ local["detail"] = str(e)
284
+
285
+ # Always use shared external port-checker for public reachability
286
+ servers = ["http://195.201.39.101:9000"]
287
+
288
+ external = {"status": "unknown", "verified_by": None, "error": None}
289
+ try:
290
+ verifier = PortVerifier(servers, discovery_port=port)
291
+ results = asyncio.run(verifier.verify_external_access({port}))
292
+ r = results.get(port)
293
+ if r and r.accessible:
294
+ external["status"] = "reachable"
295
+ external["verified_by"] = r.verified_by
296
+ elif r:
297
+ external["status"] = "unreachable"
298
+ external["error"] = r.error
299
+ else:
300
+ external["status"] = "not_verified"
301
+ except Exception as e:
302
+ external["status"] = "check_failed"
303
+ external["error"] = str(e).splitlines()[0]
304
+
305
+ # Base data structure
306
+ data = {
307
+ "version": {
308
+ "installed": current,
309
+ "latest": latest,
310
+ "update_available": update_available,
311
+ },
312
+ "environment": {
313
+ "environment": env,
314
+ "network": net,
315
+ "dev_mode": dev_mode,
316
+ },
317
+ "multipass": mp,
318
+ "ports": {
319
+ "provider": {
320
+ "port": port,
321
+ "host": host,
322
+ "local_ok": local["ok"],
323
+ "local_detail": local["detail"],
324
+ "external": external,
325
+ }
326
+ },
327
+ }
328
+
329
+ # SSH port usage summary from state file + external reachability for full range
330
+ try:
331
+ from pathlib import Path as _Path
332
+ import json as _json
333
+ state_path = _Path(_settings.PROXY_STATE_DIR) / "ports.json"
334
+ ports_in_use = []
335
+ if state_path.exists():
336
+ with open(state_path, "r") as fh:
337
+ st = _json.load(fh)
338
+ for _req_name, pinfo in (st.get("proxies", {}) or {}).items():
339
+ prt = pinfo.get("port")
340
+ if isinstance(prt, int):
341
+ ports_in_use.append(prt)
342
+ start = int(getattr(_settings, "PORT_RANGE_START", 50800))
343
+ end = int(getattr(_settings, "PORT_RANGE_END", 50900))
344
+ total = max(0, end - start)
345
+ used = sorted(set(prt for prt in ports_in_use if start <= prt < end))
346
+ # Check if used ports are actually listening
347
+ used_listening = []
348
+ used_not_listening = []
349
+ for prt in used:
350
+ try:
351
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
352
+ s.settimeout(0.5)
353
+ ok = s.connect_ex(("127.0.0.1", prt)) == 0
354
+ s.close()
355
+ (used_listening if ok else used_not_listening).append(prt)
356
+ except Exception:
357
+ used_not_listening.append(prt)
358
+ free_count = total - len(used)
359
+ # External reachability across entire range
360
+ external_ok: set[int] = set()
361
+ ext_results_map: dict[int, bool] = {}
362
+ external_batch_ok = False
363
+ external_batch_error: str | None = None
364
+ try:
365
+ verifier_all = PortVerifier(servers, discovery_port=port)
366
+ _ext_results = asyncio.run(verifier_all.verify_external_access(set(range(start, end))))
367
+ for prt, res in _ext_results.items():
368
+ p = int(prt)
369
+ ok = bool(getattr(res, "accessible", False))
370
+ ext_results_map[p] = ok
371
+ if ok:
372
+ external_ok.add(p)
373
+ external_batch_ok = True
374
+ except Exception:
375
+ # Leave external_ok empty on failure
376
+ external_batch_ok = False
377
+ try:
378
+ external_batch_error = str(_) # type: ignore[name-defined]
379
+ except Exception:
380
+ external_batch_error = None
381
+
382
+ firewall_issues = [p for p in used_listening if p not in external_ok]
383
+
384
+ # Build per-port details for JSON consumers
385
+ details = []
386
+ for p in range(start, end):
387
+ details.append({
388
+ "port": p,
389
+ "in_use": p in used,
390
+ "local_listening": p in used_listening,
391
+ "external_reachable": bool(ext_results_map.get(p, False)) if external_batch_ok else False,
392
+ })
393
+
394
+ # Compute number of free ports that are actually externally reachable (usable)
395
+ usable_free_count = None
396
+ try:
397
+ if external_batch_ok:
398
+ usable_free_count = len([
399
+ p for p in range(start, end)
400
+ if (p not in used) and bool(ext_results_map.get(p, False))
401
+ ])
402
+ except Exception:
403
+ usable_free_count = None
404
+
405
+ # Legacy detailed metrics (retained under ssh_legacy)
406
+ _ssh_legacy = {
407
+ "range": [start, end],
408
+ "total": total,
409
+ "in_use": used,
410
+ "listening_ok": used_listening,
411
+ "listening_issues": used_not_listening,
412
+ "free_count": free_count,
413
+ "external_reachable_count": len([p for p in range(start, end) if p in external_ok]),
414
+ "firewall_issues_count": len(firewall_issues),
415
+ "external_checked": external_batch_ok,
416
+ "external_error": external_batch_error,
417
+ "usable_free_count": usable_free_count,
418
+ "details": details,
419
+ }
420
+
421
+ # Concise status fields for programmatic checks (mirrors TTY)
422
+ _ext_reach = int(len(external_ok))
423
+ _issues_count = len(used_not_listening) + len(firewall_issues)
424
+ if (not external_batch_ok) or _ext_reach == 0:
425
+ ssh_status = "blocked"
426
+ elif _issues_count > 0:
427
+ ssh_status = "limited"
428
+ else:
429
+ ssh_status = "ok"
430
+
431
+ # Usable free: default to 0 when external check failed
432
+ if usable_free_count is None:
433
+ usable_free_out = 0
434
+ else:
435
+ usable_free_out = int(usable_free_count)
436
+
437
+ # Issues breakdown matching TTY wording
438
+ unreachable_count = total if (not external_batch_ok or _ext_reach == 0) else len(firewall_issues)
439
+ not_listening_count = len(used_not_listening)
440
+
441
+ # Build minimal per-port status list for JSON consumers
442
+ # Consistent definition:
443
+ # - status: reachable | unreachable | unknown (unknown only if external check failed)
444
+ # - listening: true | false
445
+ ports_detail: list[dict] = []
446
+ for p in range(start, end):
447
+ listening = p in used_listening
448
+ if external_batch_ok:
449
+ status_val = "reachable" if bool(ext_results_map.get(p, False)) else "unreachable"
450
+ else:
451
+ status_val = "unknown"
452
+ ports_detail.append({
453
+ "port": p,
454
+ "status": status_val,
455
+ "listening": bool(listening),
456
+ })
457
+
458
+ data["ports"]["ssh"] = {
459
+ "range": [start, end],
460
+ "status": ssh_status,
461
+ "usable_free": usable_free_out,
462
+ "in_use": len(used),
463
+ "issues": {
464
+ "unreachable": int(unreachable_count),
465
+ "not_listening": int(not_listening_count),
466
+ },
467
+ "ports": ports_detail,
468
+ }
469
+
470
+ except Exception:
471
+ # Non-fatal; omit ssh summary if state not available
472
+ pass
473
+
474
+ # Provider concise status: reachable | unreachable (treat check failures as unreachable)
475
+ prov_status = external.get("status")
476
+ provider_status = "reachable" if prov_status == "reachable" else "unreachable"
477
+ data["ports"]["provider"]["status"] = provider_status
478
+
479
+ # Compute overall and issues for JSON output (mirrors condensed model)
480
+ json_issues = []
481
+ json_ssh_blocked = False
482
+ json_critical_no_ssh = False
483
+ # Multipass
484
+ if not mp["ok"]:
485
+ json_issues.append("Multipass not available")
486
+ # Provider local port
487
+ if not local["ok"]:
488
+ json_issues.append(f"Provider port {port} not ready")
489
+ # SSH ports
490
+ if data["ports"].get("ssh"):
491
+ _ssh = data["ports"]["ssh"]
492
+ _status = str(_ssh.get("status") or "blocked").lower()
493
+ _issues = _ssh.get("issues") or {}
494
+ _not_listening = int(_issues.get("not_listening", 0) or 0)
495
+ _unreachable = int(_issues.get("unreachable", 0) or 0)
496
+ if _status == "blocked":
497
+ json_ssh_blocked = True
498
+ json_critical_no_ssh = True
499
+ json_issues.append("No externally reachable SSH ports")
500
+ else:
501
+ if _unreachable > 0:
502
+ json_issues.append(f"{_unreachable} SSH port(s) unreachable externally")
503
+ if _not_listening > 0:
504
+ json_issues.append(f"{_not_listening} SSH port(s) not listening")
505
+ # Provider external
506
+ json_critical_provider_external = False
507
+ if external.get("status") in ("unreachable", "check_failed"):
508
+ json_issues.append("Provider API port not reachable externally")
509
+ json_critical_provider_external = True
510
+
511
+ if json_critical_no_ssh or (not local["ok"]) or (not mp["ok"]) or json_critical_provider_external:
512
+ overall_status = "error"
513
+ else:
514
+ overall_status = "healthy" if (not json_issues and not json_ssh_blocked) else "issues"
515
+
516
+ data["overall"] = {"status": overall_status, "issues": json_issues}
517
+
518
+ if json_out:
519
+ import json as _json
520
+ print(_json.dumps(data, indent=2))
521
+ # Restore logger level
522
+ try:
523
+ _logger.setLevel(prev_level)
524
+ except Exception:
525
+ pass
526
+ if _pv_logger and _prev_pv_level is not None:
527
+ try:
528
+ _pv_logger.setLevel(_prev_pv_level)
529
+ except Exception:
530
+ pass
531
+ if _cfg_logger and _prev_cfg_level is not None:
532
+ try:
533
+ _cfg_logger.setLevel(_prev_cfg_level)
534
+ except Exception:
535
+ pass
536
+ # Restore root logger
537
+ try:
538
+ _root_logger.setLevel(_prev_root_level)
539
+ except Exception:
540
+ pass
541
+ # Restore root logger
542
+ try:
543
+ _root_logger.setLevel(_prev_root_level)
544
+ except Exception:
545
+ pass
546
+ # Unset mute env if we set it
547
+ if json_out:
548
+ try:
549
+ del _os.environ["GOLEM_SILENCE_LOGS"]
550
+ except Exception:
551
+ pass
552
+ return
553
+
554
+ console = Console()
555
+
556
+ # Overall status
557
+ issues = []
558
+ if not mp["ok"]:
559
+ issues.append("Multipass not available")
560
+ if not local["ok"]:
561
+ issues.append(f"Provider port {port} not ready")
562
+ ssh_blocked = False
563
+ critical_no_ssh = False
564
+ if data["ports"].get("ssh"):
565
+ ssh = data["ports"]["ssh"]
566
+ if ssh.get("listening_issues"):
567
+ issues.append(f"{len(ssh['listening_issues'])} SSH port(s) not listening")
568
+ if ssh.get("free_count", 0) == 0:
569
+ issues.append("No free SSH ports available")
570
+ if ssh.get("external_checked"):
571
+ if int(ssh.get("external_reachable_count", 0) or 0) == 0:
572
+ ssh_blocked = True
573
+ critical_no_ssh = True # No externally reachable SSH ports is critical
574
+ issues.append("No externally reachable SSH ports")
575
+ else:
576
+ # If we couldn't check, mark as issue but not critical
577
+ issues.append("SSH external reachability check failed")
578
+ critical_provider_external = False
579
+ if external["status"] in ("unreachable", "check_failed"):
580
+ issues.append("Provider API port not reachable externally")
581
+ critical_provider_external = True
582
+
583
+ # Severity: Error when critical conditions are met; else Issues/Healthy
584
+ if critical_no_ssh or (not local["ok"]) or (not mp["ok"]) or critical_provider_external:
585
+ overall = "Error"
586
+ else:
587
+ overall = "Healthy" if (not issues and not ssh_blocked) else "Issues detected"
588
+
589
+ # Build a single compact table
590
+ tbl = Table(box=box.SIMPLE_HEAVY, show_header=False, pad_edge=False)
591
+ tbl.add_column("Item", style="bold")
592
+ tbl.add_column("Value")
593
+
594
+ # Header
595
+ if overall == "Healthy":
596
+ overall_txt = "[green]Healthy[/green]"
597
+ elif overall == "Error":
598
+ overall_txt = "[red]Error[/red]"
599
+ else:
600
+ overall_txt = f"[yellow]{overall}[/yellow]"
601
+ tbl.add_row("Overall", overall_txt)
602
+ tbl.add_row("", "")
603
+
604
+ # Versions
605
+ tbl.add_row("Versions", "")
606
+ ver_inst = data["version"]["installed"] or "unknown"
607
+ ver_latest = data["version"]["latest"] or "unknown"
608
+ upd = data["version"]["update_available"]
609
+ tbl.add_row(" Installed", f"[white]{ver_inst}[/white]")
610
+ if upd:
611
+ tbl.add_row(" Latest", f"[bold bright_yellow]{ver_latest}[/bold bright_yellow] [grey62](pip install -U golem-vm-provider)[/grey62]")
612
+ tbl.add_row(" Update", "[bold bright_yellow]⬆️ yes[/bold bright_yellow]")
613
+ else:
614
+ tbl.add_row(" Latest", f"[cyan]{ver_latest}[/cyan]")
615
+ tbl.add_row(" Update", "[green]no[/green]")
616
+ tbl.add_row("", "")
617
+
618
+ # Environment
619
+ tbl.add_row("Environment", "")
620
+ tbl.add_row(" Environment", env + (" (dev)" if dev_mode else ""))
621
+ tbl.add_row(" Network", net or "-")
622
+ tbl.add_row("", "")
623
+
624
+ # Multipass
625
+ mp_ver = (mp.get("version") or mp.get("error") or "-").replace("\n", ", ")
626
+ tbl.add_row("Multipass", "")
627
+ tbl.add_row(" Status", "✅ OK" if mp["ok"] else "❌ Missing")
628
+ tbl.add_row(" Path", mp.get("path") or "-")
629
+ tbl.add_row(" Version", mp_ver)
630
+ tbl.add_row("", "")
631
+
632
+ # Provider port
633
+ tbl.add_row("Provider Port", f"{host}:{port}")
634
+ tbl.add_row(" Local", ("✅ " if local["ok"] else "❌ ") + (local["detail"] or ""))
635
+ # External reachability is foundational; treat unreachable and check failures the same
636
+ _ext = external.get("status") or "unknown"
637
+ _err = external.get("error")
638
+ if _ext == "reachable":
639
+ ext_row = "✅ reachable"
640
+ elif _ext in ("unreachable", "check_failed"):
641
+ ext_row = "❌ unreachable" + (f" — {_err}" if _err else "")
642
+ elif _ext == "not_verified":
643
+ ext_row = "⚠️ not verified"
644
+ else:
645
+ ext_row = "⚠️ " + _ext + (f" — {_err}" if _err else "")
646
+ tbl.add_row(" External", ext_row)
647
+
648
+ # SSH ports (condensed, actionable)
649
+ if data["ports"].get("ssh"):
650
+ ssh = data["ports"]["ssh"]
651
+ r0, r1 = ssh['range'][0], ssh['range'][1]-1
652
+ tbl.add_row("", "")
653
+ status_val = str(ssh.get("status") or "blocked").lower()
654
+ issues_obj = ssh.get("issues") or {}
655
+ unreachable_issues = int(issues_obj.get("unreachable", 0) or 0)
656
+ not_listening_issues = int(issues_obj.get("not_listening", 0) or 0)
657
+ in_use = int(ssh.get("in_use", 0) or 0)
658
+ usable_free = ssh.get("usable_free")
659
+
660
+ # Determine clear status
661
+ if status_val == "ok":
662
+ status_txt = "[green]OK[/green]"
663
+ elif status_val == "limited":
664
+ status_txt = f"[yellow]limited — {not_listening_issues + (unreachable_issues or 0)} issue(s)[/yellow]"
665
+ else:
666
+ status_txt = "[red]blocked[/red]"
667
+
668
+ tbl.add_row("SSH Ports", f"{r0}-{r1} — {status_txt}")
669
+
670
+ # Provide only the most relevant numbers
671
+ # Usable free = free and externally reachable; avoid misleading "Free" when blocked
672
+ tbl.add_row(" Usable free", str(int(usable_free or 0)))
673
+ tbl.add_row(" In use", str(in_use))
674
+ if status_val == "blocked":
675
+ # Show total not reachable externally
676
+ total_ports = (r1 - r0 + 1)
677
+ cnt = unreachable_issues if unreachable_issues else total_ports
678
+ tbl.add_row(" Issues", f"{cnt} not reachable externally")
679
+ elif (not_listening_issues or unreachable_issues):
680
+ parts = []
681
+ if unreachable_issues:
682
+ parts.append(f"{unreachable_issues} unreachable (listening but blocked)")
683
+ if not_listening_issues:
684
+ parts.append(f"{not_listening_issues} not listening")
685
+ tbl.add_row(" Issues", ", ".join(parts))
686
+
687
+ # Issues / Tips combined at bottom
688
+ # Only show Notes when there are issues
689
+ if issues:
690
+ tbl.add_row("", "")
691
+ tbl.add_row("Issues", "\n".join(f"• {t}" for t in issues))
692
+
693
+ console.print(Panel(tbl, title="Provider Status"))
694
+
695
+ # Tips
696
+ tips = []
697
+ if update_available:
698
+ tips.append("Upgrade with: pip install -U golem-vm-provider")
699
+ if not mp["ok"]:
700
+ tips.append("Install Multipass and/or set GOLEM_PROVIDER_MULTIPASS_BINARY_PATH")
701
+ if external["status"] != "reachable":
702
+ tips.append("Ensure at least one port-check server is online (see above)")
703
+ # Tips are included in the single panel under Notes
704
+
705
+ # Restore logger level
706
+ try:
707
+ _logger.setLevel(prev_level)
708
+ except Exception:
709
+ pass
710
+ if _pv_logger and _prev_pv_level is not None:
711
+ try:
712
+ _pv_logger.setLevel(_prev_pv_level)
713
+ except Exception:
714
+ pass
715
+ if _cfg_logger and _prev_cfg_level is not None:
716
+ try:
717
+ _cfg_logger.setLevel(_prev_cfg_level)
718
+ except Exception:
719
+ pass
720
+ try:
721
+ _root_logger.setLevel(_prev_root_level)
722
+ except Exception:
723
+ pass
724
+ # Unset mute env if we set it
725
+ if json_out:
726
+ try:
727
+ del _os.environ["GOLEM_SILENCE_LOGS"]
728
+ except Exception:
729
+ pass
730
+
731
+
146
732
  @wallet_app.command("faucet-l2")
147
733
  def wallet_faucet_l2():
148
734
  """Request L2 faucet funds for the provider's payment address (native ETH)."""
@@ -178,8 +764,13 @@ def streams_list(json_out: bool = typer.Option(False, "--json", help="Output in
178
764
  from web3 import Web3
179
765
  import json as _json
180
766
  try:
767
+ if json_out:
768
+ os.environ["GOLEM_SILENCE_LOGS"] = "1"
181
769
  if not settings.STREAM_PAYMENT_ADDRESS or settings.STREAM_PAYMENT_ADDRESS == "0x0000000000000000000000000000000000000000":
182
- print("Streaming payments are disabled on this provider.")
770
+ if json_out:
771
+ print(_json.dumps({"error": "streaming_disabled"}, indent=2))
772
+ else:
773
+ print("Streaming payments are disabled on this provider.")
183
774
  raise typer.Exit(code=1)
184
775
  c = Container()
185
776
  c.config.from_pydantic(settings)
@@ -279,8 +870,17 @@ def streams_list(json_out: bool = typer.Option(False, "--json", help="Output in
279
870
  for row in table_rows:
280
871
  print(fmt_row(row))
281
872
  except Exception as e:
282
- print(f"Error: {e}")
873
+ if json_out:
874
+ print(_json.dumps({"error": str(e)}, indent=2))
875
+ else:
876
+ print(f"Error: {e}")
283
877
  raise typer.Exit(code=1)
878
+ finally:
879
+ if json_out:
880
+ try:
881
+ del os.environ["GOLEM_SILENCE_LOGS"]
882
+ except Exception:
883
+ pass
284
884
 
285
885
 
286
886
  @streams_app.command("show")
@@ -294,12 +894,17 @@ def streams_show(vm_id: str = typer.Argument(..., help="VM id (requestor_name)")
294
894
  from web3 import Web3
295
895
  import json as _json
296
896
  try:
897
+ if json_out:
898
+ os.environ["GOLEM_SILENCE_LOGS"] = "1"
297
899
  c = Container()
298
900
  c.config.from_pydantic(settings)
299
901
  stream_map = c.stream_map()
300
902
  sid = asyncio.run(stream_map.get(vm_id))
301
903
  if sid is None:
302
- print("No stream mapped for this VM.")
904
+ if json_out:
905
+ print(_json.dumps({"error": "no_stream_mapping", "vm_id": vm_id}, indent=2))
906
+ else:
907
+ print("No stream mapped for this VM.")
303
908
  raise typer.Exit(code=1)
304
909
  reader = StreamPaymentReader(settings.POLYGON_RPC_URL, settings.STREAM_PAYMENT_ADDRESS)
305
910
  s = reader.get_stream(int(sid))
@@ -362,8 +967,17 @@ def streams_show(vm_id: str = typer.Argument(..., help="VM id (requestor_name)")
362
967
  print(" ".join("-" * wi for wi in w))
363
968
  print(" ".join(str(cols[i]).ljust(w[i]) for i in range(len(w))))
364
969
  except Exception as e:
365
- print(f"Error: {e}")
970
+ if json_out:
971
+ print(_json.dumps({"error": str(e), "vm_id": vm_id}, indent=2))
972
+ else:
973
+ print(f"Error: {e}")
366
974
  raise typer.Exit(code=1)
975
+ finally:
976
+ if json_out:
977
+ try:
978
+ del os.environ["GOLEM_SILENCE_LOGS"]
979
+ except Exception:
980
+ pass
367
981
 
368
982
  @streams_app.command("earnings")
369
983
  def streams_earnings(json_out: bool = typer.Option(False, "--json", help="Output in JSON")):
@@ -376,6 +990,8 @@ def streams_earnings(json_out: bool = typer.Option(False, "--json", help="Output
376
990
  from web3 import Web3
377
991
  import json as _json
378
992
  try:
993
+ if json_out:
994
+ os.environ["GOLEM_SILENCE_LOGS"] = "1"
379
995
  c = Container()
380
996
  c.config.from_pydantic(settings)
381
997
  stream_map = c.stream_map()
@@ -467,8 +1083,20 @@ def streams_earnings(json_out: bool = typer.Option(False, "--json", help="Output
467
1083
  for row in table:
468
1084
  print(" ".join(str(row[i]).ljust(w2[i]) for i in range(4)))
469
1085
  except Exception as e:
470
- print(f"Error: {e}")
1086
+ if json_out:
1087
+ try:
1088
+ print(_json.dumps({"error": str(e)}, indent=2))
1089
+ except Exception:
1090
+ print("{\"error\": \"unexpected\"}")
1091
+ else:
1092
+ print(f"Error: {e}")
471
1093
  raise typer.Exit(code=1)
1094
+ finally:
1095
+ if json_out:
1096
+ try:
1097
+ del os.environ["GOLEM_SILENCE_LOGS"]
1098
+ except Exception:
1099
+ pass
472
1100
 
473
1101
 
474
1102
  @streams_app.command("withdraw")
@@ -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():
provider/utils/logging.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import logging
2
2
  import colorlog
3
3
  import sys
4
+ import os
4
5
  from typing import Optional
5
6
 
6
7
  # Import standard logging levels
@@ -39,10 +40,23 @@ def setup_logger(name: Optional[str] = None, debug: bool = False) -> logging.Log
39
40
  Configured logger instance
40
41
  """
41
42
  logger = logging.getLogger(name or __name__)
43
+
44
+ # Global hard mute for JSON commands or other machine output scenarios
45
+ silence = os.getenv("GOLEM_SILENCE_LOGS", "").lower() in ("1", "true", "yes")
46
+
47
+ # If already configured, still adjust level according to silence/debug
42
48
  if logger.handlers:
43
- return logger # Already configured
49
+ target_level = logging.CRITICAL if silence else (logging.DEBUG if debug else logging.INFO)
50
+ logger.setLevel(target_level)
51
+ for h in logger.handlers:
52
+ try:
53
+ h.setLevel(target_level)
54
+ except Exception:
55
+ pass
56
+ return logger # Already configured (levels updated)
44
57
 
45
- handler = colorlog.StreamHandler(sys.stdout)
58
+ # Send logs to stderr so stdout can be reserved for machine output (e.g., --json)
59
+ handler = colorlog.StreamHandler(sys.stderr)
46
60
  formatter = colorlog.ColoredFormatter(
47
61
  "%(log_color)s[%(asctime)s] %(levelname)s: %(message)s",
48
62
  datefmt="%Y-%m-%d %H:%M:%S",
@@ -58,7 +72,8 @@ def setup_logger(name: Optional[str] = None, debug: bool = False) -> logging.Log
58
72
  )
59
73
  handler.setFormatter(formatter)
60
74
  logger.addHandler(handler)
61
- logger.setLevel(logging.DEBUG if debug else logging.INFO)
75
+ # Apply level based on silence/debug
76
+ logger.setLevel(logging.CRITICAL if silence else (logging.DEBUG if debug else logging.INFO))
62
77
 
63
78
  return logger
64
79
 
provider/utils/pricing.py CHANGED
@@ -7,11 +7,15 @@ import time
7
7
  import requests
8
8
 
9
9
  from ..vm.models import VMResources
10
- from ..config import settings
11
10
  from .logging import setup_logger
12
11
 
13
12
  logger = setup_logger(__name__)
14
13
 
14
+ def _get_settings():
15
+ # Lazy import to avoid side effects during module import (e.g., JSON CLI quieting)
16
+ from ..config import settings as _s
17
+ return _s
18
+
15
19
  # Increase precision for financial calcs
16
20
  getcontext().prec = 28
17
21
 
@@ -21,6 +25,7 @@ def quantize_money(value: Decimal) -> Decimal:
21
25
 
22
26
 
23
27
  def _coingecko_simple_price(ids: str) -> Optional[Decimal]:
28
+ settings = _get_settings()
24
29
  base = settings.COINGECKO_API_URL.rstrip("/")
25
30
  url = f"{base}/simple/price"
26
31
  try:
@@ -44,6 +49,7 @@ def fetch_glm_usd_price() -> Optional[Decimal]:
44
49
 
45
50
  Tries multiple IDs to hedge against slug changes.
46
51
  """
52
+ settings = _get_settings()
47
53
  return _coingecko_simple_price(settings.COINGECKO_IDS)
48
54
 
49
55
 
@@ -70,6 +76,7 @@ def calculate_monthly_cost(resources: VMResources) -> Decimal:
70
76
 
71
77
  Uses the GLM-denominated price-per-unit values configured in settings.
72
78
  """
79
+ settings = _get_settings()
73
80
  core_price = Decimal(str(settings.PRICE_GLM_PER_CORE_MONTH))
74
81
  ram_price = Decimal(str(settings.PRICE_GLM_PER_GB_RAM_MONTH))
75
82
  storage_price = Decimal(str(settings.PRICE_GLM_PER_GB_STORAGE_MONTH))
@@ -98,6 +105,7 @@ def update_glm_unit_prices_from_usd(glm_usd: Decimal) -> Tuple[Decimal, Decimal,
98
105
 
99
106
  Returns a tuple of (core_glm, ram_glm, storage_glm).
100
107
  """
108
+ settings = _get_settings()
101
109
  core_usd = Decimal(str(settings.PRICE_USD_PER_CORE_MONTH))
102
110
  ram_usd = Decimal(str(settings.PRICE_USD_PER_GB_RAM_MONTH))
103
111
  storage_usd = Decimal(str(settings.PRICE_USD_PER_GB_STORAGE_MONTH))
@@ -107,6 +115,7 @@ def update_glm_unit_prices_from_usd(glm_usd: Decimal) -> Tuple[Decimal, Decimal,
107
115
  storage_glm = usd_to_glm(storage_usd, glm_usd)
108
116
 
109
117
  # Persist on settings instance (in-memory)
118
+ settings = _get_settings()
110
119
  settings.PRICE_GLM_PER_CORE_MONTH = float(core_glm)
111
120
  settings.PRICE_GLM_PER_GB_RAM_MONTH = float(ram_glm)
112
121
  settings.PRICE_GLM_PER_GB_STORAGE_MONTH = float(storage_glm)
@@ -129,6 +138,7 @@ class PricingAutoUpdater:
129
138
  self._last_price: Optional[Decimal] = None
130
139
 
131
140
  async def start(self):
141
+ settings = _get_settings()
132
142
  if not settings.PRICING_UPDATE_ENABLED:
133
143
  return
134
144
 
@@ -173,6 +183,7 @@ class PricingAutoUpdater:
173
183
  self._last_price = new_price
174
184
  return True
175
185
  delta = abs((new_price - old) / old) * Decimal("100")
186
+ settings = _get_settings()
176
187
  if delta >= Decimal(str(settings.PRICING_UPDATE_MIN_DELTA_PERCENT)):
177
188
  self._last_price = new_price
178
189
  return True