golem-vm-provider 0.1.58__py3-none-any.whl → 0.1.60__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: golem-vm-provider
3
- Version: 0.1.58
3
+ Version: 0.1.60
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
@@ -1,27 +1,28 @@
1
1
  provider/__init__.py,sha256=HO1fkPpZqPO3z8O8-eVIyx8xXSMIVuTR_b1YF0RtXOg,45
2
2
  provider/api/__init__.py,sha256=ssX1ugDqEPt8Fn04IymgmG-Ev8PiXLsCSaiZVvHQnec,344
3
- provider/api/models.py,sha256=CmfgXqSH3m0HLqY6JvUFI-2IrdGf3EhNKtZ5kbIAX-U,4304
4
- provider/api/routes.py,sha256=E3AxzzgqR_UgLy_I8YvtSkhy_GQlIW5EzF5FmYjF90k,17548
5
- provider/config.py,sha256=nQzYBujgn-Z7Rqh6q0eOsTpk6R9-V-YF1OysmPpSH0Q,28993
6
- provider/container.py,sha256=xN1a9qClciGomppCBnEGuPPNzGQkYIWlw1lzexrjptM,3726
3
+ provider/api/models.py,sha256=LcEWVUE8zvX_9ByyzbyoZGiDIJf_4MLDYqX7_nRO6B0,4754
4
+ provider/api/routes.py,sha256=egZVUyQ8MRFB_i_wmMIyZJzakddYVy0w0K8LPtNyVYw,20617
5
+ provider/config.py,sha256=65L47ByUXoyvhD5DSsIYbM3yjlwFB5j4L6-__kAlkW0,29186
6
+ provider/container.py,sha256=QCYlIvR1m0ediA9PwJ_OXwZU3Ye-R7G6ZGsXFDji_jQ,3957
7
7
  provider/data/deployments/l2.json,sha256=XTNN2C5LkBfp4YbDKdUKfWMdp1fKnfv8D3TgcwVWxtQ,249
8
8
  provider/discovery/__init__.py,sha256=Y6o8RxGevBpuQS3k32y-zSVbP6HBXG3veBl9ElVPKaU,349
9
- provider/discovery/advertiser.py,sha256=o-LiDl1j0lXMUU0-zPe3qerjpoD2360EA60Y_V_VeBc,6571
10
- provider/discovery/golem_base_advertiser.py,sha256=A8bg40b2Ua7PIjx3Y8-SC0s-dUUPWxaiQCzr6AcpYaQ,7334
9
+ provider/discovery/advertiser.py,sha256=SZ4EN7zChEL9g-Dt4vqED8dAraGS-_4Iyx4F9AiVk9o,7056
10
+ provider/discovery/golem_base_advertiser.py,sha256=LGvPLljPxh6i-80OeZnezdphtHu3Tf6nkLE8zRYY2b0,7849
11
11
  provider/discovery/golem_base_utils.py,sha256=xk7vznhMgzrn0AuGyk6-9N9ukp9oPdBbbk1RI-sVjp0,607
12
12
  provider/discovery/multi_advertiser.py,sha256=_J79wA1-XQ4GsLzt9KrKpWigGSGBqtut7DaocIk2fyE,991
13
13
  provider/discovery/resource_monitor.py,sha256=AmiEc7yBGEGXCunQ-QKmVgosDX3gOhK1Y58LJZXrwAs,949
14
14
  provider/discovery/resource_tracker.py,sha256=MP7IXd3aIMsjB4xz5Oj9zFDTEnvrnw-Cyxpl33xcJcc,6006
15
- provider/discovery/service.py,sha256=vX_mVSxvn3arnb2cKDM_SeJp1ZgPdImP2aUubeXgdRg,915
16
- provider/main.py,sha256=Iho5lJEgGCYb1BNHqMR8IJCCRJ2qGdeUbSwyF49hmRw,64487
15
+ provider/discovery/service.py,sha256=0H4H8HuFP52xE6lrXTOUHfvaIVjbT1uzcnlPxEswjNc,1000
16
+ provider/jobs/store.py,sha256=gLT5tWS7RmxGFfpjriECl4Kfn7osCP2woA8plKTZM4g,3958
17
+ provider/main.py,sha256=7SGPH4RcS1K7aPf83xHaCKR0s_J-WfTJx-O8UwJzASU,64551
17
18
  provider/network/port_verifier.py,sha256=mlSzr9Z-W5Z5mL3EYg4zemgGoi8Z5ebNoeFgLGRaoH4,13253
18
19
  provider/payments/blockchain_service.py,sha256=4GrzDKwCSUVoENqjD4RLyJ0qwBOJKMyVk5Li-XNsyTc,3567
19
- provider/payments/monitor.py,sha256=seo8vE622IdbcRE3x69IpvHn2mel_tlMNGt_DxOIoww,5386
20
+ provider/payments/monitor.py,sha256=qBykIXD_Fks7_VS7Mh3Zb99HMC4x4Z_2PlYaMyfBORc,6069
20
21
  provider/payments/stream_map.py,sha256=qk6Y8hS72DplAifZ0ZMWPHBAyc_3IWIQyWUBuCU3_To,1191
21
22
  provider/security/ethereum.py,sha256=EwPZj4JR8OEpto6LhKjuuT3Z9pBX6P7-UQaqJtqFkYQ,1242
22
23
  provider/security/faucet.py,sha256=8T4lW1fVQgUk8EQILgbrr9UUosw9e7eA40tlZ2_KCPQ,4368
23
24
  provider/security/l2_faucet.py,sha256=yRV4xdPBgU8-LDTLqtuAijfgIoe2kYxvXqJLxFd-BVI,2662
24
- provider/service.py,sha256=hlQn0woppsYFHZDMEgq-40cOjmiPWruiWLy_dQvaCRU,6859
25
+ provider/service.py,sha256=lX017IDJYyW-zufoKts3GQptiJCR9MziQTKkJzXCk-k,8285
25
26
  provider/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
27
  provider/utils/ascii_art.py,sha256=ykBFsztk57GIiz1NJ-EII5UvN74iECqQL4h9VmiW6Z8,3161
27
28
  provider/utils/logging.py,sha256=1Br806ohJyYpDIw1i8NsNpg8Xc-8-rUYwKBU4LFomLk,2623
@@ -33,13 +34,13 @@ provider/vm/__init__.py,sha256=LJL504QGbqZvBbMN3G9ixMgAwvOWAKW37zUm_EiaW9M,508
33
34
  provider/vm/cloud_init.py,sha256=E5dDH7dqStRcJNDfbarBBe83-c9N63W8B5ycIrHI8eU,4627
34
35
  provider/vm/models.py,sha256=hNeXgOnXWyeSiYt07Pdks0B20cDi_VC8jV-tCxULNng,6350
35
36
  provider/vm/multipass.py,sha256=rjO3GtuS4O_wXyYXSUiDGWYtQV2LpGxm6kITrA-ghBQ,617
36
- provider/vm/multipass_adapter.py,sha256=HW4_7cs3O_SCi1unfIvhC44duO561bpO6yrHGkqhLQA,11578
37
+ provider/vm/multipass_adapter.py,sha256=CQlsWyI2lANVwOin7qujxRZEyMwfQNnQSxyqJNY3q2Q,12896
37
38
  provider/vm/name_mapper.py,sha256=14nKfCjJ1WkXfC4vnCYIxNGQUwcl2vcxrJYUAz4fL40,4073
38
39
  provider/vm/port_manager.py,sha256=iYSwjTjD_ziOhG8aI7juKHw1OwwRUTJQyQoRUNQvz9w,12514
39
40
  provider/vm/provider.py,sha256=A7QN89EJjcSS40_SmKeinG1Jp_NGffJaLse-XdKciAs,1164
40
41
  provider/vm/proxy_manager.py,sha256=n4NTsyz2rtrvjtf_ceKBk-g2q_mzqPwruB1q7UlQVBc,14928
41
42
  provider/vm/service.py,sha256=Ki4SGNIZUq3XmaPMwAOoNzdZzKQsmFXid374wgjFPes,4636
42
- golem_vm_provider-0.1.58.dist-info/METADATA,sha256=Hb6wIjErVQNKcOJva7xXtt-wGT6CIK6t5GYzVh-TVUc,21221
43
- golem_vm_provider-0.1.58.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
44
- golem_vm_provider-0.1.58.dist-info/entry_points.txt,sha256=5Jiie1dIXygmxmDW66bKKxQpmBLJ7leSKRrb8bkQALw,52
45
- golem_vm_provider-0.1.58.dist-info/RECORD,,
43
+ golem_vm_provider-0.1.60.dist-info/METADATA,sha256=8nPSP0__wjWtbFyX9espYR6kHs1jqp6B9BndLOizr0E,21221
44
+ golem_vm_provider-0.1.60.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
45
+ golem_vm_provider-0.1.60.dist-info/entry_points.txt,sha256=5Jiie1dIXygmxmDW66bKKxQpmBLJ7leSKRrb8bkQALw,52
46
+ golem_vm_provider-0.1.60.dist-info/RECORD,,
provider/api/models.py CHANGED
@@ -116,6 +116,9 @@ class ProviderInfoResponse(BaseModel):
116
116
  provider_id: str
117
117
  stream_payment_address: str
118
118
  glm_token_address: str
119
+ ip_address: Optional[str] = None
120
+ country: Optional[str] = None
121
+ platform: Optional[str] = None
119
122
 
120
123
 
121
124
  class StreamOnChain(BaseModel):
@@ -144,3 +147,10 @@ class StreamStatus(BaseModel):
144
147
  computed: StreamComputed
145
148
  verified: bool
146
149
  reason: str
150
+
151
+
152
+ class CreateVMJobResponse(BaseModel):
153
+ """Lightweight response for async VM creation scheduling."""
154
+ job_id: str = Field(..., description="Server-side job identifier for creation task")
155
+ vm_id: str = Field(..., description="Requestor VM identifier (name)")
156
+ status: str = Field("creating", description="Initial status indicator")
provider/api/routes.py CHANGED
@@ -2,13 +2,17 @@ import json
2
2
  import os
3
3
  from typing import List
4
4
  from pathlib import Path
5
- from fastapi import APIRouter, HTTPException, Request
5
+ import asyncio
6
+ import uuid
7
+ from fastapi import APIRouter, HTTPException, Request, Query
8
+ from fastapi.responses import JSONResponse
6
9
 
7
10
  from dependency_injector.wiring import inject, Provide
8
11
  from fastapi import APIRouter, HTTPException, Depends
9
12
 
10
13
  from typing import TYPE_CHECKING, Any
11
14
  from ..container import Container
15
+ from ..jobs.store import JobStore
12
16
  from ..utils.logging import setup_logger
13
17
  from ..utils.ascii_art import vm_creation_animation, vm_status_change
14
18
  from ..vm.models import VMInfo, VMAccessInfo, VMConfig, VMResources, VMNotFoundError
@@ -18,6 +22,7 @@ from .models import (
18
22
  StreamStatus,
19
23
  StreamOnChain,
20
24
  StreamComputed,
25
+ CreateVMJobResponse,
21
26
  )
22
27
  from ..payments.blockchain_service import StreamPaymentReader
23
28
  from ..vm.service import VMService
@@ -26,31 +31,41 @@ from ..vm.multipass_adapter import MultipassError
26
31
  logger = setup_logger(__name__)
27
32
  router = APIRouter()
28
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
29
39
 
30
- @router.post("/vms", response_model=VMInfo)
40
+ # Job status persisted in SQLite via JobStore (see Container.job_store)
41
+
42
+
43
+ @router.post("/vms")
31
44
  @inject
32
45
  async def create_vm(
33
46
  request: CreateVMRequest,
34
47
  vm_service: VMService = Depends(Provide[Container.vm_service]),
35
48
  settings: Any = Depends(Provide[Container.config]),
36
49
  stream_map = Depends(Provide[Container.stream_map]),
37
- ) -> VMInfo:
38
- """Create a new VM."""
50
+ job_store: JobStore = Depends(Provide[Container.job_store]),
51
+ async_mode: bool = Query(default=False, alias="async"),
52
+ ) -> Any:
53
+ """Create a VM (sync by default; async when `?async=true`)."""
39
54
  try:
40
- logger.info(f"📥 Received VM creation request for '{request.name}'")
41
-
55
+ logger.info(f"📥 Received VM creation request for '{request.name}' (async={async_mode})")
56
+
42
57
  resources = request.resources or VMResources()
43
58
 
44
59
  # If payments are enabled, require a valid stream before starting
45
- # Determine if we should enforce gating
46
60
  enforce = False
47
61
  spa = (settings.get("STREAM_PAYMENT_ADDRESS") if isinstance(settings, dict) else getattr(settings, "STREAM_PAYMENT_ADDRESS", None))
48
62
  if spa and spa != "0x0000000000000000000000000000000000000000":
49
63
  if os.environ.get("PYTEST_CURRENT_TEST"):
50
- # In pytest, skip gating only when using default deployment address
51
64
  try:
52
- from ..config import Settings as _Cfg # type: ignore
53
- 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
54
69
  except Exception:
55
70
  default_spa = None
56
71
  if not default_spa or spa.lower() != default_spa.lower():
@@ -73,39 +88,70 @@ async def create_vm(
73
88
  f"start={s['startTime']} stop={s['stopTime']} rate={s['ratePerSecond']} deposit={s['deposit']} withdrawn={s['withdrawn']} remaining={remaining}s"
74
89
  )
75
90
  except Exception:
76
- # Best-effort logging; creation will continue/fail based on ok
77
91
  pass
78
92
  if not ok:
79
93
  raise HTTPException(status_code=400, detail=f"invalid stream: {reason}")
80
-
81
- # Create VM config
94
+
82
95
  config = VMConfig(
83
96
  name=request.name,
84
97
  image=request.image or (settings.get("DEFAULT_VM_IMAGE") if isinstance(settings, dict) else getattr(settings, "DEFAULT_VM_IMAGE", "")),
85
98
  resources=resources,
86
- ssh_key=request.ssh_key
99
+ ssh_key=request.ssh_key,
87
100
  )
88
-
89
- vm_info = await vm_service.create_vm(config)
90
- # Persist VM->stream mapping if provided
91
- if request.stream_id is not None:
101
+
102
+ if not async_mode:
103
+ vm_info = await vm_service.create_vm(config)
104
+ if request.stream_id is not None:
105
+ try:
106
+ await stream_map.set(vm_info.id, int(request.stream_id))
107
+ except Exception as e: # noqa: BLE001
108
+ logger.warning(f"failed to persist stream mapping for {vm_info.id}: {e}")
109
+ await vm_creation_animation(request.name)
110
+ return vm_info
111
+
112
+ # Async path
113
+ job_id = str(uuid.uuid4())
114
+ await job_store.create_job(job_id, request.name, status="creating")
115
+
116
+ async def _run_creation():
92
117
  try:
93
- await stream_map.set(vm_info.id, int(request.stream_id))
94
- except Exception as e:
95
- logger.warning(f"failed to persist stream mapping for {vm_info.id}: {e}")
96
- await vm_creation_animation(request.name)
97
- return vm_info
118
+ vm_info = await vm_service.create_vm(config)
119
+ if request.stream_id is not None:
120
+ try:
121
+ await stream_map.set(vm_info.id, int(request.stream_id))
122
+ except Exception as e: # noqa: BLE001
123
+ logger.warning(f"failed to persist stream mapping for {vm_info.id}: {e}")
124
+ await vm_creation_animation(request.name)
125
+ await job_store.update_job(job_id, status="ready")
126
+ except Exception as e: # noqa: BLE001
127
+ logger.error(f"Create VM job failed: {e}")
128
+ await job_store.update_job(job_id, status="failed", error=str(e))
129
+
130
+ asyncio.create_task(_run_creation(), name=f"create-vm:{request.name}")
131
+
132
+ env = CreateVMJobResponse(job_id=job_id, vm_id=request.name, status="creating")
133
+ return JSONResponse(status_code=202, content=env.model_json_schema() and env.model_dump())
134
+
98
135
  except MultipassError as e:
99
136
  logger.error(f"Failed to create VM: {e}")
100
137
  raise HTTPException(status_code=500, detail=str(e))
101
138
  except HTTPException:
102
- # Propagate explicit HTTP errors (e.g., payment gating)
103
139
  raise
104
140
  except Exception as e:
105
141
  logger.error(f"An unexpected error occurred: {e}")
106
142
  raise HTTPException(status_code=500, detail="An unexpected error occurred")
107
143
 
108
144
 
145
+ @router.get("/vms/jobs/{job_id}")
146
+ @inject
147
+ async def get_create_job(job_id: str, job_store: JobStore = Depends(Provide[Container.job_store])):
148
+ """Return async creation job status."""
149
+ job = await job_store.get_job(job_id)
150
+ if not job:
151
+ raise HTTPException(status_code=404, detail="job not found")
152
+ return job
153
+
154
+
109
155
  @router.get("/vms", response_model=List[VMInfo])
110
156
  @inject
111
157
  async def list_vms(
@@ -163,9 +209,18 @@ async def get_vm_access(
163
209
  if not multipass_name:
164
210
  raise HTTPException(404, "VM mapping not found")
165
211
 
212
+ # If ssh_port is not yet assigned, return 202 with a simple status payload
213
+ if vm.ssh_port is None:
214
+ return JSONResponse(status_code=202, content={
215
+ "vm_id": requestor_name,
216
+ "multipass_name": multipass_name,
217
+ "status": "creating",
218
+ "ssh_port": None,
219
+ })
220
+
166
221
  return VMAccessInfo(
167
222
  ssh_host=((settings.get("PUBLIC_IP") if isinstance(settings, dict) else getattr(settings, "PUBLIC_IP", None)) or "localhost"),
168
- ssh_port=vm.ssh_port,
223
+ ssh_port=int(vm.ssh_port),
169
224
  vm_id=requestor_name,
170
225
  multipass_name=multipass_name
171
226
  )
@@ -231,10 +286,35 @@ async def delete_vm(
231
286
  @router.get("/provider/info", response_model=ProviderInfoResponse)
232
287
  @inject
233
288
  async def provider_info(settings: Any = Depends(Provide[Container.config])) -> ProviderInfoResponse:
289
+ # Derive platform similar to advertiser
290
+ import platform as _plat
291
+ raw = _plat.machine().lower()
292
+ platform_str = None
293
+ try:
294
+ if 'arm' in raw:
295
+ platform_str = 'arm64'
296
+ elif 'x86_64' in raw or 'amd64' in raw or 'x64' in raw:
297
+ platform_str = 'x86_64'
298
+ else:
299
+ platform_str = raw
300
+ except Exception:
301
+ platform_str = None
302
+
303
+ ip_addr = None
304
+ try:
305
+ ip_addr = settings.get("PUBLIC_IP") if isinstance(settings, dict) else getattr(settings, "PUBLIC_IP", None)
306
+ except Exception:
307
+ ip_addr = None
308
+
234
309
  return ProviderInfoResponse(
235
310
  provider_id=settings["PROVIDER_ID"],
236
311
  stream_payment_address=settings["STREAM_PAYMENT_ADDRESS"],
237
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"],
315
+ ip_address=ip_addr,
316
+ country=(settings.get("PROVIDER_COUNTRY") if isinstance(settings, dict) else getattr(settings, "PROVIDER_COUNTRY", None)),
317
+ platform=platform_str,
238
318
  )
239
319
 
240
320
 
provider/config.py CHANGED
@@ -186,6 +186,12 @@ class Settings(BaseSettings):
186
186
  description="Min withdrawable amount (wei) before triggering withdraw"
187
187
  )
188
188
 
189
+ # Shutdown behavior
190
+ STOP_VMS_ON_EXIT: bool = Field(
191
+ default=False,
192
+ description="When true, stop all running VMs on provider shutdown. Default keeps VMs running."
193
+ )
194
+
189
195
  # Faucet settings (L3 for Golem Base adverts)
190
196
  FAUCET_URL: str = "https://ethwarsaw.holesky.golemdb.io/faucet"
191
197
  CAPTCHA_URL: str = "https://cap.gobas.me"
provider/container.py CHANGED
@@ -14,6 +14,7 @@ from .vm.name_mapper import VMNameMapper
14
14
  from .vm.port_manager import PortManager
15
15
  from .vm.proxy_manager import PythonProxyManager
16
16
  from .payments.stream_map import StreamMap
17
+ from .jobs.store import JobStore
17
18
  from .payments.blockchain_service import StreamPaymentReader, StreamPaymentClient, StreamPaymentConfig as _SPC
18
19
  from .payments.monitor import StreamMonitor
19
20
 
@@ -119,3 +120,9 @@ class Container(containers.DeclarativeContainer):
119
120
  advertisement_service=advertisement_service,
120
121
  port_manager=port_manager,
121
122
  )
123
+
124
+ # Async job store for VM creations
125
+ job_store = providers.Singleton(
126
+ JobStore,
127
+ db_path=providers.Callable(lambda base: Path(base) / "jobs.sqlite", config.VM_DATA_DIR),
128
+ )
@@ -115,6 +115,16 @@ class DiscoveryServerAdvertiser(Advertiser):
115
115
  return
116
116
 
117
117
  try:
118
+ import platform as _plat
119
+ raw = (_plat.machine() or '').lower()
120
+ platform_str = None
121
+ if raw:
122
+ if 'aarch64' in raw or 'arm64' in raw or raw.startswith('arm'):
123
+ platform_str = 'arm64'
124
+ elif 'x86_64' in raw or 'amd64' in raw or 'x64' in raw:
125
+ platform_str = 'x86_64'
126
+ else:
127
+ platform_str = raw
118
128
  async with self.session.post(
119
129
  f"{self.discovery_url}/api/v1/advertisements",
120
130
  headers={
@@ -125,6 +135,7 @@ class DiscoveryServerAdvertiser(Advertiser):
125
135
  json={
126
136
  "ip_address": ip_address,
127
137
  "country": settings.PROVIDER_COUNTRY,
138
+ "platform": platform_str,
128
139
  "resources": resources,
129
140
  "pricing": {
130
141
  "usd_per_core_month": settings.PRICE_USD_PER_CORE_MONTH,
@@ -65,6 +65,16 @@ class GolemBaseAdvertiser(Advertiser):
65
65
  existing_keys = await get_provider_entity_keys(self.client, settings.PROVIDER_ID)
66
66
 
67
67
  # String annotations (metadata + prices as strings; on-chain numeric annotations must be ints)
68
+ import platform as _plat
69
+ raw = (_plat.machine() or '').lower()
70
+ platform_str = None
71
+ if raw:
72
+ if 'aarch64' in raw or 'arm64' in raw or raw.startswith('arm'):
73
+ platform_str = 'arm64'
74
+ elif 'x86_64' in raw or 'amd64' in raw or 'x64' in raw:
75
+ platform_str = 'x86_64'
76
+ else:
77
+ platform_str = raw
68
78
  string_annotations = [
69
79
  Annotation(key="golem_type", value="provider"),
70
80
  Annotation(key="golem_network", value=settings.NETWORK),
@@ -73,6 +83,7 @@ class GolemBaseAdvertiser(Advertiser):
73
83
  Annotation(key="golem_ip_address", value=ip_address),
74
84
  Annotation(key="golem_country", value=settings.PROVIDER_COUNTRY),
75
85
  Annotation(key="golem_provider_name", value=settings.PROVIDER_NAME),
86
+ Annotation(key="golem_platform", value=platform_str or ""),
76
87
  Annotation(key="golem_price_currency", value="USD/GLM"),
77
88
  # Prices must be strings to avoid RLP sedes errors (ints only allowed for numeric annotations)
78
89
  Annotation(key="golem_price_usd_core_month", value=str(float(settings.PRICE_USD_PER_CORE_MONTH))),
@@ -20,7 +20,10 @@ class AdvertisementService:
20
20
  """Stop the advertiser."""
21
21
  if self._task:
22
22
  self._task.cancel()
23
- await self._task
23
+ try:
24
+ await self._task
25
+ except asyncio.CancelledError:
26
+ pass
24
27
  await self.advertiser.stop()
25
28
 
26
29
  async def trigger_update(self):
provider/jobs/store.py ADDED
@@ -0,0 +1,116 @@
1
+ import asyncio
2
+ import sqlite3
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Optional, Dict, Any
7
+
8
+
9
+ @dataclass
10
+ class JobRecord:
11
+ job_id: str
12
+ vm_id: str
13
+ status: str
14
+ error: Optional[str]
15
+ created_at: str
16
+ updated_at: str
17
+
18
+
19
+ class JobStore:
20
+ """SQLite-backed store for VM creation jobs.
21
+
22
+ Keeps minimal fields to track progress and errors across restarts.
23
+ """
24
+
25
+ def __init__(self, db_path: Path):
26
+ self._db_path = Path(db_path)
27
+ # Ensure parent directory exists
28
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
29
+ self._init_schema()
30
+
31
+ def _init_schema(self) -> None:
32
+ conn = sqlite3.connect(self._db_path, check_same_thread=False)
33
+ try:
34
+ with conn:
35
+ conn.execute(
36
+ """
37
+ CREATE TABLE IF NOT EXISTS jobs (
38
+ job_id TEXT PRIMARY KEY,
39
+ vm_id TEXT NOT NULL,
40
+ status TEXT NOT NULL,
41
+ error TEXT,
42
+ created_at TEXT NOT NULL,
43
+ updated_at TEXT NOT NULL
44
+ )
45
+ """
46
+ )
47
+ finally:
48
+ conn.close()
49
+
50
+ async def create_job(self, job_id: str, vm_id: str, status: str = "creating") -> None:
51
+ now = datetime.now(timezone.utc).isoformat()
52
+
53
+ def _op():
54
+ conn = sqlite3.connect(self._db_path, check_same_thread=False)
55
+ try:
56
+ with conn:
57
+ conn.execute(
58
+ "INSERT OR REPLACE INTO jobs (job_id, vm_id, status, error, created_at, updated_at) VALUES (?, ?, ?, NULL, ?, ?)",
59
+ (job_id, vm_id, status, now, now),
60
+ )
61
+ finally:
62
+ conn.close()
63
+
64
+ await asyncio.to_thread(_op)
65
+
66
+ async def update_job(self, job_id: str, *, status: Optional[str] = None, error: Optional[str] = None) -> None:
67
+ now = datetime.now(timezone.utc).isoformat()
68
+
69
+ def _op():
70
+ conn = sqlite3.connect(self._db_path, check_same_thread=False)
71
+ try:
72
+ with conn:
73
+ if status is not None and error is not None:
74
+ conn.execute(
75
+ "UPDATE jobs SET status = ?, error = ?, updated_at = ? WHERE job_id = ?",
76
+ (status, error, now, job_id),
77
+ )
78
+ elif status is not None:
79
+ conn.execute(
80
+ "UPDATE jobs SET status = ?, updated_at = ? WHERE job_id = ?",
81
+ (status, now, job_id),
82
+ )
83
+ elif error is not None:
84
+ conn.execute(
85
+ "UPDATE jobs SET error = ?, updated_at = ? WHERE job_id = ?",
86
+ (error, now, job_id),
87
+ )
88
+ finally:
89
+ conn.close()
90
+
91
+ await asyncio.to_thread(_op)
92
+
93
+ async def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
94
+ def _op():
95
+ conn = sqlite3.connect(self._db_path, check_same_thread=False)
96
+ try:
97
+ cur = conn.execute(
98
+ "SELECT job_id, vm_id, status, error, created_at, updated_at FROM jobs WHERE job_id = ?",
99
+ (job_id,),
100
+ )
101
+ row = cur.fetchone()
102
+ if not row:
103
+ return None
104
+ return {
105
+ "job_id": row[0],
106
+ "vm_id": row[1],
107
+ "status": row[2],
108
+ "error": row[3],
109
+ "created_at": row[4],
110
+ "updated_at": row[5],
111
+ }
112
+ finally:
113
+ conn.close()
114
+
115
+ return await asyncio.to_thread(_op)
116
+
provider/main.py CHANGED
@@ -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__)
@@ -1240,8 +1241,12 @@ def streams_withdraw(
1240
1241
  def start(
1241
1242
  no_verify_port: bool = typer.Option(False, "--no-verify-port", help="Skip provider port verification."),
1242
1243
  network: str = typer.Option(None, "--network", help="Target network: 'testnet' or 'mainnet' (overrides env)"),
1243
- gui: Optional[bool] = typer.Option(None, "--gui/--no-gui", help="Auto-launch Electron GUI (default: auto)"),
1244
+ gui: bool = typer.Option(False, "--gui/--no-gui", help="Launch Electron GUI (default: no)"),
1244
1245
  daemon: bool = typer.Option(False, "--daemon", help="Start in background and write a PID file"),
1246
+ stop_vms_on_exit: Optional[bool] = typer.Option(
1247
+ None, "--stop-vms-on-exit/--keep-vms-on-exit",
1248
+ help="On shutdown: stop all VMs (default: keep VMs running)"
1249
+ ),
1245
1250
  ):
1246
1251
  """Start the provider server."""
1247
1252
  if daemon:
@@ -1258,6 +1263,8 @@ def start(
1258
1263
  args += ["--network", network]
1259
1264
  # Force no GUI for daemonized child to avoid duplicates
1260
1265
  args.append("--no-gui")
1266
+ if stop_vms_on_exit is not None:
1267
+ args.append("--stop-vms-on-exit" if stop_vms_on_exit else "--keep-vms-on-exit")
1261
1268
  cmd = _self_command(args)
1262
1269
  # Ensure GUI not auto-launched via env, regardless of defaults
1263
1270
  env = {**os.environ, "GOLEM_PROVIDER_LAUNCH_GUI": "0"}
@@ -1266,7 +1273,13 @@ def start(
1266
1273
  print(f"Started provider in background (pid={child_pid})")
1267
1274
  raise typer.Exit(code=0)
1268
1275
  else:
1269
- run_server(dev_mode=False, no_verify_port=no_verify_port, network=network, launch_gui=gui)
1276
+ run_server(
1277
+ dev_mode=False,
1278
+ no_verify_port=no_verify_port,
1279
+ network=network,
1280
+ launch_gui=gui,
1281
+ stop_vms_on_exit=stop_vms_on_exit,
1282
+ )
1270
1283
 
1271
1284
 
1272
1285
  @cli.command()
@@ -1445,17 +1458,6 @@ def _print_pricing_examples(glm_usd):
1445
1458
  f"- {name} ({res.cpu}C, {res.memory}GB RAM, {res.storage}GB Disk): ~{usd_str} per month (~{glm_str})"
1446
1459
  )
1447
1460
 
1448
- def _can_launch_gui() -> bool:
1449
- import shutil
1450
- plat = _sys.platform
1451
- # Basic headless checks
1452
- if plat.startswith("linux"):
1453
- if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
1454
- return False
1455
- # Require npm (or electron) available
1456
- return bool(shutil.which("npm") or shutil.which("electron"))
1457
-
1458
-
1459
1461
  def _maybe_launch_gui(port: int):
1460
1462
  import subprocess, shutil
1461
1463
  import os as _os
@@ -1521,7 +1523,13 @@ def _maybe_launch_gui(port: int):
1521
1523
  logger.warning(f"Failed to launch GUI: {e}")
1522
1524
 
1523
1525
 
1524
- def run_server(dev_mode: bool | None = None, no_verify_port: bool = False, network: str | None = None, launch_gui: Optional[bool] = None):
1526
+ def run_server(
1527
+ dev_mode: bool | None = None,
1528
+ no_verify_port: bool = False,
1529
+ network: str | None = None,
1530
+ launch_gui: bool = False,
1531
+ stop_vms_on_exit: bool | None = None,
1532
+ ):
1525
1533
  """Helper to run the uvicorn server."""
1526
1534
  import sys
1527
1535
  from pathlib import Path
@@ -1539,6 +1547,9 @@ def run_server(dev_mode: bool | None = None, no_verify_port: bool = False, netwo
1539
1547
  # Apply network override early (affects settings and annotations)
1540
1548
  if network:
1541
1549
  os.environ["GOLEM_PROVIDER_NETWORK"] = network
1550
+ # Apply shutdown behavior override early so it is reflected in settings
1551
+ if stop_vms_on_exit is not None:
1552
+ os.environ["GOLEM_PROVIDER_STOP_VMS_ON_EXIT"] = "1" if stop_vms_on_exit else "0"
1542
1553
 
1543
1554
  # The logic for setting the public IP in dev mode is now handled in config.py
1544
1555
  # The following lines are no longer needed and have been removed.
@@ -1577,17 +1588,8 @@ def run_server(dev_mode: bool | None = None, no_verify_port: bool = False, netwo
1577
1588
  log_config = uvicorn.config.LOGGING_CONFIG
1578
1589
  log_config["formatters"]["access"]["fmt"] = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
1579
1590
 
1580
- # Optionally launch GUI (non-blocking)
1581
- want_gui: bool
1582
- if launch_gui is None:
1583
- env_flag = os.environ.get("GOLEM_PROVIDER_LAUNCH_GUI")
1584
- if env_flag is not None:
1585
- want_gui = env_flag.strip().lower() in ("1", "true", "yes")
1586
- else:
1587
- want_gui = _can_launch_gui()
1588
- else:
1589
- want_gui = bool(launch_gui)
1590
- if want_gui:
1591
+ # Optionally launch GUI (non-blocking) — disabled by default
1592
+ if bool(launch_gui):
1591
1593
  try:
1592
1594
  _maybe_launch_gui(int(settings.PORT))
1593
1595
  except Exception:
@@ -2,6 +2,7 @@ import asyncio
2
2
  from typing import Optional
3
3
 
4
4
  from ..utils.logging import setup_logger
5
+ from ..vm.models import VMNotFoundError
5
6
 
6
7
  logger = setup_logger(__name__)
7
8
 
@@ -83,6 +84,16 @@ class StreamMonitor:
83
84
  )
84
85
  try:
85
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}")
91
+ try:
92
+ await self.stream_map.remove(vm_id)
93
+ except Exception as rem_err:
94
+ logger.debug(
95
+ f"failed to remove vm {vm_id} from stream map after not-found: {rem_err}"
96
+ )
86
97
  except Exception as e:
87
98
  logger.warning(f"stop_vm failed for {vm_id}: {e}")
88
99
  continue
provider/service.py CHANGED
@@ -17,6 +17,7 @@ class ProviderService:
17
17
  self.advertisement_service = advertisement_service
18
18
  self.port_manager = port_manager
19
19
  self._pricing_updater: PricingAutoUpdater | None = None
20
+ self._pricing_task: asyncio.Task | None = None
20
21
  self._stream_monitor = None
21
22
 
22
23
  async def setup(self, app: FastAPI):
@@ -104,7 +105,8 @@ class ProviderService:
104
105
  async def _on_price_updated(platform: str, glm_usd):
105
106
  await self.advertisement_service.trigger_update()
106
107
  self._pricing_updater = PricingAutoUpdater(on_updated_callback=_on_price_updated)
107
- asyncio.create_task(self._pricing_updater.start())
108
+ # Keep a handle to the background task so we can cancel it promptly on shutdown
109
+ self._pricing_task = asyncio.create_task(self._pricing_updater.start(), name="pricing-updater")
108
110
 
109
111
  # Start stream monitor if enabled
110
112
  from .container import Container
@@ -130,10 +132,49 @@ class ProviderService:
130
132
  async def cleanup(self):
131
133
  """Cleanup provider components."""
132
134
  logger.process("🔄 Cleaning up provider...")
133
- await self.advertisement_service.stop()
134
- await self.vm_service.provider.cleanup()
135
+ from .config import settings
136
+
137
+ # Stop advertising loop
138
+ try:
139
+ await self.advertisement_service.stop()
140
+ except Exception:
141
+ pass
142
+
143
+ # Optionally stop all running VMs based on configuration (default: keep running)
144
+ try:
145
+ if bool(getattr(settings, "STOP_VMS_ON_EXIT", False)):
146
+ try:
147
+ vms = await self.vm_service.list_vms()
148
+ except Exception:
149
+ vms = []
150
+ for vm in vms:
151
+ try:
152
+ await self.vm_service.stop_vm(vm.id)
153
+ except Exception as e:
154
+ logger.warning(f"Failed to stop VM {getattr(vm, 'id', '?')}: {e}")
155
+ except Exception:
156
+ pass
157
+
158
+ # Provider cleanup hook
159
+ try:
160
+ await self.vm_service.provider.cleanup()
161
+ except Exception:
162
+ pass
163
+
164
+ # Stop pricing updater promptly (cancel background task and set stop flag)
135
165
  if self._pricing_updater:
136
- self._pricing_updater.stop()
166
+ try:
167
+ self._pricing_updater.stop()
168
+ except Exception:
169
+ pass
170
+ if self._pricing_task:
171
+ try:
172
+ self._pricing_task.cancel()
173
+ await self._pricing_task
174
+ except asyncio.CancelledError:
175
+ pass
176
+ except Exception:
177
+ pass
137
178
  if self._stream_monitor:
138
179
  await self._stream_monitor.stop()
139
180
  logger.success("✨ Provider cleanup complete")
@@ -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.
@@ -160,8 +180,8 @@ class MultipassAdapter(VMProvider):
160
180
  vms: List[VMInfo] = []
161
181
  for requestor_name, multipass_name in list(all_mappings.items()):
162
182
  try:
163
- # get_vm_status expects multipass_name
164
- vm_info = await self.get_vm_status(multipass_name)
183
+ # Pass requestor id; get_vm_status accepts either id
184
+ vm_info = await self.get_vm_status(requestor_name)
165
185
  vms.append(vm_info)
166
186
  except VMNotFoundError:
167
187
  logger.warning(
@@ -188,30 +208,40 @@ class MultipassAdapter(VMProvider):
188
208
  await self._run_multipass(["stop", multipass_name])
189
209
  return await self.get_vm_status(multipass_name)
190
210
 
191
- async def get_vm_status(self, multipass_name: str) -> VMInfo:
192
- """Get the status of a VM."""
211
+ async def get_vm_status(self, name_or_id: str) -> VMInfo:
212
+ """Get VM status by multipass name or requestor id."""
213
+ # Resolve identifiers flexibly
214
+ requestor_name = await self.name_mapper.get_requestor_name(name_or_id)
215
+ if requestor_name:
216
+ multipass_name = name_or_id
217
+ else:
218
+ multipass_name = await self.name_mapper.get_multipass_name(name_or_id)
219
+ if not multipass_name:
220
+ raise VMNotFoundError(f"VM {name_or_id} mapping not found")
221
+ requestor_name = name_or_id
193
222
  try:
194
223
  info = await self._get_vm_info(multipass_name)
195
224
  except MultipassError:
196
225
  raise VMNotFoundError(f"VM {multipass_name} not found in multipass")
197
226
 
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
227
  ipv4 = info.get("ipv4")
203
228
  ip_address = ipv4[0] if ipv4 else None
204
229
  logger.debug(f"Parsed VM info for {requestor_name}: {info}")
205
230
 
206
231
  disks_info = info.get("disks", {})
207
- total_storage = sum(int(disk.get("total", 0)) for disk in disks_info.values())
232
+ total_storage = 0
233
+ for disk in disks_info.values():
234
+ total_storage += self._safe_int(disk.get("total"), 0)
235
+
236
+ # Memory reported by multipass is in bytes; default to 1 GiB if missing/blank
237
+ mem_total_bytes = self._safe_int(info.get("memory", {}).get("total"), 1024**3)
208
238
  vm_info_obj = VMInfo(
209
239
  id=requestor_name,
210
240
  name=requestor_name,
211
241
  status=VMStatus(info["state"].lower()),
212
242
  resources=VMResources(
213
- cpu=int(info.get("cpu_count", "1")),
214
- memory=round(info.get("memory", {}).get("total", 1024**3) / (1024**3)),
243
+ cpu=self._safe_int(info.get("cpu_count"), 1),
244
+ memory=round(mem_total_bytes / (1024**3)),
215
245
  storage=round(total_storage / (1024**3)) if total_storage > 0 else 10
216
246
  ),
217
247
  ip_address=ip_address,
@@ -228,10 +258,13 @@ class MultipassAdapter(VMProvider):
228
258
  try:
229
259
  info = await self._get_vm_info(multipass_name)
230
260
  disks_info = info.get("disks", {})
231
- total_storage = sum(int(disk.get("total", 0)) for disk in disks_info.values())
261
+ total_storage = 0
262
+ for disk in disks_info.values():
263
+ total_storage += self._safe_int(disk.get("total"), 0)
264
+ mem_total_bytes = self._safe_int(info.get("memory", {}).get("total"), 1024**3)
232
265
  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)),
266
+ cpu=self._safe_int(info.get("cpu_count"), 1),
267
+ memory=round(mem_total_bytes / (1024**3)),
235
268
  storage=round(total_storage / (1024**3)) if total_storage > 0 else 10
236
269
  )
237
270
  except (MultipassError, VMNotFoundError):