golem-vm-provider 0.1.56__tar.gz → 0.1.58__tar.gz
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.56 → golem_vm_provider-0.1.58}/PKG-INFO +14 -1
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/README.md +13 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/api/routes.py +101 -13
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/config.py +2 -1
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/container.py +2 -3
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/main.py +367 -25
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/logging.py +18 -3
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/pricing.py +14 -2
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/multipass_adapter.py +30 -7
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/pyproject.toml +1 -1
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/__init__.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/api/__init__.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/api/models.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/data/deployments/l2.json +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/__init__.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/advertiser.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/golem_base_advertiser.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/golem_base_utils.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/multi_advertiser.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/resource_monitor.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/resource_tracker.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/service.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/network/port_verifier.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/payments/blockchain_service.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/payments/monitor.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/payments/stream_map.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/security/ethereum.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/security/faucet.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/security/l2_faucet.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/service.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/__init__.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/ascii_art.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/port_display.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/retry.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/setup.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/__init__.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/cloud_init.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/models.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/multipass.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/name_mapper.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/port_manager.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/provider.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/proxy_manager.py +0 -0
- {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/service.py +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.58
|
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
|
@@ -470,6 +470,19 @@ golem-provider start
|
|
470
470
|
GOLEM_PROVIDER_ENVIRONMENT=development golem-provider start --network testnet
|
471
471
|
```
|
472
472
|
|
473
|
+
Run as a background service (no terminal):
|
474
|
+
|
475
|
+
```bash
|
476
|
+
# Start in background and write a PID file
|
477
|
+
golem-provider start --daemon [--network testnet|mainnet]
|
478
|
+
|
479
|
+
# Stop the background process
|
480
|
+
golem-provider stop
|
481
|
+
|
482
|
+
# Check environment and port health (unchanged)
|
483
|
+
golem-provider status [--json]
|
484
|
+
```
|
485
|
+
|
473
486
|
### Mode vs. Network
|
474
487
|
|
475
488
|
- Development Mode (`GOLEM_PROVIDER_ENVIRONMENT=development`)
|
@@ -425,6 +425,19 @@ golem-provider start
|
|
425
425
|
GOLEM_PROVIDER_ENVIRONMENT=development golem-provider start --network testnet
|
426
426
|
```
|
427
427
|
|
428
|
+
Run as a background service (no terminal):
|
429
|
+
|
430
|
+
```bash
|
431
|
+
# Start in background and write a PID file
|
432
|
+
golem-provider start --daemon [--network testnet|mainnet]
|
433
|
+
|
434
|
+
# Stop the background process
|
435
|
+
golem-provider stop
|
436
|
+
|
437
|
+
# Check environment and port health (unchanged)
|
438
|
+
golem-provider status [--json]
|
439
|
+
```
|
440
|
+
|
428
441
|
### Mode vs. Network
|
429
442
|
|
430
443
|
- Development Mode (`GOLEM_PROVIDER_ENVIRONMENT=development`)
|
@@ -7,13 +7,18 @@ 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
|
15
14
|
from ..vm.models import VMInfo, VMAccessInfo, VMConfig, VMResources, VMNotFoundError
|
16
|
-
from .models import
|
15
|
+
from .models import (
|
16
|
+
CreateVMRequest,
|
17
|
+
ProviderInfoResponse,
|
18
|
+
StreamStatus,
|
19
|
+
StreamOnChain,
|
20
|
+
StreamComputed,
|
21
|
+
)
|
17
22
|
from ..payments.blockchain_service import StreamPaymentReader
|
18
23
|
from ..vm.service import VMService
|
19
24
|
from ..vm.multipass_adapter import MultipassError
|
@@ -27,7 +32,7 @@ router = APIRouter()
|
|
27
32
|
async def create_vm(
|
28
33
|
request: CreateVMRequest,
|
29
34
|
vm_service: VMService = Depends(Provide[Container.vm_service]),
|
30
|
-
settings:
|
35
|
+
settings: Any = Depends(Provide[Container.config]),
|
31
36
|
stream_map = Depends(Provide[Container.stream_map]),
|
32
37
|
) -> VMInfo:
|
33
38
|
"""Create a new VM."""
|
@@ -39,11 +44,12 @@ async def create_vm(
|
|
39
44
|
# If payments are enabled, require a valid stream before starting
|
40
45
|
# Determine if we should enforce gating
|
41
46
|
enforce = False
|
42
|
-
spa = settings
|
47
|
+
spa = (settings.get("STREAM_PAYMENT_ADDRESS") if isinstance(settings, dict) else getattr(settings, "STREAM_PAYMENT_ADDRESS", None))
|
43
48
|
if spa and spa != "0x0000000000000000000000000000000000000000":
|
44
49
|
if os.environ.get("PYTEST_CURRENT_TEST"):
|
45
50
|
# In pytest, skip gating only when using default deployment address
|
46
51
|
try:
|
52
|
+
from ..config import Settings as _Cfg # type: ignore
|
47
53
|
default_spa, _ = _Cfg._load_l2_deployment() # type: ignore[attr-defined]
|
48
54
|
except Exception:
|
49
55
|
default_spa = None
|
@@ -54,8 +60,10 @@ async def create_vm(
|
|
54
60
|
if enforce:
|
55
61
|
if request.stream_id is None:
|
56
62
|
raise HTTPException(status_code=400, detail="stream_id required when payments are enabled")
|
57
|
-
|
58
|
-
|
63
|
+
rpc_url = settings.get("POLYGON_RPC_URL") if isinstance(settings, dict) else getattr(settings, "POLYGON_RPC_URL", None)
|
64
|
+
reader = StreamPaymentReader(rpc_url, spa)
|
65
|
+
expected_recipient = settings.get("PROVIDER_ID") if isinstance(settings, dict) else getattr(settings, "PROVIDER_ID", None)
|
66
|
+
ok, reason = reader.verify_stream(int(request.stream_id), expected_recipient)
|
59
67
|
try:
|
60
68
|
s = reader.get_stream(int(request.stream_id))
|
61
69
|
now = int(reader.web3.eth.get_block("latest")["timestamp"]) # type: ignore[attr-defined]
|
@@ -73,7 +81,7 @@ async def create_vm(
|
|
73
81
|
# Create VM config
|
74
82
|
config = VMConfig(
|
75
83
|
name=request.name,
|
76
|
-
image=request.image or settings
|
84
|
+
image=request.image or (settings.get("DEFAULT_VM_IMAGE") if isinstance(settings, dict) else getattr(settings, "DEFAULT_VM_IMAGE", "")),
|
77
85
|
resources=resources,
|
78
86
|
ssh_key=request.ssh_key
|
79
87
|
)
|
@@ -143,7 +151,7 @@ async def get_vm_status(
|
|
143
151
|
async def get_vm_access(
|
144
152
|
requestor_name: str,
|
145
153
|
vm_service: VMService = Depends(Provide[Container.vm_service]),
|
146
|
-
settings:
|
154
|
+
settings: Any = Depends(Provide[Container.config]),
|
147
155
|
) -> VMAccessInfo:
|
148
156
|
"""Get VM access information."""
|
149
157
|
try:
|
@@ -156,7 +164,7 @@ async def get_vm_access(
|
|
156
164
|
raise HTTPException(404, "VM mapping not found")
|
157
165
|
|
158
166
|
return VMAccessInfo(
|
159
|
-
ssh_host=settings
|
167
|
+
ssh_host=((settings.get("PUBLIC_IP") if isinstance(settings, dict) else getattr(settings, "PUBLIC_IP", None)) or "localhost"),
|
160
168
|
ssh_port=vm.ssh_port,
|
161
169
|
vm_id=requestor_name,
|
162
170
|
multipass_name=multipass_name
|
@@ -222,7 +230,7 @@ async def delete_vm(
|
|
222
230
|
raise HTTPException(status_code=500, detail="An unexpected error occurred")
|
223
231
|
@router.get("/provider/info", response_model=ProviderInfoResponse)
|
224
232
|
@inject
|
225
|
-
async def provider_info(settings:
|
233
|
+
async def provider_info(settings: Any = Depends(Provide[Container.config])) -> ProviderInfoResponse:
|
226
234
|
return ProviderInfoResponse(
|
227
235
|
provider_id=settings["PROVIDER_ID"],
|
228
236
|
stream_payment_address=settings["STREAM_PAYMENT_ADDRESS"],
|
@@ -234,7 +242,7 @@ async def provider_info(settings: Settings = Depends(Provide[Container.config]))
|
|
234
242
|
@inject
|
235
243
|
async def get_vm_stream_status(
|
236
244
|
requestor_name: str,
|
237
|
-
settings:
|
245
|
+
settings: Any = Depends(Provide[Container.config]),
|
238
246
|
stream_map = Depends(Provide[Container.stream_map]),
|
239
247
|
) -> StreamStatus:
|
240
248
|
"""Return on-chain stream status for a VM (if mapped)."""
|
@@ -266,7 +274,7 @@ async def get_vm_stream_status(
|
|
266
274
|
@router.get("/payments/streams", response_model=List[StreamStatus])
|
267
275
|
@inject
|
268
276
|
async def list_stream_statuses(
|
269
|
-
settings:
|
277
|
+
settings: Any = Depends(Provide[Container.config]),
|
270
278
|
stream_map = Depends(Provide[Container.stream_map]),
|
271
279
|
) -> List[StreamStatus]:
|
272
280
|
"""List stream status for all mapped VMs."""
|
@@ -297,3 +305,83 @@ async def list_stream_statuses(
|
|
297
305
|
logger.warning(f"stream {stream_id} lookup failed: {e}")
|
298
306
|
continue
|
299
307
|
return resp
|
308
|
+
|
309
|
+
|
310
|
+
# --- GUI support endpoints ---
|
311
|
+
@router.get("/summary")
|
312
|
+
@inject
|
313
|
+
async def provider_summary(
|
314
|
+
vm_service: VMService = Depends(Provide[Container.vm_service]),
|
315
|
+
settings: Any = Depends(Provide[Container.config]),
|
316
|
+
container: Container = Depends(Provide[Container]),
|
317
|
+
):
|
318
|
+
"""Concise provider summary for GUI: status, resources, pricing, VMs."""
|
319
|
+
try:
|
320
|
+
# Resources
|
321
|
+
rt = container.resource_tracker()
|
322
|
+
total = getattr(rt, "total_resources", {})
|
323
|
+
available = rt.get_available_resources() if hasattr(rt, "get_available_resources") else {}
|
324
|
+
|
325
|
+
# Pricing (both USD and GLM per month per unit)
|
326
|
+
pricing = {
|
327
|
+
"usd_per_core_month": float(settings["PRICE_USD_PER_CORE_MONTH"]) if isinstance(settings, dict) else float(getattr(settings, "PRICE_USD_PER_CORE_MONTH", 0)),
|
328
|
+
"usd_per_gb_ram_month": float(settings["PRICE_USD_PER_GB_RAM_MONTH"]) if isinstance(settings, dict) else float(getattr(settings, "PRICE_USD_PER_GB_RAM_MONTH", 0)),
|
329
|
+
"usd_per_gb_storage_month": float(settings["PRICE_USD_PER_GB_STORAGE_MONTH"]) if isinstance(settings, dict) else float(getattr(settings, "PRICE_USD_PER_GB_STORAGE_MONTH", 0)),
|
330
|
+
"glm_per_core_month": float(settings["PRICE_GLM_PER_CORE_MONTH"]) if isinstance(settings, dict) else float(getattr(settings, "PRICE_GLM_PER_CORE_MONTH", 0)),
|
331
|
+
"glm_per_gb_ram_month": float(settings["PRICE_GLM_PER_GB_RAM_MONTH"]) if isinstance(settings, dict) else float(getattr(settings, "PRICE_GLM_PER_GB_RAM_MONTH", 0)),
|
332
|
+
"glm_per_gb_storage_month": float(settings["PRICE_GLM_PER_GB_STORAGE_MONTH"]) if isinstance(settings, dict) else float(getattr(settings, "PRICE_GLM_PER_GB_STORAGE_MONTH", 0)),
|
333
|
+
}
|
334
|
+
|
335
|
+
# VMs
|
336
|
+
vms = []
|
337
|
+
try:
|
338
|
+
items = await vm_service.list_vms()
|
339
|
+
for vm in items:
|
340
|
+
vms.append({
|
341
|
+
"id": vm.id,
|
342
|
+
"status": vm.status.value if hasattr(vm, "status") else str(getattr(vm, "status", "")),
|
343
|
+
"ssh_port": getattr(vm, "ssh_port", None),
|
344
|
+
"resources": {
|
345
|
+
"cpu": getattr(getattr(vm, "resources", None), "cpu", None),
|
346
|
+
"memory": getattr(getattr(vm, "resources", None), "memory", None),
|
347
|
+
"storage": getattr(getattr(vm, "resources", None), "storage", None),
|
348
|
+
},
|
349
|
+
})
|
350
|
+
except Exception:
|
351
|
+
vms = []
|
352
|
+
|
353
|
+
# Basic environment info
|
354
|
+
env = {
|
355
|
+
"environment": settings["ENVIRONMENT"] if isinstance(settings, dict) else getattr(settings, "ENVIRONMENT", None),
|
356
|
+
"network": settings.get("NETWORK") if isinstance(settings, dict) else getattr(settings, "NETWORK", None),
|
357
|
+
}
|
358
|
+
|
359
|
+
return {
|
360
|
+
"status": "running",
|
361
|
+
"resources": {"total": total, "available": available},
|
362
|
+
"pricing": pricing,
|
363
|
+
"vms": vms,
|
364
|
+
"env": env,
|
365
|
+
}
|
366
|
+
except Exception as e:
|
367
|
+
logger.error(f"summary endpoint failed: {e}")
|
368
|
+
raise HTTPException(status_code=500, detail="failed to collect summary")
|
369
|
+
|
370
|
+
|
371
|
+
@router.post("/admin/shutdown")
|
372
|
+
async def admin_shutdown():
|
373
|
+
"""Schedule a graceful provider shutdown. Returns immediately."""
|
374
|
+
try:
|
375
|
+
import asyncio, os, signal
|
376
|
+
loop = asyncio.get_running_loop()
|
377
|
+
# Try to signal our own process for a clean exit shortly after responding
|
378
|
+
def _sig():
|
379
|
+
try:
|
380
|
+
os.kill(os.getpid(), signal.SIGTERM)
|
381
|
+
except Exception:
|
382
|
+
os._exit(0) # last resort
|
383
|
+
loop.call_later(0.2, _sig)
|
384
|
+
return {"ok": True}
|
385
|
+
except Exception as e:
|
386
|
+
logger.error(f"shutdown scheduling failed: {e}")
|
387
|
+
raise HTTPException(status_code=500, detail="failed to schedule shutdown")
|
@@ -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"):
|
@@ -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(
|
@@ -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
|
|
@@ -119,6 +137,11 @@ async def verify_provider_port(port: int) -> bool:
|
|
119
137
|
|
120
138
|
|
121
139
|
import typer
|
140
|
+
import platform as _platform
|
141
|
+
import signal as _signal
|
142
|
+
import time as _time
|
143
|
+
import shutil as _shutil
|
144
|
+
import psutil
|
122
145
|
try:
|
123
146
|
from importlib import metadata
|
124
147
|
except ImportError:
|
@@ -136,9 +159,8 @@ config_app = typer.Typer(help="Configure stream monitoring and withdrawals")
|
|
136
159
|
cli.add_typer(config_app, name="config")
|
137
160
|
|
138
161
|
@cli.callback()
|
139
|
-
def main():
|
162
|
+
def main(ctx: typer.Context):
|
140
163
|
"""VM on Golem Provider CLI"""
|
141
|
-
ensure_config()
|
142
164
|
# No-op callback to initialize config; avoid custom --version flag to keep help stable
|
143
165
|
return
|
144
166
|
|
@@ -154,6 +176,91 @@ def _get_latest_version_from_pypi(pkg_name: str) -> Optional[str]:
|
|
154
176
|
# Avoid network in pytest runs
|
155
177
|
if os.environ.get("PYTEST_CURRENT_TEST"):
|
156
178
|
return None
|
179
|
+
|
180
|
+
|
181
|
+
# ---------------------------
|
182
|
+
# Daemon/PID file management
|
183
|
+
# ---------------------------
|
184
|
+
|
185
|
+
def _pid_dir() -> str:
|
186
|
+
from pathlib import Path
|
187
|
+
plat = _platform.system().lower()
|
188
|
+
if plat.startswith("darwin"):
|
189
|
+
base = Path.home() / "Library" / "Application Support" / "Golem Provider"
|
190
|
+
elif plat.startswith("windows"):
|
191
|
+
base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming"))) / "Golem Provider"
|
192
|
+
else:
|
193
|
+
base = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local" / "state"))) / "golem-provider"
|
194
|
+
base.mkdir(parents=True, exist_ok=True)
|
195
|
+
return str(base)
|
196
|
+
|
197
|
+
|
198
|
+
def _pid_path() -> str:
|
199
|
+
from pathlib import Path
|
200
|
+
return str(Path(_pid_dir()) / "provider.pid")
|
201
|
+
|
202
|
+
|
203
|
+
def _write_pid(pid: int) -> None:
|
204
|
+
with open(_pid_path(), "w") as fh:
|
205
|
+
fh.write(str(pid))
|
206
|
+
|
207
|
+
|
208
|
+
def _read_pid() -> int | None:
|
209
|
+
try:
|
210
|
+
with open(_pid_path(), "r") as fh:
|
211
|
+
c = fh.read().strip()
|
212
|
+
return int(c)
|
213
|
+
except Exception:
|
214
|
+
return None
|
215
|
+
|
216
|
+
|
217
|
+
def _remove_pid_file() -> None:
|
218
|
+
try:
|
219
|
+
os.remove(_pid_path())
|
220
|
+
except Exception:
|
221
|
+
pass
|
222
|
+
|
223
|
+
|
224
|
+
def _is_running(pid: int) -> bool:
|
225
|
+
try:
|
226
|
+
return psutil.pid_exists(pid) and psutil.Process(pid).is_running()
|
227
|
+
except Exception:
|
228
|
+
return False
|
229
|
+
|
230
|
+
|
231
|
+
def _spawn_detached(argv: list[str], env: dict | None = None) -> int:
|
232
|
+
import subprocess
|
233
|
+
popen_kwargs = {
|
234
|
+
"stdin": subprocess.DEVNULL,
|
235
|
+
"stdout": subprocess.DEVNULL,
|
236
|
+
"stderr": subprocess.DEVNULL,
|
237
|
+
"env": env or os.environ.copy(),
|
238
|
+
}
|
239
|
+
if _platform.system().lower().startswith("windows"):
|
240
|
+
creationflags = 0
|
241
|
+
for flag in ("CREATE_NEW_PROCESS_GROUP", "DETACHED_PROCESS"):
|
242
|
+
v = getattr(subprocess, flag, 0)
|
243
|
+
if v:
|
244
|
+
creationflags |= v
|
245
|
+
if creationflags:
|
246
|
+
popen_kwargs["creationflags"] = creationflags # type: ignore[assignment]
|
247
|
+
else:
|
248
|
+
popen_kwargs["preexec_fn"] = os.setsid # type: ignore[assignment]
|
249
|
+
proc = subprocess.Popen(argv, **popen_kwargs)
|
250
|
+
return int(proc.pid)
|
251
|
+
|
252
|
+
|
253
|
+
def _self_command(base_args: list[str]) -> list[str]:
|
254
|
+
import sys
|
255
|
+
# When frozen (PyInstaller), sys.executable is the CLI binary
|
256
|
+
if getattr(sys, "frozen", False):
|
257
|
+
return [sys.executable] + base_args
|
258
|
+
# Prefer the console_script when available
|
259
|
+
exe = _shutil.which("golem-provider")
|
260
|
+
if exe:
|
261
|
+
return [exe] + base_args
|
262
|
+
# Fallback to module execution
|
263
|
+
return [sys.executable, "-m", "provider.main"] + base_args
|
157
264
|
try:
|
158
265
|
import json as _json
|
159
266
|
from urllib.request import urlopen
|
@@ -173,15 +280,24 @@ def status(json_out: bool = typer.Option(False, "--json", help="Output machine-r
|
|
173
280
|
from rich.panel import Panel
|
174
281
|
from rich import box
|
175
282
|
|
176
|
-
#
|
283
|
+
# For JSON, set a process-wide mute env that setup_logger() respects
|
284
|
+
import os as _os
|
285
|
+
if json_out:
|
286
|
+
_os.environ["GOLEM_SILENCE_LOGS"] = "1"
|
287
|
+
|
288
|
+
# Temporarily quiet logs; when --json, suppress near everything
|
177
289
|
prev_level = _logger.level
|
290
|
+
import logging as _logging
|
291
|
+
_root_logger = _logging.getLogger()
|
292
|
+
_prev_root_level = _root_logger.level
|
178
293
|
try:
|
179
294
|
_logger.setLevel("WARNING")
|
295
|
+
if json_out:
|
296
|
+
_root_logger.setLevel(_logging.CRITICAL)
|
180
297
|
except Exception:
|
181
298
|
pass
|
182
299
|
|
183
300
|
# Silence port_verifier warnings during status checks for clean UI
|
184
|
-
import logging as _logging
|
185
301
|
try:
|
186
302
|
_pv_logger = _logging.getLogger("provider.network.port_verifier")
|
187
303
|
_prev_pv_level = _pv_logger.level
|
@@ -507,6 +623,22 @@ def status(json_out: bool = typer.Option(False, "--json", help="Output machine-r
|
|
507
623
|
_cfg_logger.setLevel(_prev_cfg_level)
|
508
624
|
except Exception:
|
509
625
|
pass
|
626
|
+
# Restore root logger
|
627
|
+
try:
|
628
|
+
_root_logger.setLevel(_prev_root_level)
|
629
|
+
except Exception:
|
630
|
+
pass
|
631
|
+
# Restore root logger
|
632
|
+
try:
|
633
|
+
_root_logger.setLevel(_prev_root_level)
|
634
|
+
except Exception:
|
635
|
+
pass
|
636
|
+
# Unset mute env if we set it
|
637
|
+
if json_out:
|
638
|
+
try:
|
639
|
+
del _os.environ["GOLEM_SILENCE_LOGS"]
|
640
|
+
except Exception:
|
641
|
+
pass
|
510
642
|
return
|
511
643
|
|
512
644
|
console = Console()
|
@@ -675,6 +807,16 @@ def status(json_out: bool = typer.Option(False, "--json", help="Output machine-r
|
|
675
807
|
_cfg_logger.setLevel(_prev_cfg_level)
|
676
808
|
except Exception:
|
677
809
|
pass
|
810
|
+
try:
|
811
|
+
_root_logger.setLevel(_prev_root_level)
|
812
|
+
except Exception:
|
813
|
+
pass
|
814
|
+
# Unset mute env if we set it
|
815
|
+
if json_out:
|
816
|
+
try:
|
817
|
+
del _os.environ["GOLEM_SILENCE_LOGS"]
|
818
|
+
except Exception:
|
819
|
+
pass
|
678
820
|
|
679
821
|
|
680
822
|
@wallet_app.command("faucet-l2")
|
@@ -712,8 +854,13 @@ def streams_list(json_out: bool = typer.Option(False, "--json", help="Output in
|
|
712
854
|
from web3 import Web3
|
713
855
|
import json as _json
|
714
856
|
try:
|
857
|
+
if json_out:
|
858
|
+
os.environ["GOLEM_SILENCE_LOGS"] = "1"
|
715
859
|
if not settings.STREAM_PAYMENT_ADDRESS or settings.STREAM_PAYMENT_ADDRESS == "0x0000000000000000000000000000000000000000":
|
716
|
-
|
860
|
+
if json_out:
|
861
|
+
print(_json.dumps({"error": "streaming_disabled"}, indent=2))
|
862
|
+
else:
|
863
|
+
print("Streaming payments are disabled on this provider.")
|
717
864
|
raise typer.Exit(code=1)
|
718
865
|
c = Container()
|
719
866
|
c.config.from_pydantic(settings)
|
@@ -813,8 +960,17 @@ def streams_list(json_out: bool = typer.Option(False, "--json", help="Output in
|
|
813
960
|
for row in table_rows:
|
814
961
|
print(fmt_row(row))
|
815
962
|
except Exception as e:
|
816
|
-
|
963
|
+
if json_out:
|
964
|
+
print(_json.dumps({"error": str(e)}, indent=2))
|
965
|
+
else:
|
966
|
+
print(f"Error: {e}")
|
817
967
|
raise typer.Exit(code=1)
|
968
|
+
finally:
|
969
|
+
if json_out:
|
970
|
+
try:
|
971
|
+
del os.environ["GOLEM_SILENCE_LOGS"]
|
972
|
+
except Exception:
|
973
|
+
pass
|
818
974
|
|
819
975
|
|
820
976
|
@streams_app.command("show")
|
@@ -828,12 +984,17 @@ def streams_show(vm_id: str = typer.Argument(..., help="VM id (requestor_name)")
|
|
828
984
|
from web3 import Web3
|
829
985
|
import json as _json
|
830
986
|
try:
|
987
|
+
if json_out:
|
988
|
+
os.environ["GOLEM_SILENCE_LOGS"] = "1"
|
831
989
|
c = Container()
|
832
990
|
c.config.from_pydantic(settings)
|
833
991
|
stream_map = c.stream_map()
|
834
992
|
sid = asyncio.run(stream_map.get(vm_id))
|
835
993
|
if sid is None:
|
836
|
-
|
994
|
+
if json_out:
|
995
|
+
print(_json.dumps({"error": "no_stream_mapping", "vm_id": vm_id}, indent=2))
|
996
|
+
else:
|
997
|
+
print("No stream mapped for this VM.")
|
837
998
|
raise typer.Exit(code=1)
|
838
999
|
reader = StreamPaymentReader(settings.POLYGON_RPC_URL, settings.STREAM_PAYMENT_ADDRESS)
|
839
1000
|
s = reader.get_stream(int(sid))
|
@@ -896,8 +1057,17 @@ def streams_show(vm_id: str = typer.Argument(..., help="VM id (requestor_name)")
|
|
896
1057
|
print(" ".join("-" * wi for wi in w))
|
897
1058
|
print(" ".join(str(cols[i]).ljust(w[i]) for i in range(len(w))))
|
898
1059
|
except Exception as e:
|
899
|
-
|
1060
|
+
if json_out:
|
1061
|
+
print(_json.dumps({"error": str(e), "vm_id": vm_id}, indent=2))
|
1062
|
+
else:
|
1063
|
+
print(f"Error: {e}")
|
900
1064
|
raise typer.Exit(code=1)
|
1065
|
+
finally:
|
1066
|
+
if json_out:
|
1067
|
+
try:
|
1068
|
+
del os.environ["GOLEM_SILENCE_LOGS"]
|
1069
|
+
except Exception:
|
1070
|
+
pass
|
901
1071
|
|
902
1072
|
@streams_app.command("earnings")
|
903
1073
|
def streams_earnings(json_out: bool = typer.Option(False, "--json", help="Output in JSON")):
|
@@ -910,6 +1080,8 @@ def streams_earnings(json_out: bool = typer.Option(False, "--json", help="Output
|
|
910
1080
|
from web3 import Web3
|
911
1081
|
import json as _json
|
912
1082
|
try:
|
1083
|
+
if json_out:
|
1084
|
+
os.environ["GOLEM_SILENCE_LOGS"] = "1"
|
913
1085
|
c = Container()
|
914
1086
|
c.config.from_pydantic(settings)
|
915
1087
|
stream_map = c.stream_map()
|
@@ -1001,8 +1173,20 @@ def streams_earnings(json_out: bool = typer.Option(False, "--json", help="Output
|
|
1001
1173
|
for row in table:
|
1002
1174
|
print(" ".join(str(row[i]).ljust(w2[i]) for i in range(4)))
|
1003
1175
|
except Exception as e:
|
1004
|
-
|
1176
|
+
if json_out:
|
1177
|
+
try:
|
1178
|
+
print(_json.dumps({"error": str(e)}, indent=2))
|
1179
|
+
except Exception:
|
1180
|
+
print("{\"error\": \"unexpected\"}")
|
1181
|
+
else:
|
1182
|
+
print(f"Error: {e}")
|
1005
1183
|
raise typer.Exit(code=1)
|
1184
|
+
finally:
|
1185
|
+
if json_out:
|
1186
|
+
try:
|
1187
|
+
del os.environ["GOLEM_SILENCE_LOGS"]
|
1188
|
+
except Exception:
|
1189
|
+
pass
|
1006
1190
|
|
1007
1191
|
|
1008
1192
|
@streams_app.command("withdraw")
|
@@ -1055,10 +1239,77 @@ def streams_withdraw(
|
|
1055
1239
|
@cli.command()
|
1056
1240
|
def start(
|
1057
1241
|
no_verify_port: bool = typer.Option(False, "--no-verify-port", help="Skip provider port verification."),
|
1058
|
-
network: str = typer.Option(None, "--network", help="Target network: 'testnet' or 'mainnet' (overrides env)")
|
1242
|
+
network: str = typer.Option(None, "--network", help="Target network: 'testnet' or 'mainnet' (overrides env)"),
|
1243
|
+
gui: Optional[bool] = typer.Option(None, "--gui/--no-gui", help="Auto-launch Electron GUI (default: auto)"),
|
1244
|
+
daemon: bool = typer.Option(False, "--daemon", help="Start in background and write a PID file"),
|
1059
1245
|
):
|
1060
1246
|
"""Start the provider server."""
|
1061
|
-
|
1247
|
+
if daemon:
|
1248
|
+
# If a previous daemon is active, do not start another
|
1249
|
+
pid = _read_pid()
|
1250
|
+
if pid and _is_running(pid):
|
1251
|
+
print(f"Provider already running (pid={pid})")
|
1252
|
+
raise typer.Exit(code=0)
|
1253
|
+
# Build child command and detach
|
1254
|
+
args = ["start"]
|
1255
|
+
if no_verify_port:
|
1256
|
+
args.append("--no-verify-port")
|
1257
|
+
if network:
|
1258
|
+
args += ["--network", network]
|
1259
|
+
# Force no GUI for daemonized child to avoid duplicates
|
1260
|
+
args.append("--no-gui")
|
1261
|
+
cmd = _self_command(args)
|
1262
|
+
# Ensure GUI not auto-launched via env, regardless of defaults
|
1263
|
+
env = {**os.environ, "GOLEM_PROVIDER_LAUNCH_GUI": "0"}
|
1264
|
+
child_pid = _spawn_detached(cmd, env)
|
1265
|
+
_write_pid(child_pid)
|
1266
|
+
print(f"Started provider in background (pid={child_pid})")
|
1267
|
+
raise typer.Exit(code=0)
|
1268
|
+
else:
|
1269
|
+
run_server(dev_mode=False, no_verify_port=no_verify_port, network=network, launch_gui=gui)
|
1270
|
+
|
1271
|
+
|
1272
|
+
@cli.command()
|
1273
|
+
def stop(timeout: int = typer.Option(15, "--timeout", help="Seconds to wait for graceful shutdown")):
|
1274
|
+
"""Stop a background provider started with --daemon."""
|
1275
|
+
pid = _read_pid()
|
1276
|
+
if not pid:
|
1277
|
+
print("No PID file found; nothing to stop")
|
1278
|
+
raise typer.Exit(code=0)
|
1279
|
+
if not _is_running(pid):
|
1280
|
+
print("No running provider process; cleaning up PID file")
|
1281
|
+
_remove_pid_file()
|
1282
|
+
raise typer.Exit(code=0)
|
1283
|
+
try:
|
1284
|
+
p = psutil.Process(pid)
|
1285
|
+
p.terminate()
|
1286
|
+
except Exception:
|
1287
|
+
# Fallback to signal/kill
|
1288
|
+
try:
|
1289
|
+
if _platform.system().lower().startswith("windows"):
|
1290
|
+
os.system(f"taskkill /PID {pid} /T /F >NUL 2>&1")
|
1291
|
+
else:
|
1292
|
+
os.kill(pid, _signal.SIGTERM)
|
1293
|
+
except Exception:
|
1294
|
+
pass
|
1295
|
+
# Wait for exit
|
1296
|
+
start_ts = _time.time()
|
1297
|
+
while _time.time() - start_ts < max(0, int(timeout)):
|
1298
|
+
if not _is_running(pid):
|
1299
|
+
break
|
1300
|
+
_time.sleep(0.2)
|
1301
|
+
if _is_running(pid):
|
1302
|
+
print("Process did not exit in time; sending kill")
|
1303
|
+
try:
|
1304
|
+
psutil.Process(pid).kill()
|
1305
|
+
except Exception:
|
1306
|
+
try:
|
1307
|
+
if not _platform.system().lower().startswith("windows"):
|
1308
|
+
os.kill(pid, _signal.SIGKILL)
|
1309
|
+
except Exception:
|
1310
|
+
pass
|
1311
|
+
_remove_pid_file()
|
1312
|
+
print("Provider stopped")
|
1062
1313
|
|
1063
1314
|
# Removed separate 'dev' command; use environment GOLEM_PROVIDER_ENVIRONMENT=development instead.
|
1064
1315
|
|
@@ -1194,7 +1445,83 @@ def _print_pricing_examples(glm_usd):
|
|
1194
1445
|
f"- {name} ({res.cpu}C, {res.memory}GB RAM, {res.storage}GB Disk): ~{usd_str} per month (~{glm_str})"
|
1195
1446
|
)
|
1196
1447
|
|
1197
|
-
def
|
1448
|
+
def _can_launch_gui() -> bool:
|
1449
|
+
import shutil
|
1450
|
+
plat = _sys.platform
|
1451
|
+
# Basic headless checks
|
1452
|
+
if plat.startswith("linux"):
|
1453
|
+
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
|
1454
|
+
return False
|
1455
|
+
# Require npm (or electron) available
|
1456
|
+
return bool(shutil.which("npm") or shutil.which("electron"))
|
1457
|
+
|
1458
|
+
|
1459
|
+
def _maybe_launch_gui(port: int):
|
1460
|
+
import subprocess, shutil
|
1461
|
+
import os as _os
|
1462
|
+
from pathlib import Path
|
1463
|
+
root = Path(__file__).parent.parent.parent
|
1464
|
+
gui_dir = root / "provider-gui"
|
1465
|
+
if not gui_dir.exists():
|
1466
|
+
logger.info("GUI directory not found; running headless")
|
1467
|
+
return
|
1468
|
+
cmd = None
|
1469
|
+
npm = shutil.which("npm")
|
1470
|
+
electron_bin = gui_dir / "node_modules" / "electron" / "dist" / ("electron.exe" if _sys.platform.startswith("win") else "electron")
|
1471
|
+
try:
|
1472
|
+
# Ensure dependencies (electron) are present
|
1473
|
+
if npm and not electron_bin.exists():
|
1474
|
+
install_cmd = [npm, "ci", "--silent"] if (gui_dir / "package-lock.json").exists() else [npm, "install", "--silent"]
|
1475
|
+
logger.info("Installing Provider GUI dependencies…")
|
1476
|
+
subprocess.run(install_cmd, cwd=str(gui_dir), env=os.environ, check=True)
|
1477
|
+
except Exception as e:
|
1478
|
+
logger.warning(f"GUI dependencies install failed: {e}")
|
1479
|
+
|
1480
|
+
if npm:
|
1481
|
+
cmd = [npm, "start", "--silent"]
|
1482
|
+
elif shutil.which("electron"):
|
1483
|
+
cmd = ["electron", "."]
|
1484
|
+
else:
|
1485
|
+
logger.info("No npm/electron found; skipping GUI")
|
1486
|
+
return
|
1487
|
+
env = {**os.environ, "PROVIDER_API_URL": f"http://127.0.0.1:{port}/api/v1"}
|
1488
|
+
try:
|
1489
|
+
# Detach GUI so it won't receive terminal signals (e.g., Ctrl+C) or
|
1490
|
+
# be terminated when the provider process exits.
|
1491
|
+
popen_kwargs = {
|
1492
|
+
"cwd": str(gui_dir),
|
1493
|
+
"env": env,
|
1494
|
+
"stdin": subprocess.DEVNULL,
|
1495
|
+
"stdout": subprocess.DEVNULL,
|
1496
|
+
"stderr": subprocess.DEVNULL,
|
1497
|
+
}
|
1498
|
+
if _sys.platform.startswith("win"):
|
1499
|
+
# Create a new process group and detach from console on Windows
|
1500
|
+
creationflags = 0
|
1501
|
+
try:
|
1502
|
+
creationflags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP")
|
1503
|
+
except Exception:
|
1504
|
+
pass
|
1505
|
+
try:
|
1506
|
+
creationflags |= getattr(subprocess, "DETACHED_PROCESS")
|
1507
|
+
except Exception:
|
1508
|
+
pass
|
1509
|
+
if creationflags:
|
1510
|
+
popen_kwargs["creationflags"] = creationflags # type: ignore[assignment]
|
1511
|
+
else:
|
1512
|
+
# Start a new session/process group on POSIX
|
1513
|
+
try:
|
1514
|
+
popen_kwargs["preexec_fn"] = _os.setsid # type: ignore[assignment]
|
1515
|
+
except Exception:
|
1516
|
+
pass
|
1517
|
+
|
1518
|
+
subprocess.Popen(cmd, **popen_kwargs)
|
1519
|
+
logger.info("Launched Provider GUI")
|
1520
|
+
except Exception as e:
|
1521
|
+
logger.warning(f"Failed to launch GUI: {e}")
|
1522
|
+
|
1523
|
+
|
1524
|
+
def run_server(dev_mode: bool | None = None, no_verify_port: bool = False, network: str | None = None, launch_gui: Optional[bool] = None):
|
1198
1525
|
"""Helper to run the uvicorn server."""
|
1199
1526
|
import sys
|
1200
1527
|
from pathlib import Path
|
@@ -1250,9 +1577,24 @@ def run_server(dev_mode: bool | None = None, no_verify_port: bool = False, netwo
|
|
1250
1577
|
log_config = uvicorn.config.LOGGING_CONFIG
|
1251
1578
|
log_config["formatters"]["access"]["fmt"] = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
1252
1579
|
|
1580
|
+
# Optionally launch GUI (non-blocking)
|
1581
|
+
want_gui: bool
|
1582
|
+
if launch_gui is None:
|
1583
|
+
env_flag = os.environ.get("GOLEM_PROVIDER_LAUNCH_GUI")
|
1584
|
+
if env_flag is not None:
|
1585
|
+
want_gui = env_flag.strip().lower() in ("1", "true", "yes")
|
1586
|
+
else:
|
1587
|
+
want_gui = _can_launch_gui()
|
1588
|
+
else:
|
1589
|
+
want_gui = bool(launch_gui)
|
1590
|
+
if want_gui:
|
1591
|
+
try:
|
1592
|
+
_maybe_launch_gui(int(settings.PORT))
|
1593
|
+
except Exception:
|
1594
|
+
logger.warning("GUI launch attempt failed; continuing headless")
|
1595
|
+
|
1253
1596
|
# Run server
|
1254
|
-
logger.process(
|
1255
|
-
f"🚀 Starting provider server on {settings.HOST}:{settings.PORT}")
|
1597
|
+
logger.process(f"🚀 Starting provider server on {settings.HOST}:{settings.PORT}")
|
1256
1598
|
uvicorn.run(
|
1257
1599
|
"provider:app",
|
1258
1600
|
host=settings.HOST,
|
@@ -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
|
|
@@ -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
|
|
@@ -155,7 +165,8 @@ class PricingAutoUpdater:
|
|
155
165
|
update_glm_unit_prices_from_usd(glm_usd)
|
156
166
|
if callable(self._on_updated):
|
157
167
|
# Inform callback which advertising platform is active
|
158
|
-
|
168
|
+
_s = _get_settings()
|
169
|
+
platform = getattr(_s, "ADVERTISER_TYPE", "discovery_server")
|
159
170
|
await self._on_updated(platform=platform, glm_usd=glm_usd)
|
160
171
|
else:
|
161
172
|
logger.warning("Skipping pricing update; failed to fetch GLM price")
|
@@ -173,6 +184,7 @@ class PricingAutoUpdater:
|
|
173
184
|
self._last_price = new_price
|
174
185
|
return True
|
175
186
|
delta = abs((new_price - old) / old) * Decimal("100")
|
187
|
+
settings = _get_settings()
|
176
188
|
if delta >= Decimal(str(settings.PRICING_UPDATE_MIN_DELTA_PERCENT)):
|
177
189
|
self._last_price = new_price
|
178
190
|
return True
|
@@ -157,13 +157,25 @@ class MultipassAdapter(VMProvider):
|
|
157
157
|
async def list_vms(self) -> List[VMInfo]:
|
158
158
|
"""List all VMs."""
|
159
159
|
all_mappings = self.name_mapper.list_mappings()
|
160
|
-
vms = []
|
161
|
-
for requestor_name in all_mappings.
|
160
|
+
vms: List[VMInfo] = []
|
161
|
+
for requestor_name, multipass_name in list(all_mappings.items()):
|
162
162
|
try:
|
163
|
-
|
163
|
+
# get_vm_status expects multipass_name
|
164
|
+
vm_info = await self.get_vm_status(multipass_name)
|
164
165
|
vms.append(vm_info)
|
165
166
|
except VMNotFoundError:
|
166
|
-
logger.warning(
|
167
|
+
logger.warning(
|
168
|
+
f"VM {requestor_name} not found, but a mapping exists. It may have been deleted externally."
|
169
|
+
)
|
170
|
+
# Cleanup stale mapping and proxy allocation to avoid repeated warnings
|
171
|
+
try:
|
172
|
+
await self.proxy_manager.remove_vm(multipass_name)
|
173
|
+
except Exception:
|
174
|
+
pass
|
175
|
+
try:
|
176
|
+
await self.name_mapper.remove_mapping(requestor_name)
|
177
|
+
except Exception:
|
178
|
+
pass
|
167
179
|
return vms
|
168
180
|
|
169
181
|
async def start_vm(self, multipass_name: str) -> VMInfo:
|
@@ -211,8 +223,8 @@ class MultipassAdapter(VMProvider):
|
|
211
223
|
async def get_all_vms_resources(self) -> Dict[str, VMResources]:
|
212
224
|
"""Get resources for all running VMs."""
|
213
225
|
all_mappings = self.name_mapper.list_mappings()
|
214
|
-
vm_resources = {}
|
215
|
-
for requestor_name, multipass_name in all_mappings.items():
|
226
|
+
vm_resources: Dict[str, VMResources] = {}
|
227
|
+
for requestor_name, multipass_name in list(all_mappings.items()):
|
216
228
|
try:
|
217
229
|
info = await self._get_vm_info(multipass_name)
|
218
230
|
disks_info = info.get("disks", {})
|
@@ -223,7 +235,18 @@ class MultipassAdapter(VMProvider):
|
|
223
235
|
storage=round(total_storage / (1024**3)) if total_storage > 0 else 10
|
224
236
|
)
|
225
237
|
except (MultipassError, VMNotFoundError):
|
226
|
-
logger.warning(
|
238
|
+
logger.warning(
|
239
|
+
f"Could not retrieve resources for VM {requestor_name} ({multipass_name}). It may have been deleted."
|
240
|
+
)
|
241
|
+
# Cleanup stale mapping and proxy allocation
|
242
|
+
try:
|
243
|
+
await self.proxy_manager.remove_vm(multipass_name)
|
244
|
+
except Exception:
|
245
|
+
pass
|
246
|
+
try:
|
247
|
+
await self.name_mapper.remove_mapping(requestor_name)
|
248
|
+
except Exception:
|
249
|
+
pass
|
227
250
|
except Exception as e:
|
228
251
|
logger.error(f"Failed to get info for VM {requestor_name}: {e}")
|
229
252
|
return vm_resources
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "golem-vm-provider"
|
3
|
-
version = "0.1.
|
3
|
+
version = "0.1.58"
|
4
4
|
description = "VM on Golem Provider Node - Run your own provider node to offer VMs on the Golem Network"
|
5
5
|
authors = ["Phillip Jensen <phillip+vm-on-golem@golemgrid.com>"]
|
6
6
|
readme = "README.md"
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/golem_base_advertiser.py
RENAMED
File without changes
|
{golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/golem_base_utils.py
RENAMED
File without changes
|
{golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/multi_advertiser.py
RENAMED
File without changes
|
{golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/resource_monitor.py
RENAMED
File without changes
|
{golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/resource_tracker.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
{golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/payments/blockchain_service.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|