golem-vm-provider 0.1.37__tar.gz → 0.1.39__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 (42) hide show
  1. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/PKG-INFO +44 -2
  2. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/README.md +43 -1
  3. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/api/models.py +10 -0
  4. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/api/routes.py +22 -1
  5. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/config.py +70 -1
  6. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/container.py +38 -0
  7. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/discovery/advertiser.py +28 -5
  8. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/discovery/golem_base_advertiser.py +8 -1
  9. golem_vm_provider-0.1.39/provider/discovery/multi_advertiser.py +28 -0
  10. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/discovery/service.py +8 -1
  11. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/main.py +131 -0
  12. golem_vm_provider-0.1.39/provider/payments/blockchain_service.py +135 -0
  13. golem_vm_provider-0.1.39/provider/payments/monitor.py +64 -0
  14. golem_vm_provider-0.1.39/provider/payments/stream_map.py +40 -0
  15. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/service.py +19 -0
  16. golem_vm_provider-0.1.39/provider/utils/pricing.py +171 -0
  17. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/utils/retry.py +7 -1
  18. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/vm/multipass_adapter.py +19 -7
  19. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/vm/service.py +19 -2
  20. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/pyproject.toml +1 -1
  21. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/__init__.py +0 -0
  22. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/api/__init__.py +0 -0
  23. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/discovery/__init__.py +0 -0
  24. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/discovery/golem_base_utils.py +0 -0
  25. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/discovery/resource_monitor.py +0 -0
  26. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/discovery/resource_tracker.py +0 -0
  27. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/network/port_verifier.py +0 -0
  28. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/security/ethereum.py +0 -0
  29. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/security/faucet.py +0 -0
  30. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/utils/__init__.py +0 -0
  31. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/utils/ascii_art.py +0 -0
  32. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/utils/logging.py +0 -0
  33. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/utils/port_display.py +0 -0
  34. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/utils/setup.py +0 -0
  35. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/vm/__init__.py +0 -0
  36. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/vm/cloud_init.py +0 -0
  37. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/vm/models.py +0 -0
  38. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/vm/multipass.py +0 -0
  39. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/vm/name_mapper.py +0 -0
  40. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/vm/port_manager.py +0 -0
  41. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/vm/provider.py +0 -0
  42. {golem_vm_provider-0.1.37 → golem_vm_provider-0.1.39}/provider/vm/proxy_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: golem-vm-provider
3
- Version: 0.1.37
3
+ Version: 0.1.39
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
@@ -230,6 +230,29 @@ GOLEM_PROVIDER_DISCOVERY_URL="http://discovery.golem.network:9001"
230
230
  GOLEM_PROVIDER_ADVERTISEMENT_INTERVAL=240
231
231
  ```
232
232
 
233
+ ### Streaming Payments (Polygon GLM)
234
+
235
+ Enable on‑chain stream‑gated rentals by configuring the following (env prefix `GOLEM_PROVIDER_`):
236
+
237
+ - `POLYGON_RPC_URL` — Polygon PoS RPC URL (e.g., https://polygon-rpc.com)
238
+ - `STREAM_PAYMENT_ADDRESS` — StreamPayment contract address; if non‑zero, VM creation requires a valid `stream_id`
239
+ - `GLM_TOKEN_ADDRESS` — GLM ERC20 address (for info endpoint)
240
+
241
+ Optional background automation (all disabled by default):
242
+
243
+ - `STREAM_MIN_REMAINING_SECONDS` — minimum remaining runway to keep a VM running (default 3600)
244
+ - `STREAM_MONITOR_ENABLED` — stop VMs when remaining runway < threshold (default false)
245
+ - `STREAM_MONITOR_INTERVAL_SECONDS` — how frequently to check runway (default 60)
246
+ - `STREAM_WITHDRAW_ENABLED` — periodically withdraw vested funds (default false)
247
+ - `STREAM_WITHDRAW_INTERVAL_SECONDS` — how often to attempt withdrawals (default 1800)
248
+ - `STREAM_MIN_WITHDRAW_WEI` — only withdraw when >= this amount (gas‑aware)
249
+
250
+ When enabled, the provider verifies each VM creation request’s `stream_id` and refuses to start the VM if:
251
+
252
+ - stream recipient != provider’s Ethereum address
253
+ - deposit is zero, stream not started, or stream halted
254
+ - (Optional) remaining runway < `STREAM_MIN_REMAINING_SECONDS`
255
+
233
256
  ## API Reference
234
257
 
235
258
  ### Create VM
@@ -245,7 +268,8 @@ Request:
245
268
  "name": "my-webserver",
246
269
  "cpu_cores": 2,
247
270
  "memory_gb": 4,
248
- "storage_gb": 20
271
+ "storage_gb": 20,
272
+ "stream_id": 123 // required when STREAM_PAYMENT_ADDRESS is set
249
273
  }
250
274
  ```
251
275
 
@@ -273,6 +297,24 @@ Response:
273
297
  - Delete VM: `DELETE /api/v1/vms/{vm_id}`
274
298
  - Stop VM: `POST /api/v1/vms/{vm_id}/stop`
275
299
  - Get Access Info: `GET /api/v1/vms/{vm_id}/access`
300
+
301
+ ### Provider Info
302
+
303
+ ```bash
304
+ GET /api/v1/provider/info
305
+ ```
306
+
307
+ Response:
308
+
309
+ ```json
310
+ {
311
+ "provider_id": "0xProviderEthereumAddress",
312
+ "stream_payment_address": "0xStreamPayment",
313
+ "glm_token_address": "0xGLM"
314
+ }
315
+ ```
316
+
317
+ Use this endpoint to discover the correct recipient for creating a GLM stream.
276
318
 
277
319
  ## Operations
278
320
 
@@ -187,6 +187,29 @@ GOLEM_PROVIDER_DISCOVERY_URL="http://discovery.golem.network:9001"
187
187
  GOLEM_PROVIDER_ADVERTISEMENT_INTERVAL=240
188
188
  ```
189
189
 
190
+ ### Streaming Payments (Polygon GLM)
191
+
192
+ Enable on‑chain stream‑gated rentals by configuring the following (env prefix `GOLEM_PROVIDER_`):
193
+
194
+ - `POLYGON_RPC_URL` — Polygon PoS RPC URL (e.g., https://polygon-rpc.com)
195
+ - `STREAM_PAYMENT_ADDRESS` — StreamPayment contract address; if non‑zero, VM creation requires a valid `stream_id`
196
+ - `GLM_TOKEN_ADDRESS` — GLM ERC20 address (for info endpoint)
197
+
198
+ Optional background automation (all disabled by default):
199
+
200
+ - `STREAM_MIN_REMAINING_SECONDS` — minimum remaining runway to keep a VM running (default 3600)
201
+ - `STREAM_MONITOR_ENABLED` — stop VMs when remaining runway < threshold (default false)
202
+ - `STREAM_MONITOR_INTERVAL_SECONDS` — how frequently to check runway (default 60)
203
+ - `STREAM_WITHDRAW_ENABLED` — periodically withdraw vested funds (default false)
204
+ - `STREAM_WITHDRAW_INTERVAL_SECONDS` — how often to attempt withdrawals (default 1800)
205
+ - `STREAM_MIN_WITHDRAW_WEI` — only withdraw when >= this amount (gas‑aware)
206
+
207
+ When enabled, the provider verifies each VM creation request’s `stream_id` and refuses to start the VM if:
208
+
209
+ - stream recipient != provider’s Ethereum address
210
+ - deposit is zero, stream not started, or stream halted
211
+ - (Optional) remaining runway < `STREAM_MIN_REMAINING_SECONDS`
212
+
190
213
  ## API Reference
191
214
 
192
215
  ### Create VM
@@ -202,7 +225,8 @@ Request:
202
225
  "name": "my-webserver",
203
226
  "cpu_cores": 2,
204
227
  "memory_gb": 4,
205
- "storage_gb": 20
228
+ "storage_gb": 20,
229
+ "stream_id": 123 // required when STREAM_PAYMENT_ADDRESS is set
206
230
  }
207
231
  ```
208
232
 
@@ -230,6 +254,24 @@ Response:
230
254
  - Delete VM: `DELETE /api/v1/vms/{vm_id}`
231
255
  - Stop VM: `POST /api/v1/vms/{vm_id}/stop`
232
256
  - Get Access Info: `GET /api/v1/vms/{vm_id}/access`
257
+
258
+ ### Provider Info
259
+
260
+ ```bash
261
+ GET /api/v1/provider/info
262
+ ```
263
+
264
+ Response:
265
+
266
+ ```json
267
+ {
268
+ "provider_id": "0xProviderEthereumAddress",
269
+ "stream_payment_address": "0xStreamPayment",
270
+ "glm_token_address": "0xGLM"
271
+ }
272
+ ```
273
+
274
+ Use this endpoint to discover the correct recipient for creating a GLM stream.
233
275
 
234
276
  ## Operations
235
277
 
@@ -17,6 +17,10 @@ class CreateVMRequest(BaseModel):
17
17
  image: str = Field(default="24.04") # Ubuntu 24.04 LTS
18
18
  ssh_key: str = Field(..., pattern="^(ssh-rsa|ssh-ed25519) ",
19
19
  description="SSH public key for VM access")
20
+ stream_id: Optional[int] = Field(
21
+ default=None,
22
+ description="On-chain StreamPayment stream id used to fund this VM"
23
+ )
20
24
 
21
25
  @field_validator("name")
22
26
  def validate_name(cls, v: str) -> str:
@@ -106,3 +110,9 @@ class ProviderStatusResponse(BaseModel):
106
110
  resources: Dict[str, int]
107
111
  vm_count: int
108
112
  max_vms: int
113
+
114
+
115
+ class ProviderInfoResponse(BaseModel):
116
+ provider_id: str
117
+ stream_payment_address: str
118
+ glm_token_address: str
@@ -11,7 +11,8 @@ from ..container import Container
11
11
  from ..utils.logging import setup_logger
12
12
  from ..utils.ascii_art import vm_creation_animation, vm_status_change
13
13
  from ..vm.models import VMInfo, VMAccessInfo, VMConfig, VMResources, VMNotFoundError
14
- from .models import CreateVMRequest
14
+ from .models import CreateVMRequest, ProviderInfoResponse
15
+ from ..payments.blockchain_service import StreamPaymentReader
15
16
  from ..vm.service import VMService
16
17
  from ..vm.multipass_adapter import MultipassError
17
18
 
@@ -31,6 +32,15 @@ async def create_vm(
31
32
  logger.info(f"📥 Received VM creation request for '{request.name}'")
32
33
 
33
34
  resources = request.resources or VMResources()
35
+
36
+ # If payments are enabled, require a valid stream before starting
37
+ if settings["STREAM_PAYMENT_ADDRESS"] and settings["STREAM_PAYMENT_ADDRESS"] != "0x0000000000000000000000000000000000000000":
38
+ if request.stream_id is None:
39
+ raise HTTPException(status_code=400, detail="stream_id required when payments are enabled")
40
+ reader = StreamPaymentReader(settings["POLYGON_RPC_URL"], settings["STREAM_PAYMENT_ADDRESS"])
41
+ ok, reason = reader.verify_stream(int(request.stream_id), settings["PROVIDER_ID"])
42
+ if not ok:
43
+ raise HTTPException(status_code=400, detail=f"invalid stream: {reason}")
34
44
 
35
45
  # Create VM config
36
46
  config = VMConfig(
@@ -46,6 +56,9 @@ async def create_vm(
46
56
  except MultipassError as e:
47
57
  logger.error(f"Failed to create VM: {e}")
48
58
  raise HTTPException(status_code=500, detail=str(e))
59
+ except HTTPException:
60
+ # Propagate explicit HTTP errors (e.g., payment gating)
61
+ raise
49
62
  except Exception as e:
50
63
  logger.error(f"An unexpected error occurred: {e}")
51
64
  raise HTTPException(status_code=500, detail="An unexpected error occurred")
@@ -168,3 +181,11 @@ async def delete_vm(
168
181
  except Exception as e:
169
182
  logger.error(f"An unexpected error occurred: {e}")
170
183
  raise HTTPException(status_code=500, detail="An unexpected error occurred")
184
+ @router.get("/provider/info", response_model=ProviderInfoResponse)
185
+ @inject
186
+ async def provider_info(settings: Settings = Depends(Provide[Container.config])) -> ProviderInfoResponse:
187
+ return ProviderInfoResponse(
188
+ provider_id=settings["PROVIDER_ID"],
189
+ stream_payment_address=settings["STREAM_PAYMENT_ADDRESS"],
190
+ glm_token_address=settings["GLM_TOKEN_ADDRESS"],
191
+ )
@@ -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,12 +126,53 @@ 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"
132
136
  GOLEM_BASE_WS_URL: str = "wss://ethwarsaw.holesky.golemdb.io/rpc/ws"
133
137
 
138
+ # Polygon / Payments
139
+ POLYGON_RPC_URL: str = Field(
140
+ default="https://polygon-rpc.com",
141
+ description="Polygon PoS RPC URL for GLM payments"
142
+ )
143
+ STREAM_PAYMENT_ADDRESS: str = Field(
144
+ default="0x0000000000000000000000000000000000000000",
145
+ description="Deployed StreamPayment contract address"
146
+ )
147
+ GLM_TOKEN_ADDRESS: str = Field(
148
+ default="0x0000000000000000000000000000000000000000",
149
+ description="GLM ERC20 token address on target network"
150
+ )
151
+ STREAM_MIN_REMAINING_SECONDS: int = Field(
152
+ default=3600,
153
+ description="Minimum remaining seconds required to keep a VM running"
154
+ )
155
+ STREAM_MONITOR_ENABLED: bool = Field(
156
+ default=False,
157
+ description="Enable background monitor to stop VMs when runway < threshold"
158
+ )
159
+ STREAM_WITHDRAW_ENABLED: bool = Field(
160
+ default=False,
161
+ description="Enable background withdrawals for active streams"
162
+ )
163
+ STREAM_MONITOR_INTERVAL_SECONDS: int = Field(
164
+ default=60,
165
+ description="How frequently to check stream runway"
166
+ )
167
+ STREAM_WITHDRAW_INTERVAL_SECONDS: int = Field(
168
+ default=1800,
169
+ description="How frequently to attempt withdrawals"
170
+ )
171
+ STREAM_MIN_WITHDRAW_WEI: int = Field(
172
+ default=0,
173
+ description="Min withdrawable amount (wei) before triggering withdraw"
174
+ )
175
+
134
176
  # VM Settings
135
177
  MAX_VMS: int = 10
136
178
  DEFAULT_VM_IMAGE: str = "ubuntu:24.04"
@@ -274,6 +316,14 @@ class Settings(BaseSettings):
274
316
  # Rate Limiting
275
317
  RATE_LIMIT_PER_MINUTE: int = 100
276
318
 
319
+ # Retry/Timeout Settings (for long-running external calls)
320
+ RETRY_ATTEMPTS: int = 5
321
+ RETRY_DELAY_SECONDS: float = 2.0
322
+ RETRY_BACKOFF: float = 2.0
323
+ CREATE_VM_MAX_RETRIES: int = 15
324
+ CREATE_VM_RETRY_DELAY_SECONDS: float = 5.0
325
+ LAUNCH_TIMEOUT_SECONDS: int = 300
326
+
277
327
  # Multipass Settings
278
328
  MULTIPASS_BINARY_PATH: str = Field(
279
329
  default="",
@@ -467,6 +517,25 @@ class Settings(BaseSettings):
467
517
  logger.info(f"Using manually provided IP: {v}")
468
518
  return v
469
519
 
520
+ # Pricing Settings (configured in USD; auto-converted to GLM)
521
+ # Per-month prices per unit
522
+ PRICE_USD_PER_CORE_MONTH: float = Field(default=5.0, ge=0)
523
+ PRICE_USD_PER_GB_RAM_MONTH: float = Field(default=2.0, ge=0)
524
+ PRICE_USD_PER_GB_STORAGE_MONTH: float = Field(default=0.1, ge=0)
525
+
526
+ # Auto-updated GLM-denominated prices (derived from USD via CoinGecko)
527
+ PRICE_GLM_PER_CORE_MONTH: float = Field(default=0.0, ge=0)
528
+ PRICE_GLM_PER_GB_RAM_MONTH: float = Field(default=0.0, ge=0)
529
+ PRICE_GLM_PER_GB_STORAGE_MONTH: float = Field(default=0.0, ge=0)
530
+
531
+ # CoinGecko integration
532
+ COINGECKO_API_URL: str = "https://api.coingecko.com/api/v3"
533
+ COINGECKO_IDS: str = "golem,golem-network-tokens" # try both, first wins
534
+ PRICING_UPDATE_ENABLED: bool = True
535
+ PRICING_UPDATE_MIN_DELTA_PERCENT: float = Field(default=1.0, ge=0.0)
536
+ PRICING_UPDATE_INTERVAL_DISCOVERY: int = 900 # 15 minutes
537
+ PRICING_UPDATE_INTERVAL_GOLEM_BASE: int = 14400 # 4 hours
538
+
470
539
  class Config:
471
540
  env_prefix = "GOLEM_PROVIDER_"
472
541
  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
@@ -13,6 +14,9 @@ from .vm.service import VMService
13
14
  from .vm.name_mapper import VMNameMapper
14
15
  from .vm.port_manager import PortManager
15
16
  from .vm.proxy_manager import PythonProxyManager
17
+ from .payments.stream_map import StreamMap
18
+ from .payments.blockchain_service import StreamPaymentReader, StreamPaymentClient, StreamPaymentConfig as _SPC
19
+ from .payments.monitor import StreamMonitor
16
20
 
17
21
 
18
22
  class Container(containers.DeclarativeContainer):
@@ -32,6 +36,10 @@ class Container(containers.DeclarativeContainer):
32
36
  DiscoveryServerAdvertiser,
33
37
  resource_tracker=resource_tracker,
34
38
  ),
39
+ both=providers.Singleton(
40
+ MultiAdvertiser,
41
+ resource_tracker=resource_tracker,
42
+ ),
35
43
  )
36
44
 
37
45
  advertisement_service = providers.Singleton(
@@ -44,6 +52,11 @@ class Container(containers.DeclarativeContainer):
44
52
  db_path=Path(settings.VM_DATA_DIR) / "vm_names.json",
45
53
  )
46
54
 
55
+ stream_map = providers.Singleton(
56
+ StreamMap,
57
+ storage_path=Path(settings.VM_DATA_DIR) / "streams.json",
58
+ )
59
+
47
60
  port_manager = providers.Singleton(
48
61
  PortManager,
49
62
  start_port=config.PORT_RANGE_START,
@@ -76,6 +89,31 @@ class Container(containers.DeclarativeContainer):
76
89
  name_mapper=vm_name_mapper,
77
90
  )
78
91
 
92
+ # Payments
93
+ stream_reader = providers.Factory(
94
+ StreamPaymentReader,
95
+ rpc_url=config.POLYGON_RPC_URL,
96
+ contract_address=config.STREAM_PAYMENT_ADDRESS,
97
+ )
98
+ stream_client = providers.Factory(
99
+ StreamPaymentClient,
100
+ cfg=providers.Callable(
101
+ lambda rpc, addr, pk: _SPC(rpc_url=rpc, contract_address=addr, private_key=pk),
102
+ config.POLYGON_RPC_URL,
103
+ config.STREAM_PAYMENT_ADDRESS,
104
+ config.ETHEREUM_PRIVATE_KEY,
105
+ ),
106
+ )
107
+
108
+ stream_monitor = providers.Singleton(
109
+ StreamMonitor,
110
+ stream_map=stream_map,
111
+ vm_service=vm_service,
112
+ reader=stream_reader,
113
+ client=stream_client,
114
+ settings=config,
115
+ )
116
+
79
117
  provider_service = providers.Singleton(
80
118
  ProviderService,
81
119
  vm_service=vm_service,
@@ -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.")