request-vm-on-golem 0.1.50__py3-none-any.whl → 0.1.52__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.50
3
+ Version: 0.1.52
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
@@ -41,7 +41,40 @@ Description-Content-Type: text/markdown
41
41
 
42
42
  # VM on Golem Requestor
43
43
 
44
- A sophisticated command-line interface for managing virtual machines on the Golem Network. The requestor works in tandem with provider nodes to create and manage VMs with secure SSH access.
44
+ Rent compute on demand like Airbnb for servers. The `golem` CLI helps you discover providers, fund pay‑as‑you‑go streams, launch VMs, and connect via SSH.
45
+
46
+ ## Quick Start (Rent a VM)
47
+
48
+ 1) Install:
49
+
50
+ ```bash
51
+ pip install request-vm-on-golem
52
+ ```
53
+
54
+ 2) Find providers (testnet by default):
55
+
56
+ ```bash
57
+ golem vm providers
58
+ ```
59
+
60
+ 3) Create a VM (auto‑opens a payment stream if needed):
61
+
62
+ ```bash
63
+ golem vm create my-vm --provider-id 0xProvider --cpu 2 --memory 4 --storage 20
64
+ ```
65
+
66
+ 4) SSH in:
67
+
68
+ ```bash
69
+ golem vm ssh my-vm
70
+ ```
71
+
72
+ 5) Stop or destroy when done:
73
+
74
+ ```bash
75
+ golem vm stop my-vm
76
+ golem vm destroy my-vm
77
+ ```
45
78
 
46
79
  ## Architecture Overview
47
80
 
@@ -141,7 +174,7 @@ CLI helpers
141
174
  - Open a stream for a planned VM (computes rate from provider pricing):
142
175
 
143
176
  ```bash
144
- poetry run golem vm stream open \
177
+ golem vm stream open \
145
178
  --provider-id 0xProvider \
146
179
  --cpu 2 --memory 4 --storage 20 \
147
180
  --hours 1
@@ -152,29 +185,39 @@ poetry run golem vm stream open \
152
185
 
153
186
  ```bash
154
187
  # Add 3 hours at prior rate
155
- poetry run golem vm stream topup --stream-id 123 --hours 3
188
+ golem vm stream topup --stream-id 123 --hours 3
156
189
 
157
190
  # Or specify exact GLM amount
158
- poetry run golem vm stream topup --stream-id 123 --glm 25.0
191
+ golem vm stream topup --stream-id 123 --glm 25.0
159
192
  ```
160
193
 
161
194
  - Check stream status via provider (by VM name recorded in your DB):
162
195
 
163
196
  ```bash
164
- poetry run golem vm stream status my-vm
197
+ golem vm stream status my-vm
165
198
  # add --json for machine-readable output
166
199
  ```
167
200
 
168
201
  - Inspect a stream directly on-chain:
169
202
 
170
203
  ```bash
171
- poetry run golem vm stream inspect --stream-id 123
204
+ golem vm stream inspect --stream-id 123
205
+ ```
206
+
207
+ - Stopping or destroying a VM ends the stream:
208
+
209
+ ```bash
210
+ # Stop VM and terminate payment stream (best-effort)
211
+ golem vm stop my-vm
212
+
213
+ # Destroy VM and terminate stream
214
+ golem vm destroy my-vm
172
215
  ```
173
216
 
174
217
  - Create a VM and attach an existing stream (no auto-streams are created by the requestor):
175
218
 
176
219
  ```bash
177
- poetry run golem vm create my-vm \
220
+ golem vm create my-vm \
178
221
  --provider-id 0xProvider \
179
222
  --cpu 2 --memory 4 --storage 20 \
180
223
  --stream-id 123
@@ -182,7 +225,8 @@ poetry run golem vm create my-vm \
182
225
 
183
226
  Environment (env prefix `GOLEM_REQUESTOR_`):
184
227
 
185
- - `polygon_rpc_url` — EVM RPC URL (default L2 RPC)
228
+ - `payments_network` — Payments network profile (defaults to `l2.holesky`). Profiles provide RPC + faucet defaults.
229
+ - `polygon_rpc_url` — EVM RPC URL (defaults from `payments_network` profile; can be overridden)
186
230
  - `stream_payment_address` — StreamPayment address (defaults from `contracts/deployments/l2.json`; overridden by provider info)
187
231
  - `glm_token_address` — Token address (defaults from `contracts/deployments/l2.json`; zero address means native ETH)
188
232
  - Optional override of deployments directory: set `GOLEM_DEPLOYMENTS_DIR` to a folder containing `l2.json`.
@@ -195,16 +239,21 @@ Efficiency tips:
195
239
  - Withdrawals are typically executed by providers; requestors don’t need to withdraw.
196
240
  - The CLI `vm stream open` will prefer the provider’s advertised contract/token addresses to prevent mismatches.
197
241
 
198
- ## Faucet (L2 ETH)
242
+ Monitoring and auto top-up:
243
+
244
+ - 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.
245
+ - 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).
246
+
247
+ ## Faucet (Testnet only)
199
248
 
200
249
  - Request L2 test ETH to cover stream transactions:
201
250
 
202
251
  ```bash
203
- poetry run golem wallet faucet
252
+ golem wallet faucet
204
253
  ```
205
254
 
206
255
  - Defaults:
207
- - Faucet: `https://l2.holesky.golemdb.io/faucet`
256
+ - Faucet URL and enablement come from the active `payments_network` profile. On `mainnet` (or other profiles without faucet) the command is disabled.
208
257
  - CAPTCHA: `https://cap.gobas.me/05381a2cef5e`
209
258
  - Override with env: `GOLEM_REQUESTOR_l2_faucet_url`, `GOLEM_REQUESTOR_captcha_url`, `GOLEM_REQUESTOR_captcha_api_key`.
210
259
 
@@ -212,7 +261,7 @@ poetry run golem wallet faucet
212
261
 
213
262
  ```bash
214
263
  # Install using pip
215
- pip install golem-vm-requestor
264
+ pip install request-vm-on-golem
216
265
 
217
266
  # Or install from source
218
267
  git clone https://github.com/golem/vm-on-golem.git
@@ -234,11 +283,7 @@ First, source the development environment variables:
234
283
  source .env.dev
235
284
  ```
236
285
 
237
- Then, run any `golem` command. For example:
238
-
239
- ```bash
240
- poetry run golem vm providers
241
- ```
286
+ Then, run any `golem` command. For example: `golem vm providers`
242
287
 
243
288
  ### Prepending variables
244
289
 
@@ -255,19 +300,15 @@ GOLEM_REQUESTOR_ENVIRONMENT="development" GOLEM_REQUESTOR_FORCE_LOCALHOST="true"
255
300
  - Does not determine chain selection.
256
301
 
257
302
  - Network Selection (`--network` or `GOLEM_REQUESTOR_NETWORK`)
258
- - Filters Golem Base discovery results by `golem_network=testnet|mainnet`.
259
- - Combine with the appropriate RPC envs (`GOLEM_REQUESTOR_GOLEM_BASE_RPC_URL`, `GOLEM_REQUESTOR_GOLEM_BASE_WS_URL`) and any contract addresses.
260
- - Independent from dev ergonomics.
303
+ - Filters results by `testnet|mainnet`. Defaults are sensible; most users don’t need to change anything.
304
+
305
+ - Payments Network (`GOLEM_REQUESTOR_PAYMENTS_NETWORK`)
306
+ - Selects the payments chain profile (e.g., `l2.holesky`, `mainnet`) used for streaming payments; sets default RPC and faucet behavior.
307
+ - Provider discovery filters by this payments network via `vm providers` unless `--all-payments` is supplied. Override payments filter with `--payments-network <name>`.
261
308
 
262
309
  Examples:
263
- - List providers on mainnet without changing env:
264
- ```bash
265
- poetry run golem vm providers --network mainnet
266
- ```
267
- - Create a VM while targeting testnet:
268
- ```bash
269
- poetry run golem vm create my-vm --provider-id 0xProvider --cpu 2 --memory 4 --storage 20 --network testnet
270
- ```
310
+ - List providers on mainnet without changing env: `golem vm providers --network mainnet`
311
+ - Create a VM while targeting testnet: `golem vm create my-vm --provider-id 0xProvider --cpu 2 --memory 4 --storage 20 --network testnet`
271
312
 
272
313
  ## Usage
273
314
 
@@ -373,9 +414,6 @@ The requestor uses a hierarchical configuration system:
373
414
  1. Environment Variables:
374
415
 
375
416
  ```bash
376
- # Discovery Service
377
- export GOLEM_REQUESTOR_DISCOVERY_URL="http://discovery.golem.network:9001"
378
-
379
417
  # Base Directory (default: ~/.golem)
380
418
  export GOLEM_REQUESTOR_BASE_DIR="/path/to/golem/dir"
381
419
 
@@ -386,7 +424,7 @@ export GOLEM_REQUESTOR_DB_PATH="/path/to/database.db"
386
424
  # Environment Mode (defaults to "production")
387
425
  export GOLEM_REQUESTOR_ENVIRONMENT="development" # Optional: Switch to development mode
388
426
  export GOLEM_REQUESTOR_FORCE_LOCALHOST="true" # Optional: Force localhost in development mode
389
- export GOLEM_REQUESTOR_NETWORK="testnet" # Or "mainnet"; filters Golem Base results by annotation
427
+ export GOLEM_REQUESTOR_NETWORK="testnet" # Or "mainnet"; optional filter for listing/creation
390
428
  ```
391
429
 
392
430
  2. Directory Structure:
@@ -423,7 +461,7 @@ Local state is maintained in SQLite:
423
461
 
424
462
  The requestor communicates with providers through:
425
463
 
426
- 1. Discovery service for provider location
464
+ 1. Network discovery (uses sane defaults; no setup required for most users)
427
465
  2. Direct API calls for VM management
428
466
  3. SSH proxy system for secure access
429
467
  4. Resource tracking for capacity management
@@ -1,27 +1,28 @@
1
1
  requestor/__init__.py,sha256=OqSUAh1uZBMx7GW0MoSMg967PVdmT8XdPJx3QYjwkak,116
2
- requestor/api/main.py,sha256=7utCzFNbh5Ol-vsBWeSwT4lXeHD7zdA-GFZuS3rHMWc,2180
2
+ requestor/api/main.py,sha256=CTnaM7KyBtDwVlyclYbNDy-nGi5_xt9GTcGusRasDVY,2493
3
3
  requestor/cli/__init__.py,sha256=e3E4oEGxmGj-STPtFkQwg_qIWhR0JAiAQdw3G1hXciU,37
4
- requestor/cli/commands.py,sha256=JfDvDYNvji1uIUF-0fHEHZxLzHWl-NslBdG1Yw2sFuY,45748
5
- requestor/config.py,sha256=2ayNJzvIIoU0jMAVqbs-yfG4H63W_uALLScBG4EjUOw,8241
4
+ requestor/cli/commands.py,sha256=LILQeW8-aqosSkCU4iVzov542y8m5bggSO4yJi__qSk,48544
5
+ requestor/config.py,sha256=GPsr_NSj04MD40mH6xyVY5FB0ysFDwJk9FJ5h9OQuJ0,12716
6
6
  requestor/data/deployments/l2.json,sha256=XTNN2C5LkBfp4YbDKdUKfWMdp1fKnfv8D3TgcwVWxtQ,249
7
7
  requestor/db/__init__.py,sha256=Gm5DfWls6uvCZZ3HGGnyRHswbUQdeA5OGN8yPwH0hc8,88
8
8
  requestor/db/sqlite.py,sha256=l5pWbx2qlHuar1N_a0B9tVnmumLJY1w5rp3yZ7jmsC0,4146
9
9
  requestor/errors.py,sha256=wVpHBuYgQx5pTe_SamugfK-k768noikY1RxvPOjQGko,665
10
10
  requestor/payments/blockchain_service.py,sha256=CACvZH2ZstutX7f0L_PXl8K_V5WlIkxNYIaeJuhP5I0,7500
11
+ requestor/payments/monitor.py,sha256=JtSnh2plFf-f8sJU-bkOpadhoK_R82_ULwkDRmBYSbc,6012
11
12
  requestor/provider/__init__.py,sha256=fmW23aYUVciF8-gmBZkG-PLhn22upmcDzdPfAOLHG6g,103
12
13
  requestor/provider/client.py,sha256=pfJymufYR13W4kfykHZSVvs6ikRUE5AdHp0W0DB17AE,4130
13
14
  requestor/run.py,sha256=GqOG6n34szt8Sp3SEqjRV6huWm737yCN6YnBqoiwI-U,1785
14
- requestor/security/faucet.py,sha256=35d8mD3fM0YqRIhUXuIKandOL8vbw2T2IFQWVsan9Lw,2056
15
+ requestor/security/faucet.py,sha256=XF_13b66SKAaY0-40hNRcSgC8AZA4mD5gyXl3qaBLpQ,2320
15
16
  requestor/services/__init__.py,sha256=1qSn_6RMn0KB0A7LCnY2IW6_tC3HBQsdfkFeV-h94eM,172
16
17
  requestor/services/database_service.py,sha256=GlSrzzzd7PSYQJNup00sxkB-B2PMr1__04K8k5QSWvs,2996
17
- requestor/services/provider_service.py,sha256=eb4t6tkcw9VzJev2sfawT1KvVc5TxQnb1pgYgoQZcM4,15000
18
+ requestor/services/provider_service.py,sha256=UjCg6YOwhkclJ227gXfI5uCTinhBv6Jjfsgxq0x0ESo,15701
18
19
  requestor/services/ssh_service.py,sha256=tcOCtk2SlB9Uuv-P2ghR22e7BJ9kigQh5b4zSGdPFns,4280
19
- requestor/services/vm_service.py,sha256=eQ2pPMpYlfPVbVFrkFElsRO5swPq-2XZEfuvxagyHDk,7941
20
+ requestor/services/vm_service.py,sha256=1EUypRbCykdQTVJf4gYiNzkZNk66T3df32Vc51HkSMI,7983
20
21
  requestor/ssh/__init__.py,sha256=hNgSqJ5s1_AwwxVRyFjUqh_LTBpI4Hmzq0F-f_wXN9g,119
21
22
  requestor/ssh/manager.py,sha256=3jQtbbK7CVC2yD1zCO88jGXh2fBcuv3CzWEqDLuaQVk,9758
22
23
  requestor/utils/logging.py,sha256=oFNpO8pJboYM8Wp7g3HOU4HFyBTKypVdY15lUiz1a4I,3721
23
24
  requestor/utils/spinner.py,sha256=PUHJdTD9jpUHur__01_qxXy87WFfNmjQbD_sLG-KlGo,2459
24
- request_vm_on_golem-0.1.50.dist-info/METADATA,sha256=EWV0bEfyWQeyBldHWHx3nUgOmZmD_5b5K6Btyp6VQt4,14363
25
- request_vm_on_golem-0.1.50.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
26
- request_vm_on_golem-0.1.50.dist-info/entry_points.txt,sha256=Z-skRNpJ8aZcIl_En9mEm1ygkp9FKy0bzQoL3zO52-0,44
27
- request_vm_on_golem-0.1.50.dist-info/RECORD,,
25
+ request_vm_on_golem-0.1.52.dist-info/METADATA,sha256=Spq__-pjr5GGH2oN0FTrfFZ5EvmicP4l0IpCXoUQu2A,15687
26
+ request_vm_on_golem-0.1.52.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
27
+ request_vm_on_golem-0.1.52.dist-info/entry_points.txt,sha256=Z-skRNpJ8aZcIl_En9mEm1ygkp9FKy0bzQoL3zO52-0,44
28
+ request_vm_on_golem-0.1.52.dist-info/RECORD,,
requestor/api/main.py CHANGED
@@ -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"}
requestor/cli/commands.py CHANGED
@@ -101,11 +101,13 @@ def vm():
101
101
  @click.option('--storage', type=int, help='Minimum disk (GB) required')
102
102
  @click.option('--country', help='Preferred provider country')
103
103
  @click.option('--driver', type=click.Choice(['central', 'golem-base']), default=None, help='Discovery driver to use')
104
+ @click.option('--payments-network', type=str, default=None, help='Filter by payments network profile (default: current config)')
105
+ @click.option('--all-payments', is_flag=True, help='Do not filter by payments network (show all)')
104
106
  @click.option('--json', 'as_json', is_flag=True, help='Output in JSON format')
105
107
  @click.option('--network', type=click.Choice(['testnet', 'mainnet']), default=None,
106
108
  help='Override network filter for this command')
107
109
  @async_command
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):
110
+ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Optional[int], country: Optional[str], driver: Optional[str], payments_network: Optional[str] = None, all_payments: bool = False, as_json: bool = False, network: Optional[str] = None):
109
111
  """List available providers matching requirements."""
110
112
  try:
111
113
  if network:
@@ -124,7 +126,8 @@ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Opt
124
126
 
125
127
  # Determine the discovery driver being used
126
128
  discovery_driver = driver or config.discovery_driver
127
- logger.process(f"Querying discovery service via {discovery_driver} (network={config.network})")
129
+ eff_pn = payments_network if payments_network is not None else getattr(config, 'payments_network', None)
130
+ logger.process(f"Querying discovery via {discovery_driver} (network={config.network}, payments={eff_pn if not all_payments else 'ALL'})")
128
131
 
129
132
  # Initialize provider service
130
133
  provider_service = ProviderService()
@@ -132,13 +135,21 @@ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Opt
132
135
  # If a full spec is provided, enable per-provider estimate display
133
136
  if cpu and memory and storage:
134
137
  provider_service.estimate_spec = (cpu, memory, storage)
135
- providers = await provider_service.find_providers(
136
- cpu=cpu,
137
- memory=memory,
138
- storage=storage,
139
- country=country,
140
- driver=driver
141
- )
138
+ try:
139
+ providers = await provider_service.find_providers(
140
+ cpu=cpu,
141
+ memory=memory,
142
+ storage=storage,
143
+ country=country,
144
+ driver=driver,
145
+ payments_network=eff_pn,
146
+ include_all_payments=bool(all_payments),
147
+ )
148
+ except TypeError:
149
+ # Backward compatibility with older/dummy service stubs in tests
150
+ providers = await provider_service.find_providers(
151
+ cpu=cpu, memory=memory, storage=storage, country=country, driver=driver
152
+ )
142
153
 
143
154
  if not providers:
144
155
  logger.warning("No providers found matching criteria")
@@ -606,6 +617,10 @@ def wallet():
606
617
  async def wallet_faucet():
607
618
  """Request L2 faucet funds for the requestor's payment address."""
608
619
  try:
620
+ if not getattr(config, 'faucet_enabled', False):
621
+ logger.warning("Faucet is disabled for the current payments network.")
622
+ click.echo(json.dumps({"error": "faucet_disabled", "network": getattr(config, 'payments_network', None)}, indent=2))
623
+ return
609
624
  from ..security.faucet import L2FaucetService
610
625
  from eth_account import Account
611
626
  acct = Account.from_key(config.ethereum_private_key)
@@ -743,7 +758,16 @@ async def destroy_vm(name: str):
743
758
  # Initialize VM service
744
759
  provider_url = config.get_provider_url(vm['provider_ip'])
745
760
  async with ProviderClient(provider_url) as client:
746
- vm_service = VMService(db_service, SSHService(config.ssh_key_dir), client)
761
+ # Initialize blockchain client for stream termination on destroy
762
+ from ..payments.blockchain_service import StreamPaymentClient, StreamPaymentConfig
763
+ spc = StreamPaymentConfig(
764
+ rpc_url=config.polygon_rpc_url,
765
+ contract_address=config.stream_payment_address,
766
+ glm_token_address=config.glm_token_address,
767
+ private_key=config.ethereum_private_key,
768
+ )
769
+ sp_client = StreamPaymentClient(spc)
770
+ vm_service = VMService(db_service, SSHService(config.ssh_key_dir), client, sp_client)
747
771
  await vm_service.destroy_vm(name)
748
772
 
749
773
  # Show fancy success message
@@ -798,7 +822,16 @@ async def purge_vms(force: bool):
798
822
  provider_url = config.get_provider_url(vm['provider_ip'])
799
823
 
800
824
  async with ProviderClient(provider_url) as client:
801
- vm_service = VMService(db_service, SSHService(config.ssh_key_dir), client)
825
+ # Initialize blockchain client for stream termination on purge
826
+ from ..payments.blockchain_service import StreamPaymentClient, StreamPaymentConfig
827
+ spc = StreamPaymentConfig(
828
+ rpc_url=config.polygon_rpc_url,
829
+ contract_address=config.stream_payment_address,
830
+ glm_token_address=config.glm_token_address,
831
+ private_key=config.ethereum_private_key,
832
+ )
833
+ sp_client = StreamPaymentClient(spc)
834
+ vm_service = VMService(db_service, SSHService(config.ssh_key_dir), client, sp_client)
802
835
  await vm_service.destroy_vm(vm['name'])
803
836
  results['success'].append((vm['name'], 'Destroyed successfully'))
804
837
 
@@ -921,7 +954,16 @@ async def stop_vm(name: str):
921
954
  # Initialize VM service
922
955
  provider_url = config.get_provider_url(vm['provider_ip'])
923
956
  async with ProviderClient(provider_url) as client:
924
- vm_service = VMService(db_service, SSHService(config.ssh_key_dir), client)
957
+ # Initialize blockchain client for stream termination on stop
958
+ from ..payments.blockchain_service import StreamPaymentClient, StreamPaymentConfig
959
+ spc = StreamPaymentConfig(
960
+ rpc_url=config.polygon_rpc_url,
961
+ contract_address=config.stream_payment_address,
962
+ glm_token_address=config.glm_token_address,
963
+ private_key=config.ethereum_private_key,
964
+ )
965
+ sp_client = StreamPaymentClient(spc)
966
+ vm_service = VMService(db_service, SSHService(config.ssh_key_dir), client, sp_client)
925
967
  await vm_service.stop_vm(name)
926
968
 
927
969
  # Show fancy success message
requestor/config.py CHANGED
@@ -54,6 +54,13 @@ class RequestorConfig(BaseSettings):
54
54
  description="Target network: 'testnet' or 'mainnet'"
55
55
  )
56
56
 
57
+ # Payments chain selection (modular network profiles)
58
+ # Keep current standard as l2.holesky
59
+ payments_network: str = Field(
60
+ default="l2.holesky",
61
+ description="Payments network profile (e.g., 'l2.holesky', 'kaolin.holesky', 'mainnet')"
62
+ )
63
+
57
64
  # Development Settings
58
65
  force_localhost: bool = Field(
59
66
  default=False,
@@ -102,8 +109,8 @@ class RequestorConfig(BaseSettings):
102
109
 
103
110
  # Payments (EVM RPC)
104
111
  polygon_rpc_url: str = Field(
105
- default="https://l2.holesky.golemdb.io/rpc",
106
- description="EVM RPC URL for streaming payments (L2 by default)"
112
+ default="",
113
+ description="EVM RPC URL for streaming payments; defaults from payments_network profile"
107
114
  )
108
115
  stream_payment_address: str = Field(
109
116
  default="",
@@ -113,10 +120,27 @@ class RequestorConfig(BaseSettings):
113
120
  default="",
114
121
  description="Token address (0x0 means native ETH). Defaults from l2.json"
115
122
  )
116
- # Faucet settings (L2 payments)
123
+ # Stream monitor (auto top-up)
124
+ stream_monitor_enabled: bool = Field(
125
+ default=True,
126
+ description="Enable background monitor to auto top-up streams"
127
+ )
128
+ stream_monitor_interval_seconds: int = Field(
129
+ default=30,
130
+ description="How frequently to check and top up streams"
131
+ )
132
+ stream_min_remaining_seconds: int = Field(
133
+ default=3600,
134
+ description="Minimum remaining runway to maintain (seconds)"
135
+ )
136
+ stream_topup_target_seconds: int = Field(
137
+ default=3600,
138
+ description="Target runway after top-up (seconds)"
139
+ )
140
+ # Faucet settings (payments)
117
141
  l2_faucet_url: str = Field(
118
- default="https://l2.holesky.golemdb.io/faucet",
119
- description="L2 faucet base URL (no trailing /api)"
142
+ default="",
143
+ description="Faucet base URL (no trailing /api). Only used on testnets. Defaults from payments_network profile"
120
144
  )
121
145
  captcha_url: str = Field(
122
146
  default="https://cap.gobas.me",
@@ -133,8 +157,8 @@ class RequestorConfig(BaseSettings):
133
157
 
134
158
  @field_validator("polygon_rpc_url", mode='before')
135
159
  @classmethod
136
- def prefer_alt_env(cls, v: str) -> str:
137
- # Accept alt aliases
160
+ def prefer_alt_env(cls, v: str, info: ValidationInfo) -> str:
161
+ # Accept alt aliases overriding the profile
138
162
  for key in (
139
163
  "GOLEM_REQUESTOR_l2_rpc_url",
140
164
  "GOLEM_REQUESTOR_L2_RPC_URL",
@@ -143,22 +167,45 @@ class RequestorConfig(BaseSettings):
143
167
  ):
144
168
  if os.environ.get(key):
145
169
  return os.environ[key]
146
- return v
170
+ if v:
171
+ return v
172
+ # Default from payments profile
173
+ pn = info.data.get("payments_network") or "l2.holesky"
174
+ return RequestorConfig._profile_defaults(pn)["rpc_url"]
175
+
176
+ @field_validator("l2_faucet_url", mode='before')
177
+ @classmethod
178
+ def default_faucet_env(cls, v: str, info: ValidationInfo) -> str:
179
+ for key in (
180
+ "GOLEM_REQUESTOR_l2_faucet_url",
181
+ "GOLEM_REQUESTOR_L2_FAUCET_URL",
182
+ ):
183
+ if os.environ.get(key):
184
+ return os.environ[key]
185
+ if v:
186
+ return v
187
+ pn = info.data.get("payments_network") or "l2.holesky"
188
+ return RequestorConfig._profile_defaults(pn).get("faucet_url", "")
147
189
 
148
190
  @staticmethod
149
- def _load_l2_deployment() -> tuple[str | None, str | None]:
191
+ def _load_deployment(network: str) -> tuple[str | None, str | None]:
150
192
  try:
151
193
  base = os.environ.get("GOLEM_DEPLOYMENTS_DIR")
152
194
  if base:
153
- path = Path(base) / "l2.json"
195
+ path = Path(base) / f"{RequestorConfig._deployment_basename(network)}.json"
154
196
  else:
155
197
  # repo root assumption: ../../ relative to this file
156
- path = Path(__file__).resolve().parents[2] / "contracts" / "deployments" / "l2.json"
198
+ path = (
199
+ Path(__file__).resolve().parents[2]
200
+ / "contracts" / "deployments" / f"{RequestorConfig._deployment_basename(network)}.json"
201
+ )
157
202
  if not path.exists():
158
203
  # Try package resource fallback
159
204
  try:
160
205
  import importlib.resources as ir
161
- with ir.files("requestor.data.deployments").joinpath("l2.json").open("r") as fh: # type: ignore[attr-defined]
206
+ with ir.files("requestor.data.deployments").joinpath(
207
+ f"{RequestorConfig._deployment_basename(network)}.json"
208
+ ).open("r") as fh: # type: ignore[attr-defined]
162
209
  import json as _json
163
210
  data = _json.load(fh)
164
211
  except Exception:
@@ -175,22 +222,83 @@ class RequestorConfig(BaseSettings):
175
222
  pass
176
223
  return None, None
177
224
 
225
+ @staticmethod
226
+ def _deployment_basename(network: str) -> str:
227
+ # Map well-known network aliases to deployment file base names
228
+ n = (network or "").lower()
229
+ if n in ("l2", "l2.holesky"): # current standard
230
+ return "l2"
231
+ if "." in n:
232
+ return n.split(".")[0]
233
+ return n or "l2"
234
+
235
+ @staticmethod
236
+ def _profile_defaults(network: str) -> Dict[str, str]:
237
+ n = (network or "l2.holesky").lower()
238
+ # Built-in profiles; extend easily in future
239
+ profiles = {
240
+ "l2.holesky": {
241
+ "rpc_url": "https://l2.holesky.golemdb.io/rpc",
242
+ "faucet_url": "https://l2.holesky.golemdb.io/faucet",
243
+ "faucet_enabled": True,
244
+ "token_symbol": "GLM",
245
+ "gas_symbol": "ETH",
246
+ },
247
+ # Example: mainnet has no faucet by default
248
+ "mainnet": {
249
+ "rpc_url": "",
250
+ "faucet_url": "",
251
+ "faucet_enabled": False,
252
+ "token_symbol": "GLM",
253
+ "gas_symbol": "ETH",
254
+ },
255
+ }
256
+ return profiles.get(n, profiles["l2.holesky"]) # default to current standard
257
+
178
258
  @field_validator("stream_payment_address", mode='before')
179
259
  @classmethod
180
- def default_stream_addr(cls, v: str) -> str:
260
+ def default_stream_addr(cls, v: str, info: ValidationInfo) -> str:
181
261
  if v:
182
262
  return v
183
- addr, _ = RequestorConfig._load_l2_deployment()
263
+ network = info.data.get("payments_network") or "l2.holesky"
264
+ addr, _ = RequestorConfig._load_deployment(network)
184
265
  return addr or "0x0000000000000000000000000000000000000000"
185
266
 
186
267
  @field_validator("glm_token_address", mode='before')
187
268
  @classmethod
188
- def default_token_addr(cls, v: str) -> str:
269
+ def default_token_addr(cls, v: str, info: ValidationInfo) -> str:
189
270
  if v:
190
271
  return v
191
- _, token = RequestorConfig._load_l2_deployment()
272
+ network = info.data.get("payments_network") or "l2.holesky"
273
+ _, token = RequestorConfig._load_deployment(network)
192
274
  return token or "0x0000000000000000000000000000000000000000"
193
275
 
276
+ # Optional convenience: expose token and gas symbols based on profile
277
+ token_symbol: str = Field(
278
+ default="",
279
+ description="Human-friendly symbol of payment token (e.g., GLM)"
280
+ )
281
+ gas_token_symbol: str = Field(
282
+ default="",
283
+ description="Symbol of gas token for the chain (e.g., ETH)"
284
+ )
285
+
286
+ @field_validator("token_symbol", mode="before")
287
+ @classmethod
288
+ def default_token_symbol(cls, v: str, info: ValidationInfo) -> str:
289
+ if v:
290
+ return v
291
+ pn = info.data.get("payments_network") or "l2.holesky"
292
+ return RequestorConfig._profile_defaults(pn).get("token_symbol", "")
293
+
294
+ @field_validator("gas_token_symbol", mode="before")
295
+ @classmethod
296
+ def default_gas_symbol(cls, v: str, info: ValidationInfo) -> str:
297
+ if v:
298
+ return v
299
+ pn = info.data.get("payments_network") or "l2.holesky"
300
+ return RequestorConfig._profile_defaults(pn).get("gas_symbol", "")
301
+
194
302
  # Base Directory
195
303
  base_dir: Path = Field(
196
304
  default_factory=lambda: Path.home() / ".golem" / "requestor",
@@ -223,6 +331,11 @@ class RequestorConfig(BaseSettings):
223
331
  kwargs['db_path'] = base_dir / "vms.db"
224
332
  super().__init__(**kwargs)
225
333
 
334
+ @property
335
+ def faucet_enabled(self) -> bool:
336
+ """Whether requesting funds from faucet is allowed for current payments network."""
337
+ return bool(self._profile_defaults(self.payments_network).get("faucet_enabled", False))
338
+
226
339
  def get_provider_url(self, ip_address: str) -> str:
227
340
  """Get provider API URL.
228
341
 
@@ -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)
@@ -15,7 +15,7 @@ class L2FaucetService:
15
15
  self.cfg = config
16
16
  self.web3 = Web3(Web3.HTTPProvider(config.polygon_rpc_url))
17
17
  self.client = PowFaucetClient(
18
- faucet_url=getattr(config, 'l2_faucet_url', 'https://l2.holesky.golemdb.io/faucet'),
18
+ faucet_url=getattr(config, 'l2_faucet_url', '') or 'https://l2.holesky.golemdb.io/faucet',
19
19
  captcha_base_url=getattr(config, 'captcha_url', 'https://cap.gobas.me'),
20
20
  captcha_api_key=getattr(config, 'captcha_api_key', '05381a2cef5e'),
21
21
  )
@@ -29,6 +29,10 @@ class L2FaucetService:
29
29
  return 0.0
30
30
 
31
31
  async def request_funds(self, address: str) -> Optional[str]:
32
+ # Disallow faucet if explicitly disabled by profile
33
+ if hasattr(self.cfg, 'faucet_enabled') and not getattr(self.cfg, 'faucet_enabled'):
34
+ logger.info("Faucet disabled for current payments network; skipping.")
35
+ return None
32
36
  bal = self._balance_eth(address)
33
37
  if bal > 0.01:
34
38
  logger.info(f"Sufficient L2 funds ({bal} ETH), skipping faucet.")
@@ -51,4 +55,3 @@ class L2FaucetService:
51
55
  if tx:
52
56
  logger.success(f"L2 faucet sent tx: {tx}")
53
57
  return tx
54
-
@@ -66,7 +66,9 @@ class ProviderService:
66
66
  memory: Optional[int] = None,
67
67
  storage: Optional[int] = None,
68
68
  country: Optional[str] = None,
69
- driver: Optional[str] = None
69
+ driver: Optional[str] = None,
70
+ payments_network: Optional[str] = None,
71
+ include_all_payments: bool = False,
70
72
  ) -> List[Dict]:
71
73
  """Find providers matching requirements."""
72
74
  discovery_driver = driver or config.discovery_driver
@@ -79,7 +81,11 @@ class ProviderService:
79
81
  ws_url=config.golem_base_ws_url,
80
82
  private_key=private_key_bytes,
81
83
  )
82
- return await self._find_providers_golem_base(cpu, memory, storage, country)
84
+ return await self._find_providers_golem_base(
85
+ cpu, memory, storage, country,
86
+ payments_network=payments_network,
87
+ include_all_payments=include_all_payments,
88
+ )
83
89
  else:
84
90
  return await self._find_providers_central(cpu, memory, storage, country)
85
91
 
@@ -88,7 +94,9 @@ class ProviderService:
88
94
  cpu: Optional[int] = None,
89
95
  memory: Optional[int] = None,
90
96
  storage: Optional[int] = None,
91
- country: Optional[str] = None
97
+ country: Optional[str] = None,
98
+ payments_network: Optional[str] = None,
99
+ include_all_payments: bool = False,
92
100
  ) -> List[Dict]:
93
101
  """Find providers using Golem Base."""
94
102
  try:
@@ -104,6 +112,10 @@ class ProviderService:
104
112
  # Filter by advertised network to avoid cross-network results
105
113
  if config.network:
106
114
  query += f' && golem_network="{config.network}"'
115
+ # Filter by payments network unless explicitly disabled
116
+ pn = payments_network if payments_network is not None else getattr(config, 'payments_network', None)
117
+ if pn and not include_all_payments:
118
+ query += f' && golem_payments_network="{pn}"'
107
119
  if cpu:
108
120
  query += f' && golem_cpu>={cpu}'
109
121
  if memory:
@@ -130,6 +142,7 @@ class ProviderService:
130
142
  'provider_name': annotations.get('golem_provider_name'),
131
143
  'ip_address': annotations.get('golem_ip_address'),
132
144
  'country': annotations.get('golem_country'),
145
+ 'payments_network': annotations.get('golem_payments_network'),
133
146
  'resources': {
134
147
  'cpu': int(annotations.get('golem_cpu', 0)),
135
148
  'memory': int(annotations.get('golem_memory', 0)),
@@ -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