golem-vm-provider 0.1.59__tar.gz → 0.1.61__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 (45) hide show
  1. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/PKG-INFO +5 -5
  2. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/README.md +4 -4
  3. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/api/routes.py +12 -2
  4. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/config.py +48 -3
  5. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/discovery/golem_base_advertiser.py +37 -5
  6. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/main.py +7 -5
  7. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/payments/monitor.py +62 -12
  8. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/vm/multipass_adapter.py +49 -15
  9. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/vm/service.py +3 -0
  10. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/pyproject.toml +1 -1
  11. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/__init__.py +0 -0
  12. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/api/__init__.py +0 -0
  13. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/api/models.py +0 -0
  14. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/container.py +0 -0
  15. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/data/deployments/l2.json +0 -0
  16. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/discovery/__init__.py +0 -0
  17. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/discovery/advertiser.py +0 -0
  18. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/discovery/golem_base_utils.py +0 -0
  19. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/discovery/multi_advertiser.py +0 -0
  20. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/discovery/resource_monitor.py +0 -0
  21. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/discovery/resource_tracker.py +0 -0
  22. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/discovery/service.py +0 -0
  23. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/jobs/store.py +0 -0
  24. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/network/port_verifier.py +0 -0
  25. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/payments/blockchain_service.py +0 -0
  26. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/payments/stream_map.py +0 -0
  27. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/security/ethereum.py +0 -0
  28. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/security/faucet.py +0 -0
  29. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/security/l2_faucet.py +0 -0
  30. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/service.py +0 -0
  31. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/utils/__init__.py +0 -0
  32. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/utils/ascii_art.py +0 -0
  33. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/utils/logging.py +0 -0
  34. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/utils/port_display.py +0 -0
  35. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/utils/pricing.py +0 -0
  36. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/utils/retry.py +0 -0
  37. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/utils/setup.py +0 -0
  38. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/vm/__init__.py +0 -0
  39. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/vm/cloud_init.py +0 -0
  40. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/vm/models.py +0 -0
  41. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/vm/multipass.py +0 -0
  42. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/vm/name_mapper.py +0 -0
  43. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/vm/port_manager.py +0 -0
  44. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/vm/provider.py +0 -0
  45. {golem_vm_provider-0.1.59 → golem_vm_provider-0.1.61}/provider/vm/proxy_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: golem-vm-provider
3
- Version: 0.1.59
3
+ Version: 0.1.61
4
4
  Summary: VM on Golem Provider Node - Run your own provider node to offer VMs on the Golem Network
5
5
  Keywords: golem,vm,provider,cloud,decentralized
6
6
  Author: Phillip Jensen
@@ -456,7 +456,7 @@ Response (per stream):
456
456
 
457
457
  Notes:
458
458
  - Endpoints return 400 when streaming is disabled (zero `STREAM_PAYMENT_ADDRESS`).
459
- - In development mode (`GOLEM_PROVIDER_ENVIRONMENT=development`) additional debug logs are emitted around stream verification and monitor ticks.
459
+ - In development mode (`GOLEM_ENVIRONMENT=development`) additional debug logs are emitted around stream verification and monitor ticks.
460
460
 
461
461
  ## Operations
462
462
 
@@ -467,7 +467,7 @@ Notes:
467
467
  golem-provider start
468
468
 
469
469
  # Development mode with extra logs and reload
470
- GOLEM_PROVIDER_ENVIRONMENT=development golem-provider start --network testnet
470
+ GOLEM_ENVIRONMENT=development golem-provider start --network testnet
471
471
  ```
472
472
 
473
473
  Run as a background service (no terminal):
@@ -485,7 +485,7 @@ golem-provider status [--json]
485
485
 
486
486
  ### Mode vs. Network
487
487
 
488
- - Development Mode (`GOLEM_PROVIDER_ENVIRONMENT=development`)
488
+ - Development Mode (`GOLEM_ENVIRONMENT=development`)
489
489
  - Optimizes for local iteration: enables reload + debug logging and uses local defaults (e.g., local port check servers). May derive a local/LAN IP automatically and prefix the provider name with `DEVMODE-`.
490
490
  - Does not decide which chain you target.
491
491
 
@@ -498,7 +498,7 @@ golem-provider status [--json]
498
498
  - Selects the payments chain profile (e.g., `l2.holesky`, `mainnet`). Determines default payments RPC, faucet enablement, and symbols.
499
499
 
500
500
  Common setups:
501
- - Local dev on testnet: `GOLEM_PROVIDER_ENVIRONMENT=development` plus `--network testnet`.
501
+ - Local dev on testnet: `GOLEM_ENVIRONMENT=development` plus `--network testnet`.
502
502
  - Staging on testnet: keep `ENVIRONMENT=production`, set `--network testnet` and testnet RPCs.
503
503
  - Production on mainnet: `ENVIRONMENT=production` with `--network mainnet` and mainnet RPCs.
504
504
 
@@ -411,7 +411,7 @@ Response (per stream):
411
411
 
412
412
  Notes:
413
413
  - Endpoints return 400 when streaming is disabled (zero `STREAM_PAYMENT_ADDRESS`).
414
- - In development mode (`GOLEM_PROVIDER_ENVIRONMENT=development`) additional debug logs are emitted around stream verification and monitor ticks.
414
+ - In development mode (`GOLEM_ENVIRONMENT=development`) additional debug logs are emitted around stream verification and monitor ticks.
415
415
 
416
416
  ## Operations
417
417
 
@@ -422,7 +422,7 @@ Notes:
422
422
  golem-provider start
423
423
 
424
424
  # Development mode with extra logs and reload
425
- GOLEM_PROVIDER_ENVIRONMENT=development golem-provider start --network testnet
425
+ GOLEM_ENVIRONMENT=development golem-provider start --network testnet
426
426
  ```
427
427
 
428
428
  Run as a background service (no terminal):
@@ -440,7 +440,7 @@ golem-provider status [--json]
440
440
 
441
441
  ### Mode vs. Network
442
442
 
443
- - Development Mode (`GOLEM_PROVIDER_ENVIRONMENT=development`)
443
+ - Development Mode (`GOLEM_ENVIRONMENT=development`)
444
444
  - Optimizes for local iteration: enables reload + debug logging and uses local defaults (e.g., local port check servers). May derive a local/LAN IP automatically and prefix the provider name with `DEVMODE-`.
445
445
  - Does not decide which chain you target.
446
446
 
@@ -453,7 +453,7 @@ golem-provider status [--json]
453
453
  - Selects the payments chain profile (e.g., `l2.holesky`, `mainnet`). Determines default payments RPC, faucet enablement, and symbols.
454
454
 
455
455
  Common setups:
456
- - Local dev on testnet: `GOLEM_PROVIDER_ENVIRONMENT=development` plus `--network testnet`.
456
+ - Local dev on testnet: `GOLEM_ENVIRONMENT=development` plus `--network testnet`.
457
457
  - Staging on testnet: keep `ENVIRONMENT=production`, set `--network testnet` and testnet RPCs.
458
458
  - Production on mainnet: `ENVIRONMENT=production` with `--network mainnet` and mainnet RPCs.
459
459
 
@@ -31,6 +31,12 @@ from ..vm.multipass_adapter import MultipassError
31
31
  logger = setup_logger(__name__)
32
32
  router = APIRouter()
33
33
 
34
+ # Expose Settings class at module scope for tests to monkeypatch default deployment lookup
35
+ try:
36
+ from ..config import Settings as _Cfg # type: ignore
37
+ except Exception: # noqa: BLE001
38
+ _Cfg = None # type: ignore
39
+
34
40
  # Job status persisted in SQLite via JobStore (see Container.job_store)
35
41
 
36
42
 
@@ -56,8 +62,10 @@ async def create_vm(
56
62
  if spa and spa != "0x0000000000000000000000000000000000000000":
57
63
  if os.environ.get("PYTEST_CURRENT_TEST"):
58
64
  try:
59
- from ..config import Settings as _Cfg # type: ignore
60
- default_spa, _ = _Cfg._load_l2_deployment() # type: ignore[attr-defined]
65
+ if _Cfg is not None:
66
+ default_spa, _ = _Cfg._load_l2_deployment() # type: ignore[attr-defined]
67
+ else:
68
+ default_spa = None
61
69
  except Exception:
62
70
  default_spa = None
63
71
  if not default_spa or spa.lower() != default_spa.lower():
@@ -302,6 +310,8 @@ async def provider_info(settings: Any = Depends(Provide[Container.config])) -> P
302
310
  provider_id=settings["PROVIDER_ID"],
303
311
  stream_payment_address=settings["STREAM_PAYMENT_ADDRESS"],
304
312
  glm_token_address=settings["GLM_TOKEN_ADDRESS"],
313
+ # Provide ETH-focused alias for clients; keep legacy field too
314
+ eth_token_address=settings["GLM_TOKEN_ADDRESS"],
305
315
  ip_address=ip_addr,
306
316
  country=(settings.get("PROVIDER_COUNTRY") if isinstance(settings, dict) else getattr(settings, "PROVIDER_COUNTRY", None)),
307
317
  platform=platform_str,
@@ -27,7 +27,7 @@ def ensure_config() -> None:
27
27
  created = True
28
28
 
29
29
  if not env_file.exists():
30
- env_file.write_text("GOLEM_PROVIDER_ENVIRONMENT=production\n")
30
+ env_file.write_text("GOLEM_ENVIRONMENT=production\n")
31
31
  created = True
32
32
 
33
33
  from .security.ethereum import EthereumIdentity
@@ -55,8 +55,12 @@ class Settings(BaseSettings):
55
55
  PORT: int = 7466
56
56
  SKIP_PORT_VERIFICATION: bool = False
57
57
  ENVIRONMENT: str = "production"
58
- # Logical network selector for annotation and client defaults
59
- NETWORK: str = "mainnet" # one of: "testnet", "mainnet"
58
+ # Logical network selector for advertisement scope and client defaults
59
+ # If not explicitly provided, computed by validator below (dev -> testnet, else -> mainnet)
60
+ NETWORK: str = Field(
61
+ default="",
62
+ description="Logical Golem network: 'testnet' or 'mainnet'"
63
+ )
60
64
 
61
65
  # Payments chain selection (modular network profiles). Keep default on l2.holesky
62
66
  PAYMENTS_NETWORK: str = Field(
@@ -73,6 +77,37 @@ class Settings(BaseSettings):
73
77
  def DEV_MODE(self) -> bool:
74
78
  return self.ENVIRONMENT == "development"
75
79
 
80
+ @field_validator("ENVIRONMENT", mode='before')
81
+ @classmethod
82
+ def prefer_global_env(cls, v: str) -> str:
83
+ """Prefer unified GOLEM_ENVIRONMENT when provided; fallback to service-specific env."""
84
+ ge = os.environ.get("GOLEM_ENVIRONMENT")
85
+ if ge:
86
+ return ge
87
+ return v
88
+
89
+ @field_validator("NETWORK", mode='before')
90
+ @classmethod
91
+ def resolve_network(cls, v: str, values: dict) -> str:
92
+ """Resolve logical network with sensible defaults.
93
+
94
+ Priority:
95
+ 1) Explicit override via GOLEM_PROVIDER_NETWORK env or provided value
96
+ 2) If ENVIRONMENT == development -> 'testnet'
97
+ 3) Otherwise -> 'mainnet'
98
+ """
99
+ # Prefer explicit provider-scoped env override
100
+ env_override = os.environ.get("GOLEM_PROVIDER_NETWORK")
101
+ if env_override:
102
+ return env_override
103
+ # If value provided (via settings or direct assignment), keep it
104
+ val = (v or "").strip()
105
+ if val:
106
+ return val
107
+ # Default based on environment
108
+ env = (values.data.get("ENVIRONMENT") or "").lower()
109
+ return "testnet" if env == "development" else "mainnet"
110
+
76
111
  @field_validator("SKIP_PORT_VERIFICATION", mode='before')
77
112
  def set_skip_verification(cls, v: bool, values: dict) -> bool:
78
113
  """Set skip verification based on debug mode."""
@@ -186,6 +221,16 @@ class Settings(BaseSettings):
186
221
  description="Min withdrawable amount (wei) before triggering withdraw"
187
222
  )
188
223
 
224
+ # Behavior on exhausted runway
225
+ STREAM_REMOVE_MAPPING_ON_EXHAUSTED: bool = Field(
226
+ default=True,
227
+ description="When true, remove the VM->stream mapping after a successful stop on exhausted runway to prevent repeated stop attempts."
228
+ )
229
+ STREAM_DELETE_ON_EXHAUSTED: bool = Field(
230
+ default=False,
231
+ description="When true, delete the VM entirely once runway is exhausted and the VM has been stopped."
232
+ )
233
+
189
234
  # Shutdown behavior
190
235
  STOP_VMS_ON_EXIT: bool = Field(
191
236
  default=False,
@@ -1,7 +1,14 @@
1
1
  import asyncio
2
2
  from typing import Optional
3
3
 
4
- from golem_base_sdk import GolemBaseClient, GolemBaseCreate, GolemBaseUpdate, GolemBaseDelete, Annotation
4
+ from golem_base_sdk import (
5
+ GolemBaseClient,
6
+ GolemBaseCreate,
7
+ GolemBaseUpdate,
8
+ GolemBaseDelete,
9
+ Annotation,
10
+ GolemBaseExtend,
11
+ )
5
12
  from .advertiser import Advertiser
6
13
  from .golem_base_utils import get_provider_entity_keys
7
14
  from ..config import settings
@@ -36,7 +43,9 @@ class GolemBaseAdvertiser(Advertiser):
36
43
  try:
37
44
  while not self._stop_event.is_set():
38
45
  await self.post_advertisement()
39
- await asyncio.sleep(settings.GOLEM_BASE_ADVERTISEMENT_INTERVAL)
46
+ # Check more frequently than full TTL to ensure seamless renewal
47
+ interval = max(30, int(settings.GOLEM_BASE_ADVERTISEMENT_INTERVAL // 3))
48
+ await asyncio.sleep(interval)
40
49
  finally:
41
50
  await self.stop()
42
51
 
@@ -122,14 +131,36 @@ class GolemBaseAdvertiser(Advertiser):
122
131
  logger.info(f"Current on-chain annotations: {current_annotations}")
123
132
  logger.info(f"Expected annotations based on current config: {expected_annotations}")
124
133
 
134
+ desired_btl_blocks = int(settings.GOLEM_BASE_ADVERTISEMENT_INTERVAL) * 2 # ~2 blocks/sec
135
+
125
136
  if sorted(current_annotations.items()) == sorted(expected_annotations.items()):
126
- logger.info("Advertisement is up-to-date. Waiting for expiration.")
137
+ # Refresh TTL proactively if nearing expiry
138
+ try:
139
+ current_block = await self.client.http_client().eth.get_block_number()
140
+ remaining_blocks = int(metadata.expires_at_block) - int(current_block)
141
+ except Exception:
142
+ # If we cannot determine remaining blocks, extend defensively
143
+ remaining_blocks = 0
144
+
145
+ # Refresh when below 20% of desired TTL (or if unknown/negative)
146
+ refresh_threshold = max(10, desired_btl_blocks // 5)
147
+ if remaining_blocks <= refresh_threshold:
148
+ logger.info(
149
+ f"Extending advertisement TTL (remaining_blocks={remaining_blocks}, threshold={refresh_threshold})."
150
+ )
151
+ ext = GolemBaseExtend(entity_key=entity_key, number_of_blocks=desired_btl_blocks)
152
+ await self.client.extend_entities([ext])
153
+ logger.info(f"Extended advertisement. Entity key: {entity_key}")
154
+ else:
155
+ logger.info(
156
+ f"Advertisement up-to-date; TTL sufficient (remaining_blocks={remaining_blocks})."
157
+ )
127
158
  else:
128
159
  logger.info("Advertisement is outdated. Updating.")
129
160
  update = GolemBaseUpdate(
130
161
  entity_key=entity_key,
131
162
  data=b"",
132
- btl=settings.ADVERTISEMENT_INTERVAL * 2,
163
+ btl=desired_btl_blocks,
133
164
  string_annotations=string_annotations,
134
165
  numeric_annotations=numeric_annotations,
135
166
  )
@@ -144,9 +175,10 @@ class GolemBaseAdvertiser(Advertiser):
144
175
 
145
176
  async def _create_advertisement(self, string_annotations, numeric_annotations):
146
177
  """Helper to create a new advertisement."""
178
+ desired_btl_blocks = int(settings.GOLEM_BASE_ADVERTISEMENT_INTERVAL) * 2 # ~2 blocks/sec
147
179
  entity = GolemBaseCreate(
148
180
  data=b"",
149
- btl=settings.ADVERTISEMENT_INTERVAL * 2,
181
+ btl=desired_btl_blocks,
150
182
  string_annotations=string_annotations,
151
183
  numeric_annotations=numeric_annotations,
152
184
  )
@@ -15,6 +15,7 @@ if "--json" in _sys.argv:
15
15
 
16
16
  # Defer heavy local imports (may import config) until after we decide on silence
17
17
  from .container import Container
18
+ from .config import settings # used by pricing CLI and server commands
18
19
  from .service import ProviderService
19
20
 
20
21
  logger = setup_logger(__name__)
@@ -324,8 +325,8 @@ def status(json_out: bool = typer.Option(False, "--json", help="Output machine-r
324
325
  latest = _get_latest_version_from_pypi(pkg)
325
326
  update_available = bool(latest and current != latest)
326
327
 
327
- # Environment
328
- env = os.environ.get("GOLEM_PROVIDER_ENVIRONMENT", _settings.ENVIRONMENT)
328
+ # Environment (use unified GOLEM_ENVIRONMENT only)
329
+ env = os.environ.get("GOLEM_ENVIRONMENT", _settings.ENVIRONMENT)
329
330
  net = getattr(_settings, "NETWORK", None)
330
331
  dev_mode = env == "development" or bool(getattr(_settings, "DEV_MODE", False))
331
332
 
@@ -1323,7 +1324,7 @@ def stop(timeout: int = typer.Option(15, "--timeout", help="Seconds to wait for
1323
1324
  _remove_pid_file()
1324
1325
  print("Provider stopped")
1325
1326
 
1326
- # Removed separate 'dev' command; use environment GOLEM_PROVIDER_ENVIRONMENT=development instead.
1327
+ # Removed separate 'dev' command; use environment GOLEM_ENVIRONMENT=development instead.
1327
1328
 
1328
1329
  def _env_path_for(dev_mode: Optional[bool]) -> str:
1329
1330
  from pathlib import Path
@@ -1534,9 +1535,10 @@ def run_server(
1534
1535
  from pathlib import Path
1535
1536
  from dotenv import load_dotenv
1536
1537
  import uvicorn
1537
- # Decide dev mode from explicit arg or environment
1538
+ # Decide dev mode from explicit arg or environment (unified only)
1538
1539
  if dev_mode is None:
1539
- dev_mode = os.environ.get("GOLEM_PROVIDER_ENVIRONMENT", "").lower() == "development"
1540
+ env_val = os.environ.get("GOLEM_ENVIRONMENT", "")
1541
+ dev_mode = env_val.lower() == "development"
1540
1542
 
1541
1543
  # Load appropriate .env file based on mode
1542
1544
  env_file = ".env.dev" if dev_mode else ".env"
@@ -54,7 +54,18 @@ class StreamMonitor:
54
54
  try:
55
55
  s = self.reader.get_stream(stream_id)
56
56
  except Exception as e:
57
- logger.warning(f"stream {stream_id} lookup failed: {e}")
57
+ # No payment info available; delete the VM and remove mapping per unified policy
58
+ logger.info(
59
+ f"Deleting VM {vm_id} due to missing/unavailable payment stream (id={stream_id}): {e}"
60
+ )
61
+ try:
62
+ await self.vm_service.delete_vm(vm_id)
63
+ except Exception as del_err:
64
+ logger.warning(f"delete_vm failed for {vm_id} after stream lookup failure: {del_err}")
65
+ try:
66
+ await self.stream_map.remove(vm_id)
67
+ except Exception as rem_err:
68
+ logger.debug(f"failed to remove vm {vm_id} from stream map: {rem_err}")
58
69
  continue
59
70
  # Stop VM if remaining runway < threshold
60
71
  remaining = max(int(s["stopTime"]) - int(now), 0)
@@ -69,33 +80,72 @@ class StreamMonitor:
69
80
  )
70
81
  try:
71
82
  await self.vm_service.delete_vm(vm_id)
83
+ # Best-effort verification of deletion for investigation
84
+ try:
85
+ _ = await self.vm_service.get_vm_status(vm_id)
86
+ logger.info(
87
+ f"Post-delete status check: VM {vm_id} still present after delete request"
88
+ )
89
+ except VMNotFoundError:
90
+ logger.info(
91
+ f"Post-delete status check: VM {vm_id} not found (expected)"
92
+ )
93
+ except Exception as chk_err:
94
+ logger.debug(
95
+ f"Post-delete status check failed for {vm_id}: {chk_err}"
96
+ )
72
97
  except Exception as e:
73
98
  logger.warning(f"delete_vm failed for {vm_id}: {e}")
74
99
  try:
75
100
  await self.stream_map.remove(vm_id)
101
+ logger.debug(f"Removed {vm_id} from stream map after delete")
76
102
  except Exception as e:
77
103
  logger.debug(f"failed to remove vm {vm_id} from stream map: {e}")
78
104
  continue
79
105
 
80
- # Only stop a VM when runway is completely empty
106
+ # If runway is exhausted, delete the VM and remove mapping
81
107
  if remaining == 0:
82
108
  logger.info(
83
- f"Stopping VM {vm_id} as stream runway is exhausted (id={stream_id}, now={now}, stop={s.get('stopTime')})"
109
+ f"Deleting VM {vm_id} as stream runway is exhausted (id={stream_id}, now={now}, stop={s.get('stopTime')})"
84
110
  )
111
+ # Capture pre-delete status for context
112
+ try:
113
+ pre = await self.vm_service.get_vm_status(vm_id)
114
+ logger.info(
115
+ f"Pre-delete status for {vm_id}: status={getattr(pre, 'status', '?')} ip={getattr(pre, 'ip_address', '?')}"
116
+ )
117
+ except VMNotFoundError:
118
+ logger.info(
119
+ f"Pre-delete status for {vm_id}: not found (will remove mapping)"
120
+ )
121
+ except Exception as pre_err:
122
+ logger.debug(f"Pre-delete status check failed for {vm_id}: {pre_err}")
123
+
85
124
  try:
86
- await self.vm_service.stop_vm(vm_id)
87
- except VMNotFoundError as e:
88
- # If the VM cannot be found, remove it from the stream map
89
- # to avoid repeated stop attempts and log spam.
90
- logger.warning(f"stop_vm failed for {vm_id}: {e}")
125
+ await self.vm_service.delete_vm(vm_id)
126
+ # Verify deletion
91
127
  try:
92
- await self.stream_map.remove(vm_id)
93
- except Exception as rem_err:
128
+ _ = await self.vm_service.get_vm_status(vm_id)
129
+ logger.info(
130
+ f"Post-delete status check: VM {vm_id} still present after delete request"
131
+ )
132
+ except VMNotFoundError:
133
+ logger.info(
134
+ f"Post-delete status check: VM {vm_id} not found (expected)"
135
+ )
136
+ except Exception as chk_err:
94
137
  logger.debug(
95
- f"failed to remove vm {vm_id} from stream map after not-found: {rem_err}"
138
+ f"Post-delete status check failed for {vm_id}: {chk_err}"
96
139
  )
97
140
  except Exception as e:
98
- logger.warning(f"stop_vm failed for {vm_id}: {e}")
141
+ logger.warning(f"delete_vm failed for {vm_id}: {e}")
142
+ try:
143
+ await self.stream_map.remove(vm_id)
144
+ logger.info(f"Removed mapping for {vm_id} after delete on exhausted runway")
145
+ except Exception as rem_err:
146
+ logger.debug(
147
+ f"failed to remove vm {vm_id} from stream map after delete: {rem_err}"
148
+ )
99
149
  continue
100
150
 
101
151
  # Otherwise, do not stop; just log health and consider withdrawals
@@ -32,6 +32,26 @@ class MultipassAdapter(VMProvider):
32
32
  self.proxy_manager = proxy_manager
33
33
  self.name_mapper = name_mapper
34
34
 
35
+ @staticmethod
36
+ def _safe_int(value, default: int = 0) -> int:
37
+ """Best-effort int conversion that treats missing/blank values as default.
38
+
39
+ Multipass may return empty strings for numeric fields (e.g., when a VM is
40
+ stopped). This helper prevents ValueError by mapping '', None, or
41
+ unparsable values to a sensible default.
42
+ """
43
+ try:
44
+ if value is None:
45
+ return default
46
+ if isinstance(value, str):
47
+ v = value.strip()
48
+ if v == "":
49
+ return default
50
+ return int(v)
51
+ return int(value)
52
+ except (ValueError, TypeError):
53
+ return default
54
+
35
55
  async def _run_multipass(self, args: List[str], check: bool = True) -> subprocess.CompletedProcess:
36
56
  """Run a multipass command."""
37
57
  # Commands that produce JSON or version info that we need to parse.
@@ -67,7 +87,8 @@ class MultipassAdapter(VMProvider):
67
87
  """Get detailed information about a VM."""
68
88
  try:
69
89
  result = await self._run_multipass(["info", vm_id, "--format", "json"])
70
- logger.info(f"Raw multipass info for {vm_id}: {result.stdout}")
90
+ # Only log raw multipass output in debug mode to avoid noisy logs
91
+ logger.debug(f"Raw multipass info for {vm_id}: {result.stdout}")
71
92
  info = json.loads(result.stdout)
72
93
  vm_info = info["info"][vm_id]
73
94
  essential_fields = ["state", "ipv4", "cpu_count", "memory", "disks"]
@@ -160,8 +181,8 @@ class MultipassAdapter(VMProvider):
160
181
  vms: List[VMInfo] = []
161
182
  for requestor_name, multipass_name in list(all_mappings.items()):
162
183
  try:
163
- # get_vm_status expects multipass_name
164
- vm_info = await self.get_vm_status(multipass_name)
184
+ # Pass requestor id; get_vm_status accepts either id
185
+ vm_info = await self.get_vm_status(requestor_name)
165
186
  vms.append(vm_info)
166
187
  except VMNotFoundError:
167
188
  logger.warning(
@@ -188,30 +209,40 @@ class MultipassAdapter(VMProvider):
188
209
  await self._run_multipass(["stop", multipass_name])
189
210
  return await self.get_vm_status(multipass_name)
190
211
 
191
- async def get_vm_status(self, multipass_name: str) -> VMInfo:
192
- """Get the status of a VM."""
212
+ async def get_vm_status(self, name_or_id: str) -> VMInfo:
213
+ """Get VM status by multipass name or requestor id."""
214
+ # Resolve identifiers flexibly
215
+ requestor_name = await self.name_mapper.get_requestor_name(name_or_id)
216
+ if requestor_name:
217
+ multipass_name = name_or_id
218
+ else:
219
+ multipass_name = await self.name_mapper.get_multipass_name(name_or_id)
220
+ if not multipass_name:
221
+ raise VMNotFoundError(f"VM {name_or_id} mapping not found")
222
+ requestor_name = name_or_id
193
223
  try:
194
224
  info = await self._get_vm_info(multipass_name)
195
225
  except MultipassError:
196
226
  raise VMNotFoundError(f"VM {multipass_name} not found in multipass")
197
227
 
198
- requestor_name = await self.name_mapper.get_requestor_name(multipass_name)
199
- if not requestor_name:
200
- raise VMNotFoundError(f"Mapping for VM {multipass_name} not found")
201
-
202
228
  ipv4 = info.get("ipv4")
203
229
  ip_address = ipv4[0] if ipv4 else None
204
230
  logger.debug(f"Parsed VM info for {requestor_name}: {info}")
205
231
 
206
232
  disks_info = info.get("disks", {})
207
- total_storage = sum(int(disk.get("total", 0)) for disk in disks_info.values())
233
+ total_storage = 0
234
+ for disk in disks_info.values():
235
+ total_storage += self._safe_int(disk.get("total"), 0)
236
+
237
+ # Memory reported by multipass is in bytes; default to 1 GiB if missing/blank
238
+ mem_total_bytes = self._safe_int(info.get("memory", {}).get("total"), 1024**3)
208
239
  vm_info_obj = VMInfo(
209
240
  id=requestor_name,
210
241
  name=requestor_name,
211
242
  status=VMStatus(info["state"].lower()),
212
243
  resources=VMResources(
213
- cpu=int(info.get("cpu_count", "1")),
214
- memory=round(info.get("memory", {}).get("total", 1024**3) / (1024**3)),
244
+ cpu=self._safe_int(info.get("cpu_count"), 1),
245
+ memory=round(mem_total_bytes / (1024**3)),
215
246
  storage=round(total_storage / (1024**3)) if total_storage > 0 else 10
216
247
  ),
217
248
  ip_address=ip_address,
@@ -228,10 +259,13 @@ class MultipassAdapter(VMProvider):
228
259
  try:
229
260
  info = await self._get_vm_info(multipass_name)
230
261
  disks_info = info.get("disks", {})
231
- total_storage = sum(int(disk.get("total", 0)) for disk in disks_info.values())
262
+ total_storage = 0
263
+ for disk in disks_info.values():
264
+ total_storage += self._safe_int(disk.get("total"), 0)
265
+ mem_total_bytes = self._safe_int(info.get("memory", {}).get("total"), 1024**3)
232
266
  vm_resources[requestor_name] = VMResources(
233
- cpu=int(info.get("cpu_count", "1")),
234
- memory=round(info.get("memory", {}).get("total", 1024**3) / (1024**3)),
267
+ cpu=self._safe_int(info.get("cpu_count"), 1),
268
+ memory=round(mem_total_bytes / (1024**3)),
235
269
  storage=round(total_storage / (1024**3)) if total_storage > 0 else 10
236
270
  )
237
271
  except (MultipassError, VMNotFoundError):
@@ -59,6 +59,7 @@ class VMService:
59
59
 
60
60
  try:
61
61
  vm_info = await self.provider.get_vm_status(multipass_name)
62
+ logger.info(f"Deleting VM {vm_id} (multipass={multipass_name}) with status={vm_info.status}")
62
63
  await self.provider.delete_vm(multipass_name)
63
64
  await self.resource_tracker.deallocate(vm_info.resources, vm_id)
64
65
  # Optional: best-effort on-chain termination if we have a mapping
@@ -83,7 +84,9 @@ class VMService:
83
84
  multipass_name = await self.name_mapper.get_multipass_name(vm_id)
84
85
  if not multipass_name:
85
86
  raise VMNotFoundError(f"VM {vm_id} not found")
87
+ logger.info(f"Stopping VM {vm_id} (multipass={multipass_name})")
86
88
  vm = await self.provider.stop_vm(multipass_name)
89
+ logger.info(f"Stopped VM {vm_id} result status={getattr(vm, 'status', '?')}")
87
90
  # Optional: best-effort withdraw for active stream
88
91
  try:
89
92
  if self.blockchain_client:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "golem-vm-provider"
3
- version = "0.1.59"
3
+ version = "0.1.61"
4
4
  description = "VM on Golem Provider Node - Run your own provider node to offer VMs on the Golem Network"
5
5
  authors = ["Phillip Jensen <phillip+vm-on-golem@golemgrid.com>"]
6
6
  readme = "README.md"