golem-vm-provider 0.1.58__tar.gz → 0.1.59__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.
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/PKG-INFO +1 -1
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/api/models.py +10 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/api/routes.py +93 -23
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/config.py +6 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/container.py +7 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/discovery/advertiser.py +11 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/discovery/golem_base_advertiser.py +11 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/discovery/service.py +4 -1
- golem_vm_provider-0.1.59/provider/jobs/store.py +116 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/main.py +26 -25
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/payments/monitor.py +11 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/service.py +45 -4
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/pyproject.toml +1 -1
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/README.md +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/__init__.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/api/__init__.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/data/deployments/l2.json +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/discovery/__init__.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/discovery/golem_base_utils.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/discovery/multi_advertiser.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/discovery/resource_monitor.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/discovery/resource_tracker.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/network/port_verifier.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/payments/blockchain_service.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/payments/stream_map.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/security/ethereum.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/security/faucet.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/security/l2_faucet.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/utils/__init__.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/utils/ascii_art.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/utils/logging.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/utils/port_display.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/utils/pricing.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/utils/retry.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/utils/setup.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/vm/__init__.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/vm/cloud_init.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/vm/models.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/vm/multipass.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/vm/multipass_adapter.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/vm/name_mapper.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/vm/port_manager.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/vm/provider.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/vm/proxy_manager.py +0 -0
- {golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/vm/service.py +0 -0
@@ -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")
|
@@ -2,13 +2,17 @@ import json
|
|
2
2
|
import os
|
3
3
|
from typing import List
|
4
4
|
from pathlib import Path
|
5
|
-
|
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
|
-
|
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
|
-
|
38
|
-
|
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
|
-
|
90
|
-
|
91
|
-
|
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
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
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
|
|
@@ -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"
|
@@ -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,
|
{golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/discovery/golem_base_advertiser.py
RENAMED
@@ -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
|
-
|
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):
|
@@ -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
|
+
|
@@ -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:
|
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(
|
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(
|
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
|
-
|
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
|
@@ -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
|
-
|
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
|
-
|
134
|
-
|
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
|
-
|
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")
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "golem-vm-provider"
|
3
|
-
version = "0.1.
|
3
|
+
version = "0.1.59"
|
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"
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/discovery/golem_base_utils.py
RENAMED
File without changes
|
{golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/discovery/multi_advertiser.py
RENAMED
File without changes
|
{golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/discovery/resource_monitor.py
RENAMED
File without changes
|
{golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/discovery/resource_tracker.py
RENAMED
File without changes
|
File without changes
|
{golem_vm_provider-0.1.58 → golem_vm_provider-0.1.59}/provider/payments/blockchain_service.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|