request-vm-on-golem 0.1.24__py3-none-any.whl → 0.1.27__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.
requestor/config.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from pathlib import Path
2
2
  from typing import Optional, Dict
3
- from pydantic import BaseSettings, Field
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
- # Check if table exists
15
- async with db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='vms'") as cursor:
16
- table_exists = await cursor.fetchone() is not None
17
-
18
- if not table_exists:
19
- # Create new table without ssh_key_name
20
- await db.execute("""
21
- CREATE TABLE vms (
22
- name TEXT PRIMARY KEY,
23
- provider_ip TEXT NOT NULL,
24
- vm_id TEXT NOT NULL,
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
- env_path = Path(__file__).parent / '.env'
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,6 @@
1
+ """Service layer for VM on Golem requestor."""
2
+
3
+ from .vm_service import VMService
4
+ from .provider_service import ProviderService
5
+
6
+ __all__ = ['VMService', 'ProviderService']
@@ -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': 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,128 @@
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 get_key_pair_sync(self):
23
+ """Get or create SSH key pair synchronously."""
24
+ try:
25
+ return self.ssh_manager.get_key_pair_sync()
26
+ except Exception as e:
27
+ raise SSHError(f"Failed to get SSH key pair: {str(e)}")
28
+
29
+ def connect_to_vm(
30
+ self,
31
+ host: str,
32
+ port: int,
33
+ private_key_path: Path,
34
+ username: str = "ubuntu"
35
+ ) -> None:
36
+ """Connect to VM via SSH."""
37
+ try:
38
+ cmd = [
39
+ "ssh",
40
+ "-i", str(private_key_path),
41
+ "-p", str(port),
42
+ "-o", "StrictHostKeyChecking=no",
43
+ "-o", "UserKnownHostsFile=/dev/null",
44
+ f"{username}@{host}"
45
+ ]
46
+ subprocess.run(cmd, check=True)
47
+ except Exception as e:
48
+ raise SSHError(f"Failed to establish SSH connection: {str(e)}")
49
+
50
+ def format_ssh_command(
51
+ self,
52
+ host: str,
53
+ port: int,
54
+ private_key_path: Path,
55
+ username: str = "ubuntu",
56
+ colorize: bool = False
57
+ ) -> str:
58
+ """Format SSH command for display."""
59
+ from click import style
60
+
61
+ command = (
62
+ f"ssh -i {private_key_path} "
63
+ f"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
64
+ f"-p {port} {username}@{host}"
65
+ )
66
+
67
+ if colorize:
68
+ return style(command, fg="yellow")
69
+ return command
70
+
71
+ def get_vm_stats(
72
+ self,
73
+ host: str,
74
+ port: int,
75
+ private_key_path: Path,
76
+ username: str = "ubuntu"
77
+ ) -> Dict:
78
+ """Get VM stats via SSH."""
79
+ try:
80
+ cmd = [
81
+ "ssh",
82
+ "-i", str(private_key_path),
83
+ "-p", str(port),
84
+ "-o", "StrictHostKeyChecking=no",
85
+ "-o", "UserKnownHostsFile=/dev/null",
86
+ f"{username}@{host}",
87
+ "top -b -n 1; df -h /"
88
+ ]
89
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
90
+ return self._parse_stats(result.stdout)
91
+ except subprocess.CalledProcessError as e:
92
+ raise SSHError(f"Failed to get VM stats: {e.stderr}")
93
+ except Exception as e:
94
+ raise SSHError(f"An unexpected error occurred: {str(e)}")
95
+
96
+ def _parse_stats(self, stats_output: str) -> Dict:
97
+ """Parse the output of the stats command."""
98
+ lines = stats_output.strip().split('\n')
99
+ stats = {'cpu': {}, 'memory': {}, 'disk': {}}
100
+
101
+ for line in lines:
102
+ if line.startswith('%Cpu(s):'):
103
+ parts = line.split(',')
104
+ for part in parts:
105
+ if 'id' in part:
106
+ idle_str = part.strip().split()[0]
107
+ try:
108
+ idle = float(idle_str)
109
+ stats['cpu']['usage'] = f"{100.0 - idle:.1f}%"
110
+ except ValueError:
111
+ stats['cpu']['usage'] = "N/A"
112
+ break
113
+ elif 'MiB Mem' in line:
114
+ parts = line.split(',')
115
+ total_mem_str = parts[0].split(':')[1].strip().split()[0]
116
+ used_mem_str = parts[2].strip().split()[0]
117
+ stats['memory'] = {
118
+ 'total': f"{float(total_mem_str):.1f} MiB",
119
+ 'used': f"{float(used_mem_str):.1f} MiB",
120
+ }
121
+ elif line.startswith('/dev/'):
122
+ parts = line.split()
123
+ stats['disk'] = {
124
+ 'total': parts[1],
125
+ 'used': parts[2],
126
+ }
127
+
128
+ return stats