request-vm-on-golem 0.1.24__py3-none-any.whl → 0.1.26__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.
- {request_vm_on_golem-0.1.24.dist-info → request_vm_on_golem-0.1.26.dist-info}/METADATA +11 -9
- request_vm_on_golem-0.1.26.dist-info/RECORD +24 -0
- {request_vm_on_golem-0.1.24.dist-info → request_vm_on_golem-0.1.26.dist-info}/WHEEL +1 -1
- requestor/api/main.py +59 -0
- requestor/cli/commands.py +213 -256
- requestor/config.py +36 -2
- requestor/db/sqlite.py +33 -41
- requestor/run.py +4 -2
- requestor/services/__init__.py +6 -0
- requestor/services/database_service.py +91 -0
- requestor/services/provider_service.py +265 -0
- requestor/services/ssh_service.py +121 -0
- requestor/services/vm_service.py +209 -0
- request_vm_on_golem-0.1.24.dist-info/RECORD +0 -18
- {request_vm_on_golem-0.1.24.dist-info → request_vm_on_golem-0.1.26.dist-info}/entry_points.txt +0 -0
requestor/config.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
from pathlib import Path
|
2
2
|
from typing import Optional, Dict
|
3
|
-
from
|
3
|
+
from pydantic_settings import BaseSettings
|
4
|
+
from pydantic import Field, validator
|
4
5
|
|
5
6
|
class RequestorConfig(BaseSettings):
|
6
7
|
"""Configuration settings for the requestor node."""
|
@@ -19,11 +20,44 @@ class RequestorConfig(BaseSettings):
|
|
19
20
|
default=False,
|
20
21
|
description="Force localhost for provider URLs in development mode"
|
21
22
|
)
|
23
|
+
|
24
|
+
@property
|
25
|
+
def DEV_MODE(self) -> bool:
|
26
|
+
return self.environment == "development"
|
22
27
|
|
23
28
|
# Discovery Service
|
29
|
+
discovery_driver: str = Field(
|
30
|
+
default="golem-base",
|
31
|
+
description="Discovery driver: 'central' or 'golem-base'"
|
32
|
+
)
|
24
33
|
discovery_url: str = Field(
|
25
34
|
default="http://195.201.39.101:9001",
|
26
|
-
description="URL of the discovery service"
|
35
|
+
description="URL of the discovery service (for 'central' driver)"
|
36
|
+
)
|
37
|
+
|
38
|
+
@validator("discovery_url", always=True)
|
39
|
+
def set_discovery_url(cls, v: str, values: dict) -> str:
|
40
|
+
"""Prefix discovery URL with DEVMODE if in development."""
|
41
|
+
if values.get("environment") == "development":
|
42
|
+
return f"DEVMODE-{v}"
|
43
|
+
return v
|
44
|
+
|
45
|
+
# Golem Base Settings
|
46
|
+
golem_base_rpc_url: str = Field(
|
47
|
+
default="https://ethwarsaw.holesky.golemdb.io/rpc",
|
48
|
+
description="Golem Base RPC URL"
|
49
|
+
)
|
50
|
+
golem_base_ws_url: str = Field(
|
51
|
+
default="wss://ethwarsaw.holesky.golemdb.io/rpc/ws",
|
52
|
+
description="Golem Base WebSocket URL"
|
53
|
+
)
|
54
|
+
advertisement_interval: int = Field(
|
55
|
+
default=240,
|
56
|
+
description="Advertisement interval in seconds (should match provider)"
|
57
|
+
)
|
58
|
+
ethereum_private_key: str = Field(
|
59
|
+
default="0x0000000000000000000000000000000000000000000000000000000000000001",
|
60
|
+
description="Private key for Golem Base"
|
27
61
|
)
|
28
62
|
|
29
63
|
# Base Directory
|
requestor/db/sqlite.py
CHANGED
@@ -11,47 +11,17 @@ class Database:
|
|
11
11
|
async def init(self):
|
12
12
|
"""Initialize database and handle migrations."""
|
13
13
|
async with aiosqlite.connect(self.db_path) as db:
|
14
|
-
#
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
config TEXT NOT NULL,
|
26
|
-
status TEXT NOT NULL DEFAULT 'running',
|
27
|
-
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
28
|
-
)
|
29
|
-
""")
|
30
|
-
else:
|
31
|
-
# Check if ssh_key_name column exists
|
32
|
-
async with db.execute("PRAGMA table_info(vms)") as cursor:
|
33
|
-
columns = await cursor.fetchall()
|
34
|
-
has_ssh_key_name = any(col[1] == 'ssh_key_name' for col in columns)
|
35
|
-
|
36
|
-
if has_ssh_key_name:
|
37
|
-
# Migrate existing data
|
38
|
-
await db.execute("""
|
39
|
-
CREATE TABLE vms_new (
|
40
|
-
name TEXT PRIMARY KEY,
|
41
|
-
provider_ip TEXT NOT NULL,
|
42
|
-
vm_id TEXT NOT NULL,
|
43
|
-
config TEXT NOT NULL,
|
44
|
-
status TEXT NOT NULL DEFAULT 'running',
|
45
|
-
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
46
|
-
)
|
47
|
-
""")
|
48
|
-
await db.execute("""
|
49
|
-
INSERT INTO vms_new (name, provider_ip, vm_id, config, status, created_at)
|
50
|
-
SELECT name, provider_ip, vm_id, config, status, created_at FROM vms
|
51
|
-
""")
|
52
|
-
await db.execute("DROP TABLE vms")
|
53
|
-
await db.execute("ALTER TABLE vms_new RENAME TO vms")
|
54
|
-
|
14
|
+
# Create VMs table if it doesn't exist
|
15
|
+
await db.execute("""
|
16
|
+
CREATE TABLE IF NOT EXISTS vms (
|
17
|
+
name TEXT PRIMARY KEY,
|
18
|
+
provider_ip TEXT NOT NULL,
|
19
|
+
vm_id TEXT NOT NULL,
|
20
|
+
config TEXT NOT NULL,
|
21
|
+
status TEXT NOT NULL DEFAULT 'running',
|
22
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
23
|
+
)
|
24
|
+
""")
|
55
25
|
await db.commit()
|
56
26
|
|
57
27
|
async def save_vm(
|
@@ -73,6 +43,28 @@ class Database:
|
|
73
43
|
)
|
74
44
|
await db.commit()
|
75
45
|
|
46
|
+
async def execute(self, query: str, params: tuple = None) -> None:
|
47
|
+
"""Execute a raw SQL query."""
|
48
|
+
async with aiosqlite.connect(self.db_path) as db:
|
49
|
+
await db.execute(query, params or ())
|
50
|
+
await db.commit()
|
51
|
+
|
52
|
+
async def fetchone(self, query: str, params: tuple = None) -> Optional[Dict]:
|
53
|
+
"""Fetch a single row as a dictionary."""
|
54
|
+
async with aiosqlite.connect(self.db_path) as db:
|
55
|
+
db.row_factory = aiosqlite.Row
|
56
|
+
async with db.execute(query, params or ()) as cursor:
|
57
|
+
row = await cursor.fetchone()
|
58
|
+
return dict(row) if row else None
|
59
|
+
|
60
|
+
async def fetchall(self, query: str, params: tuple = None) -> List[Dict]:
|
61
|
+
"""Fetch all rows as dictionaries."""
|
62
|
+
async with aiosqlite.connect(self.db_path) as db:
|
63
|
+
db.row_factory = aiosqlite.Row
|
64
|
+
async with db.execute(query, params or ()) as cursor:
|
65
|
+
rows = await cursor.fetchall()
|
66
|
+
return [dict(row) for row in rows]
|
67
|
+
|
76
68
|
async def get_vm(self, name: str) -> Optional[Dict]:
|
77
69
|
"""Get VM details."""
|
78
70
|
async with aiosqlite.connect(self.db_path) as db:
|
requestor/run.py
CHANGED
@@ -32,9 +32,11 @@ def check_requirements():
|
|
32
32
|
def main():
|
33
33
|
"""Run the requestor CLI."""
|
34
34
|
try:
|
35
|
-
# Load environment variables from .env file
|
36
|
-
|
35
|
+
# Load environment variables from .env.dev file if it exists, otherwise use .env
|
36
|
+
dev_env_path = Path(__file__).parent.parent / '.env.dev'
|
37
|
+
env_path = dev_env_path if dev_env_path.exists() else Path(__file__).parent.parent / '.env'
|
37
38
|
load_dotenv(dotenv_path=env_path)
|
39
|
+
logger.info(f"Loading environment variables from: {env_path}")
|
38
40
|
|
39
41
|
# Check requirements
|
40
42
|
if not check_requirements():
|
@@ -0,0 +1,91 @@
|
|
1
|
+
"""Database service for VM management."""
|
2
|
+
from typing import Dict, List, Optional
|
3
|
+
from pathlib import Path
|
4
|
+
|
5
|
+
from ..db.sqlite import Database
|
6
|
+
from ..errors import DatabaseError
|
7
|
+
|
8
|
+
class DatabaseService:
|
9
|
+
"""Service for database operations."""
|
10
|
+
|
11
|
+
def __init__(self, db_path: Path):
|
12
|
+
self.db = Database(db_path)
|
13
|
+
|
14
|
+
async def init(self):
|
15
|
+
"""Initialize database."""
|
16
|
+
try:
|
17
|
+
await self.db.init()
|
18
|
+
except Exception as e:
|
19
|
+
raise DatabaseError(f"Failed to initialize database: {str(e)}")
|
20
|
+
|
21
|
+
async def save_vm(
|
22
|
+
self,
|
23
|
+
name: str,
|
24
|
+
provider_ip: str,
|
25
|
+
vm_id: str,
|
26
|
+
config: Dict,
|
27
|
+
status: str = 'running'
|
28
|
+
) -> None:
|
29
|
+
"""Save VM details."""
|
30
|
+
try:
|
31
|
+
await self.db.save_vm(
|
32
|
+
name=name,
|
33
|
+
provider_ip=provider_ip,
|
34
|
+
vm_id=vm_id,
|
35
|
+
config=config,
|
36
|
+
status=status
|
37
|
+
)
|
38
|
+
except Exception as e:
|
39
|
+
raise DatabaseError(f"Failed to save VM: {str(e)}")
|
40
|
+
|
41
|
+
async def get_vm(self, name: str) -> Optional[Dict]:
|
42
|
+
"""Get VM details by name."""
|
43
|
+
try:
|
44
|
+
vm = await self.db.get_vm(name)
|
45
|
+
if not vm:
|
46
|
+
return None
|
47
|
+
return vm
|
48
|
+
except Exception as e:
|
49
|
+
raise DatabaseError(f"Failed to get VM details: {str(e)}")
|
50
|
+
|
51
|
+
async def delete_vm(self, name: str) -> None:
|
52
|
+
"""Delete VM from database."""
|
53
|
+
try:
|
54
|
+
await self.db.delete_vm(name)
|
55
|
+
except Exception as e:
|
56
|
+
raise DatabaseError(f"Failed to delete VM: {str(e)}")
|
57
|
+
|
58
|
+
async def update_vm_status(self, name: str, status: str) -> None:
|
59
|
+
"""Update VM status."""
|
60
|
+
try:
|
61
|
+
await self.db.update_vm_status(name, status)
|
62
|
+
except Exception as e:
|
63
|
+
raise DatabaseError(f"Failed to update VM status: {str(e)}")
|
64
|
+
|
65
|
+
async def list_vms(self) -> List[Dict]:
|
66
|
+
"""List all VMs."""
|
67
|
+
try:
|
68
|
+
return await self.db.list_vms()
|
69
|
+
except Exception as e:
|
70
|
+
raise DatabaseError(f"Failed to list VMs: {str(e)}")
|
71
|
+
|
72
|
+
async def execute(self, query: str, params: tuple = None) -> None:
|
73
|
+
"""Execute a raw SQL query."""
|
74
|
+
try:
|
75
|
+
await self.db.execute(query, params)
|
76
|
+
except Exception as e:
|
77
|
+
raise DatabaseError(f"Failed to execute query: {str(e)}")
|
78
|
+
|
79
|
+
async def fetchone(self, query: str, params: tuple = None) -> Optional[Dict]:
|
80
|
+
"""Fetch a single row as a dictionary."""
|
81
|
+
try:
|
82
|
+
return await self.db.fetchone(query, params)
|
83
|
+
except Exception as e:
|
84
|
+
raise DatabaseError(f"Failed to fetch row: {str(e)}")
|
85
|
+
|
86
|
+
async def fetchall(self, query: str, params: tuple = None) -> List[Dict]:
|
87
|
+
"""Fetch all rows as dictionaries."""
|
88
|
+
try:
|
89
|
+
return await self.db.fetchall(query, params)
|
90
|
+
except Exception as e:
|
91
|
+
raise DatabaseError(f"Failed to fetch rows: {str(e)}")
|
@@ -0,0 +1,265 @@
|
|
1
|
+
"""Provider discovery and management service."""
|
2
|
+
from typing import Dict, List, Optional
|
3
|
+
import aiohttp
|
4
|
+
import time
|
5
|
+
from datetime import datetime, timezone
|
6
|
+
from ..errors import DiscoveryError, ProviderError
|
7
|
+
from ..config import config
|
8
|
+
from golem_base_sdk import GolemBaseClient
|
9
|
+
from golem_base_sdk.types import EntityKey, GenericBytes
|
10
|
+
|
11
|
+
|
12
|
+
class ProviderService:
|
13
|
+
"""Service for provider operations."""
|
14
|
+
|
15
|
+
def __init__(self):
|
16
|
+
self.session = None
|
17
|
+
self.golem_base_client = None
|
18
|
+
|
19
|
+
async def __aenter__(self):
|
20
|
+
self.session = aiohttp.ClientSession()
|
21
|
+
# The GolemBaseClient is now initialized on-demand in find_providers
|
22
|
+
return self
|
23
|
+
|
24
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
25
|
+
if self.session:
|
26
|
+
await self.session.close()
|
27
|
+
if self.golem_base_client:
|
28
|
+
await self.golem_base_client.disconnect()
|
29
|
+
|
30
|
+
async def find_providers(
|
31
|
+
self,
|
32
|
+
cpu: Optional[int] = None,
|
33
|
+
memory: Optional[int] = None,
|
34
|
+
storage: Optional[int] = None,
|
35
|
+
country: Optional[str] = None,
|
36
|
+
driver: Optional[str] = None
|
37
|
+
) -> List[Dict]:
|
38
|
+
"""Find providers matching requirements."""
|
39
|
+
discovery_driver = driver or config.discovery_driver
|
40
|
+
if discovery_driver == "golem-base":
|
41
|
+
if not self.golem_base_client:
|
42
|
+
private_key_hex = config.ethereum_private_key.replace("0x", "")
|
43
|
+
private_key_bytes = bytes.fromhex(private_key_hex)
|
44
|
+
self.golem_base_client = await GolemBaseClient.create(
|
45
|
+
rpc_url=config.golem_base_rpc_url,
|
46
|
+
ws_url=config.golem_base_ws_url,
|
47
|
+
private_key=private_key_bytes,
|
48
|
+
)
|
49
|
+
return await self._find_providers_golem_base(cpu, memory, storage, country)
|
50
|
+
else:
|
51
|
+
return await self._find_providers_central(cpu, memory, storage, country)
|
52
|
+
|
53
|
+
async def _find_providers_golem_base(
|
54
|
+
self,
|
55
|
+
cpu: Optional[int] = None,
|
56
|
+
memory: Optional[int] = None,
|
57
|
+
storage: Optional[int] = None,
|
58
|
+
country: Optional[str] = None
|
59
|
+
) -> List[Dict]:
|
60
|
+
"""Find providers using Golem Base."""
|
61
|
+
try:
|
62
|
+
query = 'golem_type="provider"'
|
63
|
+
if cpu:
|
64
|
+
query += f' && golem_cpu>={cpu}'
|
65
|
+
if memory:
|
66
|
+
query += f' && golem_memory>={memory}'
|
67
|
+
if storage:
|
68
|
+
query += f' && golem_storage>={storage}'
|
69
|
+
if country:
|
70
|
+
query += f' && golem_country="{country}"'
|
71
|
+
|
72
|
+
results = await self.golem_base_client.query_entities(query)
|
73
|
+
|
74
|
+
providers = []
|
75
|
+
for result in results:
|
76
|
+
entity_key = EntityKey(
|
77
|
+
GenericBytes.from_hex_string(result.entity_key)
|
78
|
+
)
|
79
|
+
metadata = await self.golem_base_client.get_entity_metadata(entity_key)
|
80
|
+
annotations = {
|
81
|
+
ann.key: ann.value for ann in metadata.string_annotations}
|
82
|
+
annotations.update(
|
83
|
+
{ann.key: ann.value for ann in metadata.numeric_annotations})
|
84
|
+
provider = {
|
85
|
+
'provider_id': annotations.get('golem_provider_id'),
|
86
|
+
'provider_name': annotations.get('golem_provider_name'),
|
87
|
+
'ip_address': '127.0.0.1' if 'DEVMODE' in annotations.get('golem_provider_name', '') else annotations.get('golem_ip_address'),
|
88
|
+
'country': annotations.get('golem_country'),
|
89
|
+
'resources': {
|
90
|
+
'cpu': int(annotations.get('golem_cpu', 0)),
|
91
|
+
'memory': int(annotations.get('golem_memory', 0)),
|
92
|
+
'storage': int(annotations.get('golem_storage', 0)),
|
93
|
+
},
|
94
|
+
'created_at_block': metadata.expires_at_block - (config.advertisement_interval * 2)
|
95
|
+
}
|
96
|
+
if provider['provider_id']:
|
97
|
+
providers.append(provider)
|
98
|
+
|
99
|
+
return providers
|
100
|
+
except Exception as e:
|
101
|
+
raise DiscoveryError(
|
102
|
+
f"Error finding providers on Golem Base: {str(e)}")
|
103
|
+
|
104
|
+
async def _find_providers_central(
|
105
|
+
self,
|
106
|
+
cpu: Optional[int] = None,
|
107
|
+
memory: Optional[int] = None,
|
108
|
+
storage: Optional[int] = None,
|
109
|
+
country: Optional[str] = None
|
110
|
+
) -> List[Dict]:
|
111
|
+
"""Find providers using the central discovery service."""
|
112
|
+
try:
|
113
|
+
# Build query parameters
|
114
|
+
params = {
|
115
|
+
k: v for k, v in {
|
116
|
+
'cpu': cpu,
|
117
|
+
'memory': memory,
|
118
|
+
'storage': storage,
|
119
|
+
'country': country
|
120
|
+
}.items() if v is not None
|
121
|
+
}
|
122
|
+
|
123
|
+
# Query discovery service
|
124
|
+
async with self.session.get(
|
125
|
+
f"{config.discovery_url}/api/v1/advertisements",
|
126
|
+
params=params
|
127
|
+
) as response:
|
128
|
+
if not response.ok:
|
129
|
+
raise DiscoveryError(
|
130
|
+
f"Failed to query discovery service: {await response.text()}"
|
131
|
+
)
|
132
|
+
providers = await response.json()
|
133
|
+
|
134
|
+
# Process provider IPs based on environment
|
135
|
+
for provider in providers:
|
136
|
+
provider['ip_address'] = (
|
137
|
+
'localhost' if config.environment == "development"
|
138
|
+
else provider.get('ip_address')
|
139
|
+
)
|
140
|
+
|
141
|
+
return providers
|
142
|
+
|
143
|
+
except aiohttp.ClientError as e:
|
144
|
+
raise DiscoveryError(
|
145
|
+
f"Failed to connect to discovery service: {str(e)}")
|
146
|
+
except Exception as e:
|
147
|
+
raise DiscoveryError(f"Error finding providers: {str(e)}")
|
148
|
+
|
149
|
+
async def verify_provider(self, provider_id: str) -> Dict:
|
150
|
+
"""Verify provider exists and is available."""
|
151
|
+
try:
|
152
|
+
providers = await self.find_providers()
|
153
|
+
provider = next(
|
154
|
+
(p for p in providers if p['provider_id'] == provider_id),
|
155
|
+
None
|
156
|
+
)
|
157
|
+
|
158
|
+
if not provider:
|
159
|
+
raise ProviderError(f"Provider {provider_id} not found")
|
160
|
+
|
161
|
+
return provider
|
162
|
+
|
163
|
+
except Exception as e:
|
164
|
+
if isinstance(e, ProviderError):
|
165
|
+
raise
|
166
|
+
raise ProviderError(f"Failed to verify provider: {str(e)}")
|
167
|
+
|
168
|
+
async def get_provider_resources(self, provider_id: str) -> Dict:
|
169
|
+
"""Get current resource availability for a provider."""
|
170
|
+
try:
|
171
|
+
provider = await self.verify_provider(provider_id)
|
172
|
+
return {
|
173
|
+
'cpu': provider['resources']['cpu'],
|
174
|
+
'memory': provider['resources']['memory'],
|
175
|
+
'storage': provider['resources']['storage']
|
176
|
+
}
|
177
|
+
except Exception as e:
|
178
|
+
raise ProviderError(f"Failed to get provider resources: {str(e)}")
|
179
|
+
|
180
|
+
async def check_resource_availability(
|
181
|
+
self,
|
182
|
+
provider_id: str,
|
183
|
+
cpu: int,
|
184
|
+
memory: int,
|
185
|
+
storage: int
|
186
|
+
) -> bool:
|
187
|
+
"""Check if provider has sufficient resources."""
|
188
|
+
try:
|
189
|
+
resources = await self.get_provider_resources(provider_id)
|
190
|
+
|
191
|
+
return (
|
192
|
+
resources['cpu'] >= cpu and
|
193
|
+
resources['memory'] >= memory and
|
194
|
+
resources['storage'] >= storage
|
195
|
+
)
|
196
|
+
|
197
|
+
except Exception as e:
|
198
|
+
raise ProviderError(
|
199
|
+
f"Failed to check resource availability: {str(e)}"
|
200
|
+
)
|
201
|
+
|
202
|
+
async def _format_block_timestamp(self, block_number: int) -> str:
|
203
|
+
"""Format a block number into a human-readable 'time ago' string."""
|
204
|
+
if not self.golem_base_client:
|
205
|
+
return "N/A"
|
206
|
+
try:
|
207
|
+
latest_block = await self.golem_base_client.http_client().eth.get_block('latest')
|
208
|
+
block_diff = latest_block.number - block_number
|
209
|
+
seconds_ago = block_diff * 2 # Approximate block time
|
210
|
+
|
211
|
+
if seconds_ago < 60:
|
212
|
+
return f"{int(seconds_ago)}s ago"
|
213
|
+
elif seconds_ago < 3600:
|
214
|
+
return f"{int(seconds_ago / 60)}m ago"
|
215
|
+
elif seconds_ago < 86400:
|
216
|
+
return f"{int(seconds_ago / 3600)}h ago"
|
217
|
+
else:
|
218
|
+
return f"{int(seconds_ago / 86400)}d ago"
|
219
|
+
except Exception:
|
220
|
+
return "N/A"
|
221
|
+
|
222
|
+
async def format_provider_row(self, provider: Dict, colorize: bool = False) -> List:
|
223
|
+
"""Format provider information for display."""
|
224
|
+
from click import style
|
225
|
+
|
226
|
+
updated_at_str = await self._format_block_timestamp(provider.get('created_at_block', 0))
|
227
|
+
|
228
|
+
row = [
|
229
|
+
provider['provider_id'],
|
230
|
+
provider['provider_name'],
|
231
|
+
provider['ip_address'] or 'N/A',
|
232
|
+
provider['country'],
|
233
|
+
provider['resources']['cpu'],
|
234
|
+
provider['resources']['memory'],
|
235
|
+
provider['resources']['storage'],
|
236
|
+
updated_at_str
|
237
|
+
]
|
238
|
+
|
239
|
+
if colorize:
|
240
|
+
# Format Provider ID
|
241
|
+
row[0] = style(row[0], fg="yellow")
|
242
|
+
|
243
|
+
# Format resources with icons and colors
|
244
|
+
row[4] = style(f"💻 {row[4]}", fg="cyan", bold=True)
|
245
|
+
row[5] = style(f"🧠 {row[5]}", fg="cyan", bold=True)
|
246
|
+
row[6] = style(f"💾 {row[6]}", fg="cyan", bold=True)
|
247
|
+
|
248
|
+
# Format location info
|
249
|
+
row[3] = style(f"🌍 {row[3]}", fg="green", bold=True)
|
250
|
+
|
251
|
+
return row
|
252
|
+
|
253
|
+
@property
|
254
|
+
def provider_headers(self) -> List[str]:
|
255
|
+
"""Get headers for provider display."""
|
256
|
+
return [
|
257
|
+
"Provider ID",
|
258
|
+
"Name",
|
259
|
+
"IP Address",
|
260
|
+
"Country",
|
261
|
+
"CPU",
|
262
|
+
"Memory (GB)",
|
263
|
+
"Storage (GB)",
|
264
|
+
"Updated"
|
265
|
+
]
|
@@ -0,0 +1,121 @@
|
|
1
|
+
"""SSH connection service."""
|
2
|
+
import subprocess
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Dict, List, Optional
|
5
|
+
|
6
|
+
from ..ssh.manager import SSHKeyManager
|
7
|
+
from ..errors import SSHError
|
8
|
+
|
9
|
+
class SSHService:
|
10
|
+
"""Service for handling SSH connections."""
|
11
|
+
|
12
|
+
def __init__(self, ssh_key_dir: Path):
|
13
|
+
self.ssh_manager = SSHKeyManager(ssh_key_dir)
|
14
|
+
|
15
|
+
async def get_key_pair(self):
|
16
|
+
"""Get or create SSH key pair."""
|
17
|
+
try:
|
18
|
+
return await self.ssh_manager.get_key_pair()
|
19
|
+
except Exception as e:
|
20
|
+
raise SSHError(f"Failed to get SSH key pair: {str(e)}")
|
21
|
+
|
22
|
+
def connect_to_vm(
|
23
|
+
self,
|
24
|
+
host: str,
|
25
|
+
port: int,
|
26
|
+
private_key_path: Path,
|
27
|
+
username: str = "ubuntu"
|
28
|
+
) -> None:
|
29
|
+
"""Connect to VM via SSH."""
|
30
|
+
try:
|
31
|
+
cmd = [
|
32
|
+
"ssh",
|
33
|
+
"-i", str(private_key_path),
|
34
|
+
"-p", str(port),
|
35
|
+
"-o", "StrictHostKeyChecking=no",
|
36
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
37
|
+
f"{username}@{host}"
|
38
|
+
]
|
39
|
+
subprocess.run(cmd)
|
40
|
+
except Exception as e:
|
41
|
+
raise SSHError(f"Failed to establish SSH connection: {str(e)}")
|
42
|
+
|
43
|
+
def format_ssh_command(
|
44
|
+
self,
|
45
|
+
host: str,
|
46
|
+
port: int,
|
47
|
+
private_key_path: Path,
|
48
|
+
username: str = "ubuntu",
|
49
|
+
colorize: bool = False
|
50
|
+
) -> str:
|
51
|
+
"""Format SSH command for display."""
|
52
|
+
from click import style
|
53
|
+
|
54
|
+
command = (
|
55
|
+
f"ssh -i {private_key_path} "
|
56
|
+
f"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
|
57
|
+
f"-p {port} {username}@{host}"
|
58
|
+
)
|
59
|
+
|
60
|
+
if colorize:
|
61
|
+
return style(command, fg="yellow")
|
62
|
+
return command
|
63
|
+
|
64
|
+
def get_vm_stats(
|
65
|
+
self,
|
66
|
+
host: str,
|
67
|
+
port: int,
|
68
|
+
private_key_path: Path,
|
69
|
+
username: str = "ubuntu"
|
70
|
+
) -> Dict:
|
71
|
+
"""Get VM stats via SSH."""
|
72
|
+
try:
|
73
|
+
cmd = [
|
74
|
+
"ssh",
|
75
|
+
"-i", str(private_key_path),
|
76
|
+
"-p", str(port),
|
77
|
+
"-o", "StrictHostKeyChecking=no",
|
78
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
79
|
+
f"{username}@{host}",
|
80
|
+
"top -b -n 1; df -h /"
|
81
|
+
]
|
82
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
83
|
+
return self._parse_stats(result.stdout)
|
84
|
+
except subprocess.CalledProcessError as e:
|
85
|
+
raise SSHError(f"Failed to get VM stats: {e.stderr}")
|
86
|
+
except Exception as e:
|
87
|
+
raise SSHError(f"An unexpected error occurred: {str(e)}")
|
88
|
+
|
89
|
+
def _parse_stats(self, stats_output: str) -> Dict:
|
90
|
+
"""Parse the output of the stats command."""
|
91
|
+
lines = stats_output.strip().split('\n')
|
92
|
+
stats = {'cpu': {}, 'memory': {}, 'disk': {}}
|
93
|
+
|
94
|
+
for line in lines:
|
95
|
+
if line.startswith('%Cpu(s):'):
|
96
|
+
parts = line.split(',')
|
97
|
+
for part in parts:
|
98
|
+
if 'id' in part:
|
99
|
+
idle_str = part.strip().split()[0]
|
100
|
+
try:
|
101
|
+
idle = float(idle_str)
|
102
|
+
stats['cpu']['usage'] = f"{100.0 - idle:.1f}%"
|
103
|
+
except ValueError:
|
104
|
+
stats['cpu']['usage'] = "N/A"
|
105
|
+
break
|
106
|
+
elif 'MiB Mem' in line:
|
107
|
+
parts = line.split(',')
|
108
|
+
total_mem_str = parts[0].split(':')[1].strip().split()[0]
|
109
|
+
used_mem_str = parts[2].strip().split()[0]
|
110
|
+
stats['memory'] = {
|
111
|
+
'total': f"{float(total_mem_str):.1f} MiB",
|
112
|
+
'used': f"{float(used_mem_str):.1f} MiB",
|
113
|
+
}
|
114
|
+
elif line.startswith('/dev/'):
|
115
|
+
parts = line.split()
|
116
|
+
stats['disk'] = {
|
117
|
+
'total': parts[1],
|
118
|
+
'used': parts[2],
|
119
|
+
}
|
120
|
+
|
121
|
+
return stats
|