request-vm-on-golem 0.1.40__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.40
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
@@ -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=sCodN_skF24IkWo6x73H3SeahdE1uFlzjJ2BpMfiLrs,28991
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
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=LhNBf9V5-m0ssPGikEM8R2K0vwwFDPQ8KIgCM6vX0XI,6817
17
+ requestor/services/vm_service.py,sha256=UPPj7aD0b7nCyNH6I-b35xdsqAkRUiQlpQBVGK4B7ms,8858
17
18
  requestor/ssh/__init__.py,sha256=hNgSqJ5s1_AwwxVRyFjUqh_LTBpI4Hmzq0F-f_wXN9g,119
18
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.40.dist-info/METADATA,sha256=eyg8EzOeDCRq_wZKZw1KgGN89HvxMhHYCfWr49SxyHc,9944
22
- request_vm_on_golem-0.1.40.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
23
- request_vm_on_golem-0.1.40.dist-info/entry_points.txt,sha256=Z-skRNpJ8aZcIl_En9mEm1ygkp9FKy0bzQoL3zO52-0,44
24
- request_vm_on_golem-0.1.40.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
@@ -173,9 +173,10 @@ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Opt
173
173
  @click.option('--cpu', type=int, required=True, help='Number of CPU cores')
174
174
  @click.option('--memory', type=int, required=True, help='Memory in GB')
175
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')
176
177
  @click.option('--yes', is_flag=True, help='Do not prompt for confirmation')
177
178
  @async_command
178
- async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage: int, yes: bool):
179
+ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage: int, stream_id: int | None, yes: bool):
179
180
  """Create a new VM on a specific provider."""
180
181
  try:
181
182
  # Show configuration details
@@ -232,7 +233,8 @@ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage:
232
233
  memory=memory,
233
234
  storage=storage,
234
235
  provider_ip=provider_ip,
235
- ssh_key=key_pair.public_key_content
236
+ ssh_key=key_pair.public_key_content,
237
+ stream_id=stream_id
236
238
  )
237
239
 
238
240
  # Get access info from config
@@ -281,6 +283,88 @@ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage:
281
283
  raise click.Abort()
282
284
 
283
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
+
284
368
  @vm.command(name='ssh')
285
369
  @click.argument('name')
286
370
  @async_command
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(
@@ -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