request-vm-on-golem 0.1.40__py3-none-any.whl → 0.1.44__py3-none-any.whl
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.
- {request_vm_on_golem-0.1.40.dist-info → request_vm_on_golem-0.1.44.dist-info}/METADATA +98 -1
- {request_vm_on_golem-0.1.40.dist-info → request_vm_on_golem-0.1.44.dist-info}/RECORD +11 -9
- requestor/cli/commands.py +133 -5
- requestor/config.py +51 -0
- requestor/payments/blockchain_service.py +161 -0
- requestor/provider/client.py +21 -10
- requestor/security/faucet.py +54 -0
- requestor/services/provider_service.py +3 -0
- requestor/services/vm_service.py +29 -4
- {request_vm_on_golem-0.1.40.dist-info → request_vm_on_golem-0.1.44.dist-info}/WHEEL +0 -0
- {request_vm_on_golem-0.1.40.dist-info → request_vm_on_golem-0.1.44.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: request-vm-on-golem
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.44
|
4
4
|
Summary: VM on Golem Requestor CLI - Create and manage virtual machines on the Golem Network
|
5
5
|
Keywords: golem,vm,cloud,decentralized,cli
|
6
6
|
Author: Phillip Jensen
|
@@ -25,6 +25,8 @@ Requires-Dist: colorlog (>=6.8.0,<7.0.0)
|
|
25
25
|
Requires-Dist: cryptography (>=3.4.7,<4.0.0)
|
26
26
|
Requires-Dist: fastapi (>=0.103.0,<0.104.0)
|
27
27
|
Requires-Dist: golem-base-sdk (==0.1.0)
|
28
|
+
Requires-Dist: golem-faucet (>=0.1.0,<0.2.0)
|
29
|
+
Requires-Dist: golem-streaming-abi (>=0.1.0,<0.2.0)
|
28
30
|
Requires-Dist: httptools (>=0.6.0,<0.7.0)
|
29
31
|
Requires-Dist: pydantic (>=2.4.0,<3.0.0)
|
30
32
|
Requires-Dist: pydantic-settings (>=2.1.0,<3.0.0)
|
@@ -119,6 +121,79 @@ The SSH connection process:
|
|
119
121
|
2. The provider's proxy system forwards your SSH connection to the VM
|
120
122
|
3. All traffic is securely routed through the allocated port
|
121
123
|
|
124
|
+
## Streaming Payments (Native ETH on L2)
|
125
|
+
|
126
|
+
This requestor integrates with an on‑chain StreamPayment contract to enable “pay‑as‑you‑go” rentals using native ETH (no ERC20 approvals when the token address is zero).
|
127
|
+
|
128
|
+
Flow:
|
129
|
+
|
130
|
+
1. Fetch provider info (preferred addresses):
|
131
|
+
- `GET http://{provider}:7466/api/v1/provider/info` → `provider_id`, `stream_payment_address`, `glm_token_address` (zero address means native ETH).
|
132
|
+
2. Compute `ratePerSecond` from provider pricing and requested VM resources.
|
133
|
+
3. Ensure `deposit >= ratePerSecond * 3600` (≥ 1 hour runway recommended/minimum).
|
134
|
+
4. Create a stream (`createStream(0x000...0, provider_id, deposit, ratePerSecond)` plus `value=deposit`), capture `stream_id`. For ERC20 mode use a token address and approve first.
|
135
|
+
5. Create VM: `POST /api/v1/vms` with `stream_id` included.
|
136
|
+
6. Top‑up over time with `topUp(stream_id, amount)` to extend stopTime and keep the VM running indefinitely.
|
137
|
+
7. On stop/destroy: the requestor will best‑effort `withdraw` / `terminate` to settle.
|
138
|
+
|
139
|
+
CLI helpers
|
140
|
+
|
141
|
+
- Open a stream for a planned VM (computes rate from provider pricing):
|
142
|
+
|
143
|
+
```bash
|
144
|
+
poetry run golem vm stream open \
|
145
|
+
--provider-id 0xProvider \
|
146
|
+
--cpu 2 --memory 4 --storage 20 \
|
147
|
+
--hours 1
|
148
|
+
# prints { stream_id, rate_per_second_wei, deposit_wei }
|
149
|
+
```
|
150
|
+
|
151
|
+
- Top up an existing stream:
|
152
|
+
|
153
|
+
```bash
|
154
|
+
# Add 3 hours at prior rate
|
155
|
+
poetry run golem vm stream topup --stream-id 123 --hours 3
|
156
|
+
|
157
|
+
# Or specify exact GLM amount
|
158
|
+
poetry run golem vm stream topup --stream-id 123 --glm 25.0
|
159
|
+
```
|
160
|
+
|
161
|
+
- Create a VM and attach an existing stream (no auto-streams are created by the requestor):
|
162
|
+
|
163
|
+
```bash
|
164
|
+
poetry run golem vm create my-vm \
|
165
|
+
--provider-id 0xProvider \
|
166
|
+
--cpu 2 --memory 4 --storage 20 \
|
167
|
+
--stream-id 123
|
168
|
+
```
|
169
|
+
|
170
|
+
Environment (env prefix `GOLEM_REQUESTOR_`):
|
171
|
+
|
172
|
+
- `polygon_rpc_url` — EVM RPC URL (default L2 RPC)
|
173
|
+
- `stream_payment_address` — StreamPayment address (fallback if provider doesn’t advertise)
|
174
|
+
- `glm_token_address` — Token address; set to zero address to use native ETH
|
175
|
+
- `provider_eth_address` — optional dev helper; in production always use `/provider/info`
|
176
|
+
- `network` — Target network for discovery filtering: `testnet` (default) or `mainnet`
|
177
|
+
|
178
|
+
Efficiency tips:
|
179
|
+
|
180
|
+
- Batch top‑ups (e.g., add several hours at once) to reduce on‑chain calls.
|
181
|
+
- Withdrawals are typically executed by providers; requestors don’t need to withdraw.
|
182
|
+
- The CLI `vm stream open` will prefer the provider’s advertised contract/token addresses to prevent mismatches.
|
183
|
+
|
184
|
+
## Faucet (L2 ETH)
|
185
|
+
|
186
|
+
- Request L2 test ETH to cover stream transactions:
|
187
|
+
|
188
|
+
```bash
|
189
|
+
poetry run golem wallet faucet
|
190
|
+
```
|
191
|
+
|
192
|
+
- Defaults:
|
193
|
+
- Faucet: `https://l2.holesky.golemdb.io/faucet`
|
194
|
+
- CAPTCHA: `https://cap.gobas.me/05381a2cef5e`
|
195
|
+
- Override with env: `GOLEM_REQUESTOR_l2_faucet_url`, `GOLEM_REQUESTOR_captcha_url`, `GOLEM_REQUESTOR_captcha_api_key`.
|
196
|
+
|
122
197
|
## Installation
|
123
198
|
|
124
199
|
```bash
|
@@ -159,6 +234,27 @@ Alternatively, you can prepend the environment variables directly to the command
|
|
159
234
|
GOLEM_REQUESTOR_ENVIRONMENT="development" GOLEM_REQUESTOR_FORCE_LOCALHOST="true" poetry run golem vm providers
|
160
235
|
```
|
161
236
|
|
237
|
+
### Mode vs. Network
|
238
|
+
|
239
|
+
- Development Mode (`GOLEM_REQUESTOR_ENVIRONMENT=development`)
|
240
|
+
- Improves local workflows: prefixes central discovery URL with `DEVMODE-` and, when using the central driver, maps provider IPs to `localhost` for easier testing.
|
241
|
+
- Does not determine chain selection.
|
242
|
+
|
243
|
+
- Network Selection (`--network` or `GOLEM_REQUESTOR_NETWORK`)
|
244
|
+
- Filters Golem Base discovery results by `golem_network=testnet|mainnet`.
|
245
|
+
- Combine with the appropriate RPC envs (`GOLEM_REQUESTOR_GOLEM_BASE_RPC_URL`, `GOLEM_REQUESTOR_GOLEM_BASE_WS_URL`) and any contract addresses.
|
246
|
+
- Independent from dev ergonomics.
|
247
|
+
|
248
|
+
Examples:
|
249
|
+
- List providers on mainnet without changing env:
|
250
|
+
```bash
|
251
|
+
poetry run golem vm providers --network mainnet
|
252
|
+
```
|
253
|
+
- Create a VM while targeting testnet:
|
254
|
+
```bash
|
255
|
+
poetry run golem vm create my-vm --provider-id 0xProvider --cpu 2 --memory 4 --storage 20 --network testnet
|
256
|
+
```
|
257
|
+
|
162
258
|
## Usage
|
163
259
|
|
164
260
|
### Provider Discovery
|
@@ -276,6 +372,7 @@ export GOLEM_REQUESTOR_DB_PATH="/path/to/database.db"
|
|
276
372
|
# Environment Mode (defaults to "production")
|
277
373
|
export GOLEM_REQUESTOR_ENVIRONMENT="development" # Optional: Switch to development mode
|
278
374
|
export GOLEM_REQUESTOR_FORCE_LOCALHOST="true" # Optional: Force localhost in development mode
|
375
|
+
export GOLEM_REQUESTOR_NETWORK="testnet" # Or "mainnet"; filters Golem Base results by annotation
|
279
376
|
```
|
280
377
|
|
281
378
|
2. Directory Structure:
|
@@ -1,24 +1,26 @@
|
|
1
1
|
requestor/__init__.py,sha256=OqSUAh1uZBMx7GW0MoSMg967PVdmT8XdPJx3QYjwkak,116
|
2
2
|
requestor/api/main.py,sha256=7utCzFNbh5Ol-vsBWeSwT4lXeHD7zdA-GFZuS3rHMWc,2180
|
3
3
|
requestor/cli/__init__.py,sha256=e3E4oEGxmGj-STPtFkQwg_qIWhR0JAiAQdw3G1hXciU,37
|
4
|
-
requestor/cli/commands.py,sha256=
|
5
|
-
requestor/config.py,sha256=
|
4
|
+
requestor/cli/commands.py,sha256=YiJvCWDp__c8OZ1YdrVc7gAYRUab5ygNGPCGxDmn3jE,35288
|
5
|
+
requestor/config.py,sha256=v6557eetioJmuOPWKV4iAl7DeqGjgQ0gegqc0zEzJZU,6396
|
6
6
|
requestor/db/__init__.py,sha256=Gm5DfWls6uvCZZ3HGGnyRHswbUQdeA5OGN8yPwH0hc8,88
|
7
7
|
requestor/db/sqlite.py,sha256=l5pWbx2qlHuar1N_a0B9tVnmumLJY1w5rp3yZ7jmsC0,4146
|
8
8
|
requestor/errors.py,sha256=wVpHBuYgQx5pTe_SamugfK-k768noikY1RxvPOjQGko,665
|
9
|
+
requestor/payments/blockchain_service.py,sha256=EejW51A6Xqc3PKnKnzjRQ6pIVkH1FqacG4lwQAQ0HiM,6888
|
9
10
|
requestor/provider/__init__.py,sha256=fmW23aYUVciF8-gmBZkG-PLhn22upmcDzdPfAOLHG6g,103
|
10
|
-
requestor/provider/client.py,sha256=
|
11
|
+
requestor/provider/client.py,sha256=WXCm-1bytcgsuHEZzpg7RjjDOTuaXC9cj0Mrm7e6DSw,3676
|
11
12
|
requestor/run.py,sha256=GqOG6n34szt8Sp3SEqjRV6huWm737yCN6YnBqoiwI-U,1785
|
13
|
+
requestor/security/faucet.py,sha256=35d8mD3fM0YqRIhUXuIKandOL8vbw2T2IFQWVsan9Lw,2056
|
12
14
|
requestor/services/__init__.py,sha256=1qSn_6RMn0KB0A7LCnY2IW6_tC3HBQsdfkFeV-h94eM,172
|
13
15
|
requestor/services/database_service.py,sha256=GlSrzzzd7PSYQJNup00sxkB-B2PMr1__04K8k5QSWvs,2996
|
14
|
-
requestor/services/provider_service.py,sha256=
|
16
|
+
requestor/services/provider_service.py,sha256=SH76qxnbIm4EgCwQ0SHiwCidN19aTQMCxGUj1e0yGRQ,14712
|
15
17
|
requestor/services/ssh_service.py,sha256=tcOCtk2SlB9Uuv-P2ghR22e7BJ9kigQh5b4zSGdPFns,4280
|
16
|
-
requestor/services/vm_service.py,sha256=
|
18
|
+
requestor/services/vm_service.py,sha256=eQ2pPMpYlfPVbVFrkFElsRO5swPq-2XZEfuvxagyHDk,7941
|
17
19
|
requestor/ssh/__init__.py,sha256=hNgSqJ5s1_AwwxVRyFjUqh_LTBpI4Hmzq0F-f_wXN9g,119
|
18
20
|
requestor/ssh/manager.py,sha256=3jQtbbK7CVC2yD1zCO88jGXh2fBcuv3CzWEqDLuaQVk,9758
|
19
21
|
requestor/utils/logging.py,sha256=oFNpO8pJboYM8Wp7g3HOU4HFyBTKypVdY15lUiz1a4I,3721
|
20
22
|
requestor/utils/spinner.py,sha256=PUHJdTD9jpUHur__01_qxXy87WFfNmjQbD_sLG-KlGo,2459
|
21
|
-
request_vm_on_golem-0.1.
|
22
|
-
request_vm_on_golem-0.1.
|
23
|
-
request_vm_on_golem-0.1.
|
24
|
-
request_vm_on_golem-0.1.
|
23
|
+
request_vm_on_golem-0.1.44.dist-info/METADATA,sha256=oNqjV3CuQMnhZA1SRGPBf9M8J_-J8WsVBaSOdJnrxKE,13912
|
24
|
+
request_vm_on_golem-0.1.44.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
25
|
+
request_vm_on_golem-0.1.44.dist-info/entry_points.txt,sha256=Z-skRNpJ8aZcIl_En9mEm1ygkp9FKy0bzQoL3zO52-0,44
|
26
|
+
request_vm_on_golem-0.1.44.dist-info/RECORD,,
|
requestor/cli/commands.py
CHANGED
@@ -75,9 +75,17 @@ def print_version(ctx, param, value):
|
|
75
75
|
@click.group()
|
76
76
|
@click.option('--version', is_flag=True, callback=print_version,
|
77
77
|
expose_value=False, is_eager=True, help="Show the version and exit.")
|
78
|
-
|
78
|
+
@click.option('--network', type=click.Choice(['testnet', 'mainnet']), default=None,
|
79
|
+
help="Override network for discovery filtering ('testnet' or 'mainnet')")
|
80
|
+
def cli(network: str | None):
|
79
81
|
"""VM on Golem management CLI"""
|
80
82
|
ensure_config()
|
83
|
+
# Allow on-demand override without touching env
|
84
|
+
if network:
|
85
|
+
try:
|
86
|
+
config.network = network
|
87
|
+
except Exception:
|
88
|
+
pass
|
81
89
|
pass
|
82
90
|
|
83
91
|
|
@@ -94,10 +102,14 @@ def vm():
|
|
94
102
|
@click.option('--country', help='Preferred provider country')
|
95
103
|
@click.option('--driver', type=click.Choice(['central', 'golem-base']), default=None, help='Discovery driver to use')
|
96
104
|
@click.option('--json', 'as_json', is_flag=True, help='Output in JSON format')
|
105
|
+
@click.option('--network', type=click.Choice(['testnet', 'mainnet']), default=None,
|
106
|
+
help='Override network filter for this command')
|
97
107
|
@async_command
|
98
|
-
async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Optional[int], country: Optional[str], driver: Optional[str], as_json: bool):
|
108
|
+
async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Optional[int], country: Optional[str], driver: Optional[str], as_json: bool, network: Optional[str] = None):
|
99
109
|
"""List available providers matching requirements."""
|
100
110
|
try:
|
111
|
+
if network:
|
112
|
+
config.network = network
|
101
113
|
# Log search criteria if any
|
102
114
|
if any([cpu, memory, storage, country]):
|
103
115
|
logger.command("🔍 Searching for providers with criteria:")
|
@@ -112,7 +124,7 @@ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Opt
|
|
112
124
|
|
113
125
|
# Determine the discovery driver being used
|
114
126
|
discovery_driver = driver or config.discovery_driver
|
115
|
-
logger.process(f"Querying discovery service via {discovery_driver}")
|
127
|
+
logger.process(f"Querying discovery service via {discovery_driver} (network={config.network})")
|
116
128
|
|
117
129
|
# Initialize provider service
|
118
130
|
provider_service = ProviderService()
|
@@ -173,11 +185,15 @@ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Opt
|
|
173
185
|
@click.option('--cpu', type=int, required=True, help='Number of CPU cores')
|
174
186
|
@click.option('--memory', type=int, required=True, help='Memory in GB')
|
175
187
|
@click.option('--storage', type=int, required=True, help='Disk in GB')
|
188
|
+
@click.option('--stream-id', type=int, default=None, help='Optional StreamPayment stream id to fund this VM')
|
176
189
|
@click.option('--yes', is_flag=True, help='Do not prompt for confirmation')
|
190
|
+
@click.option('--network', type=click.Choice(['testnet', 'mainnet']), default=None, help='Override network for discovery during creation')
|
177
191
|
@async_command
|
178
|
-
async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage: int, yes: bool):
|
192
|
+
async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage: int, stream_id: int | None, yes: bool, network: Optional[str] = None):
|
179
193
|
"""Create a new VM on a specific provider."""
|
180
194
|
try:
|
195
|
+
if network:
|
196
|
+
config.network = network
|
181
197
|
# Show configuration details
|
182
198
|
click.echo("\n" + "─" * 60)
|
183
199
|
click.echo(click.style(" VM Configuration", fg="blue", bold=True))
|
@@ -232,7 +248,8 @@ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage:
|
|
232
248
|
memory=memory,
|
233
249
|
storage=storage,
|
234
250
|
provider_ip=provider_ip,
|
235
|
-
ssh_key=key_pair.public_key_content
|
251
|
+
ssh_key=key_pair.public_key_content,
|
252
|
+
stream_id=stream_id
|
236
253
|
)
|
237
254
|
|
238
255
|
# Get access info from config
|
@@ -281,6 +298,117 @@ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage:
|
|
281
298
|
raise click.Abort()
|
282
299
|
|
283
300
|
|
301
|
+
@vm.group(name='stream')
|
302
|
+
def vm_stream():
|
303
|
+
"""Streaming payments helpers"""
|
304
|
+
pass
|
305
|
+
|
306
|
+
|
307
|
+
@vm_stream.command('open')
|
308
|
+
@click.option('--provider-id', required=True, help='Provider ID to use')
|
309
|
+
@click.option('--cpu', type=int, required=True, help='CPU cores for rate calc')
|
310
|
+
@click.option('--memory', type=int, required=True, help='Memory (GB) for rate calc')
|
311
|
+
@click.option('--storage', type=int, required=True, help='Storage (GB) for rate calc')
|
312
|
+
@click.option('--hours', type=int, default=1, help='Deposit coverage in hours (default 1)')
|
313
|
+
@click.option('--network', type=click.Choice(['testnet', 'mainnet']), default=None, help='Override network for discovery during stream open')
|
314
|
+
@async_command
|
315
|
+
async def stream_open(provider_id: str, cpu: int, memory: int, storage: int, hours: int, network: Optional[str] = None):
|
316
|
+
"""Create a GLM stream for a planned VM rental."""
|
317
|
+
from ..payments.blockchain_service import StreamPaymentClient, StreamPaymentConfig
|
318
|
+
try:
|
319
|
+
if network:
|
320
|
+
config.network = network
|
321
|
+
provider_service = ProviderService()
|
322
|
+
async with provider_service:
|
323
|
+
provider = await provider_service.verify_provider(provider_id)
|
324
|
+
est = provider_service.compute_estimate(provider, (cpu, memory, storage))
|
325
|
+
if not est or est.get('glm_per_month') is None:
|
326
|
+
raise RequestorError('Provider does not advertise GLM pricing; cannot compute ratePerSecond')
|
327
|
+
glm_month = est['glm_per_month']
|
328
|
+
glm_per_second = float(glm_month) / (730.0 * 3600.0)
|
329
|
+
rate_per_second_wei = int(glm_per_second * (10**18))
|
330
|
+
|
331
|
+
provider_ip = 'localhost' if config.environment == "development" else provider.get('ip_address')
|
332
|
+
if not provider_ip and config.environment == "production":
|
333
|
+
raise RequestorError("Provider IP address not found in advertisement")
|
334
|
+
provider_url = config.get_provider_url(provider_ip)
|
335
|
+
async with ProviderClient(provider_url) as client:
|
336
|
+
info = await client.get_provider_info()
|
337
|
+
recipient = info['provider_id']
|
338
|
+
|
339
|
+
deposit_wei = rate_per_second_wei * int(hours) * 3600
|
340
|
+
# Prefer provider-advertised contract addresses to avoid mismatches
|
341
|
+
spc = StreamPaymentConfig(
|
342
|
+
rpc_url=config.polygon_rpc_url,
|
343
|
+
contract_address=info.get('stream_payment_address') or config.stream_payment_address,
|
344
|
+
glm_token_address=info.get('glm_token_address') or config.glm_token_address,
|
345
|
+
private_key=config.ethereum_private_key,
|
346
|
+
)
|
347
|
+
sp = StreamPaymentClient(spc)
|
348
|
+
stream_id = sp.create_stream(recipient, deposit_wei, rate_per_second_wei)
|
349
|
+
click.echo(json.dumps({"stream_id": stream_id, "rate_per_second_wei": rate_per_second_wei, "deposit_wei": deposit_wei}, indent=2))
|
350
|
+
except Exception as e:
|
351
|
+
logger.error(f"Failed to open stream: {e}")
|
352
|
+
raise click.Abort()
|
353
|
+
|
354
|
+
|
355
|
+
@vm_stream.command('topup')
|
356
|
+
@click.option('--stream-id', type=int, required=True)
|
357
|
+
@click.option('--glm', type=float, required=False, help='GLM amount to add')
|
358
|
+
@click.option('--hours', type=int, required=False, help='Hours of coverage to add at prior rate')
|
359
|
+
@async_command
|
360
|
+
async def stream_topup(stream_id: int, glm: float | None, hours: int | None):
|
361
|
+
"""Top up a stream. Provide either --glm or --hours (using prior rate)."""
|
362
|
+
from ..payments.blockchain_service import StreamPaymentClient, StreamPaymentConfig
|
363
|
+
try:
|
364
|
+
spc = StreamPaymentConfig(
|
365
|
+
rpc_url=config.polygon_rpc_url,
|
366
|
+
contract_address=config.stream_payment_address,
|
367
|
+
glm_token_address=config.glm_token_address,
|
368
|
+
private_key=config.ethereum_private_key,
|
369
|
+
)
|
370
|
+
sp = StreamPaymentClient(spc)
|
371
|
+
add_wei: int
|
372
|
+
if glm is not None:
|
373
|
+
add_wei = int(float(glm) * (10**18))
|
374
|
+
elif hours is not None:
|
375
|
+
# naive: use last known rate by reading on-chain stream
|
376
|
+
rate = sp.contract.functions.streams(int(stream_id)).call()[5] # ratePerSecond
|
377
|
+
add_wei = int(rate) * int(hours) * 3600
|
378
|
+
else:
|
379
|
+
raise RequestorError('Provide either --glm or --hours')
|
380
|
+
tx = sp.top_up(stream_id, add_wei)
|
381
|
+
click.echo(json.dumps({"stream_id": stream_id, "topped_up_wei": add_wei, "tx": tx}, indent=2))
|
382
|
+
except Exception as e:
|
383
|
+
logger.error(f"Failed to top up stream: {e}")
|
384
|
+
raise click.Abort()
|
385
|
+
|
386
|
+
|
387
|
+
@cli.group()
|
388
|
+
def wallet():
|
389
|
+
"""Wallet utilities (funding, balance)."""
|
390
|
+
pass
|
391
|
+
|
392
|
+
|
393
|
+
@wallet.command('faucet')
|
394
|
+
@async_command
|
395
|
+
async def wallet_faucet():
|
396
|
+
"""Request L2 faucet funds for the requestor's payment address."""
|
397
|
+
try:
|
398
|
+
from ..security.faucet import L2FaucetService
|
399
|
+
from eth_account import Account
|
400
|
+
acct = Account.from_key(config.ethereum_private_key)
|
401
|
+
service = L2FaucetService(config)
|
402
|
+
tx = await service.request_funds(acct.address)
|
403
|
+
if tx:
|
404
|
+
click.echo(json.dumps({"address": acct.address, "tx": tx}, indent=2))
|
405
|
+
else:
|
406
|
+
click.echo(json.dumps({"address": acct.address, "tx": None}, indent=2))
|
407
|
+
except Exception as e:
|
408
|
+
logger.error(f"Faucet request failed: {e}")
|
409
|
+
raise click.Abort()
|
410
|
+
|
411
|
+
|
284
412
|
@vm.command(name='ssh')
|
285
413
|
@click.argument('name')
|
286
414
|
@async_command
|
requestor/config.py
CHANGED
@@ -3,6 +3,7 @@ from typing import Optional, Dict
|
|
3
3
|
import os
|
4
4
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
5
5
|
from pydantic import Field, field_validator, ValidationInfo
|
6
|
+
import os
|
6
7
|
|
7
8
|
|
8
9
|
def ensure_config() -> None:
|
@@ -47,6 +48,11 @@ class RequestorConfig(BaseSettings):
|
|
47
48
|
default="production",
|
48
49
|
description="Environment mode: 'development' or 'production'"
|
49
50
|
)
|
51
|
+
# Network (for discovery filtering and defaults)
|
52
|
+
network: str = Field(
|
53
|
+
default="mainnet",
|
54
|
+
description="Target network: 'testnet' or 'mainnet'"
|
55
|
+
)
|
50
56
|
|
51
57
|
# Development Settings
|
52
58
|
force_localhost: bool = Field(
|
@@ -94,6 +100,51 @@ class RequestorConfig(BaseSettings):
|
|
94
100
|
description="Private key for Golem Base"
|
95
101
|
)
|
96
102
|
|
103
|
+
# Payments (EVM RPC)
|
104
|
+
polygon_rpc_url: str = Field(
|
105
|
+
default="https://l2.holesky.golemdb.io/rpc",
|
106
|
+
description="EVM RPC URL for streaming payments (L2 by default)"
|
107
|
+
)
|
108
|
+
stream_payment_address: str = Field(
|
109
|
+
default="0x0000000000000000000000000000000000000000",
|
110
|
+
description="Deployed StreamPayment contract address"
|
111
|
+
)
|
112
|
+
glm_token_address: str = Field(
|
113
|
+
default="0x0000000000000000000000000000000000000000",
|
114
|
+
description="Token address (0x0 means native ETH)"
|
115
|
+
)
|
116
|
+
# Faucet settings (L2 payments)
|
117
|
+
l2_faucet_url: str = Field(
|
118
|
+
default="https://l2.holesky.golemdb.io/faucet",
|
119
|
+
description="L2 faucet base URL (no trailing /api)"
|
120
|
+
)
|
121
|
+
captcha_url: str = Field(
|
122
|
+
default="https://cap.gobas.me",
|
123
|
+
description="CAPTCHA base URL"
|
124
|
+
)
|
125
|
+
captcha_api_key: str = Field(
|
126
|
+
default="05381a2cef5e",
|
127
|
+
description="CAPTCHA API key path segment"
|
128
|
+
)
|
129
|
+
provider_eth_address: str = Field(
|
130
|
+
default="",
|
131
|
+
description="Optional provider Ethereum address for test/dev streaming"
|
132
|
+
)
|
133
|
+
|
134
|
+
@field_validator("polygon_rpc_url", mode='before')
|
135
|
+
@classmethod
|
136
|
+
def prefer_alt_env(cls, v: str) -> str:
|
137
|
+
# Accept alt aliases
|
138
|
+
for key in (
|
139
|
+
"GOLEM_REQUESTOR_l2_rpc_url",
|
140
|
+
"GOLEM_REQUESTOR_L2_RPC_URL",
|
141
|
+
"GOLEM_REQUESTOR_kaolin_rpc_url",
|
142
|
+
"GOLEM_REQUESTOR_KAOLIN_RPC_URL",
|
143
|
+
):
|
144
|
+
if os.environ.get(key):
|
145
|
+
return os.environ[key]
|
146
|
+
return v
|
147
|
+
|
97
148
|
# Base Directory
|
98
149
|
base_dir: Path = Field(
|
99
150
|
default_factory=lambda: Path.home() / ".golem" / "requestor",
|
@@ -0,0 +1,161 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from typing import Optional, Any, Dict
|
5
|
+
|
6
|
+
from web3 import Web3
|
7
|
+
from eth_account import Account
|
8
|
+
from golem_streaming_abi import STREAM_PAYMENT_ABI, ERC20_ABI
|
9
|
+
|
10
|
+
|
11
|
+
@dataclass
|
12
|
+
class StreamPaymentConfig:
|
13
|
+
rpc_url: str
|
14
|
+
contract_address: str
|
15
|
+
glm_token_address: str
|
16
|
+
private_key: str
|
17
|
+
|
18
|
+
|
19
|
+
class StreamPaymentClient:
|
20
|
+
def __init__(self, cfg: StreamPaymentConfig):
|
21
|
+
self.web3 = Web3(Web3.HTTPProvider(cfg.rpc_url))
|
22
|
+
self.account = Account.from_key(cfg.private_key)
|
23
|
+
self.web3.eth.default_account = self.account.address
|
24
|
+
|
25
|
+
self.contract = self.web3.eth.contract(
|
26
|
+
address=Web3.to_checksum_address(cfg.contract_address), abi=STREAM_PAYMENT_ABI
|
27
|
+
)
|
28
|
+
self.token_address = Web3.to_checksum_address(cfg.glm_token_address)
|
29
|
+
self.native_eth = self.token_address.lower() == Web3.to_checksum_address("0x0000000000000000000000000000000000000000").lower()
|
30
|
+
self.erc20 = None
|
31
|
+
if not self.native_eth:
|
32
|
+
self.erc20 = self.web3.eth.contract(
|
33
|
+
address=self.token_address, abi=ERC20_ABI
|
34
|
+
)
|
35
|
+
|
36
|
+
def _send(self, fn) -> Dict[str, Any]:
|
37
|
+
base = {
|
38
|
+
"from": self.account.address,
|
39
|
+
"nonce": self.web3.eth.get_transaction_count(self.account.address),
|
40
|
+
}
|
41
|
+
# Fill chainId
|
42
|
+
try:
|
43
|
+
base["chainId"] = getattr(self.web3.eth, "chain_id", None) or self.web3.eth.chain_id
|
44
|
+
except Exception:
|
45
|
+
pass
|
46
|
+
# Try gas estimation and fee fields
|
47
|
+
try:
|
48
|
+
tx_preview = fn.build_transaction(base)
|
49
|
+
gas = self.web3.eth.estimate_gas(tx_preview)
|
50
|
+
base["gas"] = gas
|
51
|
+
except Exception:
|
52
|
+
pass
|
53
|
+
try:
|
54
|
+
# Prefer EIP-1559 if available
|
55
|
+
max_fee = getattr(self.web3.eth, "max_priority_fee", None)
|
56
|
+
if max_fee is not None:
|
57
|
+
base.setdefault("maxPriorityFeePerGas", max_fee)
|
58
|
+
base.setdefault("maxFeePerGas", getattr(self.web3.eth, "gas_price", lambda: None)() or self.web3.eth.gas_price)
|
59
|
+
except Exception:
|
60
|
+
try:
|
61
|
+
base.setdefault("gasPrice", self.web3.eth.gas_price)
|
62
|
+
except Exception:
|
63
|
+
pass
|
64
|
+
|
65
|
+
tx = fn.build_transaction(base)
|
66
|
+
# In production, sign and send raw; in tests, Account may be a dummy without signer
|
67
|
+
if hasattr(self.account, "sign_transaction"):
|
68
|
+
signed = self.account.sign_transaction(tx)
|
69
|
+
tx_hash = self.web3.eth.send_raw_transaction(signed.rawTransaction)
|
70
|
+
else:
|
71
|
+
tx_hash = self.web3.eth.send_transaction(tx)
|
72
|
+
receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash)
|
73
|
+
return {"transactionHash": tx_hash.hex(), "status": receipt.status, "logs": receipt.logs}
|
74
|
+
|
75
|
+
def create_stream(self, provider_address: str, deposit_wei: int, rate_per_second_wei: int) -> int:
|
76
|
+
tx_value = None
|
77
|
+
token_param = self.token_address if not self.native_eth else Web3.to_checksum_address("0x0000000000000000000000000000000000000000")
|
78
|
+
|
79
|
+
if not self.native_eth:
|
80
|
+
# Approve deposit for the StreamPayment contract (only if needed)
|
81
|
+
try:
|
82
|
+
allowance = self.erc20.functions.allowance(self.account.address, self.contract.address).call()
|
83
|
+
except Exception:
|
84
|
+
allowance = 0
|
85
|
+
if int(allowance) < int(deposit_wei):
|
86
|
+
approve = self.erc20.functions.approve(self.contract.address, int(deposit_wei))
|
87
|
+
self._send(approve)
|
88
|
+
else:
|
89
|
+
tx_value = int(deposit_wei)
|
90
|
+
|
91
|
+
# Create stream
|
92
|
+
fn = self.contract.functions.createStream(
|
93
|
+
token_param,
|
94
|
+
Web3.to_checksum_address(provider_address),
|
95
|
+
int(deposit_wei),
|
96
|
+
int(rate_per_second_wei),
|
97
|
+
)
|
98
|
+
# Include ETH value if native
|
99
|
+
if tx_value is not None:
|
100
|
+
# Build/send with value field
|
101
|
+
base = {
|
102
|
+
"from": self.account.address,
|
103
|
+
"nonce": self.web3.eth.get_transaction_count(self.account.address),
|
104
|
+
"value": tx_value,
|
105
|
+
}
|
106
|
+
tx = fn.build_transaction(base)
|
107
|
+
signed = self.account.sign_transaction(tx)
|
108
|
+
tx_hash = self.web3.eth.send_raw_transaction(signed.rawTransaction)
|
109
|
+
receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash)
|
110
|
+
tx_receipt = {"transactionHash": tx_hash.hex(), "status": receipt.status, "logs": receipt.logs}
|
111
|
+
else:
|
112
|
+
tx_receipt = self._send(fn)
|
113
|
+
|
114
|
+
# Try to parse StreamCreated event for streamId
|
115
|
+
try:
|
116
|
+
for log in tx_receipt["logs"]:
|
117
|
+
# very naive filter: topic0 = keccak256(StreamCreated(...))
|
118
|
+
# When ABI is attached to contract, use contract.events
|
119
|
+
ev = self.contract.events.StreamCreated().process_log(log)
|
120
|
+
return int(ev["args"]["streamId"])
|
121
|
+
except Exception:
|
122
|
+
pass
|
123
|
+
# As a fallback, cannot easily fetch return value from a tx; caller should query later
|
124
|
+
raise RuntimeError("create_stream: could not parse streamId from receipt")
|
125
|
+
|
126
|
+
def withdraw(self, stream_id: int) -> str:
|
127
|
+
fn = self.contract.functions.withdraw(int(stream_id))
|
128
|
+
receipt = self._send(fn)
|
129
|
+
return receipt["transactionHash"]
|
130
|
+
|
131
|
+
def terminate(self, stream_id: int) -> str:
|
132
|
+
fn = self.contract.functions.terminate(int(stream_id))
|
133
|
+
receipt = self._send(fn)
|
134
|
+
return receipt["transactionHash"]
|
135
|
+
|
136
|
+
def top_up(self, stream_id: int, amount_wei: int) -> str:
|
137
|
+
if not self.native_eth:
|
138
|
+
# Approve first (only if needed)
|
139
|
+
try:
|
140
|
+
allowance = self.erc20.functions.allowance(self.account.address, self.contract.address).call()
|
141
|
+
except Exception:
|
142
|
+
allowance = 0
|
143
|
+
if int(allowance) < int(amount_wei):
|
144
|
+
approve = self.erc20.functions.approve(self.contract.address, int(amount_wei))
|
145
|
+
self._send(approve)
|
146
|
+
fn = self.contract.functions.topUp(int(stream_id), int(amount_wei))
|
147
|
+
receipt = self._send(fn)
|
148
|
+
return receipt["transactionHash"]
|
149
|
+
else:
|
150
|
+
# Native ETH mode: send value along with call
|
151
|
+
fn = self.contract.functions.topUp(int(stream_id), int(amount_wei))
|
152
|
+
base = {
|
153
|
+
"from": self.account.address,
|
154
|
+
"nonce": self.web3.eth.get_transaction_count(self.account.address),
|
155
|
+
"value": int(amount_wei),
|
156
|
+
}
|
157
|
+
tx = fn.build_transaction(base)
|
158
|
+
signed = self.account.sign_transaction(tx)
|
159
|
+
tx_hash = self.web3.eth.send_raw_transaction(signed.rawTransaction)
|
160
|
+
self.web3.eth.wait_for_transaction_receipt(tx_hash)
|
161
|
+
return tx_hash.hex()
|
requestor/provider/client.py
CHANGED
@@ -21,26 +21,37 @@ class ProviderClient:
|
|
21
21
|
cpu: int,
|
22
22
|
memory: int,
|
23
23
|
storage: int,
|
24
|
-
ssh_key: str
|
24
|
+
ssh_key: str,
|
25
|
+
stream_id: int | None = None,
|
25
26
|
) -> Dict:
|
26
27
|
"""Create a VM on the provider."""
|
28
|
+
payload = {
|
29
|
+
"name": name,
|
30
|
+
"resources": {
|
31
|
+
"cpu": cpu,
|
32
|
+
"memory": memory,
|
33
|
+
"storage": storage
|
34
|
+
},
|
35
|
+
"ssh_key": ssh_key
|
36
|
+
}
|
37
|
+
if stream_id is not None:
|
38
|
+
payload["stream_id"] = int(stream_id)
|
27
39
|
async with self.session.post(
|
28
40
|
f"{self.provider_url}/api/v1/vms",
|
29
|
-
json=
|
30
|
-
"name": name,
|
31
|
-
"resources": {
|
32
|
-
"cpu": cpu,
|
33
|
-
"memory": memory,
|
34
|
-
"storage": storage
|
35
|
-
},
|
36
|
-
"ssh_key": ssh_key
|
37
|
-
}
|
41
|
+
json=payload
|
38
42
|
) as response:
|
39
43
|
if not response.ok:
|
40
44
|
error_text = await response.text()
|
41
45
|
raise Exception(f"Failed to create VM: {error_text}")
|
42
46
|
return await response.json()
|
43
47
|
|
48
|
+
async def get_provider_info(self) -> Dict:
|
49
|
+
async with self.session.get(f"{self.provider_url}/api/v1/provider/info") as response:
|
50
|
+
if not response.ok:
|
51
|
+
error_text = await response.text()
|
52
|
+
raise Exception(f"Failed to fetch provider info: {error_text}")
|
53
|
+
return await response.json()
|
54
|
+
|
44
55
|
async def add_ssh_key(self, vm_id: str, key: str) -> None:
|
45
56
|
"""Add SSH key to VM."""
|
46
57
|
async with self.session.post(
|
@@ -0,0 +1,54 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Optional, List, Tuple
|
4
|
+
|
5
|
+
from web3 import Web3
|
6
|
+
from golem_faucet import PowFaucetClient
|
7
|
+
from ..utils.logging import setup_logger
|
8
|
+
from ..config import RequestorConfig
|
9
|
+
|
10
|
+
logger = setup_logger(__name__)
|
11
|
+
|
12
|
+
|
13
|
+
class L2FaucetService:
|
14
|
+
def __init__(self, config: RequestorConfig):
|
15
|
+
self.cfg = config
|
16
|
+
self.web3 = Web3(Web3.HTTPProvider(config.polygon_rpc_url))
|
17
|
+
self.client = PowFaucetClient(
|
18
|
+
faucet_url=getattr(config, 'l2_faucet_url', 'https://l2.holesky.golemdb.io/faucet'),
|
19
|
+
captcha_base_url=getattr(config, 'captcha_url', 'https://cap.gobas.me'),
|
20
|
+
captcha_api_key=getattr(config, 'captcha_api_key', '05381a2cef5e'),
|
21
|
+
)
|
22
|
+
|
23
|
+
def _balance_eth(self, address: str) -> float:
|
24
|
+
try:
|
25
|
+
wei = self.web3.eth.get_balance(Web3.to_checksum_address(address))
|
26
|
+
return float(self.web3.from_wei(wei, 'ether'))
|
27
|
+
except Exception as e:
|
28
|
+
logger.warning(f"balance check failed: {e}")
|
29
|
+
return 0.0
|
30
|
+
|
31
|
+
async def request_funds(self, address: str) -> Optional[str]:
|
32
|
+
bal = self._balance_eth(address)
|
33
|
+
if bal > 0.01:
|
34
|
+
logger.info(f"Sufficient L2 funds ({bal} ETH), skipping faucet.")
|
35
|
+
return None
|
36
|
+
chall = await self.client.get_challenge()
|
37
|
+
if not chall:
|
38
|
+
logger.error("could not fetch faucet challenge")
|
39
|
+
return None
|
40
|
+
token = chall.get('token')
|
41
|
+
challenge_list = chall.get('challenge') or []
|
42
|
+
solutions: List[Tuple[str, str, int]] = []
|
43
|
+
for salt, target in challenge_list:
|
44
|
+
nonce = PowFaucetClient.solve_challenge(salt, target)
|
45
|
+
solutions.append((salt, target, nonce))
|
46
|
+
redeemed = await self.client.redeem(token, solutions)
|
47
|
+
if not redeemed:
|
48
|
+
logger.error("failed to redeem challenge")
|
49
|
+
return None
|
50
|
+
tx = await self.client.request_funds(address, redeemed)
|
51
|
+
if tx:
|
52
|
+
logger.success(f"L2 faucet sent tx: {tx}")
|
53
|
+
return tx
|
54
|
+
|
@@ -93,6 +93,9 @@ class ProviderService:
|
|
93
93
|
"""Find providers using Golem Base."""
|
94
94
|
try:
|
95
95
|
query = 'golem_type="provider"'
|
96
|
+
# Filter by advertised network to avoid cross-network results
|
97
|
+
if config.network:
|
98
|
+
query += f' && golem_network="{config.network}"'
|
96
99
|
if cpu:
|
97
100
|
query += f' && golem_cpu>={cpu}'
|
98
101
|
if memory:
|
requestor/services/vm_service.py
CHANGED
@@ -14,11 +14,13 @@ class VMService:
|
|
14
14
|
self,
|
15
15
|
db_service: DatabaseService,
|
16
16
|
ssh_service: SSHService,
|
17
|
-
provider_client: Optional[ProviderClient] = None
|
17
|
+
provider_client: Optional[ProviderClient] = None,
|
18
|
+
blockchain_client: Optional[object] = None,
|
18
19
|
):
|
19
20
|
self.db = db_service
|
20
21
|
self.ssh_service = ssh_service
|
21
22
|
self.provider_client = provider_client
|
23
|
+
self.blockchain_client = blockchain_client
|
22
24
|
|
23
25
|
async def create_vm(
|
24
26
|
self,
|
@@ -27,7 +29,8 @@ class VMService:
|
|
27
29
|
memory: int,
|
28
30
|
storage: int,
|
29
31
|
provider_ip: str,
|
30
|
-
ssh_key: str
|
32
|
+
ssh_key: str,
|
33
|
+
stream_id: int | None = None,
|
31
34
|
) -> Dict:
|
32
35
|
"""Create a new VM with validation and error handling."""
|
33
36
|
try:
|
@@ -42,18 +45,23 @@ class VMService:
|
|
42
45
|
cpu=cpu,
|
43
46
|
memory=memory,
|
44
47
|
storage=storage,
|
45
|
-
ssh_key=ssh_key
|
48
|
+
ssh_key=ssh_key,
|
49
|
+
stream_id=stream_id
|
46
50
|
)
|
47
51
|
|
48
52
|
# Get VM access info
|
49
53
|
access_info = await self.provider_client.get_vm_access(vm['id'])
|
50
54
|
|
55
|
+
# Preserve any provided stream_id; do not auto-create streams here
|
56
|
+
# Stream creation should be explicit via CLI `vm stream open` command.
|
57
|
+
|
51
58
|
# Save VM details to database
|
52
59
|
config = {
|
53
60
|
'cpu': cpu,
|
54
61
|
'memory': memory,
|
55
62
|
'storage': storage,
|
56
|
-
'ssh_port': access_info['ssh_port']
|
63
|
+
'ssh_port': access_info['ssh_port'],
|
64
|
+
**({"stream_id": stream_id} if stream_id is not None else {}),
|
57
65
|
}
|
58
66
|
await self.db.save_vm(
|
59
67
|
name=name,
|
@@ -88,6 +96,15 @@ class VMService:
|
|
88
96
|
if "Not Found" not in str(e):
|
89
97
|
raise
|
90
98
|
|
99
|
+
# Attempt to terminate stream if present
|
100
|
+
try:
|
101
|
+
stream_id = vm.get('config', {}).get('stream_id')
|
102
|
+
if stream_id is not None and self.blockchain_client:
|
103
|
+
self.blockchain_client.terminate(stream_id)
|
104
|
+
except Exception:
|
105
|
+
# Best-effort: do not block deletion on chain failure
|
106
|
+
pass
|
107
|
+
|
91
108
|
# Remove from database
|
92
109
|
await self.db.delete_vm(name)
|
93
110
|
|
@@ -125,6 +142,14 @@ class VMService:
|
|
125
142
|
# Update status in database
|
126
143
|
await self.db.update_vm_status(name, "stopped")
|
127
144
|
|
145
|
+
# Best-effort withdraw on stop
|
146
|
+
try:
|
147
|
+
stream_id = vm.get('config', {}).get('stream_id')
|
148
|
+
if stream_id is not None and self.blockchain_client:
|
149
|
+
self.blockchain_client.withdraw(stream_id)
|
150
|
+
except Exception:
|
151
|
+
pass
|
152
|
+
|
128
153
|
except Exception as e:
|
129
154
|
raise VMError(f"Failed to stop VM: {str(e)}")
|
130
155
|
|
File without changes
|
{request_vm_on_golem-0.1.40.dist-info → request_vm_on_golem-0.1.44.dist-info}/entry_points.txt
RENAMED
File without changes
|