golem-vm-provider 0.1.37__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.
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/PKG-INFO +1 -1
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/config.py +32 -1
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/container.py +5 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/discovery/advertiser.py +28 -5
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/discovery/golem_base_advertiser.py +8 -1
- golem_vm_provider-0.1.38/provider/discovery/multi_advertiser.py +28 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/discovery/service.py +8 -1
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/main.py +131 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/service.py +9 -0
- golem_vm_provider-0.1.38/provider/utils/pricing.py +171 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/utils/retry.py +7 -1
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/vm/multipass_adapter.py +19 -7
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/pyproject.toml +1 -1
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/README.md +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/__init__.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/api/__init__.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/api/models.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/api/routes.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/discovery/__init__.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/discovery/golem_base_utils.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/discovery/resource_monitor.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/discovery/resource_tracker.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/network/port_verifier.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/security/ethereum.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/security/faucet.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/utils/__init__.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/utils/ascii_art.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/utils/logging.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/utils/port_display.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/utils/setup.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/vm/__init__.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/vm/cloud_init.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/vm/models.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/vm/multipass.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/vm/name_mapper.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/vm/port_manager.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/vm/provider.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/vm/proxy_manager.py +0 -0
- {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/vm/service.py +0 -0
@@ -39,7 +39,8 @@ def ensure_config() -> None:
|
|
39
39
|
print("Using default settings – run with --help to customize")
|
40
40
|
|
41
41
|
|
42
|
-
|
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.
|
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(
|
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(
|
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(
|
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:
|
{golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/discovery/golem_base_advertiser.py
RENAMED
@@ -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.
|
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
|
-
|
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 =
|
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(
|
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
|
-
|
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 =
|
104
|
-
retry_delay =
|
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.
|
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"
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/discovery/golem_base_utils.py
RENAMED
File without changes
|
{golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/discovery/resource_monitor.py
RENAMED
File without changes
|
{golem_vm_provider-0.1.37 → golem_vm_provider-0.1.38}/provider/discovery/resource_tracker.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
|