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.
- {golem_vm_provider-0.1.55.dist-info → golem_vm_provider-0.1.57.dist-info}/METADATA +74 -8
- {golem_vm_provider-0.1.55.dist-info → golem_vm_provider-0.1.57.dist-info}/RECORD +11 -11
- provider/api/routes.py +14 -12
- provider/config.py +2 -1
- provider/container.py +2 -3
- provider/main.py +646 -18
- provider/network/port_verifier.py +32 -34
- provider/utils/logging.py +18 -3
- provider/utils/pricing.py +12 -1
- {golem_vm_provider-0.1.55.dist-info → golem_vm_provider-0.1.57.dist-info}/WHEEL +0 -0
- {golem_vm_provider-0.1.55.dist-info → golem_vm_provider-0.1.57.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: golem-vm-provider
|
3
|
-
Version: 0.1.
|
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
|
-
```
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
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=
|
5
|
-
provider/config.py,sha256=
|
6
|
-
provider/container.py,sha256=
|
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=
|
17
|
-
provider/network/port_verifier.py,sha256=
|
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=
|
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=
|
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.
|
43
|
-
golem_vm_provider-0.1.
|
44
|
-
golem_vm_provider-0.1.
|
45
|
-
golem_vm_provider-0.1.
|
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
|
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:
|
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
|
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
|
-
|
58
|
-
|
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
|
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:
|
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
|
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:
|
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:
|
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:
|
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
|
-
|
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(
|
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(
|
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
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
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
|
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
|
-
#
|
213
|
-
|
214
|
-
|
215
|
-
|
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=
|
221
|
-
attempts=[]
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
File without changes
|
File without changes
|