golem-vm-provider 0.1.38__tar.gz → 0.1.42__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.38 → golem_vm_provider-0.1.42}/PKG-INFO +89 -4
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/README.md +86 -3
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/api/models.py +10 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/api/routes.py +34 -1
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/config.py +69 -3
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/container.py +33 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/golem_base_advertiser.py +1 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/main.py +68 -9
- golem_vm_provider-0.1.42/provider/payments/blockchain_service.py +93 -0
- golem_vm_provider-0.1.42/provider/payments/monitor.py +67 -0
- golem_vm_provider-0.1.42/provider/payments/stream_map.py +40 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/security/faucet.py +17 -41
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/service.py +10 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/service.py +19 -2
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/pyproject.toml +5 -4
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/__init__.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/api/__init__.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/__init__.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/advertiser.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/golem_base_utils.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/multi_advertiser.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/resource_monitor.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/resource_tracker.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/service.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/network/port_verifier.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/security/ethereum.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/__init__.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/ascii_art.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/logging.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/port_display.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/pricing.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/retry.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/setup.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/__init__.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/cloud_init.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/models.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/multipass.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/multipass_adapter.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/name_mapper.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/port_manager.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/provider.py +0 -0
- {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/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.
|
3
|
+
Version: 0.1.42
|
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
|
@@ -23,6 +23,8 @@ Requires-Dist: dependency-injector (>=4.41.0,<5.0.0)
|
|
23
23
|
Requires-Dist: eth-account (>=0.13.6,<0.14.0)
|
24
24
|
Requires-Dist: fastapi (>=0.103.0,<0.104.0)
|
25
25
|
Requires-Dist: golem-base-sdk (==0.1.0)
|
26
|
+
Requires-Dist: golem-faucet (>=0.1.0,<0.2.0)
|
27
|
+
Requires-Dist: golem-streaming-abi (>=0.1.0,<0.2.0)
|
26
28
|
Requires-Dist: httpx (>=0.23.0,<0.24.0)
|
27
29
|
Requires-Dist: psutil (>=5.9.0,<6.0.0)
|
28
30
|
Requires-Dist: pydantic (>=2.4.0,<3.0.0)
|
@@ -228,8 +230,41 @@ GOLEM_PROVIDER_PUBLIC_IP="auto"
|
|
228
230
|
# Discovery Settings
|
229
231
|
GOLEM_PROVIDER_DISCOVERY_URL="http://discovery.golem.network:9001"
|
230
232
|
GOLEM_PROVIDER_ADVERTISEMENT_INTERVAL=240
|
233
|
+
|
234
|
+
# Network Selection
|
235
|
+
# Adds an annotation to on-chain advertisements and can be used by requestors to filter
|
236
|
+
GOLEM_PROVIDER_NETWORK="testnet" # or "mainnet"
|
231
237
|
```
|
232
238
|
|
239
|
+
### Streaming Payments (Native ETH on L2)
|
240
|
+
|
241
|
+
Enable on‑chain stream‑gated rentals funded in native ETH. Configure (env prefix `GOLEM_PROVIDER_`):
|
242
|
+
|
243
|
+
- `POLYGON_RPC_URL` — EVM RPC URL (default points to L2: https://l2.holesky.golemdb.io/rpc)
|
244
|
+
- `STREAM_PAYMENT_ADDRESS` — StreamPayment contract address; if non‑zero, VM creation requires a valid `stream_id`
|
245
|
+
- `GLM_TOKEN_ADDRESS` — Token address; set to `0x0000000000000000000000000000000000000000` to indicate native ETH
|
246
|
+
|
247
|
+
Optional background automation (all disabled by default):
|
248
|
+
|
249
|
+
- `STREAM_MIN_REMAINING_SECONDS` — minimum remaining runway to keep a VM running (default 3600)
|
250
|
+
- `STREAM_MONITOR_ENABLED` — stop VMs when remaining runway < threshold (default false)
|
251
|
+
- `STREAM_MONITOR_INTERVAL_SECONDS` — how frequently to check runway (default 60)
|
252
|
+
- `STREAM_WITHDRAW_ENABLED` — periodically withdraw vested funds (default false)
|
253
|
+
- `STREAM_WITHDRAW_INTERVAL_SECONDS` — how often to attempt withdrawals (default 1800)
|
254
|
+
- `STREAM_MIN_WITHDRAW_WEI` — only withdraw when >= this amount (gas‑aware)
|
255
|
+
|
256
|
+
Implementation notes:
|
257
|
+
|
258
|
+
- The provider exposes `GET /api/v1/provider/info` returning `provider_id`, `stream_payment_address`, and `glm_token_address`. For ETH mode this field is the zero address (`0x000...000`). Requestors should prefer these values when opening streams.
|
259
|
+
- On successful VM creation with a valid `stream_id`, the provider persists a VM→stream mapping in `streams.json`. This enables the background monitor to stop VMs with low remaining runway and to withdraw vested funds according to configured intervals.
|
260
|
+
- When a VM is deleted, the VM→stream mapping is cleaned up.
|
261
|
+
|
262
|
+
When enabled, the provider verifies each VM creation request’s `stream_id` and refuses to start the VM if:
|
263
|
+
|
264
|
+
- stream recipient != provider’s Ethereum address
|
265
|
+
- deposit is zero, stream not started, or stream halted
|
266
|
+
- (Optional) remaining runway < `STREAM_MIN_REMAINING_SECONDS`
|
267
|
+
|
233
268
|
## API Reference
|
234
269
|
|
235
270
|
### Create VM
|
@@ -245,7 +280,8 @@ Request:
|
|
245
280
|
"name": "my-webserver",
|
246
281
|
"cpu_cores": 2,
|
247
282
|
"memory_gb": 4,
|
248
|
-
"storage_gb": 20
|
283
|
+
"storage_gb": 20,
|
284
|
+
"stream_id": 123 // required when STREAM_PAYMENT_ADDRESS is set
|
249
285
|
}
|
250
286
|
```
|
251
287
|
|
@@ -273,6 +309,25 @@ Response:
|
|
273
309
|
- Delete VM: `DELETE /api/v1/vms/{vm_id}`
|
274
310
|
- Stop VM: `POST /api/v1/vms/{vm_id}/stop`
|
275
311
|
- Get Access Info: `GET /api/v1/vms/{vm_id}/access`
|
312
|
+
|
313
|
+
### Provider Info
|
314
|
+
|
315
|
+
```bash
|
316
|
+
GET /api/v1/provider/info
|
317
|
+
```
|
318
|
+
|
319
|
+
Response:
|
320
|
+
|
321
|
+
```json
|
322
|
+
{
|
323
|
+
"provider_id": "0xProviderEthereumAddress",
|
324
|
+
"stream_payment_address": "0xStreamPayment",
|
325
|
+
"glm_token_address": "0x0000000000000000000000000000000000000000"
|
326
|
+
|
327
|
+
}
|
328
|
+
```
|
329
|
+
|
330
|
+
Use this endpoint to discover the correct recipient for creating a GLM stream.
|
276
331
|
|
277
332
|
## Operations
|
278
333
|
|
@@ -282,10 +337,26 @@ Response:
|
|
282
337
|
# To run in production mode
|
283
338
|
poetry run golem-provider start
|
284
339
|
|
285
|
-
# To run in development mode
|
286
|
-
poetry run golem-provider
|
340
|
+
# To run in development mode, set the environment and optionally network
|
341
|
+
GOLEM_PROVIDER_ENVIRONMENT=development poetry run golem-provider start --network testnet
|
287
342
|
```
|
288
343
|
|
344
|
+
### Mode vs. Network
|
345
|
+
|
346
|
+
- Development Mode (`GOLEM_PROVIDER_ENVIRONMENT=development`)
|
347
|
+
- Optimizes for local iteration: enables reload + debug logging and uses local defaults (e.g., local port check servers). May derive a local/LAN IP automatically and prefix the provider name with `DEVMODE-`.
|
348
|
+
- Does not decide which chain you target.
|
349
|
+
|
350
|
+
- Network Selection (`--network` or `GOLEM_PROVIDER_NETWORK`)
|
351
|
+
- Chooses the discovery/advertisement scope: providers advertise `golem_network=testnet|mainnet` and requestors filter accordingly.
|
352
|
+
- Pair with appropriate RPC envs (`GOLEM_PROVIDER_GOLEM_BASE_RPC_URL`, `GOLEM_PROVIDER_GOLEM_BASE_WS_URL`).
|
353
|
+
- Does not change dev ergonomics (logging, reload, or port verification behavior).
|
354
|
+
|
355
|
+
Common setups:
|
356
|
+
- Local dev on testnet: `GOLEM_PROVIDER_ENVIRONMENT=development` plus `--network testnet`.
|
357
|
+
- Staging on testnet: keep `ENVIRONMENT=production`, set `--network testnet` and testnet RPCs.
|
358
|
+
- Production on mainnet: `ENVIRONMENT=production` with `--network mainnet` and mainnet RPCs.
|
359
|
+
|
289
360
|
The provider will:
|
290
361
|
|
291
362
|
1. Verify port accessibility
|
@@ -297,6 +368,20 @@ The provider will:
|
|
297
368
|
4. Begin resource advertisement
|
298
369
|
5. Listen for VM requests
|
299
370
|
|
371
|
+
### Faucet
|
372
|
+
|
373
|
+
- L3 (Golem Base adverts): provider auto-requests funds on startup from `FAUCET_URL` (defaults to EthWarsaw Holesky) protected by CAPTCHA at `CAPTCHA_URL/05381a2cef5e`.
|
374
|
+
- L2 (payments): Use the CLI to request native ETH:
|
375
|
+
|
376
|
+
```bash
|
377
|
+
poetry run golem-provider wallet faucet-l2
|
378
|
+
```
|
379
|
+
|
380
|
+
Defaults:
|
381
|
+
- L2 faucet: `https://l2.holesky.golemdb.io/faucet`
|
382
|
+
- CAPTCHA: `https://cap.gobas.me/05381a2cef5e`
|
383
|
+
- Override with env: `GOLEM_PROVIDER_L2_FAUCET_URL`, `GOLEM_PROVIDER_L2_CAPTCHA_URL`, `GOLEM_PROVIDER_L2_CAPTCHA_API_KEY`.
|
384
|
+
|
300
385
|
### Resource Advertisement Flow
|
301
386
|
|
302
387
|
```mermaid
|
@@ -185,8 +185,41 @@ GOLEM_PROVIDER_PUBLIC_IP="auto"
|
|
185
185
|
# Discovery Settings
|
186
186
|
GOLEM_PROVIDER_DISCOVERY_URL="http://discovery.golem.network:9001"
|
187
187
|
GOLEM_PROVIDER_ADVERTISEMENT_INTERVAL=240
|
188
|
+
|
189
|
+
# Network Selection
|
190
|
+
# Adds an annotation to on-chain advertisements and can be used by requestors to filter
|
191
|
+
GOLEM_PROVIDER_NETWORK="testnet" # or "mainnet"
|
188
192
|
```
|
189
193
|
|
194
|
+
### Streaming Payments (Native ETH on L2)
|
195
|
+
|
196
|
+
Enable on‑chain stream‑gated rentals funded in native ETH. Configure (env prefix `GOLEM_PROVIDER_`):
|
197
|
+
|
198
|
+
- `POLYGON_RPC_URL` — EVM RPC URL (default points to L2: https://l2.holesky.golemdb.io/rpc)
|
199
|
+
- `STREAM_PAYMENT_ADDRESS` — StreamPayment contract address; if non‑zero, VM creation requires a valid `stream_id`
|
200
|
+
- `GLM_TOKEN_ADDRESS` — Token address; set to `0x0000000000000000000000000000000000000000` to indicate native ETH
|
201
|
+
|
202
|
+
Optional background automation (all disabled by default):
|
203
|
+
|
204
|
+
- `STREAM_MIN_REMAINING_SECONDS` — minimum remaining runway to keep a VM running (default 3600)
|
205
|
+
- `STREAM_MONITOR_ENABLED` — stop VMs when remaining runway < threshold (default false)
|
206
|
+
- `STREAM_MONITOR_INTERVAL_SECONDS` — how frequently to check runway (default 60)
|
207
|
+
- `STREAM_WITHDRAW_ENABLED` — periodically withdraw vested funds (default false)
|
208
|
+
- `STREAM_WITHDRAW_INTERVAL_SECONDS` — how often to attempt withdrawals (default 1800)
|
209
|
+
- `STREAM_MIN_WITHDRAW_WEI` — only withdraw when >= this amount (gas‑aware)
|
210
|
+
|
211
|
+
Implementation notes:
|
212
|
+
|
213
|
+
- The provider exposes `GET /api/v1/provider/info` returning `provider_id`, `stream_payment_address`, and `glm_token_address`. For ETH mode this field is the zero address (`0x000...000`). Requestors should prefer these values when opening streams.
|
214
|
+
- On successful VM creation with a valid `stream_id`, the provider persists a VM→stream mapping in `streams.json`. This enables the background monitor to stop VMs with low remaining runway and to withdraw vested funds according to configured intervals.
|
215
|
+
- When a VM is deleted, the VM→stream mapping is cleaned up.
|
216
|
+
|
217
|
+
When enabled, the provider verifies each VM creation request’s `stream_id` and refuses to start the VM if:
|
218
|
+
|
219
|
+
- stream recipient != provider’s Ethereum address
|
220
|
+
- deposit is zero, stream not started, or stream halted
|
221
|
+
- (Optional) remaining runway < `STREAM_MIN_REMAINING_SECONDS`
|
222
|
+
|
190
223
|
## API Reference
|
191
224
|
|
192
225
|
### Create VM
|
@@ -202,7 +235,8 @@ Request:
|
|
202
235
|
"name": "my-webserver",
|
203
236
|
"cpu_cores": 2,
|
204
237
|
"memory_gb": 4,
|
205
|
-
"storage_gb": 20
|
238
|
+
"storage_gb": 20,
|
239
|
+
"stream_id": 123 // required when STREAM_PAYMENT_ADDRESS is set
|
206
240
|
}
|
207
241
|
```
|
208
242
|
|
@@ -230,6 +264,25 @@ Response:
|
|
230
264
|
- Delete VM: `DELETE /api/v1/vms/{vm_id}`
|
231
265
|
- Stop VM: `POST /api/v1/vms/{vm_id}/stop`
|
232
266
|
- Get Access Info: `GET /api/v1/vms/{vm_id}/access`
|
267
|
+
|
268
|
+
### Provider Info
|
269
|
+
|
270
|
+
```bash
|
271
|
+
GET /api/v1/provider/info
|
272
|
+
```
|
273
|
+
|
274
|
+
Response:
|
275
|
+
|
276
|
+
```json
|
277
|
+
{
|
278
|
+
"provider_id": "0xProviderEthereumAddress",
|
279
|
+
"stream_payment_address": "0xStreamPayment",
|
280
|
+
"glm_token_address": "0x0000000000000000000000000000000000000000"
|
281
|
+
|
282
|
+
}
|
283
|
+
```
|
284
|
+
|
285
|
+
Use this endpoint to discover the correct recipient for creating a GLM stream.
|
233
286
|
|
234
287
|
## Operations
|
235
288
|
|
@@ -239,10 +292,26 @@ Response:
|
|
239
292
|
# To run in production mode
|
240
293
|
poetry run golem-provider start
|
241
294
|
|
242
|
-
# To run in development mode
|
243
|
-
poetry run golem-provider
|
295
|
+
# To run in development mode, set the environment and optionally network
|
296
|
+
GOLEM_PROVIDER_ENVIRONMENT=development poetry run golem-provider start --network testnet
|
244
297
|
```
|
245
298
|
|
299
|
+
### Mode vs. Network
|
300
|
+
|
301
|
+
- Development Mode (`GOLEM_PROVIDER_ENVIRONMENT=development`)
|
302
|
+
- Optimizes for local iteration: enables reload + debug logging and uses local defaults (e.g., local port check servers). May derive a local/LAN IP automatically and prefix the provider name with `DEVMODE-`.
|
303
|
+
- Does not decide which chain you target.
|
304
|
+
|
305
|
+
- Network Selection (`--network` or `GOLEM_PROVIDER_NETWORK`)
|
306
|
+
- Chooses the discovery/advertisement scope: providers advertise `golem_network=testnet|mainnet` and requestors filter accordingly.
|
307
|
+
- Pair with appropriate RPC envs (`GOLEM_PROVIDER_GOLEM_BASE_RPC_URL`, `GOLEM_PROVIDER_GOLEM_BASE_WS_URL`).
|
308
|
+
- Does not change dev ergonomics (logging, reload, or port verification behavior).
|
309
|
+
|
310
|
+
Common setups:
|
311
|
+
- Local dev on testnet: `GOLEM_PROVIDER_ENVIRONMENT=development` plus `--network testnet`.
|
312
|
+
- Staging on testnet: keep `ENVIRONMENT=production`, set `--network testnet` and testnet RPCs.
|
313
|
+
- Production on mainnet: `ENVIRONMENT=production` with `--network mainnet` and mainnet RPCs.
|
314
|
+
|
246
315
|
The provider will:
|
247
316
|
|
248
317
|
1. Verify port accessibility
|
@@ -254,6 +323,20 @@ The provider will:
|
|
254
323
|
4. Begin resource advertisement
|
255
324
|
5. Listen for VM requests
|
256
325
|
|
326
|
+
### Faucet
|
327
|
+
|
328
|
+
- L3 (Golem Base adverts): provider auto-requests funds on startup from `FAUCET_URL` (defaults to EthWarsaw Holesky) protected by CAPTCHA at `CAPTCHA_URL/05381a2cef5e`.
|
329
|
+
- L2 (payments): Use the CLI to request native ETH:
|
330
|
+
|
331
|
+
```bash
|
332
|
+
poetry run golem-provider wallet faucet-l2
|
333
|
+
```
|
334
|
+
|
335
|
+
Defaults:
|
336
|
+
- L2 faucet: `https://l2.holesky.golemdb.io/faucet`
|
337
|
+
- CAPTCHA: `https://cap.gobas.me/05381a2cef5e`
|
338
|
+
- Override with env: `GOLEM_PROVIDER_L2_FAUCET_URL`, `GOLEM_PROVIDER_L2_CAPTCHA_URL`, `GOLEM_PROVIDER_L2_CAPTCHA_API_KEY`.
|
339
|
+
|
257
340
|
### Resource Advertisement Flow
|
258
341
|
|
259
342
|
```mermaid
|
@@ -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
|
|
@@ -25,12 +26,22 @@ async def create_vm(
|
|
25
26
|
request: CreateVMRequest,
|
26
27
|
vm_service: VMService = Depends(Provide[Container.vm_service]),
|
27
28
|
settings: Settings = Depends(Provide[Container.config]),
|
29
|
+
stream_map = Depends(Provide[Container.stream_map]),
|
28
30
|
) -> VMInfo:
|
29
31
|
"""Create a new VM."""
|
30
32
|
try:
|
31
33
|
logger.info(f"📥 Received VM creation request for '{request.name}'")
|
32
34
|
|
33
35
|
resources = request.resources or VMResources()
|
36
|
+
|
37
|
+
# If payments are enabled, require a valid stream before starting
|
38
|
+
if settings["STREAM_PAYMENT_ADDRESS"] and settings["STREAM_PAYMENT_ADDRESS"] != "0x0000000000000000000000000000000000000000":
|
39
|
+
if request.stream_id is None:
|
40
|
+
raise HTTPException(status_code=400, detail="stream_id required when payments are enabled")
|
41
|
+
reader = StreamPaymentReader(settings["POLYGON_RPC_URL"], settings["STREAM_PAYMENT_ADDRESS"])
|
42
|
+
ok, reason = reader.verify_stream(int(request.stream_id), settings["PROVIDER_ID"])
|
43
|
+
if not ok:
|
44
|
+
raise HTTPException(status_code=400, detail=f"invalid stream: {reason}")
|
34
45
|
|
35
46
|
# Create VM config
|
36
47
|
config = VMConfig(
|
@@ -41,11 +52,20 @@ async def create_vm(
|
|
41
52
|
)
|
42
53
|
|
43
54
|
vm_info = await vm_service.create_vm(config)
|
55
|
+
# Persist VM->stream mapping if provided
|
56
|
+
if request.stream_id is not None:
|
57
|
+
try:
|
58
|
+
await stream_map.set(vm_info.id, int(request.stream_id))
|
59
|
+
except Exception as e:
|
60
|
+
logger.warning(f"failed to persist stream mapping for {vm_info.id}: {e}")
|
44
61
|
await vm_creation_animation(request.name)
|
45
62
|
return vm_info
|
46
63
|
except MultipassError as e:
|
47
64
|
logger.error(f"Failed to create VM: {e}")
|
48
65
|
raise HTTPException(status_code=500, detail=str(e))
|
66
|
+
except HTTPException:
|
67
|
+
# Propagate explicit HTTP errors (e.g., payment gating)
|
68
|
+
raise
|
49
69
|
except Exception as e:
|
50
70
|
logger.error(f"An unexpected error occurred: {e}")
|
51
71
|
raise HTTPException(status_code=500, detail="An unexpected error occurred")
|
@@ -151,12 +171,17 @@ async def stop_vm(
|
|
151
171
|
async def delete_vm(
|
152
172
|
requestor_name: str,
|
153
173
|
vm_service: VMService = Depends(Provide[Container.vm_service]),
|
174
|
+
stream_map = Depends(Provide[Container.stream_map]),
|
154
175
|
) -> None:
|
155
176
|
"""Delete a VM."""
|
156
177
|
try:
|
157
178
|
logger.process(f"🗑️ Deleting VM '{requestor_name}'")
|
158
179
|
vm_status_change(requestor_name, "STOPPING", "Cleanup in progress")
|
159
180
|
await vm_service.delete_vm(requestor_name)
|
181
|
+
try:
|
182
|
+
await stream_map.remove(requestor_name)
|
183
|
+
except Exception as e:
|
184
|
+
logger.warning(f"failed to remove stream mapping for {requestor_name}: {e}")
|
160
185
|
vm_status_change(requestor_name, "TERMINATED", "Cleanup complete")
|
161
186
|
logger.success(f"✨ Successfully deleted VM '{requestor_name}'")
|
162
187
|
except VMNotFoundError as e:
|
@@ -168,3 +193,11 @@ async def delete_vm(
|
|
168
193
|
except Exception as e:
|
169
194
|
logger.error(f"An unexpected error occurred: {e}")
|
170
195
|
raise HTTPException(status_code=500, detail="An unexpected error occurred")
|
196
|
+
@router.get("/provider/info", response_model=ProviderInfoResponse)
|
197
|
+
@inject
|
198
|
+
async def provider_info(settings: Settings = Depends(Provide[Container.config])) -> ProviderInfoResponse:
|
199
|
+
return ProviderInfoResponse(
|
200
|
+
provider_id=settings["PROVIDER_ID"],
|
201
|
+
stream_payment_address=settings["STREAM_PAYMENT_ADDRESS"],
|
202
|
+
glm_token_address=settings["GLM_TOKEN_ADDRESS"],
|
203
|
+
)
|
@@ -6,6 +6,7 @@ import socket
|
|
6
6
|
|
7
7
|
from pydantic_settings import BaseSettings
|
8
8
|
from pydantic import field_validator, Field
|
9
|
+
import os
|
9
10
|
from .utils.logging import setup_logger
|
10
11
|
|
11
12
|
logger = setup_logger(__name__)
|
@@ -52,6 +53,8 @@ class Settings(BaseSettings):
|
|
52
53
|
PORT: int = 7466
|
53
54
|
SKIP_PORT_VERIFICATION: bool = False
|
54
55
|
ENVIRONMENT: str = "production"
|
56
|
+
# Logical network selector for annotation and client defaults
|
57
|
+
NETWORK: str = "mainnet" # one of: "testnet", "mainnet"
|
55
58
|
|
56
59
|
@property
|
57
60
|
def DEV_MODE(self) -> bool:
|
@@ -68,9 +71,6 @@ class Settings(BaseSettings):
|
|
68
71
|
ETHEREUM_KEY_DIR: str = ""
|
69
72
|
ETHEREUM_PRIVATE_KEY: Optional[str] = None
|
70
73
|
PROVIDER_ID: str = "" # Will be set from Ethereum identity
|
71
|
-
FAUCET_URL: str = "https://ethwarsaw.holesky.golemdb.io/faucet"
|
72
|
-
CAPTCHA_URL: str = "https://cap.gobas.me"
|
73
|
-
CAPTCHA_API_KEY: str = "05381a2cef5e"
|
74
74
|
|
75
75
|
@field_validator("ETHEREUM_KEY_DIR", mode='before')
|
76
76
|
def resolve_key_dir(cls, v: str) -> str:
|
@@ -135,6 +135,72 @@ class Settings(BaseSettings):
|
|
135
135
|
GOLEM_BASE_RPC_URL: str = "https://ethwarsaw.holesky.golemdb.io/rpc"
|
136
136
|
GOLEM_BASE_WS_URL: str = "wss://ethwarsaw.holesky.golemdb.io/rpc/ws"
|
137
137
|
|
138
|
+
# Polygon / Payments
|
139
|
+
POLYGON_RPC_URL: str = Field(
|
140
|
+
default="https://l2.holesky.golemdb.io/rpc",
|
141
|
+
description="EVM RPC URL for streaming payments (L2 by default)"
|
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="Token address (0x0 means native ETH)"
|
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
|
+
|
176
|
+
# Faucet settings (L3 for Golem Base adverts)
|
177
|
+
FAUCET_URL: str = "https://ethwarsaw.holesky.golemdb.io/faucet"
|
178
|
+
CAPTCHA_URL: str = "https://cap.gobas.me"
|
179
|
+
CAPTCHA_API_KEY: str = "05381a2cef5e"
|
180
|
+
|
181
|
+
# L2 payments faucet (native ETH)
|
182
|
+
L2_FAUCET_URL: str = Field(
|
183
|
+
default="https://l2.holesky.golemdb.io/faucet",
|
184
|
+
description="L2 faucet base URL (no trailing /api)"
|
185
|
+
)
|
186
|
+
L2_CAPTCHA_URL: str = Field(
|
187
|
+
default="https://cap.gobas.me",
|
188
|
+
description="CAPTCHA base URL"
|
189
|
+
)
|
190
|
+
L2_CAPTCHA_API_KEY: str = Field(
|
191
|
+
default="05381a2cef5e",
|
192
|
+
description="CAPTCHA API key path segment"
|
193
|
+
)
|
194
|
+
|
195
|
+
@field_validator("POLYGON_RPC_URL", mode='before')
|
196
|
+
@classmethod
|
197
|
+
def prefer_custom_env(cls, v: str) -> str:
|
198
|
+
# Accept alternative aliases for payments RPC
|
199
|
+
for key in ("GOLEM_PROVIDER_L2_RPC_URL", "GOLEM_PROVIDER_KAOLIN_RPC_URL"):
|
200
|
+
if os.environ.get(key):
|
201
|
+
return os.environ[key]
|
202
|
+
return v
|
203
|
+
|
138
204
|
# VM Settings
|
139
205
|
MAX_VMS: int = 10
|
140
206
|
DEFAULT_VM_IMAGE: str = "ubuntu:24.04"
|
@@ -14,6 +14,9 @@ from .vm.service import VMService
|
|
14
14
|
from .vm.name_mapper import VMNameMapper
|
15
15
|
from .vm.port_manager import PortManager
|
16
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
|
17
20
|
|
18
21
|
|
19
22
|
class Container(containers.DeclarativeContainer):
|
@@ -49,6 +52,11 @@ class Container(containers.DeclarativeContainer):
|
|
49
52
|
db_path=Path(settings.VM_DATA_DIR) / "vm_names.json",
|
50
53
|
)
|
51
54
|
|
55
|
+
stream_map = providers.Singleton(
|
56
|
+
StreamMap,
|
57
|
+
storage_path=Path(settings.VM_DATA_DIR) / "streams.json",
|
58
|
+
)
|
59
|
+
|
52
60
|
port_manager = providers.Singleton(
|
53
61
|
PortManager,
|
54
62
|
start_port=config.PORT_RANGE_START,
|
@@ -81,6 +89,31 @@ class Container(containers.DeclarativeContainer):
|
|
81
89
|
name_mapper=vm_name_mapper,
|
82
90
|
)
|
83
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
|
+
|
84
117
|
provider_service = providers.Singleton(
|
85
118
|
ProviderService,
|
86
119
|
vm_service=vm_service,
|
{golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/golem_base_advertiser.py
RENAMED
@@ -66,6 +66,7 @@ class GolemBaseAdvertiser(Advertiser):
|
|
66
66
|
|
67
67
|
string_annotations = [
|
68
68
|
Annotation(key="golem_type", value="provider"),
|
69
|
+
Annotation(key="golem_network", value=settings.NETWORK),
|
69
70
|
Annotation(key="golem_provider_id", value=settings.PROVIDER_ID),
|
70
71
|
Annotation(key="golem_ip_address", value=ip_address),
|
71
72
|
Annotation(key="golem_country", value=settings.PROVIDER_COUNTRY),
|
@@ -122,7 +122,9 @@ except ImportError:
|
|
122
122
|
|
123
123
|
cli = typer.Typer()
|
124
124
|
pricing_app = typer.Typer(help="Configure USD pricing; auto-converts to GLM.")
|
125
|
+
wallet_app = typer.Typer(help="Wallet utilities (funding, balance)")
|
125
126
|
cli.add_typer(pricing_app, name="pricing")
|
127
|
+
cli.add_typer(wallet_app, name="wallet")
|
126
128
|
|
127
129
|
def print_version(ctx: typer.Context, value: bool):
|
128
130
|
if not value:
|
@@ -141,15 +143,57 @@ def main(
|
|
141
143
|
ensure_config()
|
142
144
|
pass
|
143
145
|
|
146
|
+
|
147
|
+
@wallet_app.command("faucet-l2")
|
148
|
+
def wallet_faucet_l2():
|
149
|
+
"""Request L2 faucet funds for the provider's payment address (native ETH)."""
|
150
|
+
from .config import settings
|
151
|
+
from golem_faucet import PowFaucetClient
|
152
|
+
from web3 import Web3
|
153
|
+
try:
|
154
|
+
addr = settings.PROVIDER_ID
|
155
|
+
faucet = PowFaucetClient(settings.L2_FAUCET_URL, settings.L2_CAPTCHA_URL, settings.L2_CAPTCHA_API_KEY)
|
156
|
+
# Check current L2 balance
|
157
|
+
w3 = Web3(Web3.HTTPProvider(settings.POLYGON_RPC_URL))
|
158
|
+
bal = 0.0
|
159
|
+
try:
|
160
|
+
bal = float(w3.from_wei(w3.eth.get_balance(Web3.to_checksum_address(addr)), 'ether'))
|
161
|
+
except Exception:
|
162
|
+
pass
|
163
|
+
if bal > 0.01:
|
164
|
+
print(f"Sufficient L2 funds ({bal} ETH); skipping faucet.")
|
165
|
+
return
|
166
|
+
async def _run():
|
167
|
+
chall = await faucet.get_challenge()
|
168
|
+
if not chall:
|
169
|
+
print("Failed to get challenge")
|
170
|
+
raise typer.Exit(code=1)
|
171
|
+
sols = []
|
172
|
+
for salt, target in chall.get('challenge') or []:
|
173
|
+
sols.append((salt, target, PowFaucetClient.solve_challenge(salt, target)))
|
174
|
+
redeemed = await faucet.redeem(chall.get('token'), sols)
|
175
|
+
if not redeemed:
|
176
|
+
print("Failed to redeem solutions")
|
177
|
+
raise typer.Exit(code=1)
|
178
|
+
tx = await faucet.request_funds(addr, redeemed)
|
179
|
+
if tx:
|
180
|
+
print(f"Faucet tx: {tx}")
|
181
|
+
else:
|
182
|
+
print("Faucet request failed")
|
183
|
+
asyncio.run(_run())
|
184
|
+
except Exception as e:
|
185
|
+
print(f"Error: {e}")
|
186
|
+
raise typer.Exit(code=1)
|
187
|
+
|
144
188
|
@cli.command()
|
145
|
-
def start(
|
189
|
+
def start(
|
190
|
+
no_verify_port: bool = typer.Option(False, "--no-verify-port", help="Skip provider port verification."),
|
191
|
+
network: str = typer.Option(None, "--network", help="Target network: 'testnet' or 'mainnet' (overrides env)")
|
192
|
+
):
|
146
193
|
"""Start the provider server."""
|
147
|
-
run_server(dev_mode=False, no_verify_port=no_verify_port)
|
194
|
+
run_server(dev_mode=False, no_verify_port=no_verify_port, network=network)
|
148
195
|
|
149
|
-
|
150
|
-
def dev(no_verify_port: bool = typer.Option(True, "--no-verify-port", help="Skip provider port verification.")):
|
151
|
-
"""Start the provider server in development mode."""
|
152
|
-
run_server(dev_mode=True, no_verify_port=no_verify_port)
|
196
|
+
# Removed separate 'dev' command; use environment GOLEM_PROVIDER_ENVIRONMENT=development instead.
|
153
197
|
|
154
198
|
def _env_path_for(dev_mode: Optional[bool]) -> str:
|
155
199
|
from pathlib import Path
|
@@ -209,22 +253,35 @@ def _print_pricing_examples(glm_usd):
|
|
209
253
|
f"- {name} ({res.cpu}C, {res.memory}GB RAM, {res.storage}GB Disk): ~{usd_str} per month (~{glm_str})"
|
210
254
|
)
|
211
255
|
|
212
|
-
def run_server(dev_mode: bool, no_verify_port: bool):
|
256
|
+
def run_server(dev_mode: bool | None = None, no_verify_port: bool = False, network: str | None = None):
|
213
257
|
"""Helper to run the uvicorn server."""
|
214
258
|
import sys
|
215
259
|
from pathlib import Path
|
216
260
|
from dotenv import load_dotenv
|
217
261
|
import uvicorn
|
218
|
-
#
|
262
|
+
# Decide dev mode from explicit arg or environment
|
263
|
+
if dev_mode is None:
|
264
|
+
dev_mode = os.environ.get("GOLEM_PROVIDER_ENVIRONMENT", "").lower() == "development"
|
265
|
+
|
266
|
+
# Load appropriate .env file based on mode
|
219
267
|
env_file = ".env.dev" if dev_mode else ".env"
|
220
268
|
env_path = Path(__file__).parent.parent / env_file
|
221
269
|
load_dotenv(dotenv_path=env_path)
|
270
|
+
|
271
|
+
# Apply network override early (affects settings and annotations)
|
272
|
+
if network:
|
273
|
+
os.environ["GOLEM_PROVIDER_NETWORK"] = network
|
222
274
|
|
223
275
|
# The logic for setting the public IP in dev mode is now handled in config.py
|
224
276
|
# The following lines are no longer needed and have been removed.
|
225
277
|
|
226
278
|
# Import settings after loading env
|
227
279
|
from .config import settings
|
280
|
+
if network:
|
281
|
+
try:
|
282
|
+
settings.NETWORK = network
|
283
|
+
except Exception:
|
284
|
+
pass
|
228
285
|
|
229
286
|
# Configure logging with debug mode
|
230
287
|
logger = setup_logger(__name__, debug=dev_mode)
|
@@ -235,6 +292,8 @@ def run_server(dev_mode: bool, no_verify_port: bool):
|
|
235
292
|
for key, value in os.environ.items():
|
236
293
|
if key.startswith('GOLEM_PROVIDER_'):
|
237
294
|
logger.info(f"{key}={value}")
|
295
|
+
if network:
|
296
|
+
logger.info(f"Overridden network: {network}")
|
238
297
|
|
239
298
|
# Check requirements
|
240
299
|
if not check_requirements():
|
@@ -257,7 +316,7 @@ def run_server(dev_mode: bool, no_verify_port: bool):
|
|
257
316
|
"provider:app",
|
258
317
|
host=settings.HOST,
|
259
318
|
port=settings.PORT,
|
260
|
-
reload=
|
319
|
+
reload=dev_mode,
|
261
320
|
log_level="debug" if dev_mode else "info",
|
262
321
|
log_config=log_config,
|
263
322
|
timeout_keep_alive=60, # Increase keep-alive timeout
|
@@ -0,0 +1,93 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from typing import Any, Dict
|
5
|
+
|
6
|
+
from web3 import Web3
|
7
|
+
from eth_account import Account
|
8
|
+
from golem_streaming_abi import STREAM_PAYMENT_ABI
|
9
|
+
|
10
|
+
|
11
|
+
# ABI imported from shared package
|
12
|
+
|
13
|
+
|
14
|
+
@dataclass
|
15
|
+
class StreamPaymentConfig:
|
16
|
+
rpc_url: str
|
17
|
+
contract_address: str
|
18
|
+
private_key: str
|
19
|
+
|
20
|
+
|
21
|
+
class StreamPaymentClient:
|
22
|
+
def __init__(self, cfg: StreamPaymentConfig):
|
23
|
+
self.web3 = Web3(Web3.HTTPProvider(cfg.rpc_url))
|
24
|
+
self.account = Account.from_key(cfg.private_key)
|
25
|
+
self.web3.eth.default_account = self.account.address
|
26
|
+
self.contract = self.web3.eth.contract(
|
27
|
+
address=Web3.to_checksum_address(cfg.contract_address), abi=STREAM_PAYMENT_ABI
|
28
|
+
)
|
29
|
+
|
30
|
+
def _send(self, fn) -> Dict[str, Any]:
|
31
|
+
tx = fn.build_transaction(
|
32
|
+
{
|
33
|
+
"from": self.account.address,
|
34
|
+
"nonce": self.web3.eth.get_transaction_count(self.account.address),
|
35
|
+
}
|
36
|
+
)
|
37
|
+
if hasattr(self.account, "sign_transaction"):
|
38
|
+
signed = self.account.sign_transaction(tx)
|
39
|
+
tx_hash = self.web3.eth.send_raw_transaction(signed.rawTransaction)
|
40
|
+
else:
|
41
|
+
tx_hash = self.web3.eth.send_transaction(tx)
|
42
|
+
receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash)
|
43
|
+
return {"transactionHash": tx_hash.hex(), "status": receipt.status}
|
44
|
+
|
45
|
+
def withdraw(self, stream_id: int) -> str:
|
46
|
+
fn = self.contract.functions.withdraw(int(stream_id))
|
47
|
+
receipt = self._send(fn)
|
48
|
+
return receipt["transactionHash"]
|
49
|
+
def terminate(self, stream_id: int) -> str:
|
50
|
+
fn = self.contract.functions.terminate(int(stream_id))
|
51
|
+
receipt = self._send(fn)
|
52
|
+
return receipt["transactionHash"]
|
53
|
+
|
54
|
+
class StreamPaymentReader:
|
55
|
+
def __init__(self, rpc_url: str, contract_address: str):
|
56
|
+
self.web3 = Web3(Web3.HTTPProvider(rpc_url))
|
57
|
+
self.contract = self.web3.eth.contract(
|
58
|
+
address=Web3.to_checksum_address(contract_address), abi=STREAM_PAYMENT_ABI
|
59
|
+
)
|
60
|
+
|
61
|
+
def get_stream(self, stream_id: int) -> dict:
|
62
|
+
token, sender, recipient, startTime, stopTime, ratePerSecond, deposit, withdrawn, halted = (
|
63
|
+
self.contract.functions.streams(int(stream_id)).call()
|
64
|
+
)
|
65
|
+
return {
|
66
|
+
"token": token,
|
67
|
+
"sender": sender,
|
68
|
+
"recipient": recipient,
|
69
|
+
"startTime": int(startTime),
|
70
|
+
"stopTime": int(stopTime),
|
71
|
+
"ratePerSecond": int(ratePerSecond),
|
72
|
+
"deposit": int(deposit),
|
73
|
+
"withdrawn": int(withdrawn),
|
74
|
+
"halted": bool(halted),
|
75
|
+
}
|
76
|
+
|
77
|
+
def verify_stream(self, stream_id: int, expected_recipient: str) -> tuple[bool, str]:
|
78
|
+
try:
|
79
|
+
s = self.get_stream(stream_id)
|
80
|
+
except Exception as e:
|
81
|
+
return False, f"stream lookup failed: {e}"
|
82
|
+
if s["recipient"].lower() != expected_recipient.lower():
|
83
|
+
return False, "recipient mismatch"
|
84
|
+
if s["deposit"] <= 0:
|
85
|
+
return False, "no deposit"
|
86
|
+
now = int(self.web3.eth.get_block("latest")["timestamp"])
|
87
|
+
if s["startTime"] > now:
|
88
|
+
return False, "stream not started"
|
89
|
+
if s["halted"]:
|
90
|
+
return False, "stream halted"
|
91
|
+
return True, "ok"
|
92
|
+
|
93
|
+
# Reader should remain read-only; no terminate here
|
@@ -0,0 +1,67 @@
|
|
1
|
+
import asyncio
|
2
|
+
from typing import Optional
|
3
|
+
|
4
|
+
from ..utils.logging import setup_logger
|
5
|
+
|
6
|
+
logger = setup_logger(__name__)
|
7
|
+
|
8
|
+
|
9
|
+
class StreamMonitor:
|
10
|
+
def __init__(self, *, stream_map, vm_service, reader, client, settings):
|
11
|
+
self.stream_map = stream_map
|
12
|
+
self.vm_service = vm_service
|
13
|
+
self.reader = reader
|
14
|
+
self.client = client
|
15
|
+
self.settings = settings
|
16
|
+
self._task: Optional[asyncio.Task] = None
|
17
|
+
|
18
|
+
def start(self):
|
19
|
+
if self.settings.STREAM_MONITOR_ENABLED or self.settings.STREAM_WITHDRAW_ENABLED:
|
20
|
+
self._task = asyncio.create_task(self._run(), name="stream-monitor")
|
21
|
+
|
22
|
+
async def stop(self):
|
23
|
+
if self._task:
|
24
|
+
self._task.cancel()
|
25
|
+
try:
|
26
|
+
await self._task
|
27
|
+
except asyncio.CancelledError:
|
28
|
+
pass
|
29
|
+
|
30
|
+
async def _run(self):
|
31
|
+
last_withdraw = 0
|
32
|
+
while True:
|
33
|
+
try:
|
34
|
+
await asyncio.sleep(self.settings.STREAM_MONITOR_INTERVAL_SECONDS)
|
35
|
+
items = await self.stream_map.all_items()
|
36
|
+
now = int(self.reader.web3.eth.get_block("latest")["timestamp"]) if items else 0
|
37
|
+
for vm_id, stream_id in items.items():
|
38
|
+
try:
|
39
|
+
s = self.reader.get_stream(stream_id)
|
40
|
+
except Exception as e:
|
41
|
+
logger.warning(f"stream {stream_id} lookup failed: {e}")
|
42
|
+
continue
|
43
|
+
# Stop VM if remaining runway < threshold
|
44
|
+
remaining = max(s["stopTime"] - now, 0)
|
45
|
+
if self.settings.STREAM_MONITOR_ENABLED and remaining < self.settings.STREAM_MIN_REMAINING_SECONDS:
|
46
|
+
logger.info(f"Stopping VM {vm_id} due to low stream runway ({remaining}s)")
|
47
|
+
try:
|
48
|
+
await self.vm_service.stop_vm(vm_id)
|
49
|
+
except Exception as e:
|
50
|
+
logger.warning(f"stop_vm failed for {vm_id}: {e}")
|
51
|
+
# Withdraw if enough vested and configured
|
52
|
+
if self.settings.STREAM_WITHDRAW_ENABLED and self.client:
|
53
|
+
vested = max(min(now, s["stopTime"]) - s["startTime"], 0) * s["ratePerSecond"]
|
54
|
+
withdrawable = max(vested - s["withdrawn"], 0)
|
55
|
+
# Enforce a minimum interval between withdrawals
|
56
|
+
if withdrawable >= self.settings.STREAM_MIN_WITHDRAW_WEI and (
|
57
|
+
now - last_withdraw >= self.settings.STREAM_WITHDRAW_INTERVAL_SECONDS
|
58
|
+
):
|
59
|
+
try:
|
60
|
+
self.client.withdraw(stream_id)
|
61
|
+
last_withdraw = now
|
62
|
+
except Exception as e:
|
63
|
+
logger.warning(f"withdraw failed for {stream_id}: {e}")
|
64
|
+
except asyncio.CancelledError:
|
65
|
+
break
|
66
|
+
except Exception as e:
|
67
|
+
logger.error(f"stream monitor error: {e}")
|
@@ -0,0 +1,40 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Dict, Optional
|
5
|
+
|
6
|
+
|
7
|
+
class StreamMap:
|
8
|
+
def __init__(self, storage_path: Path):
|
9
|
+
self._path = storage_path
|
10
|
+
self._lock = asyncio.Lock()
|
11
|
+
self._data: Dict[str, int] = {}
|
12
|
+
if self._path.exists():
|
13
|
+
try:
|
14
|
+
self._data = json.loads(self._path.read_text())
|
15
|
+
except Exception:
|
16
|
+
self._data = {}
|
17
|
+
|
18
|
+
async def set(self, vm_id: str, stream_id: int) -> None:
|
19
|
+
async with self._lock:
|
20
|
+
self._data[vm_id] = int(stream_id)
|
21
|
+
self._persist()
|
22
|
+
|
23
|
+
async def get(self, vm_id: str) -> Optional[int]:
|
24
|
+
return self._data.get(vm_id)
|
25
|
+
|
26
|
+
async def remove(self, vm_id: str) -> None:
|
27
|
+
async with self._lock:
|
28
|
+
if vm_id in self._data:
|
29
|
+
del self._data[vm_id]
|
30
|
+
self._persist()
|
31
|
+
|
32
|
+
async def all_items(self) -> Dict[str, int]:
|
33
|
+
return dict(self._data)
|
34
|
+
|
35
|
+
def _persist(self) -> None:
|
36
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
37
|
+
tmp = self._path.with_suffix(".tmp")
|
38
|
+
tmp.write_text(json.dumps(self._data, indent=2))
|
39
|
+
tmp.replace(self._path)
|
40
|
+
|
@@ -1,10 +1,9 @@
|
|
1
1
|
import asyncio
|
2
|
-
import
|
3
|
-
import httpx
|
4
|
-
from typing import Optional
|
2
|
+
from typing import Optional, List, Tuple
|
5
3
|
|
6
4
|
from golem_base_sdk import GolemBaseClient
|
7
5
|
from provider.utils.logging import setup_logger
|
6
|
+
from golem_faucet import PowFaucetClient
|
8
7
|
|
9
8
|
logger = setup_logger(__name__)
|
10
9
|
|
@@ -13,11 +12,12 @@ class FaucetClient:
|
|
13
12
|
"""A client for interacting with a Proof of Work-protected faucet."""
|
14
13
|
|
15
14
|
def __init__(self, faucet_url: str, captcha_url: str, captcha_api_key: str):
|
16
|
-
self.faucet_url = faucet_url
|
17
|
-
self.captcha_url = captcha_url
|
15
|
+
self.faucet_url = faucet_url.rstrip("/")
|
16
|
+
self.captcha_url = captcha_url.rstrip("/")
|
18
17
|
self.captcha_api_key = captcha_api_key
|
19
|
-
self.api_endpoint = f"{faucet_url}/api"
|
18
|
+
self.api_endpoint = f"{self.faucet_url}/api"
|
20
19
|
self.client: Optional[GolemBaseClient] = None
|
20
|
+
self._pow = PowFaucetClient(self.faucet_url, self.captcha_url, self.captcha_api_key)
|
21
21
|
|
22
22
|
async def _ensure_client(self):
|
23
23
|
if not self.client:
|
@@ -82,51 +82,27 @@ class FaucetClient:
|
|
82
82
|
async def _get_challenge(self) -> Optional[dict]:
|
83
83
|
"""Get a PoW challenge from the faucet."""
|
84
84
|
try:
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
response.raise_for_status()
|
89
|
-
return response.json()
|
90
|
-
except httpx.HTTPStatusError as e:
|
91
|
-
logger.error(f"Failed to get PoW challenge: {e.response.text}")
|
85
|
+
return await self._pow.get_challenge()
|
86
|
+
except Exception as e:
|
87
|
+
logger.error(f"Failed to get PoW challenge: {e}")
|
92
88
|
return None
|
93
89
|
|
94
90
|
def _solve_challenge(self, salt: str, target: str) -> int:
|
95
91
|
"""Solve the PoW challenge."""
|
96
|
-
|
97
|
-
nonce = 0
|
98
|
-
while True:
|
99
|
-
hasher = hashlib.sha256()
|
100
|
-
hasher.update(f"{salt}{nonce}".encode())
|
101
|
-
if hasher.digest().startswith(target_hash):
|
102
|
-
return nonce
|
103
|
-
nonce += 1
|
92
|
+
return PowFaucetClient.solve_challenge(salt, target)
|
104
93
|
|
105
94
|
async def _redeem_solution(self, token: str, solutions: list) -> Optional[str]:
|
106
95
|
"""Redeem the PoW solution to get a CAPTCHA token."""
|
107
96
|
try:
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
url,
|
112
|
-
json={"token": token, "solutions": solutions}
|
113
|
-
)
|
114
|
-
response.raise_for_status()
|
115
|
-
return response.json().get("token")
|
116
|
-
except httpx.HTTPStatusError as e:
|
117
|
-
logger.error(f"Failed to redeem PoW solution: {e.response.text}")
|
97
|
+
return await self._pow.redeem(token, solutions)
|
98
|
+
except Exception as e:
|
99
|
+
logger.error(f"Failed to redeem PoW solution: {e}")
|
118
100
|
return None
|
119
101
|
|
120
102
|
async def _request_faucet(self, address: str, token: str) -> Optional[str]:
|
121
103
|
"""Request funds from the faucet with the CAPTCHA token."""
|
122
104
|
try:
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
)
|
128
|
-
response.raise_for_status()
|
129
|
-
return response.json().get("txHash")
|
130
|
-
except httpx.HTTPStatusError as e:
|
131
|
-
logger.error(f"Faucet request failed: {e.response.text}")
|
132
|
-
return None
|
105
|
+
return await self._pow.request_funds(address, token)
|
106
|
+
except Exception as e:
|
107
|
+
logger.error(f"Faucet request failed: {e}")
|
108
|
+
return None
|
@@ -17,6 +17,7 @@ class ProviderService:
|
|
17
17
|
self.advertisement_service = advertisement_service
|
18
18
|
self.port_manager = port_manager
|
19
19
|
self._pricing_updater: PricingAutoUpdater | None = None
|
20
|
+
self._stream_monitor = None
|
20
21
|
|
21
22
|
async def setup(self, app: FastAPI):
|
22
23
|
"""Setup and initialize the provider components."""
|
@@ -43,6 +44,13 @@ class ProviderService:
|
|
43
44
|
self._pricing_updater = PricingAutoUpdater(on_updated_callback=_on_price_updated)
|
44
45
|
asyncio.create_task(self._pricing_updater.start())
|
45
46
|
|
47
|
+
# Start stream monitor if enabled
|
48
|
+
from .container import Container
|
49
|
+
from .config import settings as cfg
|
50
|
+
if cfg.STREAM_MONITOR_ENABLED or cfg.STREAM_WITHDRAW_ENABLED:
|
51
|
+
self._stream_monitor = app.container.stream_monitor()
|
52
|
+
self._stream_monitor.start()
|
53
|
+
|
46
54
|
# Check wallet balance and request funds if needed
|
47
55
|
faucet_client = FaucetClient(
|
48
56
|
faucet_url=settings.FAUCET_URL,
|
@@ -64,6 +72,8 @@ class ProviderService:
|
|
64
72
|
await self.vm_service.provider.cleanup()
|
65
73
|
if self._pricing_updater:
|
66
74
|
self._pricing_updater.stop()
|
75
|
+
if self._stream_monitor:
|
76
|
+
await self._stream_monitor.stop()
|
67
77
|
logger.success("✨ Provider cleanup complete")
|
68
78
|
|
69
79
|
def _setup_directories(self):
|
@@ -19,10 +19,12 @@ class VMService:
|
|
19
19
|
provider: VMProvider,
|
20
20
|
resource_tracker: ResourceTracker,
|
21
21
|
name_mapper: VMNameMapper,
|
22
|
+
blockchain_client: object | None = None,
|
22
23
|
):
|
23
24
|
self.provider = provider
|
24
25
|
self.resource_tracker = resource_tracker
|
25
26
|
self.name_mapper = name_mapper
|
27
|
+
self.blockchain_client = blockchain_client
|
26
28
|
|
27
29
|
async def create_vm(self, config: VMConfig) -> VMInfo:
|
28
30
|
"""Create a new VM."""
|
@@ -59,6 +61,13 @@ class VMService:
|
|
59
61
|
vm_info = await self.provider.get_vm_status(multipass_name)
|
60
62
|
await self.provider.delete_vm(multipass_name)
|
61
63
|
await self.resource_tracker.deallocate(vm_info.resources, vm_id)
|
64
|
+
# Optional: best-effort on-chain termination if we have a mapping
|
65
|
+
try:
|
66
|
+
if self.blockchain_client:
|
67
|
+
# In future: look up stream id associated to this vm_id
|
68
|
+
pass
|
69
|
+
except Exception:
|
70
|
+
pass
|
62
71
|
except VMNotFoundError:
|
63
72
|
logger.warning(f"VM {multipass_name} not found on provider, cleaning up resources")
|
64
73
|
# If the VM is not found, we still need to deallocate the resources we have tracked for it
|
@@ -74,7 +83,15 @@ class VMService:
|
|
74
83
|
multipass_name = await self.name_mapper.get_multipass_name(vm_id)
|
75
84
|
if not multipass_name:
|
76
85
|
raise VMNotFoundError(f"VM {vm_id} not found")
|
77
|
-
|
86
|
+
vm = await self.provider.stop_vm(multipass_name)
|
87
|
+
# Optional: best-effort withdraw for active stream
|
88
|
+
try:
|
89
|
+
if self.blockchain_client:
|
90
|
+
# In future: look up stream id associated to this vm_id
|
91
|
+
pass
|
92
|
+
except Exception:
|
93
|
+
pass
|
94
|
+
return vm
|
78
95
|
|
79
96
|
async def list_vms(self) -> List[VMInfo]:
|
80
97
|
"""List all VMs."""
|
@@ -95,4 +112,4 @@ class VMService:
|
|
95
112
|
await self.provider.initialize()
|
96
113
|
|
97
114
|
async def shutdown(self):
|
98
|
-
await self.provider.cleanup()
|
115
|
+
await self.provider.cleanup()
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "golem-vm-provider"
|
3
|
-
version = "0.1.
|
3
|
+
version = "0.1.42"
|
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"
|
@@ -21,7 +21,6 @@ packages = [
|
|
21
21
|
|
22
22
|
[tool.poetry.scripts]
|
23
23
|
golem-provider = "provider.main:cli"
|
24
|
-
dev = "provider.main:dev"
|
25
24
|
|
26
25
|
[tool.poetry.dependencies]
|
27
26
|
python = "^3.11"
|
@@ -46,10 +45,12 @@ typer = "^0.4.0"
|
|
46
45
|
web3 = "==7.13.0"
|
47
46
|
golem-base-sdk = "==0.1.0"
|
48
47
|
dependency-injector = "^4.41.0"
|
48
|
+
golem-streaming-abi = ">=0.1.0,<0.2.0"
|
49
|
+
golem-faucet = ">=0.1.0,<0.2.0"
|
49
50
|
|
50
51
|
[tool.poetry.group.dev.dependencies]
|
51
|
-
pytest = "^
|
52
|
-
pytest-asyncio = "^
|
52
|
+
pytest = "^8.2.0"
|
53
|
+
pytest-asyncio = "^1.1.0"
|
53
54
|
pytest-mock = "^3.8.2"
|
54
55
|
pytest-cov = "^3.0.0"
|
55
56
|
black = "^22.3.0"
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/golem_base_utils.py
RENAMED
File without changes
|
{golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/multi_advertiser.py
RENAMED
File without changes
|
{golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/resource_monitor.py
RENAMED
File without changes
|
{golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/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
|
File without changes
|
File without changes
|