golem-vm-provider 0.1.36__tar.gz → 0.1.38__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 (39) hide show
  1. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/PKG-INFO +1 -1
  2. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/config.py +32 -1
  3. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/container.py +5 -0
  4. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/discovery/advertiser.py +28 -5
  5. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/discovery/golem_base_advertiser.py +8 -1
  6. golem_vm_provider-0.1.38/provider/discovery/multi_advertiser.py +28 -0
  7. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/discovery/service.py +8 -1
  8. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/main.py +131 -0
  9. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/service.py +9 -0
  10. golem_vm_provider-0.1.38/provider/utils/pricing.py +171 -0
  11. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/utils/retry.py +7 -1
  12. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/vm/multipass_adapter.py +19 -7
  13. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/pyproject.toml +1 -1
  14. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/README.md +0 -0
  15. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/__init__.py +0 -0
  16. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/api/__init__.py +0 -0
  17. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/api/models.py +0 -0
  18. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/api/routes.py +0 -0
  19. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/discovery/__init__.py +0 -0
  20. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/discovery/golem_base_utils.py +0 -0
  21. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/discovery/resource_monitor.py +0 -0
  22. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/discovery/resource_tracker.py +0 -0
  23. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/network/port_verifier.py +0 -0
  24. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/security/ethereum.py +0 -0
  25. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/security/faucet.py +0 -0
  26. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/utils/__init__.py +0 -0
  27. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/utils/ascii_art.py +0 -0
  28. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/utils/logging.py +0 -0
  29. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/utils/port_display.py +0 -0
  30. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/utils/setup.py +0 -0
  31. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/vm/__init__.py +0 -0
  32. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/vm/cloud_init.py +0 -0
  33. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/vm/models.py +0 -0
  34. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/vm/multipass.py +0 -0
  35. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/vm/name_mapper.py +0 -0
  36. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/vm/port_manager.py +0 -0
  37. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/vm/provider.py +0 -0
  38. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/provider/vm/proxy_manager.py +0 -0
  39. {golem_vm_provider-0.1.36 → golem_vm_provider-0.1.38}/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.36
3
+ Version: 0.1.38
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
@@ -39,7 +39,8 @@ def ensure_config() -> None:
39
39
  print("Using default settings – run with --help to customize")
40
40
 
41
41
 
42
- ensure_config()
42
+ if not os.environ.get("GOLEM_PROVIDER_SKIP_BOOTSTRAP") and not os.environ.get("PYTEST_CURRENT_TEST"):
43
+ ensure_config()
43
44
 
44
45
 
45
46
  class Settings(BaseSettings):
@@ -125,7 +126,10 @@ class Settings(BaseSettings):
125
126
  # Discovery Service Settings
126
127
  DISCOVERY_URL: str = "http://195.201.39.101:9001"
127
128
  ADVERTISER_TYPE: str = "golem_base" # or "discovery_server"
129
+ # Deprecated: use platform-specific intervals below
128
130
  ADVERTISEMENT_INTERVAL: int = 240 # seconds
131
+ DISCOVERY_ADVERTISEMENT_INTERVAL: int = 240 # seconds
132
+ GOLEM_BASE_ADVERTISEMENT_INTERVAL: int = 3600 # seconds (on-chain cost, keep higher)
129
133
 
130
134
  # Golem Base Settings
131
135
  GOLEM_BASE_RPC_URL: str = "https://ethwarsaw.holesky.golemdb.io/rpc"
@@ -274,6 +278,14 @@ class Settings(BaseSettings):
274
278
  # Rate Limiting
275
279
  RATE_LIMIT_PER_MINUTE: int = 100
276
280
 
281
+ # Retry/Timeout Settings (for long-running external calls)
282
+ RETRY_ATTEMPTS: int = 5
283
+ RETRY_DELAY_SECONDS: float = 2.0
284
+ RETRY_BACKOFF: float = 2.0
285
+ CREATE_VM_MAX_RETRIES: int = 15
286
+ CREATE_VM_RETRY_DELAY_SECONDS: float = 5.0
287
+ LAUNCH_TIMEOUT_SECONDS: int = 300
288
+
277
289
  # Multipass Settings
278
290
  MULTIPASS_BINARY_PATH: str = Field(
279
291
  default="",
@@ -467,6 +479,25 @@ class Settings(BaseSettings):
467
479
  logger.info(f"Using manually provided IP: {v}")
468
480
  return v
469
481
 
482
+ # Pricing Settings (configured in USD; auto-converted to GLM)
483
+ # Per-month prices per unit
484
+ PRICE_USD_PER_CORE_MONTH: float = Field(default=5.0, ge=0)
485
+ PRICE_USD_PER_GB_RAM_MONTH: float = Field(default=2.0, ge=0)
486
+ PRICE_USD_PER_GB_STORAGE_MONTH: float = Field(default=0.1, ge=0)
487
+
488
+ # Auto-updated GLM-denominated prices (derived from USD via CoinGecko)
489
+ PRICE_GLM_PER_CORE_MONTH: float = Field(default=0.0, ge=0)
490
+ PRICE_GLM_PER_GB_RAM_MONTH: float = Field(default=0.0, ge=0)
491
+ PRICE_GLM_PER_GB_STORAGE_MONTH: float = Field(default=0.0, ge=0)
492
+
493
+ # CoinGecko integration
494
+ COINGECKO_API_URL: str = "https://api.coingecko.com/api/v3"
495
+ COINGECKO_IDS: str = "golem,golem-network-tokens" # try both, first wins
496
+ PRICING_UPDATE_ENABLED: bool = True
497
+ PRICING_UPDATE_MIN_DELTA_PERCENT: float = Field(default=1.0, ge=0.0)
498
+ PRICING_UPDATE_INTERVAL_DISCOVERY: int = 900 # 15 minutes
499
+ PRICING_UPDATE_INTERVAL_GOLEM_BASE: int = 14400 # 4 hours
500
+
470
501
  class Config:
471
502
  env_prefix = "GOLEM_PROVIDER_"
472
503
  case_sensitive = True
@@ -6,6 +6,7 @@ from .config import settings
6
6
  from .discovery.resource_tracker import ResourceTracker
7
7
  from .discovery.golem_base_advertiser import GolemBaseAdvertiser
8
8
  from .discovery.advertiser import DiscoveryServerAdvertiser
9
+ from .discovery.multi_advertiser import MultiAdvertiser
9
10
  from .discovery.service import AdvertisementService
10
11
  from .service import ProviderService
11
12
  from .vm.multipass_adapter import MultipassAdapter
@@ -32,6 +33,10 @@ class Container(containers.DeclarativeContainer):
32
33
  DiscoveryServerAdvertiser,
33
34
  resource_tracker=resource_tracker,
34
35
  ),
36
+ both=providers.Singleton(
37
+ MultiAdvertiser,
38
+ resource_tracker=resource_tracker,
39
+ ),
35
40
  )
36
41
 
37
42
  advertisement_service = providers.Singleton(
@@ -65,7 +65,7 @@ class DiscoveryServerAdvertiser(Advertiser):
65
65
  try:
66
66
  while not self._stop_event.is_set():
67
67
  await self.post_advertisement()
68
- await asyncio.sleep(settings.ADVERTISEMENT_INTERVAL)
68
+ await asyncio.sleep(settings.DISCOVERY_ADVERTISEMENT_INTERVAL)
69
69
  finally:
70
70
  await self.stop()
71
71
 
@@ -76,7 +76,12 @@ class DiscoveryServerAdvertiser(Advertiser):
76
76
  await self.session.close()
77
77
  self.session = None
78
78
 
79
- @async_retry(retries=5, delay=1.0, backoff=2.0, exceptions=(aiohttp.ClientError, asyncio.TimeoutError))
79
+ @async_retry(
80
+ retries=settings.RETRY_ATTEMPTS,
81
+ delay=settings.RETRY_DELAY_SECONDS,
82
+ backoff=settings.RETRY_BACKOFF,
83
+ exceptions=(aiohttp.ClientError, asyncio.TimeoutError),
84
+ )
80
85
  async def _check_discovery_health(self):
81
86
  """Check discovery service health with retries."""
82
87
  if not self.session:
@@ -86,7 +91,12 @@ class DiscoveryServerAdvertiser(Advertiser):
86
91
  if not response.ok:
87
92
  raise Exception(f"Discovery service health check failed: {response.status}")
88
93
 
89
- @async_retry(retries=3, delay=1.0, backoff=2.0, exceptions=(aiohttp.ClientError, asyncio.TimeoutError))
94
+ @async_retry(
95
+ retries=settings.RETRY_ATTEMPTS,
96
+ delay=settings.RETRY_DELAY_SECONDS,
97
+ backoff=settings.RETRY_BACKOFF,
98
+ exceptions=(aiohttp.ClientError, asyncio.TimeoutError),
99
+ )
90
100
  async def post_advertisement(self):
91
101
  """Post resource advertisement to discovery service."""
92
102
  if not self.session:
@@ -115,7 +125,15 @@ class DiscoveryServerAdvertiser(Advertiser):
115
125
  json={
116
126
  "ip_address": ip_address,
117
127
  "country": settings.PROVIDER_COUNTRY,
118
- "resources": resources
128
+ "resources": resources,
129
+ "pricing": {
130
+ "usd_per_core_month": settings.PRICE_USD_PER_CORE_MONTH,
131
+ "usd_per_gb_ram_month": settings.PRICE_USD_PER_GB_RAM_MONTH,
132
+ "usd_per_gb_storage_month": settings.PRICE_USD_PER_GB_STORAGE_MONTH,
133
+ "glm_per_core_month": settings.PRICE_GLM_PER_CORE_MONTH,
134
+ "glm_per_gb_ram_month": settings.PRICE_GLM_PER_GB_RAM_MONTH,
135
+ "glm_per_gb_storage_month": settings.PRICE_GLM_PER_GB_STORAGE_MONTH,
136
+ }
119
137
  },
120
138
  timeout=aiohttp.ClientTimeout(total=5)
121
139
  ) as response:
@@ -132,7 +150,12 @@ class DiscoveryServerAdvertiser(Advertiser):
132
150
  logger.error("Advertisement request timed out")
133
151
  raise
134
152
 
135
- @async_retry(retries=3, delay=1.0, backoff=2.0, exceptions=(aiohttp.ClientError, asyncio.TimeoutError))
153
+ @async_retry(
154
+ retries=settings.RETRY_ATTEMPTS,
155
+ delay=settings.RETRY_DELAY_SECONDS,
156
+ backoff=settings.RETRY_BACKOFF,
157
+ exceptions=(aiohttp.ClientError, asyncio.TimeoutError),
158
+ )
136
159
  async def _get_public_ip(self) -> str:
137
160
  """Get public IP address with retries."""
138
161
  if not self.session:
@@ -36,7 +36,7 @@ class GolemBaseAdvertiser(Advertiser):
36
36
  try:
37
37
  while not self._stop_event.is_set():
38
38
  await self.post_advertisement()
39
- await asyncio.sleep(settings.ADVERTISEMENT_INTERVAL)
39
+ await asyncio.sleep(settings.GOLEM_BASE_ADVERTISEMENT_INTERVAL)
40
40
  finally:
41
41
  await self.stop()
42
42
 
@@ -70,11 +70,18 @@ class GolemBaseAdvertiser(Advertiser):
70
70
  Annotation(key="golem_ip_address", value=ip_address),
71
71
  Annotation(key="golem_country", value=settings.PROVIDER_COUNTRY),
72
72
  Annotation(key="golem_provider_name", value=settings.PROVIDER_NAME),
73
+ Annotation(key="golem_price_currency", value="USD/GLM"),
73
74
  ]
74
75
  numeric_annotations = [
75
76
  Annotation(key="golem_cpu", value=resources["cpu"]),
76
77
  Annotation(key="golem_memory", value=resources["memory"]),
77
78
  Annotation(key="golem_storage", value=resources["storage"]),
79
+ Annotation(key="golem_price_usd_core_month", value=float(settings.PRICE_USD_PER_CORE_MONTH)),
80
+ Annotation(key="golem_price_usd_ram_gb_month", value=float(settings.PRICE_USD_PER_GB_RAM_MONTH)),
81
+ Annotation(key="golem_price_usd_storage_gb_month", value=float(settings.PRICE_USD_PER_GB_STORAGE_MONTH)),
82
+ Annotation(key="golem_price_glm_core_month", value=float(settings.PRICE_GLM_PER_CORE_MONTH)),
83
+ Annotation(key="golem_price_glm_ram_gb_month", value=float(settings.PRICE_GLM_PER_GB_RAM_MONTH)),
84
+ Annotation(key="golem_price_glm_storage_gb_month", value=float(settings.PRICE_GLM_PER_GB_STORAGE_MONTH)),
78
85
  ]
79
86
 
80
87
  if len(existing_keys) > 1:
@@ -0,0 +1,28 @@
1
+ import asyncio
2
+ from typing import Optional
3
+
4
+ from .advertiser import Advertiser
5
+ from .golem_base_advertiser import GolemBaseAdvertiser
6
+ from .advertiser import DiscoveryServerAdvertiser
7
+ from .resource_tracker import ResourceTracker
8
+
9
+
10
+ class MultiAdvertiser(Advertiser):
11
+ """Advertise to both Golem Base and the Discovery Server."""
12
+
13
+ def __init__(self, resource_tracker: ResourceTracker):
14
+ self.golem = GolemBaseAdvertiser(resource_tracker)
15
+ self.discovery = DiscoveryServerAdvertiser(resource_tracker)
16
+
17
+ async def initialize(self):
18
+ await asyncio.gather(self.golem.initialize(), self.discovery.initialize())
19
+
20
+ async def start_loop(self):
21
+ await asyncio.gather(self.golem.start_loop(), self.discovery.start_loop())
22
+
23
+ async def stop(self):
24
+ await asyncio.gather(self.golem.stop(), self.discovery.stop())
25
+
26
+ async def post_advertisement(self):
27
+ await asyncio.gather(self.golem.post_advertisement(), self.discovery.post_advertisement())
28
+
@@ -21,4 +21,11 @@ class AdvertisementService:
21
21
  if self._task:
22
22
  self._task.cancel()
23
23
  await self._task
24
- await self.advertiser.stop()
24
+ await self.advertiser.stop()
25
+
26
+ async def trigger_update(self):
27
+ """Trigger an immediate advertisement update."""
28
+ try:
29
+ await self.advertiser.post_advertisement()
30
+ except Exception:
31
+ pass
@@ -121,6 +121,8 @@ except ImportError:
121
121
  import importlib_metadata as metadata
122
122
 
123
123
  cli = typer.Typer()
124
+ pricing_app = typer.Typer(help="Configure USD pricing; auto-converts to GLM.")
125
+ cli.add_typer(pricing_app, name="pricing")
124
126
 
125
127
  def print_version(ctx: typer.Context, value: bool):
126
128
  if not value:
@@ -149,6 +151,64 @@ def dev(no_verify_port: bool = typer.Option(True, "--no-verify-port", help="Skip
149
151
  """Start the provider server in development mode."""
150
152
  run_server(dev_mode=True, no_verify_port=no_verify_port)
151
153
 
154
+ def _env_path_for(dev_mode: Optional[bool]) -> str:
155
+ from pathlib import Path
156
+ env_file = ".env.dev" if dev_mode else ".env"
157
+ return str(Path(__file__).parent.parent / env_file)
158
+
159
+ def _write_env_vars(path: str, updates: dict):
160
+ # Simple .env updater: preserves other lines, replaces/append updated keys
161
+ import re
162
+ import io
163
+ try:
164
+ with open(path, "r") as f:
165
+ lines = f.readlines()
166
+ except FileNotFoundError:
167
+ lines = []
168
+
169
+ kv = {**updates}
170
+ pattern = re.compile(r"^(?P<k>[A-Z0-9_]+)=.*$")
171
+ out = []
172
+ seen = set()
173
+ for line in lines:
174
+ m = pattern.match(line.strip())
175
+ if not m:
176
+ out.append(line)
177
+ continue
178
+ k = m.group("k")
179
+ if k in kv:
180
+ out.append(f"{k}={kv[k]}\n")
181
+ seen.add(k)
182
+ else:
183
+ out.append(line)
184
+ for k, v in kv.items():
185
+ if k not in seen:
186
+ out.append(f"{k}={v}\n")
187
+
188
+ with open(path, "w") as f:
189
+ f.writelines(out)
190
+
191
+ def _print_pricing_examples(glm_usd):
192
+ from decimal import Decimal
193
+ from .utils.pricing import calculate_monthly_cost, calculate_monthly_cost_usd
194
+ from .vm.models import VMResources
195
+ examples = [
196
+ ("Small", VMResources(cpu=1, memory=1, storage=10)),
197
+ ("Medium", VMResources(cpu=2, memory=4, storage=20)),
198
+ ("Example 2c/2g/10g", VMResources(cpu=2, memory=2, storage=10)),
199
+ ]
200
+ # Maintain legacy header for tests while adding a clearer caption
201
+ print("\nExample monthly costs with current settings:")
202
+ print("(Estimated monthly earnings with your current pricing)")
203
+ for name, res in examples:
204
+ glm = calculate_monthly_cost(res)
205
+ usd = calculate_monthly_cost_usd(res, glm_usd)
206
+ usd_str = f"${usd:.2f}" if usd is not None else "—"
207
+ glm_str = f"{glm:.4f} GLM"
208
+ print(
209
+ f"- {name} ({res.cpu}C, {res.memory}GB RAM, {res.storage}GB Disk): ~{usd_str} per month (~{glm_str})"
210
+ )
211
+
152
212
  def run_server(dev_mode: bool, no_verify_port: bool):
153
213
  """Helper to run the uvicorn server."""
154
214
  import sys
@@ -209,3 +269,74 @@ def run_server(dev_mode: bool, no_verify_port: bool):
209
269
 
210
270
  if __name__ == "__main__":
211
271
  cli()
272
+
273
+
274
+ @pricing_app.command("show")
275
+ def pricing_show():
276
+ """Show current USD and GLM per-unit monthly prices and examples."""
277
+ from decimal import Decimal
278
+ from .utils.pricing import fetch_glm_usd_price, update_glm_unit_prices_from_usd
279
+
280
+ print("Current pricing (per month):")
281
+ print(
282
+ f" - USD per unit: CPU ${settings.PRICE_USD_PER_CORE_MONTH}/core, RAM ${settings.PRICE_USD_PER_GB_RAM_MONTH}/GB, Disk ${settings.PRICE_USD_PER_GB_STORAGE_MONTH}/GB"
283
+ )
284
+ glm_usd = fetch_glm_usd_price()
285
+ if not glm_usd:
286
+ print("Error: Could not fetch GLM/USD price. Please try again later.")
287
+ raise typer.Exit(code=1)
288
+ # Coerce to Decimal for calculations if needed
289
+ from decimal import Decimal
290
+ if not isinstance(glm_usd, Decimal):
291
+ glm_usd = Decimal(str(glm_usd))
292
+ update_glm_unit_prices_from_usd(glm_usd)
293
+ print(f" - GLM price: ${glm_usd} per GLM")
294
+ print(f" - Rate: {glm_usd} USD/GLM")
295
+ print(
296
+ f" - GLM per unit: CPU {round(float(settings.PRICE_GLM_PER_CORE_MONTH), 6)} GLM/core, RAM {round(float(settings.PRICE_GLM_PER_GB_RAM_MONTH), 6)} GLM/GB, Disk {round(float(settings.PRICE_GLM_PER_GB_STORAGE_MONTH), 6)} GLM/GB"
297
+ )
298
+ _print_pricing_examples(glm_usd)
299
+
300
+
301
+ @pricing_app.command("set")
302
+ def pricing_set(
303
+ usd_per_core: float = typer.Option(
304
+ ..., "--usd-per-core", "--core-usd", help="USD per CPU core per month"
305
+ ),
306
+ usd_per_mem: float = typer.Option(
307
+ ..., "--usd-per-mem", "--ram-usd", help="USD per GB of RAM per month"
308
+ ),
309
+ usd_per_disk: float = typer.Option(
310
+ ..., "--usd-per-disk", "--usd-per-storage", "--storage-usd", help="USD per GB of disk per month"
311
+ ),
312
+ dev: bool = typer.Option(False, "--dev", help="Write to .env.dev instead of .env"),
313
+ ):
314
+ """Set USD pricing; GLM rates auto-update via CoinGecko in background."""
315
+ if usd_per_core < 0 or usd_per_mem < 0 or usd_per_disk < 0:
316
+ raise typer.BadParameter("All pricing values must be >= 0")
317
+ env_path = _env_path_for(dev)
318
+ updates = {
319
+ "GOLEM_PROVIDER_PRICE_USD_PER_CORE_MONTH": usd_per_core,
320
+ "GOLEM_PROVIDER_PRICE_USD_PER_GB_RAM_MONTH": usd_per_mem,
321
+ "GOLEM_PROVIDER_PRICE_USD_PER_GB_STORAGE_MONTH": usd_per_disk,
322
+ }
323
+ _write_env_vars(env_path, updates)
324
+ print(f"Updated pricing in {env_path}")
325
+ # Immediately reflect in current process settings as well
326
+ settings.PRICE_USD_PER_CORE_MONTH = usd_per_core
327
+ settings.PRICE_USD_PER_GB_RAM_MONTH = usd_per_mem
328
+ settings.PRICE_USD_PER_GB_STORAGE_MONTH = usd_per_disk
329
+
330
+ from .utils.pricing import fetch_glm_usd_price, update_glm_unit_prices_from_usd
331
+ glm_usd = fetch_glm_usd_price()
332
+ if glm_usd:
333
+ # Coerce to Decimal for calculations if needed
334
+ from decimal import Decimal
335
+ if not isinstance(glm_usd, Decimal):
336
+ glm_usd = Decimal(str(glm_usd))
337
+ update_glm_unit_prices_from_usd(glm_usd)
338
+ print("Recalculated GLM prices due to updated USD configuration.")
339
+ _print_pricing_examples(glm_usd)
340
+ else:
341
+ print("Warning: could not fetch GLM/USD; GLM unit prices not recalculated.")
342
+ print("Tip: run 'golem-provider pricing show' when online to verify pricing with USD examples.")
@@ -4,6 +4,7 @@ from fastapi import FastAPI
4
4
  from .utils.logging import setup_logger
5
5
  from .vm.service import VMService
6
6
  from .discovery.service import AdvertisementService
7
+ from .utils.pricing import PricingAutoUpdater
7
8
 
8
9
  logger = setup_logger(__name__)
9
10
 
@@ -15,6 +16,7 @@ class ProviderService:
15
16
  self.vm_service = vm_service
16
17
  self.advertisement_service = advertisement_service
17
18
  self.port_manager = port_manager
19
+ self._pricing_updater: PricingAutoUpdater | None = None
18
20
 
19
21
  async def setup(self, app: FastAPI):
20
22
  """Setup and initialize the provider components."""
@@ -35,6 +37,11 @@ class ProviderService:
35
37
  await self.port_manager.initialize()
36
38
  await self.vm_service.provider.initialize()
37
39
  await self.advertisement_service.start()
40
+ # Start pricing auto-updater; trigger re-advertise after updates
41
+ async def _on_price_updated(platform: str, glm_usd):
42
+ await self.advertisement_service.trigger_update()
43
+ self._pricing_updater = PricingAutoUpdater(on_updated_callback=_on_price_updated)
44
+ asyncio.create_task(self._pricing_updater.start())
38
45
 
39
46
  # Check wallet balance and request funds if needed
40
47
  faucet_client = FaucetClient(
@@ -55,6 +62,8 @@ class ProviderService:
55
62
  logger.process("🔄 Cleaning up provider...")
56
63
  await self.advertisement_service.stop()
57
64
  await self.vm_service.provider.cleanup()
65
+ if self._pricing_updater:
66
+ self._pricing_updater.stop()
58
67
  logger.success("✨ Provider cleanup complete")
59
68
 
60
69
  def _setup_directories(self):
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from decimal import Decimal, ROUND_HALF_UP, getcontext
5
+ from typing import Optional, Tuple
6
+ import time
7
+ import requests
8
+
9
+ from ..vm.models import VMResources
10
+ from ..config import settings
11
+ from .logging import setup_logger
12
+
13
+ logger = setup_logger(__name__)
14
+
15
+ # Increase precision for financial calcs
16
+ getcontext().prec = 28
17
+
18
+
19
+ def quantize_money(value: Decimal) -> Decimal:
20
+ return value.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
21
+
22
+
23
+ def _coingecko_simple_price(ids: str) -> Optional[Decimal]:
24
+ base = settings.COINGECKO_API_URL.rstrip("/")
25
+ url = f"{base}/simple/price"
26
+ try:
27
+ resp = requests.get(url, params={"ids": ids, "vs_currencies": "usd"}, timeout=10)
28
+ resp.raise_for_status()
29
+ data = resp.json()
30
+ # Try ids in order and return the first available
31
+ for _id in ids.split(","):
32
+ _id = _id.strip()
33
+ if _id and _id in data and "usd" in data[_id]:
34
+ usd = Decimal(str(data[_id]["usd"]))
35
+ if usd > 0:
36
+ return usd
37
+ except Exception as e:
38
+ logger.warning(f"CoinGecko price fetch failed: {e}")
39
+ return None
40
+
41
+
42
+ def fetch_glm_usd_price() -> Optional[Decimal]:
43
+ """Fetch the current GLM price in USD from CoinGecko.
44
+
45
+ Tries multiple IDs to hedge against slug changes.
46
+ """
47
+ return _coingecko_simple_price(settings.COINGECKO_IDS)
48
+
49
+
50
+ def usd_to_glm(usd_amount: Decimal, glm_usd: Decimal) -> Decimal:
51
+ if glm_usd <= 0:
52
+ raise ValueError("Invalid GLM/USD price")
53
+ return (usd_amount / glm_usd).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
54
+
55
+
56
+ def glm_to_usd(glm_amount: Decimal, glm_usd: Decimal) -> Decimal:
57
+ return (glm_amount * glm_usd).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
58
+
59
+
60
+ def calculate_monthly_cost(resources: VMResources) -> Decimal:
61
+ """Calculate monthly cost in GLM for the given resources.
62
+
63
+ Uses the GLM-denominated price-per-unit values configured in settings.
64
+ """
65
+ core_price = Decimal(str(settings.PRICE_GLM_PER_CORE_MONTH))
66
+ ram_price = Decimal(str(settings.PRICE_GLM_PER_GB_RAM_MONTH))
67
+ storage_price = Decimal(str(settings.PRICE_GLM_PER_GB_STORAGE_MONTH))
68
+
69
+ total = (
70
+ core_price * Decimal(resources.cpu) +
71
+ ram_price * Decimal(resources.memory) +
72
+ storage_price * Decimal(resources.storage)
73
+ )
74
+ return total.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
75
+
76
+
77
+ # Note: function to derive per-unit prices from a total was intentionally not added.
78
+
79
+
80
+ def calculate_monthly_cost_usd(resources: VMResources, glm_usd: Decimal) -> Optional[Decimal]:
81
+ cost_glm = calculate_monthly_cost(resources)
82
+ try:
83
+ return glm_to_usd(cost_glm, glm_usd)
84
+ except Exception:
85
+ return None
86
+
87
+
88
+ def update_glm_unit_prices_from_usd(glm_usd: Decimal) -> Tuple[Decimal, Decimal, Decimal]:
89
+ """Recompute GLM per-unit monthly prices using current USD config and a GLM/USD rate.
90
+
91
+ Returns a tuple of (core_glm, ram_glm, storage_glm).
92
+ """
93
+ core_usd = Decimal(str(settings.PRICE_USD_PER_CORE_MONTH))
94
+ ram_usd = Decimal(str(settings.PRICE_USD_PER_GB_RAM_MONTH))
95
+ storage_usd = Decimal(str(settings.PRICE_USD_PER_GB_STORAGE_MONTH))
96
+
97
+ core_glm = usd_to_glm(core_usd, glm_usd)
98
+ ram_glm = usd_to_glm(ram_usd, glm_usd)
99
+ storage_glm = usd_to_glm(storage_usd, glm_usd)
100
+
101
+ # Persist on settings instance (in-memory)
102
+ settings.PRICE_GLM_PER_CORE_MONTH = float(core_glm)
103
+ settings.PRICE_GLM_PER_GB_RAM_MONTH = float(ram_glm)
104
+ settings.PRICE_GLM_PER_GB_STORAGE_MONTH = float(storage_glm)
105
+
106
+ logger.info(
107
+ f"Updated GLM prices from USD @ {glm_usd} USD/GLM: core={core_glm}, ram={ram_glm}, storage={storage_glm}"
108
+ )
109
+ return core_glm, ram_glm, storage_glm
110
+
111
+
112
+ class PricingAutoUpdater:
113
+ """Background updater for pricing based on CoinGecko.
114
+
115
+ It refreshes GLM-per-unit prices from USD config and can trigger callbacks (e.g., re-advertise).
116
+ """
117
+
118
+ def __init__(self, on_updated_callback=None):
119
+ self._stop = False
120
+ self._on_updated = on_updated_callback
121
+ self._last_price: Optional[Decimal] = None
122
+
123
+ async def start(self):
124
+ if not settings.PRICING_UPDATE_ENABLED:
125
+ return
126
+
127
+ # Choose update interval based on platform to avoid excessive on-chain updates
128
+ interval = (
129
+ settings.PRICING_UPDATE_INTERVAL_GOLEM_BASE
130
+ if getattr(settings, "ADVERTISER_TYPE", "discovery_server") == "golem_base"
131
+ else settings.PRICING_UPDATE_INTERVAL_DISCOVERY
132
+ )
133
+ await self._run_loop(interval)
134
+
135
+ def stop(self):
136
+ self._stop = True
137
+
138
+ async def _run_loop(self, interval_discovery: int):
139
+ import asyncio
140
+
141
+ while not self._stop:
142
+ try:
143
+ glm_usd = fetch_glm_usd_price()
144
+ if glm_usd:
145
+ changed = self._should_update(glm_usd)
146
+ if changed:
147
+ update_glm_unit_prices_from_usd(glm_usd)
148
+ if callable(self._on_updated):
149
+ # Inform callback which advertising platform is active
150
+ platform = getattr(settings, "ADVERTISER_TYPE", "discovery_server")
151
+ await self._on_updated(platform=platform, glm_usd=glm_usd)
152
+ else:
153
+ logger.warning("Skipping pricing update; failed to fetch GLM price")
154
+ except Exception as e:
155
+ logger.error(f"Pricing update error: {e}")
156
+
157
+ await asyncio.sleep(interval_discovery)
158
+
159
+ def _should_update(self, new_price: Decimal) -> bool:
160
+ if self._last_price is None:
161
+ self._last_price = new_price
162
+ return True
163
+ old = self._last_price
164
+ if old == 0:
165
+ self._last_price = new_price
166
+ return True
167
+ delta = abs((new_price - old) / old) * Decimal("100")
168
+ if delta >= Decimal(str(settings.PRICING_UPDATE_MIN_DELTA_PERCENT)):
169
+ self._last_price = new_price
170
+ return True
171
+ return False
@@ -7,6 +7,11 @@ logger = logging.getLogger(__name__)
7
7
 
8
8
  T = TypeVar('T')
9
9
 
10
+
11
+ class NonRetryableError(Exception):
12
+ """Base class for errors that should not be retried by retry decorators."""
13
+ pass
14
+
10
15
  def async_retry(
11
16
  retries: int = 3,
12
17
  delay: float = 1.0,
@@ -68,7 +73,8 @@ def async_retry_unless_not_found(
68
73
  try:
69
74
  return await func(*args, **kwargs)
70
75
  except exceptions as e:
71
- if isinstance(e, VMNotFoundError):
76
+ # Skip retries for known non-retryable cases
77
+ if isinstance(e, (VMNotFoundError, NonRetryableError)):
72
78
  raise e
73
79
 
74
80
  last_exception = e
@@ -4,7 +4,7 @@ import subprocess
4
4
  from pathlib import Path
5
5
  import asyncio
6
6
  from typing import Dict, List, Optional
7
- from ..utils.retry import async_retry_unless_not_found
7
+ from ..utils.retry import async_retry_unless_not_found, NonRetryableError
8
8
 
9
9
  from ..config import settings
10
10
  from ..utils.logging import setup_logger
@@ -19,6 +19,11 @@ class MultipassError(VMError):
19
19
  pass
20
20
 
21
21
 
22
+ class NonRetryableMultipassError(MultipassError, NonRetryableError):
23
+ """Multipass error that should not be retried (e.g., parse/validation errors)."""
24
+ pass
25
+
26
+
22
27
  class MultipassAdapter(VMProvider):
23
28
  """Manages VMs using Multipass."""
24
29
 
@@ -35,7 +40,7 @@ class MultipassAdapter(VMProvider):
35
40
 
36
41
  # We add a timeout to the launch command to prevent it from hanging indefinitely
37
42
  # e.g. during image download. 300 seconds = 5 minutes.
38
- timeout = 300 if args[0] == 'launch' else None
43
+ timeout = settings.LAUNCH_TIMEOUT_SECONDS if args[0] == 'launch' else None
39
44
 
40
45
  try:
41
46
  return await asyncio.to_thread(
@@ -53,7 +58,11 @@ class MultipassAdapter(VMProvider):
53
58
  stderr = e.stderr if should_capture and e.stderr else "No stderr captured. See provider logs for command output."
54
59
  raise MultipassError(f"Multipass command '{' '.join(args)}' timed out after {timeout} seconds. Stderr: {stderr}")
55
60
 
56
- @async_retry_unless_not_found(retries=5, delay=2.0)
61
+ @async_retry_unless_not_found(
62
+ retries=settings.RETRY_ATTEMPTS,
63
+ delay=settings.RETRY_DELAY_SECONDS,
64
+ backoff=settings.RETRY_BACKOFF,
65
+ )
57
66
  async def _get_vm_info(self, vm_id: str) -> Dict:
58
67
  """Get detailed information about a VM."""
59
68
  try:
@@ -70,7 +79,10 @@ class MultipassAdapter(VMProvider):
70
79
  raise VMNotFoundError(f"VM {vm_id} not found in multipass") from e
71
80
  raise
72
81
  except (json.JSONDecodeError, KeyError) as e:
73
- raise MultipassError(f"Failed to parse VM info or essential fields are missing: {e}")
82
+ # Parsing/validation issues are not transient; do not waste time retrying
83
+ raise NonRetryableMultipassError(
84
+ f"Failed to parse VM info or essential fields are missing: {e}"
85
+ )
74
86
 
75
87
  async def initialize(self) -> None:
76
88
  """Initialize the VM provider."""
@@ -100,8 +112,8 @@ class MultipassAdapter(VMProvider):
100
112
  logger.info(f"VM {multipass_name} launched, waiting for it to be ready...")
101
113
 
102
114
  ip_address = None
103
- max_retries = 15
104
- retry_delay = 5 # seconds
115
+ max_retries = settings.CREATE_VM_MAX_RETRIES
116
+ retry_delay = settings.CREATE_VM_RETRY_DELAY_SECONDS # seconds
105
117
  for attempt in range(max_retries):
106
118
  try:
107
119
  info = await self._get_vm_info(multipass_name)
@@ -218,4 +230,4 @@ class MultipassAdapter(VMProvider):
218
230
 
219
231
  async def cleanup(self) -> None:
220
232
  """Cleanup resources used by the provider."""
221
- pass
233
+ pass
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "golem-vm-provider"
3
- version = "0.1.36"
3
+ version = "0.1.38"
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"