golem-vm-provider 0.1.58__py3-none-any.whl → 0.1.59__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.59
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=4luagwoqW84PPvQRE3LR-W5XfoHpAbG0NK2fWnE1qSo,20228
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=CFMlTeXAkTnE8zRVEGOdkbICzFYwwoZUjBPsUOLJsoM,64479
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
@@ -39,7 +40,7 @@ provider/vm/port_manager.py,sha256=iYSwjTjD_ziOhG8aI7juKHw1OwwRUTJQyQoRUNQvz9w,1
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.59.dist-info/METADATA,sha256=gk1kKhCSdXFCqD5sHNC81F8nl3PtgNSp1Rwxwpk2CXg,21221
44
+ golem_vm_provider-0.1.59.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
45
+ golem_vm_provider-0.1.59.dist-info/entry_points.txt,sha256=5Jiie1dIXygmxmDW66bKKxQpmBLJ7leSKRrb8bkQALw,52
46
+ golem_vm_provider-0.1.59.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,28 +31,30 @@ from ..vm.multipass_adapter import MultipassError
26
31
  logger = setup_logger(__name__)
27
32
  router = APIRouter()
28
33
 
34
+ # Job status persisted in SQLite via JobStore (see Container.job_store)
29
35
 
30
- @router.post("/vms", response_model=VMInfo)
36
+
37
+ @router.post("/vms")
31
38
  @inject
32
39
  async def create_vm(
33
40
  request: CreateVMRequest,
34
41
  vm_service: VMService = Depends(Provide[Container.vm_service]),
35
42
  settings: Any = Depends(Provide[Container.config]),
36
43
  stream_map = Depends(Provide[Container.stream_map]),
37
- ) -> VMInfo:
38
- """Create a new VM."""
44
+ job_store: JobStore = Depends(Provide[Container.job_store]),
45
+ async_mode: bool = Query(default=False, alias="async"),
46
+ ) -> Any:
47
+ """Create a VM (sync by default; async when `?async=true`)."""
39
48
  try:
40
- logger.info(f"📥 Received VM creation request for '{request.name}'")
41
-
49
+ logger.info(f"📥 Received VM creation request for '{request.name}' (async={async_mode})")
50
+
42
51
  resources = request.resources or VMResources()
43
52
 
44
53
  # If payments are enabled, require a valid stream before starting
45
- # Determine if we should enforce gating
46
54
  enforce = False
47
55
  spa = (settings.get("STREAM_PAYMENT_ADDRESS") if isinstance(settings, dict) else getattr(settings, "STREAM_PAYMENT_ADDRESS", None))
48
56
  if spa and spa != "0x0000000000000000000000000000000000000000":
49
57
  if os.environ.get("PYTEST_CURRENT_TEST"):
50
- # In pytest, skip gating only when using default deployment address
51
58
  try:
52
59
  from ..config import Settings as _Cfg # type: ignore
53
60
  default_spa, _ = _Cfg._load_l2_deployment() # type: ignore[attr-defined]
@@ -73,39 +80,70 @@ async def create_vm(
73
80
  f"start={s['startTime']} stop={s['stopTime']} rate={s['ratePerSecond']} deposit={s['deposit']} withdrawn={s['withdrawn']} remaining={remaining}s"
74
81
  )
75
82
  except Exception:
76
- # Best-effort logging; creation will continue/fail based on ok
77
83
  pass
78
84
  if not ok:
79
85
  raise HTTPException(status_code=400, detail=f"invalid stream: {reason}")
80
-
81
- # Create VM config
86
+
82
87
  config = VMConfig(
83
88
  name=request.name,
84
89
  image=request.image or (settings.get("DEFAULT_VM_IMAGE") if isinstance(settings, dict) else getattr(settings, "DEFAULT_VM_IMAGE", "")),
85
90
  resources=resources,
86
- ssh_key=request.ssh_key
91
+ ssh_key=request.ssh_key,
87
92
  )
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:
93
+
94
+ if not async_mode:
95
+ vm_info = await vm_service.create_vm(config)
96
+ if request.stream_id is not None:
97
+ try:
98
+ await stream_map.set(vm_info.id, int(request.stream_id))
99
+ except Exception as e: # noqa: BLE001
100
+ logger.warning(f"failed to persist stream mapping for {vm_info.id}: {e}")
101
+ await vm_creation_animation(request.name)
102
+ return vm_info
103
+
104
+ # Async path
105
+ job_id = str(uuid.uuid4())
106
+ await job_store.create_job(job_id, request.name, status="creating")
107
+
108
+ async def _run_creation():
92
109
  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
110
+ vm_info = await vm_service.create_vm(config)
111
+ if request.stream_id is not None:
112
+ try:
113
+ await stream_map.set(vm_info.id, int(request.stream_id))
114
+ except Exception as e: # noqa: BLE001
115
+ logger.warning(f"failed to persist stream mapping for {vm_info.id}: {e}")
116
+ await vm_creation_animation(request.name)
117
+ await job_store.update_job(job_id, status="ready")
118
+ except Exception as e: # noqa: BLE001
119
+ logger.error(f"Create VM job failed: {e}")
120
+ await job_store.update_job(job_id, status="failed", error=str(e))
121
+
122
+ asyncio.create_task(_run_creation(), name=f"create-vm:{request.name}")
123
+
124
+ env = CreateVMJobResponse(job_id=job_id, vm_id=request.name, status="creating")
125
+ return JSONResponse(status_code=202, content=env.model_json_schema() and env.model_dump())
126
+
98
127
  except MultipassError as e:
99
128
  logger.error(f"Failed to create VM: {e}")
100
129
  raise HTTPException(status_code=500, detail=str(e))
101
130
  except HTTPException:
102
- # Propagate explicit HTTP errors (e.g., payment gating)
103
131
  raise
104
132
  except Exception as e:
105
133
  logger.error(f"An unexpected error occurred: {e}")
106
134
  raise HTTPException(status_code=500, detail="An unexpected error occurred")
107
135
 
108
136
 
137
+ @router.get("/vms/jobs/{job_id}")
138
+ @inject
139
+ async def get_create_job(job_id: str, job_store: JobStore = Depends(Provide[Container.job_store])):
140
+ """Return async creation job status."""
141
+ job = await job_store.get_job(job_id)
142
+ if not job:
143
+ raise HTTPException(status_code=404, detail="job not found")
144
+ return job
145
+
146
+
109
147
  @router.get("/vms", response_model=List[VMInfo])
110
148
  @inject
111
149
  async def list_vms(
@@ -163,9 +201,18 @@ async def get_vm_access(
163
201
  if not multipass_name:
164
202
  raise HTTPException(404, "VM mapping not found")
165
203
 
204
+ # If ssh_port is not yet assigned, return 202 with a simple status payload
205
+ if vm.ssh_port is None:
206
+ return JSONResponse(status_code=202, content={
207
+ "vm_id": requestor_name,
208
+ "multipass_name": multipass_name,
209
+ "status": "creating",
210
+ "ssh_port": None,
211
+ })
212
+
166
213
  return VMAccessInfo(
167
214
  ssh_host=((settings.get("PUBLIC_IP") if isinstance(settings, dict) else getattr(settings, "PUBLIC_IP", None)) or "localhost"),
168
- ssh_port=vm.ssh_port,
215
+ ssh_port=int(vm.ssh_port),
169
216
  vm_id=requestor_name,
170
217
  multipass_name=multipass_name
171
218
  )
@@ -231,10 +278,33 @@ async def delete_vm(
231
278
  @router.get("/provider/info", response_model=ProviderInfoResponse)
232
279
  @inject
233
280
  async def provider_info(settings: Any = Depends(Provide[Container.config])) -> ProviderInfoResponse:
281
+ # Derive platform similar to advertiser
282
+ import platform as _plat
283
+ raw = _plat.machine().lower()
284
+ platform_str = None
285
+ try:
286
+ if 'arm' in raw:
287
+ platform_str = 'arm64'
288
+ elif 'x86_64' in raw or 'amd64' in raw or 'x64' in raw:
289
+ platform_str = 'x86_64'
290
+ else:
291
+ platform_str = raw
292
+ except Exception:
293
+ platform_str = None
294
+
295
+ ip_addr = None
296
+ try:
297
+ ip_addr = settings.get("PUBLIC_IP") if isinstance(settings, dict) else getattr(settings, "PUBLIC_IP", None)
298
+ except Exception:
299
+ ip_addr = None
300
+
234
301
  return ProviderInfoResponse(
235
302
  provider_id=settings["PROVIDER_ID"],
236
303
  stream_payment_address=settings["STREAM_PAYMENT_ADDRESS"],
237
304
  glm_token_address=settings["GLM_TOKEN_ADDRESS"],
305
+ ip_address=ip_addr,
306
+ country=(settings.get("PROVIDER_COUNTRY") if isinstance(settings, dict) else getattr(settings, "PROVIDER_COUNTRY", None)),
307
+ platform=platform_str,
238
308
  )
239
309
 
240
310
 
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
@@ -1240,8 +1240,12 @@ def streams_withdraw(
1240
1240
  def start(
1241
1241
  no_verify_port: bool = typer.Option(False, "--no-verify-port", help="Skip provider port verification."),
1242
1242
  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)"),
1243
+ gui: bool = typer.Option(False, "--gui/--no-gui", help="Launch Electron GUI (default: no)"),
1244
1244
  daemon: bool = typer.Option(False, "--daemon", help="Start in background and write a PID file"),
1245
+ stop_vms_on_exit: Optional[bool] = typer.Option(
1246
+ None, "--stop-vms-on-exit/--keep-vms-on-exit",
1247
+ help="On shutdown: stop all VMs (default: keep VMs running)"
1248
+ ),
1245
1249
  ):
1246
1250
  """Start the provider server."""
1247
1251
  if daemon:
@@ -1258,6 +1262,8 @@ def start(
1258
1262
  args += ["--network", network]
1259
1263
  # Force no GUI for daemonized child to avoid duplicates
1260
1264
  args.append("--no-gui")
1265
+ if stop_vms_on_exit is not None:
1266
+ args.append("--stop-vms-on-exit" if stop_vms_on_exit else "--keep-vms-on-exit")
1261
1267
  cmd = _self_command(args)
1262
1268
  # Ensure GUI not auto-launched via env, regardless of defaults
1263
1269
  env = {**os.environ, "GOLEM_PROVIDER_LAUNCH_GUI": "0"}
@@ -1266,7 +1272,13 @@ def start(
1266
1272
  print(f"Started provider in background (pid={child_pid})")
1267
1273
  raise typer.Exit(code=0)
1268
1274
  else:
1269
- run_server(dev_mode=False, no_verify_port=no_verify_port, network=network, launch_gui=gui)
1275
+ run_server(
1276
+ dev_mode=False,
1277
+ no_verify_port=no_verify_port,
1278
+ network=network,
1279
+ launch_gui=gui,
1280
+ stop_vms_on_exit=stop_vms_on_exit,
1281
+ )
1270
1282
 
1271
1283
 
1272
1284
  @cli.command()
@@ -1445,17 +1457,6 @@ def _print_pricing_examples(glm_usd):
1445
1457
  f"- {name} ({res.cpu}C, {res.memory}GB RAM, {res.storage}GB Disk): ~{usd_str} per month (~{glm_str})"
1446
1458
  )
1447
1459
 
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
1460
  def _maybe_launch_gui(port: int):
1460
1461
  import subprocess, shutil
1461
1462
  import os as _os
@@ -1521,7 +1522,13 @@ def _maybe_launch_gui(port: int):
1521
1522
  logger.warning(f"Failed to launch GUI: {e}")
1522
1523
 
1523
1524
 
1524
- def run_server(dev_mode: bool | None = None, no_verify_port: bool = False, network: str | None = None, launch_gui: Optional[bool] = None):
1525
+ def run_server(
1526
+ dev_mode: bool | None = None,
1527
+ no_verify_port: bool = False,
1528
+ network: str | None = None,
1529
+ launch_gui: bool = False,
1530
+ stop_vms_on_exit: bool | None = None,
1531
+ ):
1525
1532
  """Helper to run the uvicorn server."""
1526
1533
  import sys
1527
1534
  from pathlib import Path
@@ -1539,6 +1546,9 @@ def run_server(dev_mode: bool | None = None, no_verify_port: bool = False, netwo
1539
1546
  # Apply network override early (affects settings and annotations)
1540
1547
  if network:
1541
1548
  os.environ["GOLEM_PROVIDER_NETWORK"] = network
1549
+ # Apply shutdown behavior override early so it is reflected in settings
1550
+ if stop_vms_on_exit is not None:
1551
+ os.environ["GOLEM_PROVIDER_STOP_VMS_ON_EXIT"] = "1" if stop_vms_on_exit else "0"
1542
1552
 
1543
1553
  # The logic for setting the public IP in dev mode is now handled in config.py
1544
1554
  # The following lines are no longer needed and have been removed.
@@ -1577,17 +1587,8 @@ def run_server(dev_mode: bool | None = None, no_verify_port: bool = False, netwo
1577
1587
  log_config = uvicorn.config.LOGGING_CONFIG
1578
1588
  log_config["formatters"]["access"]["fmt"] = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
1579
1589
 
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:
1590
+ # Optionally launch GUI (non-blocking) — disabled by default
1591
+ if bool(launch_gui):
1591
1592
  try:
1592
1593
  _maybe_launch_gui(int(settings.PORT))
1593
1594
  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")