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.
Files changed (44) hide show
  1. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/PKG-INFO +14 -1
  2. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/README.md +13 -0
  3. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/api/routes.py +101 -13
  4. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/config.py +2 -1
  5. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/container.py +2 -3
  6. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/main.py +367 -25
  7. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/logging.py +18 -3
  8. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/pricing.py +14 -2
  9. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/multipass_adapter.py +30 -7
  10. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/pyproject.toml +1 -1
  11. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/__init__.py +0 -0
  12. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/api/__init__.py +0 -0
  13. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/api/models.py +0 -0
  14. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/data/deployments/l2.json +0 -0
  15. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/__init__.py +0 -0
  16. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/advertiser.py +0 -0
  17. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/golem_base_advertiser.py +0 -0
  18. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/golem_base_utils.py +0 -0
  19. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/multi_advertiser.py +0 -0
  20. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/resource_monitor.py +0 -0
  21. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/resource_tracker.py +0 -0
  22. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/discovery/service.py +0 -0
  23. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/network/port_verifier.py +0 -0
  24. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/payments/blockchain_service.py +0 -0
  25. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/payments/monitor.py +0 -0
  26. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/payments/stream_map.py +0 -0
  27. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/security/ethereum.py +0 -0
  28. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/security/faucet.py +0 -0
  29. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/security/l2_faucet.py +0 -0
  30. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/service.py +0 -0
  31. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/__init__.py +0 -0
  32. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/ascii_art.py +0 -0
  33. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/port_display.py +0 -0
  34. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/retry.py +0 -0
  35. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/utils/setup.py +0 -0
  36. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/__init__.py +0 -0
  37. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/cloud_init.py +0 -0
  38. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/models.py +0 -0
  39. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/multipass.py +0 -0
  40. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/name_mapper.py +0 -0
  41. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/port_manager.py +0 -0
  42. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/provider.py +0 -0
  43. {golem_vm_provider-0.1.56 → golem_vm_provider-0.1.58}/provider/vm/proxy_manager.py +0 -0
  44. {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.56
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 ..config import Settings
11
- from ..config import Settings as _Cfg
10
+ from typing import TYPE_CHECKING, Any
12
11
  from ..container import Container
13
12
  from ..utils.logging import setup_logger
14
13
  from ..utils.ascii_art import vm_creation_animation, vm_status_change
15
14
  from ..vm.models import VMInfo, VMAccessInfo, VMConfig, VMResources, VMNotFoundError
16
- from .models import CreateVMRequest, ProviderInfoResponse, StreamStatus, StreamOnChain, StreamComputed
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: Settings = Depends(Provide[Container.config]),
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["STREAM_PAYMENT_ADDRESS"]
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
- reader = StreamPaymentReader(settings["POLYGON_RPC_URL"], settings["STREAM_PAYMENT_ADDRESS"])
58
- ok, reason = reader.verify_stream(int(request.stream_id), settings["PROVIDER_ID"])
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["DEFAULT_VM_IMAGE"],
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: Settings = Depends(Provide[Container.config]),
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["PUBLIC_IP"] or "localhost",
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: Settings = Depends(Provide[Container.config])) -> ProviderInfoResponse:
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: Settings = Depends(Provide[Container.config]),
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: Settings = Depends(Provide[Container.config]),
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
- print("Using default settings run with --help to customize")
41
+ # Inform the user, but write to stderr so JSON outputs on stdout remain clean
42
+ logger.info("Using default settings – run with --help to customize")
42
43
 
43
44
 
44
45
  if not os.environ.get("GOLEM_PROVIDER_SKIP_BOOTSTRAP") and not os.environ.get("PYTEST_CURRENT_TEST"):
@@ -2,7 +2,6 @@ import os
2
2
  from dependency_injector import containers, providers
3
3
  from pathlib import Path
4
4
 
5
- from .config import settings
6
5
  from .discovery.resource_tracker import ResourceTracker
7
6
  from .discovery.golem_base_advertiser import GolemBaseAdvertiser
8
7
  from .discovery.advertiser import DiscoveryServerAdvertiser
@@ -49,12 +48,12 @@ class Container(containers.DeclarativeContainer):
49
48
 
50
49
  vm_name_mapper = providers.Singleton(
51
50
  VMNameMapper,
52
- db_path=Path(settings.VM_DATA_DIR) / "vm_names.json",
51
+ db_path=providers.Callable(lambda base: Path(base) / "vm_names.json", config.VM_DATA_DIR),
53
52
  )
54
53
 
55
54
  stream_map = providers.Singleton(
56
55
  StreamMap,
57
- storage_path=Path(settings.VM_DATA_DIR) / "streams.json",
56
+ storage_path=providers.Callable(lambda base: Path(base) / "streams.json", config.VM_DATA_DIR),
58
57
  )
59
58
 
60
59
  port_manager = providers.Singleton(
@@ -1,32 +1,43 @@
1
1
  import asyncio
2
2
  import os
3
+ import sys as _sys
3
4
  import socket
4
5
  from fastapi import FastAPI
5
6
  from fastapi.middleware.cors import CORSMiddleware
6
7
  from typing import Optional
7
8
 
8
- from .config import settings, ensure_config
9
9
  from .utils.logging import setup_logger
10
- from .utils.ascii_art import startup_animation
11
- from .discovery.resource_tracker import ResourceTracker
12
- from .discovery.advertiser import DiscoveryServerAdvertiser
10
+
11
+
12
+ # If the invocation includes --json, mute logs as early as possible
13
+ if "--json" in _sys.argv:
14
+ os.environ["GOLEM_SILENCE_LOGS"] = "1"
15
+
16
+ # Defer heavy local imports (may import config) until after we decide on silence
13
17
  from .container import Container
14
18
  from .service import ProviderService
15
19
 
16
-
17
20
  logger = setup_logger(__name__)
18
21
 
19
22
  app = FastAPI(title="VM on Golem Provider")
20
23
  container = Container()
21
- # Load configuration using a dict to avoid version-specific adapters
22
- try:
23
- container.config.from_dict(settings.model_dump())
24
- except Exception:
25
- # Fallback for environments without pydantic v2 model_dump
26
- container.config.from_pydantic(settings)
27
24
  app.container = container
28
25
  container.wire(modules=[".api.routes"])
29
26
 
27
+ # Minimal safe defaults so DI providers that rely on config have paths before runtime
28
+ try:
29
+ from pathlib import Path as _Path
30
+ container.config.from_dict({
31
+ "VM_DATA_DIR": str(_Path.home() / ".golem" / "provider" / "vms"),
32
+ "PROXY_STATE_DIR": str(_Path.home() / ".golem" / "provider" / "proxy"),
33
+ "PORT_RANGE_START": 50800,
34
+ "PORT_RANGE_END": 50900,
35
+ "PORT": 7466,
36
+ "SKIP_PORT_VERIFICATION": True,
37
+ })
38
+ except Exception:
39
+ pass
40
+
30
41
  from .vm.models import VMNotFoundError
31
42
  from fastapi import Request
32
43
  from fastapi.responses import JSONResponse
@@ -57,6 +68,13 @@ app.add_middleware(
57
68
  @app.on_event("startup")
58
69
  async def startup_event():
59
70
  """Handle application startup."""
71
+ # Load configuration into container lazily at runtime
72
+ from .config import settings as _settings
73
+ try:
74
+ container.config.from_dict(_settings.model_dump())
75
+ except Exception:
76
+ # Fallback for environments without pydantic v2 model_dump
77
+ container.config.from_pydantic(_settings)
60
78
  provider_service = container.provider_service()
61
79
  await provider_service.setup(app)
62
80
 
@@ -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
- # Temporarily quiet info logs during checks for cleaner UI
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
- print("Streaming payments are disabled on this provider.")
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
- print(f"Error: {e}")
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
- print("No stream mapped for this VM.")
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
- print(f"Error: {e}")
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
- print(f"Error: {e}")
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
- run_server(dev_mode=False, no_verify_port=no_verify_port, network=network)
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 run_server(dev_mode: bool | None = None, no_verify_port: bool = False, network: str | None = None):
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
- return logger # Already configured
49
+ target_level = logging.CRITICAL if silence else (logging.DEBUG if debug else logging.INFO)
50
+ logger.setLevel(target_level)
51
+ for h in logger.handlers:
52
+ try:
53
+ h.setLevel(target_level)
54
+ except Exception:
55
+ pass
56
+ return logger # Already configured (levels updated)
44
57
 
45
- handler = colorlog.StreamHandler(sys.stdout)
58
+ # Send logs to stderr so stdout can be reserved for machine output (e.g., --json)
59
+ handler = colorlog.StreamHandler(sys.stderr)
46
60
  formatter = colorlog.ColoredFormatter(
47
61
  "%(log_color)s[%(asctime)s] %(levelname)s: %(message)s",
48
62
  datefmt="%Y-%m-%d %H:%M:%S",
@@ -58,7 +72,8 @@ def setup_logger(name: Optional[str] = None, debug: bool = False) -> logging.Log
58
72
  )
59
73
  handler.setFormatter(formatter)
60
74
  logger.addHandler(handler)
61
- logger.setLevel(logging.DEBUG if debug else logging.INFO)
75
+ # Apply level based on silence/debug
76
+ logger.setLevel(logging.CRITICAL if silence else (logging.DEBUG if debug else logging.INFO))
62
77
 
63
78
  return logger
64
79
 
@@ -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
- platform = getattr(settings, "ADVERTISER_TYPE", "discovery_server")
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.keys():
160
+ vms: List[VMInfo] = []
161
+ for requestor_name, multipass_name in list(all_mappings.items()):
162
162
  try:
163
- vm_info = await self.get_vm_status(requestor_name)
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(f"VM {requestor_name} not found, but a mapping exists. It may have been deleted externally.")
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(f"Could not retrieve resources for VM {requestor_name} ({multipass_name}). It may have been deleted.")
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.56"
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"