request-vm-on-golem 0.1.49__tar.gz → 0.1.51__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/PKG-INFO +16 -1
  2. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/README.md +15 -0
  3. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/pyproject.toml +1 -1
  4. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/api/main.py +9 -1
  5. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/cli/commands.py +114 -3
  6. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/config.py +17 -0
  7. request_vm_on_golem-0.1.51/requestor/payments/monitor.py +126 -0
  8. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/services/vm_service.py +2 -2
  9. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/__init__.py +0 -0
  10. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/cli/__init__.py +0 -0
  11. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/data/deployments/l2.json +0 -0
  12. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/db/__init__.py +0 -0
  13. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/db/sqlite.py +0 -0
  14. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/errors.py +0 -0
  15. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/payments/blockchain_service.py +0 -0
  16. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/provider/__init__.py +0 -0
  17. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/provider/client.py +0 -0
  18. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/run.py +0 -0
  19. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/security/faucet.py +0 -0
  20. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/services/__init__.py +0 -0
  21. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/services/database_service.py +0 -0
  22. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/services/provider_service.py +0 -0
  23. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/services/ssh_service.py +0 -0
  24. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/ssh/__init__.py +0 -0
  25. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/ssh/manager.py +0 -0
  26. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/utils/logging.py +0 -0
  27. {request_vm_on_golem-0.1.49 → request_vm_on_golem-0.1.51}/requestor/utils/spinner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: request-vm-on-golem
3
- Version: 0.1.49
3
+ Version: 0.1.51
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
@@ -171,6 +171,16 @@ poetry run golem vm stream status my-vm
171
171
  poetry run golem vm stream inspect --stream-id 123
172
172
  ```
173
173
 
174
+ - Stopping or destroying a VM ends the stream:
175
+
176
+ ```bash
177
+ # Stop VM and terminate payment stream (best-effort)
178
+ poetry run golem vm stop my-vm
179
+
180
+ # Destroy VM and terminate stream
181
+ poetry run golem vm destroy my-vm
182
+ ```
183
+
174
184
  - Create a VM and attach an existing stream (no auto-streams are created by the requestor):
175
185
 
176
186
  ```bash
@@ -195,6 +205,11 @@ Efficiency tips:
195
205
  - Withdrawals are typically executed by providers; requestors don’t need to withdraw.
196
206
  - The CLI `vm stream open` will prefer the provider’s advertised contract/token addresses to prevent mismatches.
197
207
 
208
+ Monitoring and auto top-up:
209
+
210
+ - The requestor API runs a background monitor that keeps each running VM’s stream funded with at least 1 hour runway (configurable). It checks every 30s and tops up to the target runway.
211
+ - Configure via env (prefix `GOLEM_REQUESTOR_`): `stream_monitor_enabled` (default true), `stream_monitor_interval_seconds` (default 30), `stream_min_remaining_seconds` (default 3600), `stream_topup_target_seconds` (default 3600).
212
+
198
213
  ## Faucet (L2 ETH)
199
214
 
200
215
  - Request L2 test ETH to cover stream transactions:
@@ -130,6 +130,16 @@ poetry run golem vm stream status my-vm
130
130
  poetry run golem vm stream inspect --stream-id 123
131
131
  ```
132
132
 
133
+ - Stopping or destroying a VM ends the stream:
134
+
135
+ ```bash
136
+ # Stop VM and terminate payment stream (best-effort)
137
+ poetry run golem vm stop my-vm
138
+
139
+ # Destroy VM and terminate stream
140
+ poetry run golem vm destroy my-vm
141
+ ```
142
+
133
143
  - Create a VM and attach an existing stream (no auto-streams are created by the requestor):
134
144
 
135
145
  ```bash
@@ -154,6 +164,11 @@ Efficiency tips:
154
164
  - Withdrawals are typically executed by providers; requestors don’t need to withdraw.
155
165
  - The CLI `vm stream open` will prefer the provider’s advertised contract/token addresses to prevent mismatches.
156
166
 
167
+ Monitoring and auto top-up:
168
+
169
+ - The requestor API runs a background monitor that keeps each running VM’s stream funded with at least 1 hour runway (configurable). It checks every 30s and tops up to the target runway.
170
+ - Configure via env (prefix `GOLEM_REQUESTOR_`): `stream_monitor_enabled` (default true), `stream_monitor_interval_seconds` (default 30), `stream_min_remaining_seconds` (default 3600), `stream_topup_target_seconds` (default 3600).
171
+
157
172
  ## Faucet (L2 ETH)
158
173
 
159
174
  - Request L2 test ETH to cover stream transactions:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "request-vm-on-golem"
3
- version = "0.1.49"
3
+ version = "0.1.51"
4
4
  description = "VM on Golem Requestor CLI - Create and manage virtual machines on the Golem Network"
5
5
  authors = ["Phillip Jensen <phillip+vm-on-golem@golemgrid.com>"]
6
6
  readme = "README.md"
@@ -5,11 +5,13 @@ from contextlib import asynccontextmanager
5
5
  from ..services.database_service import DatabaseService
6
6
  from ..config import config
7
7
  from ..errors import DatabaseError
8
+ from ..payments.monitor import RequestorStreamMonitor
8
9
 
9
10
  logger = logging.getLogger(__name__)
10
11
 
11
12
  # Global variable to hold the database service instance
12
13
  db_service: DatabaseService = None
14
+ stream_monitor: RequestorStreamMonitor | None = None
13
15
 
14
16
  @asynccontextmanager
15
17
  async def lifespan(app: FastAPI):
@@ -26,9 +28,15 @@ async def lifespan(app: FastAPI):
26
28
  logger.error(f"Failed to initialize database during startup: {e}")
27
29
  # Depending on requirements, you might want to prevent the app from starting
28
30
  # raise RuntimeError(f"Database initialization failed: {e}") from e
31
+ # Start requestor stream monitor
32
+ global stream_monitor
33
+ stream_monitor = RequestorStreamMonitor(db_service)
34
+ stream_monitor.start()
29
35
  yield
30
36
  # Shutdown: Cleanup (if needed)
31
37
  logger.info("Shutting down API.")
38
+ if stream_monitor:
39
+ await stream_monitor.stop()
32
40
  # No explicit cleanup needed for aiosqlite connection usually
33
41
 
34
42
  app = FastAPI(lifespan=lifespan)
@@ -56,4 +64,4 @@ async def list_vms():
56
64
  # Example of another endpoint (can be removed if not needed)
57
65
  @app.get("/")
58
66
  async def read_root():
59
- return {"message": "Golem Requestor API"}
67
+ return {"message": "Golem Requestor API"}
@@ -340,6 +340,90 @@ def vm_stream():
340
340
  pass
341
341
 
342
342
 
343
+ @vm_stream.command('list')
344
+ @click.option('--json', 'as_json', is_flag=True, help='Output in JSON format')
345
+ @async_command
346
+ async def stream_list(as_json: bool):
347
+ """List payment stream status for all known VMs."""
348
+ try:
349
+ vms = await db_service.list_vms()
350
+ if not vms:
351
+ logger.warning("No VMs found in local database")
352
+ click.echo(json.dumps({"streams": []}, indent=2) if as_json else "No VMs found.")
353
+ return {"streams": []}
354
+
355
+ results = []
356
+ for vm in vms:
357
+ item: dict = {
358
+ "name": vm.get("name"),
359
+ "provider_ip": vm.get("provider_ip"),
360
+ "stream_id": None,
361
+ "verified": False,
362
+ "reason": None,
363
+ "computed": {},
364
+ "error": None,
365
+ }
366
+ try:
367
+ provider_url = config.get_provider_url(vm['provider_ip'])
368
+ async with ProviderClient(provider_url) as client:
369
+ status = await client.get_vm_stream_status(vm['vm_id'])
370
+ item.update({
371
+ "stream_id": status.get("stream_id"),
372
+ "verified": bool(status.get("verified")),
373
+ "reason": status.get("reason"),
374
+ "computed": status.get("computed", {}),
375
+ })
376
+ except Exception as e:
377
+ msg = str(e)
378
+ # Normalize common provider errors
379
+ if "no stream mapped" in msg.lower():
380
+ item.update({
381
+ "stream_id": None,
382
+ "verified": False,
383
+ "reason": "unmapped",
384
+ })
385
+ else:
386
+ item["error"] = msg
387
+ results.append(item)
388
+
389
+ out = {"streams": results}
390
+
391
+ if as_json:
392
+ click.echo(json.dumps(out, indent=2))
393
+ else:
394
+ # Render a concise table
395
+ headers = [
396
+ "VM",
397
+ "Stream ID",
398
+ "Verified",
399
+ "Reason",
400
+ "Remaining (s)",
401
+ "Withdrawable (wei)",
402
+ ]
403
+ rows = []
404
+ for r in results:
405
+ comp = r.get("computed") or {}
406
+ rows.append([
407
+ r.get("name"),
408
+ r.get("stream_id") if r.get("stream_id") is not None else "—",
409
+ "✔" if r.get("verified") else "✖",
410
+ r.get("reason") or ("error: " + r.get("error") if r.get("error") else ""),
411
+ comp.get("remaining_seconds", ""),
412
+ comp.get("withdrawable_wei", ""),
413
+ ])
414
+ click.echo("\n" + "─" * 60)
415
+ click.echo(click.style(f" 💸 Streams ({len(results)} VMs)", fg="blue", bold=True))
416
+ click.echo("─" * 60)
417
+ click.echo("\n" + tabulate(rows, headers=[click.style(h, bold=True) for h in headers], tablefmt="grid"))
418
+ click.echo("\n" + "─" * 60)
419
+
420
+ return out
421
+
422
+ except Exception as e:
423
+ logger.error(f"Failed to list streams: {e}")
424
+ raise click.Abort()
425
+
426
+
343
427
  @vm_stream.command('open')
344
428
  @click.option('--provider-id', required=True, help='Provider ID to use')
345
429
  @click.option('--cpu', type=int, required=True, help='CPU cores for rate calc')
@@ -659,7 +743,16 @@ async def destroy_vm(name: str):
659
743
  # Initialize VM service
660
744
  provider_url = config.get_provider_url(vm['provider_ip'])
661
745
  async with ProviderClient(provider_url) as client:
662
- vm_service = VMService(db_service, SSHService(config.ssh_key_dir), client)
746
+ # Initialize blockchain client for stream termination on destroy
747
+ from ..payments.blockchain_service import StreamPaymentClient, StreamPaymentConfig
748
+ spc = StreamPaymentConfig(
749
+ rpc_url=config.polygon_rpc_url,
750
+ contract_address=config.stream_payment_address,
751
+ glm_token_address=config.glm_token_address,
752
+ private_key=config.ethereum_private_key,
753
+ )
754
+ sp_client = StreamPaymentClient(spc)
755
+ vm_service = VMService(db_service, SSHService(config.ssh_key_dir), client, sp_client)
663
756
  await vm_service.destroy_vm(name)
664
757
 
665
758
  # Show fancy success message
@@ -714,7 +807,16 @@ async def purge_vms(force: bool):
714
807
  provider_url = config.get_provider_url(vm['provider_ip'])
715
808
 
716
809
  async with ProviderClient(provider_url) as client:
717
- vm_service = VMService(db_service, SSHService(config.ssh_key_dir), client)
810
+ # Initialize blockchain client for stream termination on purge
811
+ from ..payments.blockchain_service import StreamPaymentClient, StreamPaymentConfig
812
+ spc = StreamPaymentConfig(
813
+ rpc_url=config.polygon_rpc_url,
814
+ contract_address=config.stream_payment_address,
815
+ glm_token_address=config.glm_token_address,
816
+ private_key=config.ethereum_private_key,
817
+ )
818
+ sp_client = StreamPaymentClient(spc)
819
+ vm_service = VMService(db_service, SSHService(config.ssh_key_dir), client, sp_client)
718
820
  await vm_service.destroy_vm(vm['name'])
719
821
  results['success'].append((vm['name'], 'Destroyed successfully'))
720
822
 
@@ -837,7 +939,16 @@ async def stop_vm(name: str):
837
939
  # Initialize VM service
838
940
  provider_url = config.get_provider_url(vm['provider_ip'])
839
941
  async with ProviderClient(provider_url) as client:
840
- vm_service = VMService(db_service, SSHService(config.ssh_key_dir), client)
942
+ # Initialize blockchain client for stream termination on stop
943
+ from ..payments.blockchain_service import StreamPaymentClient, StreamPaymentConfig
944
+ spc = StreamPaymentConfig(
945
+ rpc_url=config.polygon_rpc_url,
946
+ contract_address=config.stream_payment_address,
947
+ glm_token_address=config.glm_token_address,
948
+ private_key=config.ethereum_private_key,
949
+ )
950
+ sp_client = StreamPaymentClient(spc)
951
+ vm_service = VMService(db_service, SSHService(config.ssh_key_dir), client, sp_client)
841
952
  await vm_service.stop_vm(name)
842
953
 
843
954
  # Show fancy success message
@@ -113,6 +113,23 @@ class RequestorConfig(BaseSettings):
113
113
  default="",
114
114
  description="Token address (0x0 means native ETH). Defaults from l2.json"
115
115
  )
116
+ # Stream monitor (auto top-up)
117
+ stream_monitor_enabled: bool = Field(
118
+ default=True,
119
+ description="Enable background monitor to auto top-up streams"
120
+ )
121
+ stream_monitor_interval_seconds: int = Field(
122
+ default=30,
123
+ description="How frequently to check and top up streams"
124
+ )
125
+ stream_min_remaining_seconds: int = Field(
126
+ default=3600,
127
+ description="Minimum remaining runway to maintain (seconds)"
128
+ )
129
+ stream_topup_target_seconds: int = Field(
130
+ default=3600,
131
+ description="Target runway after top-up (seconds)"
132
+ )
116
133
  # Faucet settings (L2 payments)
117
134
  l2_faucet_url: str = Field(
118
135
  default="https://l2.holesky.golemdb.io/faucet",
@@ -0,0 +1,126 @@
1
+ import asyncio
2
+ from typing import Optional
3
+
4
+ from ..services.database_service import DatabaseService
5
+ from ..provider.client import ProviderClient
6
+ from ..config import config
7
+ from ..utils.logging import setup_logger
8
+ from .blockchain_service import StreamPaymentClient, StreamPaymentConfig
9
+
10
+
11
+ class RequestorStreamMonitor:
12
+ def __init__(self, db: DatabaseService):
13
+ self.db = db
14
+ self._task: Optional[asyncio.Task] = None
15
+ self._logger = setup_logger(__name__)
16
+ self._sp = StreamPaymentClient(
17
+ StreamPaymentConfig(
18
+ rpc_url=config.polygon_rpc_url,
19
+ contract_address=config.stream_payment_address,
20
+ glm_token_address=config.glm_token_address,
21
+ private_key=config.ethereum_private_key,
22
+ )
23
+ )
24
+
25
+ def start(self):
26
+ if not config.stream_monitor_enabled:
27
+ return
28
+ self._logger.info(
29
+ f"⏱️ Requestor stream auto-topup enabled interval={config.stream_monitor_interval_seconds}s "
30
+ f"min_remaining={config.stream_min_remaining_seconds}s target={config.stream_topup_target_seconds}s"
31
+ )
32
+ self._task = asyncio.create_task(self._run(), name="requestor-stream-monitor")
33
+
34
+ async def stop(self):
35
+ if self._task:
36
+ self._task.cancel()
37
+ try:
38
+ await self._task
39
+ except asyncio.CancelledError:
40
+ pass
41
+ self._logger.info("Requestor stream auto-topup stopped")
42
+
43
+ async def _resolve_stream_id(self, vm: dict) -> Optional[int]:
44
+ # Prefer local DB recorded stream_id
45
+ sid = vm.get("config", {}).get("stream_id")
46
+ if isinstance(sid, int):
47
+ return sid
48
+ # Ask provider for mapping
49
+ try:
50
+ provider_url = config.get_provider_url(vm["provider_ip"])
51
+ async with ProviderClient(provider_url) as client:
52
+ status = await client.get_vm_stream_status(vm["vm_id"])
53
+ sid = status.get("stream_id")
54
+ self._logger.debug(f"Resolved stream for VM {vm['name']}: {sid}")
55
+ return int(sid) if sid is not None else None
56
+ except Exception as e:
57
+ self._logger.debug(f"Could not resolve stream for VM {vm['name']}: {e}")
58
+ return None
59
+
60
+ async def _run(self):
61
+ interval = max(int(config.stream_monitor_interval_seconds), 5)
62
+ min_remaining = max(int(config.stream_min_remaining_seconds), 0)
63
+ target_seconds = max(int(config.stream_topup_target_seconds), min_remaining)
64
+ while True:
65
+ try:
66
+ vms = await self.db.list_vms()
67
+ self._logger.debug(f"stream monitor tick: {len(vms)} VMs to check")
68
+ for vm in vms:
69
+ # Only manage running VMs
70
+ if vm.get("status") != "running":
71
+ self._logger.debug(f"skip VM {vm.get('name')} status={vm.get('status')}")
72
+ continue
73
+ stream_id = await self._resolve_stream_id(vm)
74
+ if stream_id is None:
75
+ self._logger.debug(f"skip VM {vm.get('name')} no stream mapped")
76
+ continue
77
+ # Read on-chain stream tuple via contract
78
+ try:
79
+ token, sender, recipient, startTime, stopTime, ratePerSecond, deposit, withdrawn, halted = (
80
+ self._sp.contract.functions.streams(int(stream_id)).call()
81
+ )
82
+ except Exception as e:
83
+ self._logger.warning(f"stream lookup failed for {stream_id}: {e}")
84
+ continue
85
+ if bool(halted):
86
+ # Respect terminated streams
87
+ self._logger.debug(f"skip stream {stream_id} halted=true")
88
+ continue
89
+ # Compute remaining seconds using chain time
90
+ try:
91
+ now = int(self._sp.web3.eth.get_block("latest")["timestamp"])
92
+ except Exception as e:
93
+ self._logger.warning(f"could not get chain time: {e}")
94
+ continue
95
+ remaining = max(int(stopTime) - now, 0)
96
+ self._logger.debug(
97
+ f"VM {vm.get('name')} stream {stream_id}: remaining={remaining}s rate={int(ratePerSecond)}"
98
+ )
99
+ if remaining < min_remaining:
100
+ # Top up to reach target_seconds of runway
101
+ deficit = max(target_seconds - remaining, 0)
102
+ add_wei = int(deficit) * int(ratePerSecond)
103
+ if add_wei <= 0:
104
+ continue
105
+ try:
106
+ self._logger.info(
107
+ f"⛽ topping up stream {stream_id} by {add_wei} wei to reach {target_seconds}s"
108
+ )
109
+ self._sp.top_up(int(stream_id), int(add_wei))
110
+ self._logger.success(
111
+ f"topped up stream {stream_id} (+{add_wei} wei); VM={vm.get('name')}"
112
+ )
113
+ except Exception as e:
114
+ # Ignore failures; will retry next tick
115
+ self._logger.warning(f"top-up failed for stream {stream_id}: {e}")
116
+ else:
117
+ self._logger.debug(
118
+ f"stream {stream_id} healthy (remaining={remaining}s >= {min_remaining}s)"
119
+ )
120
+ await asyncio.sleep(interval)
121
+ except asyncio.CancelledError:
122
+ break
123
+ except Exception as e:
124
+ # Keep the monitor resilient
125
+ self._logger.error(f"requestor stream monitor error: {e}")
126
+ await asyncio.sleep(interval)
@@ -142,11 +142,11 @@ class VMService:
142
142
  # Update status in database
143
143
  await self.db.update_vm_status(name, "stopped")
144
144
 
145
- # Best-effort withdraw on stop
145
+ # Best-effort terminate stream on stop (treat stop as end of agreement)
146
146
  try:
147
147
  stream_id = vm.get('config', {}).get('stream_id')
148
148
  if stream_id is not None and self.blockchain_client:
149
- self.blockchain_client.withdraw(stream_id)
149
+ self.blockchain_client.terminate(stream_id)
150
150
  except Exception:
151
151
  pass
152
152