request-vm-on-golem 0.1.39__py3-none-any.whl → 0.1.41__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: request-vm-on-golem
3
- Version: 0.1.39
3
+ Version: 0.1.41
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
@@ -119,6 +119,64 @@ The SSH connection process:
119
119
  2. The provider's proxy system forwards your SSH connection to the VM
120
120
  3. All traffic is securely routed through the allocated port
121
121
 
122
+ ## Streaming Payments (Polygon GLM)
123
+
124
+ This requestor integrates with an on‑chain StreamPayment contract to enable “pay‑as‑you‑go” rentals.
125
+
126
+ Flow:
127
+
128
+ 1. Fetch provider info:
129
+ - `GET http://{provider}:7466/api/v1/provider/info` → `provider_id`, `stream_payment_address`, `glm_token_address`.
130
+ 2. Compute `ratePerSecond` from provider pricing and requested VM resources.
131
+ 3. Ensure `deposit >= ratePerSecond * 3600` (≥ 1 hour runway recommended/minimum).
132
+ 4. Create a stream (approve + `createStream(GLM, provider_id, deposit, ratePerSecond)`), capture `stream_id`.
133
+ 5. Create VM: `POST /api/v1/vms` with `stream_id` included.
134
+ 6. Top‑up over time with `topUp(stream_id, amount)` to extend stopTime and keep the VM running indefinitely.
135
+ 7. On stop/destroy: the requestor will best‑effort `withdraw` / `terminate` to settle.
136
+
137
+ CLI helpers
138
+
139
+ - Open a stream for a planned VM (computes rate from provider pricing):
140
+
141
+ ```bash
142
+ poetry run golem vm stream open \
143
+ --provider-id 0xProvider \
144
+ --cpu 2 --memory 4 --storage 20 \
145
+ --hours 1
146
+ # prints { stream_id, rate_per_second_wei, deposit_wei }
147
+ ```
148
+
149
+ - Top up an existing stream:
150
+
151
+ ```bash
152
+ # Add 3 hours at prior rate
153
+ poetry run golem vm stream topup --stream-id 123 --hours 3
154
+
155
+ # Or specify exact GLM amount
156
+ poetry run golem vm stream topup --stream-id 123 --glm 25.0
157
+ ```
158
+
159
+ - Create a VM and attach an existing stream:
160
+
161
+ ```bash
162
+ poetry run golem vm create my-vm \
163
+ --provider-id 0xProvider \
164
+ --cpu 2 --memory 4 --storage 20 \
165
+ --stream-id 123
166
+ ```
167
+
168
+ Environment (env prefix `GOLEM_REQUESTOR_`):
169
+
170
+ - `polygon_rpc_url` — Polygon PoS RPC URL
171
+ - `stream_payment_address` — StreamPayment address
172
+ - `glm_token_address` — GLM ERC20 address
173
+ - `provider_eth_address` — optional helper for development; in production always use `/provider/info`
174
+
175
+ Efficiency tips:
176
+
177
+ - Batch top‑ups (e.g., add several hours at once) to reduce on‑chain calls.
178
+ - Withdrawals are typically executed by providers; requestors don’t need to withdraw.
179
+
122
180
  ## Installation
123
181
 
124
182
  ```bash
@@ -175,7 +233,7 @@ Example output:
175
233
  ────────────────────────────────────────────────
176
234
  🌍 Available Providers (3 total)
177
235
  ────────────────────────────────────────────────
178
- Provider ID Country CPU Memory Storage
236
+ Provider ID Country CPU Memory Disk
179
237
  provider-1 🌍 SE 💻 4 🧠 8GB 💾 40GB
180
238
  provider-2 🌍 US 💻 8 🧠 16GB 💾 80GB
181
239
  provider-3 🌍 DE 💻 2 🧠 4GB 💾 20GB
@@ -206,7 +264,7 @@ Example output:
206
264
  VM Details
207
265
  ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈
208
266
  🏷️ Name : my-webserver
209
- 💻 Resources : 2 CPU, 4GB RAM, 20GB Storage
267
+ 💻 Resources : 2 CPU, 4GB RAM, 20GB Disk
210
268
  🟢 Status : running
211
269
 
212
270
  Connection Details
@@ -1,24 +1,25 @@
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=lQCjx6uJH0Gy1mtnByaOG1Szk3kER3Vc6ZpfJqUEcD8,27453
5
- requestor/config.py,sha256=O39E-Wa-ewqdC9XP5nvj3zkOs52mevvFMyQGtHaqANk,4668
4
+ requestor/cli/commands.py,sha256=b9_Vy30rf3hpoBb_9Gu9z4mPWF3_oBjUjd-5pX2bVvU,33277
5
+ requestor/config.py,sha256=suWN2-4AL0Pphu2t-_WiTKIFG-0_cJjWwR_CIOW1c24,5320
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=w9ONdKVf2E7BCEgDRiTeEqwdNTr8SqZiXHKdV5JH2ZQ,5757
9
10
  requestor/provider/__init__.py,sha256=fmW23aYUVciF8-gmBZkG-PLhn22upmcDzdPfAOLHG6g,103
10
- requestor/provider/client.py,sha256=OUP7CoOCCtKD6DB9eqFkOXK6A2BLFdM4DWSkoulJQxg,3213
11
+ requestor/provider/client.py,sha256=WXCm-1bytcgsuHEZzpg7RjjDOTuaXC9cj0Mrm7e6DSw,3676
11
12
  requestor/run.py,sha256=GqOG6n34szt8Sp3SEqjRV6huWm737yCN6YnBqoiwI-U,1785
12
13
  requestor/services/__init__.py,sha256=1qSn_6RMn0KB0A7LCnY2IW6_tC3HBQsdfkFeV-h94eM,172
13
14
  requestor/services/database_service.py,sha256=GlSrzzzd7PSYQJNup00sxkB-B2PMr1__04K8k5QSWvs,2996
14
- requestor/services/provider_service.py,sha256=iejw8Q-ziK3Ny0cEAD1EHejUsAqf9BwJTa7jFmei0_8,9773
15
+ requestor/services/provider_service.py,sha256=auUc5XSHWbtOzyLHFqJ4RF337rCNYThzb7TjvUaK_uo,14542
15
16
  requestor/services/ssh_service.py,sha256=tcOCtk2SlB9Uuv-P2ghR22e7BJ9kigQh5b4zSGdPFns,4280
16
- requestor/services/vm_service.py,sha256=yHvGtfzoWw_wAC7MwgZEudbK8ElxB_2fCuG5Xa-F1KE,6820
17
+ requestor/services/vm_service.py,sha256=UPPj7aD0b7nCyNH6I-b35xdsqAkRUiQlpQBVGK4B7ms,8858
17
18
  requestor/ssh/__init__.py,sha256=hNgSqJ5s1_AwwxVRyFjUqh_LTBpI4Hmzq0F-f_wXN9g,119
18
- requestor/ssh/manager.py,sha256=XhZjz7_BRPnmpu-zxqnGHLCq0b2JZ8Xr8zc1OlMNDkc,9355
19
+ requestor/ssh/manager.py,sha256=3jQtbbK7CVC2yD1zCO88jGXh2fBcuv3CzWEqDLuaQVk,9758
19
20
  requestor/utils/logging.py,sha256=oFNpO8pJboYM8Wp7g3HOU4HFyBTKypVdY15lUiz1a4I,3721
20
21
  requestor/utils/spinner.py,sha256=PUHJdTD9jpUHur__01_qxXy87WFfNmjQbD_sLG-KlGo,2459
21
- request_vm_on_golem-0.1.39.dist-info/METADATA,sha256=O_wg13P3E6efitdI5Zxr2urcvky3RZEWpOfgzdzcWlg,9950
22
- request_vm_on_golem-0.1.39.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
23
- request_vm_on_golem-0.1.39.dist-info/entry_points.txt,sha256=Z-skRNpJ8aZcIl_En9mEm1ygkp9FKy0bzQoL3zO52-0,44
24
- request_vm_on_golem-0.1.39.dist-info/RECORD,,
22
+ request_vm_on_golem-0.1.41.dist-info/METADATA,sha256=znwTKJHh21R9DsDgeHeaKFwxZFFC95vOTj-kScsc2Tc,11924
23
+ request_vm_on_golem-0.1.41.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
24
+ request_vm_on_golem-0.1.41.dist-info/entry_points.txt,sha256=Z-skRNpJ8aZcIl_En9mEm1ygkp9FKy0bzQoL3zO52-0,44
25
+ request_vm_on_golem-0.1.41.dist-info/RECORD,,
requestor/cli/commands.py CHANGED
@@ -90,7 +90,7 @@ def vm():
90
90
  @vm.command(name='providers')
91
91
  @click.option('--cpu', type=int, help='Minimum CPU cores required')
92
92
  @click.option('--memory', type=int, help='Minimum memory (GB) required')
93
- @click.option('--storage', type=int, help='Minimum storage (GB) required')
93
+ @click.option('--storage', type=int, help='Minimum disk (GB) required')
94
94
  @click.option('--country', help='Preferred provider country')
95
95
  @click.option('--driver', type=click.Choice(['central', 'golem-base']), default=None, help='Discovery driver to use')
96
96
  @click.option('--json', 'as_json', is_flag=True, help='Output in JSON format')
@@ -106,7 +106,7 @@ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Opt
106
106
  if memory:
107
107
  logger.detail(f"Memory: {memory}GB+")
108
108
  if storage:
109
- logger.detail(f"Storage: {storage}GB+")
109
+ logger.detail(f"Disk: {storage}GB+")
110
110
  if country:
111
111
  logger.detail(f"Country: {country}")
112
112
 
@@ -117,6 +117,9 @@ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Opt
117
117
  # Initialize provider service
118
118
  provider_service = ProviderService()
119
119
  async with provider_service:
120
+ # If a full spec is provided, enable per-provider estimate display
121
+ if cpu and memory and storage:
122
+ provider_service.estimate_spec = (cpu, memory, storage)
120
123
  providers = await provider_service.find_providers(
121
124
  cpu=cpu,
122
125
  memory=memory,
@@ -129,6 +132,12 @@ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Opt
129
132
  logger.warning("No providers found matching criteria")
130
133
  return {"providers": []}
131
134
 
135
+ # If JSON requested and full spec provided, include estimates per provider
136
+ if as_json and cpu and memory and storage:
137
+ for p in providers:
138
+ est = provider_service.compute_estimate(p, (cpu, memory, storage))
139
+ if est is not None:
140
+ p['estimate'] = est
132
141
  result = {"providers": providers}
133
142
 
134
143
  if as_json:
@@ -163,9 +172,11 @@ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Opt
163
172
  @click.option('--provider-id', required=True, help='Provider ID to use')
164
173
  @click.option('--cpu', type=int, required=True, help='Number of CPU cores')
165
174
  @click.option('--memory', type=int, required=True, help='Memory in GB')
166
- @click.option('--storage', type=int, required=True, help='Storage in GB')
175
+ @click.option('--storage', type=int, required=True, help='Disk in GB')
176
+ @click.option('--stream-id', type=int, default=None, help='Optional StreamPayment stream id to fund this VM')
177
+ @click.option('--yes', is_flag=True, help='Do not prompt for confirmation')
167
178
  @async_command
168
- async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage: int):
179
+ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage: int, stream_id: int | None, yes: bool):
169
180
  """Create a new VM on a specific provider."""
170
181
  try:
171
182
  # Show configuration details
@@ -173,7 +184,7 @@ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage:
173
184
  click.echo(click.style(" VM Configuration", fg="blue", bold=True))
174
185
  click.echo("─" * 60)
175
186
  click.echo(f" Provider : {click.style(provider_id, fg='cyan')}")
176
- click.echo(f" Resources : {click.style(f'{cpu} CPU, {memory}GB RAM, {storage}GB Storage', fg='cyan')}")
187
+ click.echo(f" Resources : {click.style(f'{cpu} CPU, {memory}GB RAM, {storage}GB Disk', fg='cyan')}")
177
188
  click.echo("─" * 60 + "\n")
178
189
 
179
190
  # Now start the deployment with spinner
@@ -191,6 +202,21 @@ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage:
191
202
  if not provider_ip and config.environment == "production":
192
203
  raise RequestorError("Provider IP address not found in advertisement")
193
204
 
205
+ # Before proceeding, show estimated monthly price and confirm
206
+ provider_service.estimate_spec = (cpu, memory, storage)
207
+ est_row = await provider_service.format_provider_row(provider, colorize=False)
208
+ # Columns: ... [7]=USD/core/mo, [8]=USD/GB RAM/mo, [9]=USD/GB Disk/mo, [10]=Est. $/mo, [11]=Est. GLM/mo
209
+ est_usd = est_row[10]
210
+ est_glm = est_row[11]
211
+ price_str = f"~${est_usd}/mo" if est_usd != '—' else "(no USD pricing)"
212
+ if est_glm != '—':
213
+ price_str += f" (~{est_glm} GLM/mo)"
214
+ click.echo(click.style(f" 💵 Estimated Monthly Cost: {price_str}", fg='yellow', bold=True))
215
+ if not yes:
216
+ if not click.confirm("Proceed with VM creation?", default=True):
217
+ logger.warning("Creation cancelled by user")
218
+ return
219
+
194
220
  # Setup SSH
195
221
  ssh_service = SSHService(config.ssh_key_dir)
196
222
  key_pair = await ssh_service.get_key_pair()
@@ -207,7 +233,8 @@ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage:
207
233
  memory=memory,
208
234
  storage=storage,
209
235
  provider_ip=provider_ip,
210
- ssh_key=key_pair.public_key_content
236
+ ssh_key=key_pair.public_key_content,
237
+ stream_id=stream_id
211
238
  )
212
239
 
213
240
  # Get access info from config
@@ -222,7 +249,7 @@ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage:
222
249
  click.echo(click.style(" VM Details", fg="blue", bold=True))
223
250
  click.echo(" " + "┈" * 25)
224
251
  click.echo(f" 🏷️ Name : {click.style(name, fg='cyan')}")
225
- click.echo(f" 💻 Resources : {click.style(f'{cpu} CPU, {memory}GB RAM, {storage}GB Storage', fg='cyan')}")
252
+ click.echo(f" 💻 Resources : {click.style(f'{cpu} CPU, {memory}GB RAM, {storage}GB Disk', fg='cyan')}")
226
253
  click.echo(f" 🟢 Status : {click.style('running', fg='green')}")
227
254
 
228
255
  # Connection Details Section
@@ -256,6 +283,88 @@ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage:
256
283
  raise click.Abort()
257
284
 
258
285
 
286
+ @vm.group(name='stream')
287
+ def vm_stream():
288
+ """Streaming payments helpers"""
289
+ pass
290
+
291
+
292
+ @vm_stream.command('open')
293
+ @click.option('--provider-id', required=True, help='Provider ID to use')
294
+ @click.option('--cpu', type=int, required=True, help='CPU cores for rate calc')
295
+ @click.option('--memory', type=int, required=True, help='Memory (GB) for rate calc')
296
+ @click.option('--storage', type=int, required=True, help='Storage (GB) for rate calc')
297
+ @click.option('--hours', type=int, default=1, help='Deposit coverage in hours (default 1)')
298
+ @async_command
299
+ async def stream_open(provider_id: str, cpu: int, memory: int, storage: int, hours: int):
300
+ """Create a GLM stream for a planned VM rental."""
301
+ from ..payments.blockchain_service import StreamPaymentClient, StreamPaymentConfig
302
+ try:
303
+ provider_service = ProviderService()
304
+ async with provider_service:
305
+ provider = await provider_service.verify_provider(provider_id)
306
+ est = provider_service.compute_estimate(provider, (cpu, memory, storage))
307
+ if not est or est.get('glm_per_month') is None:
308
+ raise RequestorError('Provider does not advertise GLM pricing; cannot compute ratePerSecond')
309
+ glm_month = est['glm_per_month']
310
+ glm_per_second = float(glm_month) / (730.0 * 3600.0)
311
+ rate_per_second_wei = int(glm_per_second * (10**18))
312
+
313
+ provider_ip = 'localhost' if config.environment == "development" else provider.get('ip_address')
314
+ if not provider_ip and config.environment == "production":
315
+ raise RequestorError("Provider IP address not found in advertisement")
316
+ provider_url = config.get_provider_url(provider_ip)
317
+ async with ProviderClient(provider_url) as client:
318
+ info = await client.get_provider_info()
319
+ recipient = info['provider_id']
320
+
321
+ deposit_wei = rate_per_second_wei * int(hours) * 3600
322
+ spc = StreamPaymentConfig(
323
+ rpc_url=config.polygon_rpc_url,
324
+ contract_address=config.stream_payment_address,
325
+ glm_token_address=config.glm_token_address,
326
+ private_key=config.ethereum_private_key,
327
+ )
328
+ sp = StreamPaymentClient(spc)
329
+ stream_id = sp.create_stream(recipient, deposit_wei, rate_per_second_wei)
330
+ click.echo(json.dumps({"stream_id": stream_id, "rate_per_second_wei": rate_per_second_wei, "deposit_wei": deposit_wei}, indent=2))
331
+ except Exception as e:
332
+ logger.error(f"Failed to open stream: {e}")
333
+ raise click.Abort()
334
+
335
+
336
+ @vm_stream.command('topup')
337
+ @click.option('--stream-id', type=int, required=True)
338
+ @click.option('--glm', type=float, required=False, help='GLM amount to add')
339
+ @click.option('--hours', type=int, required=False, help='Hours of coverage to add at prior rate')
340
+ @async_command
341
+ async def stream_topup(stream_id: int, glm: float | None, hours: int | None):
342
+ """Top up a stream. Provide either --glm or --hours (using prior rate)."""
343
+ from ..payments.blockchain_service import StreamPaymentClient, StreamPaymentConfig
344
+ try:
345
+ spc = StreamPaymentConfig(
346
+ rpc_url=config.polygon_rpc_url,
347
+ contract_address=config.stream_payment_address,
348
+ glm_token_address=config.glm_token_address,
349
+ private_key=config.ethereum_private_key,
350
+ )
351
+ sp = StreamPaymentClient(spc)
352
+ add_wei: int
353
+ if glm is not None:
354
+ add_wei = int(float(glm) * (10**18))
355
+ elif hours is not None:
356
+ # naive: use last known rate by reading on-chain stream
357
+ rate = sp.contract.functions.streams(int(stream_id)).call()[5] # ratePerSecond
358
+ add_wei = int(rate) * int(hours) * 3600
359
+ else:
360
+ raise RequestorError('Provide either --glm or --hours')
361
+ tx = sp.top_up(stream_id, add_wei)
362
+ click.echo(json.dumps({"stream_id": stream_id, "topped_up_wei": add_wei, "tx": tx}, indent=2))
363
+ except Exception as e:
364
+ logger.error(f"Failed to top up stream: {e}")
365
+ raise click.Abort()
366
+
367
+
259
368
  @vm.command(name='ssh')
260
369
  @click.argument('name')
261
370
  @async_command
@@ -341,7 +450,7 @@ async def info_vm(name: str, as_json: bool):
341
450
  "SSH Port",
342
451
  "CPU",
343
452
  "Memory (GB)",
344
- "Storage (GB)",
453
+ "Disk (GB)",
345
454
  ]
346
455
 
347
456
  row = [
requestor/config.py CHANGED
@@ -94,6 +94,24 @@ class RequestorConfig(BaseSettings):
94
94
  description="Private key for Golem Base"
95
95
  )
96
96
 
97
+ # Polygon / Payments
98
+ polygon_rpc_url: str = Field(
99
+ default="https://polygon-rpc.com",
100
+ description="Polygon PoS RPC URL for GLM payments"
101
+ )
102
+ stream_payment_address: str = Field(
103
+ default="0x0000000000000000000000000000000000000000",
104
+ description="Deployed StreamPayment contract address"
105
+ )
106
+ glm_token_address: str = Field(
107
+ default="0x0000000000000000000000000000000000000000",
108
+ description="GLM ERC20 token address on target network"
109
+ )
110
+ provider_eth_address: str = Field(
111
+ default="",
112
+ description="Optional provider Ethereum address for test/dev streaming"
113
+ )
114
+
97
115
  # Base Directory
98
116
  base_dir: Path = Field(
99
117
  default_factory=lambda: Path.home() / ".golem" / "requestor",
@@ -0,0 +1,148 @@
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
+
9
+
10
+ STREAM_PAYMENT_ABI = [
11
+ {
12
+ "inputs": [
13
+ {"internalType": "address", "name": "token", "type": "address"},
14
+ {"internalType": "address", "name": "recipient", "type": "address"},
15
+ {"internalType": "uint256", "name": "deposit", "type": "uint256"},
16
+ {"internalType": "uint128", "name": "ratePerSecond", "type": "uint128"},
17
+ ],
18
+ "name": "createStream",
19
+ "outputs": [{"internalType": "uint256", "name": "streamId", "type": "uint256"}],
20
+ "stateMutability": "nonpayable",
21
+ "type": "function",
22
+ },
23
+ {
24
+ "inputs": [{"internalType": "uint256", "name": "streamId", "type": "uint256"}],
25
+ "name": "withdraw",
26
+ "outputs": [],
27
+ "stateMutability": "nonpayable",
28
+ "type": "function",
29
+ },
30
+ {
31
+ "inputs": [{"internalType": "uint256", "name": "streamId", "type": "uint256"}],
32
+ "name": "terminate",
33
+ "outputs": [],
34
+ "stateMutability": "nonpayable",
35
+ "type": "function",
36
+ },
37
+ {
38
+ "anonymous": False,
39
+ "inputs": [
40
+ {"indexed": True, "internalType": "uint256", "name": "streamId", "type": "uint256"},
41
+ {"indexed": True, "internalType": "address", "name": "sender", "type": "address"},
42
+ {"indexed": True, "internalType": "address", "name": "recipient", "type": "address"},
43
+ {"indexed": False, "internalType": "address", "name": "token", "type": "address"},
44
+ {"indexed": False, "internalType": "uint256", "name": "deposit", "type": "uint256"},
45
+ {"indexed": False, "internalType": "uint256", "name": "ratePerSecond", "type": "uint256"},
46
+ {"indexed": False, "internalType": "uint256", "name": "startTime", "type": "uint256"},
47
+ {"indexed": False, "internalType": "uint256", "name": "stopTime", "type": "uint256"},
48
+ ],
49
+ "name": "StreamCreated",
50
+ "type": "event",
51
+ },
52
+ ]
53
+
54
+ ERC20_ABI = [
55
+ {
56
+ "name": "approve",
57
+ "type": "function",
58
+ "stateMutability": "nonpayable",
59
+ "inputs": [
60
+ {"name": "spender", "type": "address"},
61
+ {"name": "amount", "type": "uint256"},
62
+ ],
63
+ "outputs": [{"name": "", "type": "bool"}],
64
+ }
65
+ ]
66
+
67
+
68
+ @dataclass
69
+ class StreamPaymentConfig:
70
+ rpc_url: str
71
+ contract_address: str
72
+ glm_token_address: str
73
+ private_key: str
74
+
75
+
76
+ class StreamPaymentClient:
77
+ def __init__(self, cfg: StreamPaymentConfig):
78
+ self.web3 = Web3(Web3.HTTPProvider(cfg.rpc_url))
79
+ self.account = Account.from_key(cfg.private_key)
80
+ self.web3.eth.default_account = self.account.address
81
+
82
+ self.contract = self.web3.eth.contract(
83
+ address=Web3.to_checksum_address(cfg.contract_address), abi=STREAM_PAYMENT_ABI
84
+ )
85
+ self.erc20 = self.web3.eth.contract(
86
+ address=Web3.to_checksum_address(cfg.glm_token_address), abi=ERC20_ABI
87
+ )
88
+
89
+ def _send(self, fn) -> Dict[str, Any]:
90
+ tx = fn.build_transaction(
91
+ {
92
+ "from": self.account.address,
93
+ "nonce": self.web3.eth.get_transaction_count(self.account.address),
94
+ }
95
+ )
96
+ # In production, sign and send raw; in tests, Account may be a dummy without signer
97
+ if hasattr(self.account, "sign_transaction"):
98
+ signed = self.account.sign_transaction(tx)
99
+ tx_hash = self.web3.eth.send_raw_transaction(signed.rawTransaction)
100
+ else:
101
+ tx_hash = self.web3.eth.send_transaction(tx)
102
+ receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash)
103
+ return {"transactionHash": tx_hash.hex(), "status": receipt.status, "logs": receipt.logs}
104
+
105
+ def create_stream(self, provider_address: str, deposit_wei: int, rate_per_second_wei: int) -> int:
106
+ # 1) Approve deposit for the StreamPayment contract
107
+ approve = self.erc20.functions.approve(self.contract.address, int(deposit_wei))
108
+ self._send(approve)
109
+
110
+ # 2) Create stream
111
+ fn = self.contract.functions.createStream(
112
+ self.erc20.address,
113
+ Web3.to_checksum_address(provider_address),
114
+ int(deposit_wei),
115
+ int(rate_per_second_wei),
116
+ )
117
+ receipt = self._send(fn)
118
+
119
+ # Try to parse StreamCreated event for streamId
120
+ try:
121
+ for log in receipt["logs"]:
122
+ # very naive filter: topic0 = keccak256(StreamCreated(...))
123
+ # When ABI is attached to contract, use contract.events
124
+ ev = self.contract.events.StreamCreated().process_log(log)
125
+ return int(ev["args"]["streamId"])
126
+ except Exception:
127
+ pass
128
+ # As a fallback, cannot easily fetch return value from a tx; caller should query later
129
+ raise RuntimeError("create_stream: could not parse streamId from receipt")
130
+
131
+ def withdraw(self, stream_id: int) -> str:
132
+ fn = self.contract.functions.withdraw(int(stream_id))
133
+ receipt = self._send(fn)
134
+ return receipt["transactionHash"]
135
+
136
+ def terminate(self, stream_id: int) -> str:
137
+ fn = self.contract.functions.terminate(int(stream_id))
138
+ receipt = self._send(fn)
139
+ return receipt["transactionHash"]
140
+
141
+ def top_up(self, stream_id: int, amount_wei: int) -> str:
142
+ # Approve first
143
+ approve = self.erc20.functions.approve(self.contract.address, int(amount_wei))
144
+ self._send(approve)
145
+ # Top up
146
+ fn = self.contract.functions.topUp(int(stream_id), int(amount_wei))
147
+ receipt = self._send(fn)
148
+ return receipt["transactionHash"]
@@ -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(
@@ -15,6 +15,39 @@ class ProviderService:
15
15
  def __init__(self):
16
16
  self.session = None
17
17
  self.golem_base_client = None
18
+ # Optional spec (cpu, memory, storage) to compute estimates for display
19
+ self.estimate_spec: Optional[tuple[int, int, int]] = None
20
+
21
+ def compute_estimate(self, provider: Dict, spec: tuple[int, int, int]) -> Optional[Dict]:
22
+ """Compute estimated pricing for a given spec, if provider has pricing.
23
+
24
+ Returns dict with usd_per_month, glm_per_month (if GLM per-unit available),
25
+ and usd_per_hour, or None if insufficient pricing data.
26
+ """
27
+ pricing = provider.get('pricing') or {}
28
+ usd_core = pricing.get('usd_per_core_month')
29
+ usd_ram = pricing.get('usd_per_gb_ram_month')
30
+ usd_storage = pricing.get('usd_per_gb_storage_month')
31
+ if usd_core is None or usd_ram is None or usd_storage is None:
32
+ return None
33
+ cpu, mem, sto = spec
34
+ try:
35
+ usd_per_month = float(usd_core) * cpu + float(usd_ram) * mem + float(usd_storage) * sto
36
+ glm_core = pricing.get('glm_per_core_month')
37
+ glm_ram = pricing.get('glm_per_gb_ram_month')
38
+ glm_storage = pricing.get('glm_per_gb_storage_month')
39
+ glm_per_month = None
40
+ if glm_core is not None and glm_ram is not None and glm_storage is not None:
41
+ glm_per_month = float(glm_core) * cpu + float(glm_ram) * mem + float(glm_storage) * sto
42
+ usd_per_hour = usd_per_month / 730.0
43
+ # Round for display consistency
44
+ return {
45
+ 'usd_per_month': round(usd_per_month, 4),
46
+ 'usd_per_hour': round(usd_per_hour, 6),
47
+ 'glm_per_month': round(glm_per_month, 8) if glm_per_month is not None else None,
48
+ }
49
+ except Exception:
50
+ return None
18
51
 
19
52
  async def __aenter__(self):
20
53
  self.session = aiohttp.ClientSession()
@@ -91,6 +124,14 @@ class ProviderService:
91
124
  'memory': int(annotations.get('golem_memory', 0)),
92
125
  'storage': int(annotations.get('golem_storage', 0)),
93
126
  },
127
+ 'pricing': {
128
+ 'usd_per_core_month': annotations.get('golem_price_usd_core_month'),
129
+ 'usd_per_gb_ram_month': annotations.get('golem_price_usd_ram_gb_month'),
130
+ 'usd_per_gb_storage_month': annotations.get('golem_price_usd_storage_gb_month'),
131
+ 'glm_per_core_month': annotations.get('golem_price_glm_core_month'),
132
+ 'glm_per_gb_ram_month': annotations.get('golem_price_glm_ram_gb_month'),
133
+ 'glm_per_gb_storage_month': annotations.get('golem_price_glm_storage_gb_month'),
134
+ },
94
135
  'created_at_block': metadata.expires_at_block - (config.advertisement_interval * 2)
95
136
  }
96
137
  if provider['provider_id']:
@@ -225,6 +266,31 @@ class ProviderService:
225
266
 
226
267
  updated_at_str = await self._format_block_timestamp(provider.get('created_at_block', 0))
227
268
 
269
+ pricing = provider.get('pricing') or {}
270
+ usd_core = pricing.get('usd_per_core_month')
271
+ usd_ram = pricing.get('usd_per_gb_ram_month')
272
+ usd_storage = pricing.get('usd_per_gb_storage_month')
273
+
274
+ # Precompute estimates if a spec is set and pricing available
275
+ est_usd = '—'
276
+ est_glm = '—'
277
+ est_hr_usd = '—'
278
+ if self.estimate_spec and all(p is not None for p in (usd_core, usd_ram, usd_storage)):
279
+ spec_cpu, spec_mem, spec_sto = self.estimate_spec
280
+ try:
281
+ est_usd_val = (float(usd_core) * spec_cpu) + (float(usd_ram) * spec_mem) + (float(usd_storage) * spec_sto)
282
+ est_usd = round(est_usd_val, 4)
283
+ est_hr_usd = round(est_usd_val / 730.0, 6)
284
+ # If GLM per-unit is present, compute GLM estimate as well
285
+ glm_core = pricing.get('glm_per_core_month')
286
+ glm_ram = pricing.get('glm_per_gb_ram_month')
287
+ glm_storage = pricing.get('glm_per_gb_storage_month')
288
+ if all(x is not None for x in (glm_core, glm_ram, glm_storage)):
289
+ est_glm_val = (float(glm_core) * spec_cpu) + (float(glm_ram) * spec_mem) + (float(glm_storage) * spec_sto)
290
+ est_glm = round(est_glm_val, 8)
291
+ except Exception:
292
+ pass
293
+
228
294
  row = [
229
295
  provider['provider_id'],
230
296
  provider['provider_name'],
@@ -233,18 +299,38 @@ class ProviderService:
233
299
  provider['resources']['cpu'],
234
300
  provider['resources']['memory'],
235
301
  provider['resources']['storage'],
302
+ usd_core if usd_core is not None else '—',
303
+ usd_ram if usd_ram is not None else '—',
304
+ usd_storage if usd_storage is not None else '—',
305
+ est_usd,
306
+ est_glm,
236
307
  updated_at_str
237
308
  ]
238
309
 
239
310
  if colorize:
240
311
  # Format Provider ID
241
- row[0] = style(row[0], fg="yellow")
312
+ id_txt = style(row[0], fg="yellow")
313
+ if est_hr_usd != '—':
314
+ id_txt += style(f" (~${est_hr_usd}/hr)", fg="yellow")
315
+ row[0] = id_txt
242
316
 
243
317
  # Format resources with icons and colors
244
318
  row[4] = style(f"💻 {row[4]}", fg="cyan", bold=True)
245
319
  row[5] = style(f"🧠 {row[5]}", fg="cyan", bold=True)
246
320
  row[6] = style(f"💾 {row[6]}", fg="cyan", bold=True)
247
321
 
322
+ # Format pricing with currency markers
323
+ if usd_core != '—':
324
+ row[7] = style(f"${row[7]}/mo", fg="magenta")
325
+ if usd_ram != '—':
326
+ row[8] = style(f"${row[8]}/GB/mo", fg="magenta")
327
+ if usd_storage != '—':
328
+ row[9] = style(f"${row[9]}/GB/mo", fg="magenta")
329
+ if est_usd != '—':
330
+ row[10] = style(f"~${row[10]}/mo", fg="yellow", bold=True)
331
+ if est_glm != '—':
332
+ row[11] = style(f"~{row[11]} GLM/mo", fg="yellow")
333
+
248
334
  # Format location info
249
335
  row[3] = style(f"🌍 {row[3]}", fg="green", bold=True)
250
336
 
@@ -260,6 +346,11 @@ class ProviderService:
260
346
  "Country",
261
347
  "CPU",
262
348
  "Memory (GB)",
263
- "Storage (GB)",
349
+ "Disk (GB)",
350
+ "USD/core/mo",
351
+ "USD/GB RAM/mo",
352
+ "USD/GB Disk/mo",
353
+ "Est. $/mo",
354
+ "Est. GLM/mo",
264
355
  "Updated"
265
356
  ]
@@ -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,42 @@ 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
+ # Optionally create a GLM stream (if configured)
56
+ from ..config import config as app_config
57
+ stream_id = None
58
+ try:
59
+ if (
60
+ self.blockchain_client
61
+ and app_config.stream_payment_address != "0x0000000000000000000000000000000000000000"
62
+ and app_config.glm_token_address != "0x0000000000000000000000000000000000000000"
63
+ and app_config.provider_eth_address
64
+ ):
65
+ # Simple heuristic: deposit for 1 hour at a nominal rate
66
+ rate_per_second = 10**18 # 1 GLM / second (example)
67
+ deposit = rate_per_second * 3600 # 1 hour worth
68
+ stream_id = self.blockchain_client.create_stream(
69
+ app_config.provider_eth_address,
70
+ deposit,
71
+ rate_per_second,
72
+ )
73
+ except Exception:
74
+ # Do not fail VM creation if streaming setup fails
75
+ stream_id = None
76
+
51
77
  # Save VM details to database
52
78
  config = {
53
79
  'cpu': cpu,
54
80
  'memory': memory,
55
81
  'storage': storage,
56
- 'ssh_port': access_info['ssh_port']
82
+ 'ssh_port': access_info['ssh_port'],
83
+ **({"stream_id": stream_id} if stream_id is not None else {}),
57
84
  }
58
85
  await self.db.save_vm(
59
86
  name=name,
@@ -88,6 +115,15 @@ class VMService:
88
115
  if "Not Found" not in str(e):
89
116
  raise
90
117
 
118
+ # Attempt to terminate stream if present
119
+ try:
120
+ stream_id = vm.get('config', {}).get('stream_id')
121
+ if stream_id is not None and self.blockchain_client:
122
+ self.blockchain_client.terminate(stream_id)
123
+ except Exception:
124
+ # Best-effort: do not block deletion on chain failure
125
+ pass
126
+
91
127
  # Remove from database
92
128
  await self.db.delete_vm(name)
93
129
 
@@ -125,6 +161,14 @@ class VMService:
125
161
  # Update status in database
126
162
  await self.db.update_vm_status(name, "stopped")
127
163
 
164
+ # Best-effort withdraw on stop
165
+ try:
166
+ stream_id = vm.get('config', {}).get('stream_id')
167
+ if stream_id is not None and self.blockchain_client:
168
+ self.blockchain_client.withdraw(stream_id)
169
+ except Exception:
170
+ pass
171
+
128
172
  except Exception as e:
129
173
  raise VMError(f"Failed to stop VM: {str(e)}")
130
174
 
@@ -195,7 +239,7 @@ class VMService:
195
239
  "SSH Port",
196
240
  "CPU",
197
241
  "Memory (GB)",
198
- "Storage (GB)",
242
+ "Disk (GB)",
199
243
  "Connect Command",
200
244
  "Created"
201
245
  ]
requestor/ssh/manager.py CHANGED
@@ -53,9 +53,17 @@ class SSHKeyManager:
53
53
 
54
54
  # Create Golem directory if needed
55
55
  self.golem_dir.mkdir(parents=True, exist_ok=True)
56
- # Secure directory permissions (on Unix-like systems)
56
+ # Secure directory permissions (on Unix-like systems). If the directory
57
+ # is a system path (e.g., "/tmp") or not owned/permission-changeable
58
+ # by the current user, ignore the error to avoid test and runtime failures.
57
59
  if os.name == 'posix':
58
- os.chmod(self.golem_dir, 0o700)
60
+ try:
61
+ os.chmod(self.golem_dir, 0o700)
62
+ except PermissionError:
63
+ logger.warning(
64
+ "Could not set permissions on %s; continuing without chmod",
65
+ self.golem_dir,
66
+ )
59
67
 
60
68
  async def get_key_pair(self) -> KeyPair:
61
69
  """Get the SSH key pair to use.