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.
- {golem_vm_provider-0.1.58.dist-info → golem_vm_provider-0.1.60.dist-info}/METADATA +1 -1
- {golem_vm_provider-0.1.58.dist-info → golem_vm_provider-0.1.60.dist-info}/RECORD +16 -15
- provider/api/models.py +10 -0
- provider/api/routes.py +105 -25
- provider/config.py +6 -0
- provider/container.py +7 -0
- provider/discovery/advertiser.py +11 -0
- provider/discovery/golem_base_advertiser.py +11 -0
- provider/discovery/service.py +4 -1
- provider/jobs/store.py +116 -0
- provider/main.py +27 -25
- provider/payments/monitor.py +11 -0
- provider/service.py +45 -4
- provider/vm/multipass_adapter.py +47 -14
- {golem_vm_provider-0.1.58.dist-info → golem_vm_provider-0.1.60.dist-info}/WHEEL +0 -0
- {golem_vm_provider-0.1.58.dist-info → golem_vm_provider-0.1.60.dist-info}/entry_points.txt +0 -0
@@ -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=
|
4
|
-
provider/api/routes.py,sha256=
|
5
|
-
provider/config.py,sha256=
|
6
|
-
provider/container.py,sha256=
|
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=
|
10
|
-
provider/discovery/golem_base_advertiser.py,sha256=
|
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=
|
16
|
-
provider/
|
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=
|
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=
|
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=
|
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.
|
43
|
-
golem_vm_provider-0.1.
|
44
|
-
golem_vm_provider-0.1.
|
45
|
-
golem_vm_provider-0.1.
|
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
|
-
|
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
|
-
|
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
|
-
|
38
|
-
|
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
|
-
|
53
|
-
|
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
|
-
|
90
|
-
|
91
|
-
|
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
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
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
|
+
)
|
provider/discovery/advertiser.py
CHANGED
@@ -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))),
|
provider/discovery/service.py
CHANGED
@@ -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):
|
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:
|
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(
|
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(
|
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
|
-
|
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:
|
provider/payments/monitor.py
CHANGED
@@ -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
|
-
|
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")
|
provider/vm/multipass_adapter.py
CHANGED
@@ -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
|
164
|
-
vm_info = await self.get_vm_status(
|
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,
|
192
|
-
"""Get
|
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 =
|
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=
|
214
|
-
memory=round(
|
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 =
|
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=
|
234
|
-
memory=round(
|
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):
|
File without changes
|
File without changes
|