portacode 1.4.16.dev9__py3-none-any.whl → 1.4.16.dev11__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.
portacode/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.4.16.dev9'
32
- __version_tuple__ = version_tuple = (1, 4, 16, 'dev9')
31
+ __version__ = version = '1.4.16.dev11'
32
+ __version_tuple__ = version_tuple = (1, 4, 16, 'dev11')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -19,7 +19,7 @@ import time
19
19
  import threading
20
20
  from datetime import datetime, timezone
21
21
  from pathlib import Path
22
- from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple
22
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
23
23
 
24
24
  import platformdirs
25
25
 
@@ -202,72 +202,65 @@ def _current_time_iso() -> str:
202
202
  return datetime.now(timezone.utc).isoformat()
203
203
 
204
204
 
205
- def _to_int(value: Any, default: int = 0) -> int:
206
- try:
207
- return int(value)
208
- except (TypeError, ValueError):
209
- return default
210
-
211
-
212
- def _to_float(value: Any, default: float = 0.0) -> float:
205
+ def _parse_iso_timestamp(value: str) -> Optional[datetime]:
206
+ if not value:
207
+ return None
208
+ text = value
209
+ if text.endswith("Z"):
210
+ text = text[:-1] + "+00:00"
213
211
  try:
214
- return float(value)
215
- except (TypeError, ValueError):
216
- return default
212
+ return datetime.fromisoformat(text)
213
+ except ValueError:
214
+ return None
217
215
 
218
216
 
219
- def _calculate_available(total: Optional[float], used: float) -> Optional[float]:
220
- if total is None:
221
- return None
222
- delta = total - used
223
- return delta if delta >= 0 else 0.0
217
+ def _templates_need_refresh(config: Dict[str, Any]) -> bool:
218
+ if not config or not config.get("token_value"):
219
+ return False
220
+ last = _parse_iso_timestamp(config.get("templates_last_refreshed") or "")
221
+ if not last:
222
+ return True
223
+ return (datetime.now(timezone.utc) - last).total_seconds() >= TEMPLATES_REFRESH_INTERVAL_S
224
224
 
225
225
 
226
- def _normalize_bytes(value: Any) -> float:
227
- if isinstance(value, (int, float)):
228
- return float(value)
229
- text = str(value or "").strip()
230
- if not text:
231
- return 0.0
232
- match = re.match(r"(?i)^\s*([0-9]*\.?[0-9]+)\s*([kmgtp]?i?b?)?\s*$", text)
233
- if not match:
234
- return 0.0
235
- number = match.group(1)
236
- unit = (match.group(2) or "").lower()
226
+ def _ensure_templates_refreshed_on_startup(config: Dict[str, Any]) -> None:
227
+ if not _templates_need_refresh(config):
228
+ return
237
229
  try:
238
- value = float(number)
239
- except ValueError:
240
- return 0.0
241
- if unit.startswith("k"):
242
- return value * 1024
243
- if unit.startswith("m"):
244
- return value * 1024**2
245
- if unit.startswith("g"):
246
- return value * 1024**3
247
- if unit.startswith("t"):
248
- return value * 1024**4
249
- if unit.startswith("p"):
250
- return value * 1024**5
251
- return value
230
+ client = _build_proxmox_client_from_config(config)
231
+ node = config.get("node") or _pick_node(client)
232
+ storages = client.nodes(node).storage.get()
233
+ templates = _list_templates(client, node, storages)
234
+ if templates:
235
+ config["templates"] = templates
236
+ config["templates_last_refreshed"] = _current_time_iso()
237
+ _save_config(config)
238
+ except Exception as exc:
239
+ logger.warning("Unable to refresh Proxmox templates on startup: %s", exc)
252
240
 
253
241
 
254
- def _bytes_to_mib(value: Any) -> int:
255
- return int(round(_normalize_bytes(value) / (1024**2)))
242
+ def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
243
+ candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
244
+ if not candidates:
245
+ candidates = [s for s in storages if "rootdir" in s.get("content", "")]
246
+ if not candidates:
247
+ return ""
248
+ candidates.sort(key=lambda entry: entry.get("avail", 0), reverse=True)
249
+ return candidates[0].get("storage", "")
256
250
 
257
251
 
258
- def _bytes_to_gib(value: Any) -> int:
259
- return int(round(_normalize_bytes(value) / (1024**3)))
252
+ def _bytes_to_gib(value: Any) -> float:
253
+ try:
254
+ return float(value) / 1024**3
255
+ except (TypeError, ValueError):
256
+ return 0.0
260
257
 
261
258
 
262
- def _extract_storage(value: Any) -> str:
263
- if not value:
264
- return "unknown"
265
- text = str(value).strip()
266
- if not text:
267
- return "unknown"
268
- primary = text.split(",", 1)[0]
269
- storage = primary.split(":", 1)[0].strip()
270
- return storage or "unknown"
259
+ def _bytes_to_mib(value: Any) -> float:
260
+ try:
261
+ return float(value) / 1024**2
262
+ except (TypeError, ValueError):
263
+ return 0.0
271
264
 
272
265
 
273
266
  def _size_token_to_gib(token: str) -> float:
@@ -298,21 +291,30 @@ def _extract_size_gib(value: Any) -> float:
298
291
  return _size_token_to_gib(text)
299
292
 
300
293
 
294
+ def _extract_storage_token(value: Any) -> str:
295
+ if not value:
296
+ return "unknown"
297
+ text = str(value)
298
+ if ":" in text:
299
+ return text.split(":", 1)[0].strip() or "unknown"
300
+ return text.strip() or "unknown"
301
+
302
+
301
303
  def _storage_from_lxc(cfg: Dict[str, Any], entry: Dict[str, Any]) -> str:
302
304
  rootfs = cfg.get("rootfs") or entry.get("rootfs")
303
- storage = _extract_storage(rootfs)
305
+ storage = _extract_storage_token(rootfs)
304
306
  if storage != "unknown":
305
307
  return storage
306
308
  for idx in range(0, 10):
307
309
  mp_value = cfg.get(f"mp{idx}")
308
- storage = _extract_storage(mp_value)
310
+ storage = _extract_storage_token(mp_value)
309
311
  if storage != "unknown":
310
312
  return storage
311
313
  return "unknown"
312
314
 
313
315
 
314
316
  def _storage_from_qemu(cfg: Dict[str, Any]) -> str:
315
- preferred_keys = []
317
+ preferred_keys: List[str] = []
316
318
  for prefix in ("scsi", "virtio", "sata", "ide"):
317
319
  preferred_keys.extend(f"{prefix}{idx}" for idx in range(0, 6))
318
320
  seen = set()
@@ -324,7 +326,7 @@ def _storage_from_qemu(cfg: Dict[str, Any]) -> str:
324
326
  text = str(value)
325
327
  if "media=cdrom" in text or "cloudinit" in text:
326
328
  continue
327
- storage = _extract_storage(text)
329
+ storage = _extract_storage_token(text)
328
330
  if storage != "unknown":
329
331
  return storage
330
332
  for key in sorted(cfg.keys()):
@@ -338,11 +340,11 @@ def _storage_from_qemu(cfg: Dict[str, Any]) -> str:
338
340
  text = str(value)
339
341
  if "media=cdrom" in text or "cloudinit" in text:
340
342
  continue
341
- storage = _extract_storage(text)
343
+ storage = _extract_storage_token(text)
342
344
  if storage != "unknown":
343
345
  return storage
344
346
  for key in ("efidisk0", "tpmstate0"):
345
- storage = _extract_storage(cfg.get(key))
347
+ storage = _extract_storage_token(cfg.get(key))
346
348
  if storage != "unknown":
347
349
  return storage
348
350
  return "unknown"
@@ -353,7 +355,7 @@ def _primary_lxc_disk(cfg: Dict[str, Any], entry: Dict[str, Any]) -> str:
353
355
 
354
356
 
355
357
  def _primary_qemu_disk(cfg: Dict[str, Any]) -> str:
356
- preferred_keys = []
358
+ preferred_keys: List[str] = []
357
359
  for prefix in ("scsi", "virtio", "sata", "ide"):
358
360
  preferred_keys.extend(f"{prefix}{idx}" for idx in range(0, 6))
359
361
  seen = set()
@@ -381,8 +383,8 @@ def _primary_qemu_disk(cfg: Dict[str, Any]) -> str:
381
383
  return ""
382
384
 
383
385
 
384
- def _pick_storage(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> str:
385
- storage = _extract_storage(cfg.get("storage") or entry.get("storage"))
386
+ def _pick_container_storage(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> str:
387
+ storage = _extract_storage_token(cfg.get("storage") or entry.get("storage"))
386
388
  if storage != "unknown":
387
389
  return storage
388
390
  if kind == "lxc":
@@ -390,7 +392,7 @@ def _pick_storage(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> str:
390
392
  return _storage_from_qemu(cfg)
391
393
 
392
394
 
393
- def _pick_disk_gib(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> float:
395
+ def _pick_container_disk_gib(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> float:
394
396
  if kind == "lxc":
395
397
  size = _extract_size_gib(_primary_lxc_disk(cfg, entry))
396
398
  if size:
@@ -400,70 +402,62 @@ def _pick_disk_gib(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> flo
400
402
  if size:
401
403
  return size
402
404
  for candidate in (entry.get("maxdisk"), entry.get("disk"), cfg.get("disk")):
403
- if candidate in (None, 0):
405
+ if candidate is None or candidate == 0:
404
406
  continue
405
407
  return _bytes_to_gib(candidate)
406
408
  return 0.0
407
409
 
408
410
 
409
- def _normalize_storage_name(name: Any) -> str:
410
- if not name:
411
- return ""
412
- return str(name).strip().lower()
411
+ def _to_mib(value: Any) -> float:
412
+ try:
413
+ val = float(value)
414
+ except (TypeError, ValueError):
415
+ return 0.0
416
+ if val <= 0:
417
+ return 0.0
418
+ # Heuristic: large values are bytes, smaller ones are already MiB.
419
+ return _bytes_to_mib(val) if val > 10000 else val
413
420
 
414
421
 
415
- def _parse_bool_flag(value: Any) -> bool:
416
- if isinstance(value, bool):
417
- return value
418
- text = str(value or "").strip().lower()
419
- return text in {"1", "true", "yes", "on"}
422
+ def _pick_container_ram_mib(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> float:
423
+ for candidate in (cfg.get("memory"), entry.get("maxmem"), entry.get("mem")):
424
+ ram = _to_mib(candidate)
425
+ if ram:
426
+ return ram
427
+ return 0.0
420
428
 
421
429
 
422
- def _parse_iso_timestamp(value: str) -> Optional[datetime]:
423
- if not value:
424
- return None
425
- text = value
426
- if text.endswith("Z"):
427
- text = text[:-1] + "+00:00"
430
+ def _safe_float(value: Any) -> float:
428
431
  try:
429
- return datetime.fromisoformat(text)
430
- except ValueError:
431
- return None
432
-
433
-
434
- def _templates_need_refresh(config: Dict[str, Any]) -> bool:
435
- if not config or not config.get("token_value"):
436
- return False
437
- last = _parse_iso_timestamp(config.get("templates_last_refreshed") or "")
438
- if not last:
439
- return True
440
- return (datetime.now(timezone.utc) - last).total_seconds() >= TEMPLATES_REFRESH_INTERVAL_S
432
+ return float(value)
433
+ except (TypeError, ValueError):
434
+ return 0.0
441
435
 
442
436
 
443
- def _ensure_templates_refreshed_on_startup(config: Dict[str, Any]) -> None:
444
- if not _templates_need_refresh(config):
445
- return
446
- try:
447
- client = _build_proxmox_client_from_config(config)
448
- node = config.get("node") or _pick_node(client)
449
- storages = client.nodes(node).storage.get()
450
- templates = _list_templates(client, node, storages)
451
- if templates:
452
- config["templates"] = templates
453
- config["templates_last_refreshed"] = _current_time_iso()
454
- _save_config(config)
455
- except Exception as exc:
456
- logger.warning("Unable to refresh Proxmox templates on startup: %s", exc)
437
+ def _pick_container_cpu_share(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> float:
438
+ if kind == "lxc":
439
+ for key in ("cpulimit", "cores", "cpus"):
440
+ val = _safe_float(cfg.get(key))
441
+ if val:
442
+ return val
443
+ return _safe_float(entry.get("cpus"))
444
+
445
+ cores = _safe_float(cfg.get("cores"))
446
+ sockets = _safe_float(cfg.get("sockets")) or 1.0
447
+ if cores:
448
+ return cores * sockets
449
+ val = _safe_float(cfg.get("vcpus"))
450
+ if val:
451
+ return val
452
+ val = _safe_float(entry.get("cpus") or entry.get("maxcpu"))
453
+ if val:
454
+ return val
455
+ return 0.0
457
456
 
458
457
 
459
- def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
460
- candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
461
- if not candidates:
462
- candidates = [s for s in storages if "rootdir" in s.get("content", "")]
463
- if not candidates:
464
- return ""
465
- candidates.sort(key=lambda entry: entry.get("avail", 0), reverse=True)
466
- return candidates[0].get("storage", "")
458
+ def _parse_onboot_flag(value: Any) -> bool:
459
+ text = str(value).strip().lower()
460
+ return text in {"1", "true", "yes", "on"}
467
461
 
468
462
 
469
463
  def _write_bridge_config(bridge: str) -> None:
@@ -599,154 +593,35 @@ def _load_managed_container_records() -> List[Dict[str, Any]]:
599
593
  return records
600
594
 
601
595
 
602
- def _extract_host_totals(node_status: Dict[str, Any] | None) -> Tuple[Optional[int], Optional[int], Optional[int]]:
603
- if not node_status:
604
- return None, None, None
605
- memory_total = node_status.get("memory", {}).get("total")
606
- disk_total = node_status.get("disk", {}).get("total")
607
- cpu_cores = node_status.get("cpuinfo", {}).get("cores")
608
- host_ram = _bytes_to_mib(memory_total) if memory_total is not None else None
609
- host_disk = _bytes_to_gib(disk_total) if disk_total is not None else None
610
- host_cpu = _to_int(cpu_cores) if cpu_cores is not None else None
611
- return host_ram, host_disk, host_cpu
612
-
613
-
614
- def _parse_disk_reference(value: Any) -> Tuple[str | None, int]:
615
- if not value:
616
- return None, 0
617
- text = str(value)
618
- primary = text.split(",", 1)[0]
619
- if ":" not in primary:
620
- return primary.strip() or None, 0
621
- storage_name, size_part = primary.split(":", 1)
622
- storage_name = storage_name.strip() or None
623
- size_text = size_part.strip()
624
- if not size_text:
625
- return storage_name, 0
626
- unit = size_text[-1].upper()
627
- number = size_text[:-1] if unit in {"G", "M"} else size_text
628
- try:
629
- value_num = float(number)
630
- except ValueError:
631
- return storage_name, 0
632
- if unit == "M":
633
- gib = value_num / 1024.0
634
- else:
635
- gib = value_num
636
- return storage_name, int(round(gib))
637
-
638
-
639
- def _build_unmanaged_container_entry(
640
- ct: Dict[str, Any],
641
- cfg: Dict[str, Any],
642
- vmid: str,
643
- default_storage: str | None,
644
- *,
645
- disk_gib_override: int | None = None,
646
- storage_override: str | None = None,
647
- entry_type: str = "lxc",
648
- ) -> Dict[str, Any]:
649
- ram_mib = _to_int(cfg.get("memory")) or _bytes_to_mib(ct.get("maxmem"))
650
- disk_gib = disk_gib_override if disk_gib_override is not None else _pick_disk_gib(entry_type, cfg, ct)
651
- cpu_share = _to_float(
652
- cfg.get("cpulimit")
653
- or cfg.get("cpus")
654
- or cfg.get("cores")
655
- or ct.get("cpus")
656
- or ct.get("cpu")
657
- )
658
- hostname = ct.get("name") or cfg.get("hostname") or f"ct{vmid}"
659
- storage = storage_override or _pick_storage(entry_type, cfg, ct)
660
- status = (ct.get("status") or "unknown").lower()
661
- reserved = _parse_bool_flag(cfg.get("onboot"))
662
- storage_matches_default = _storage_matches_default(storage, default_storage)
663
- return {
664
- "vmid": vmid,
665
- "hostname": hostname,
666
- "template": cfg.get("ostemplate"),
667
- "storage": storage,
668
- "disk_gib": disk_gib,
669
- "ram_mib": ram_mib,
670
- "cpu_share": cpu_share,
671
- "reserve_on_boot": reserved,
672
- "matches_default_storage": storage_matches_default,
673
- "type": entry_type,
674
- "status": status,
675
- "managed": False,
676
- }
677
-
678
-
679
- def _get_storage_snapshot(proxmox: Any, node: str, storage_name: str | None) -> Dict[str, Any] | None:
680
- if not storage_name:
681
- return None
682
- try:
683
- storage = proxmox.nodes(node).storage(storage_name).status.get()
684
- except Exception as exc:
685
- logger.debug("Unable to read storage status %s:%s: %s", node, storage_name, exc)
686
- return None
687
- total_bytes = storage.get("total")
688
- avail_bytes = storage.get("avail")
689
- used_bytes = storage.get("used")
690
- return {
691
- "storage": storage_name,
692
- "total_gib": _bytes_to_gib(total_bytes) if total_bytes is not None else None,
693
- "avail_gib": _bytes_to_gib(avail_bytes) if avail_bytes is not None else None,
694
- "used_gib": _bytes_to_gib(used_bytes) if used_bytes is not None else None,
695
- }
696
-
697
-
698
- def _extract_qemu_disk_info(cfg: Dict[str, Any]) -> Tuple[str | None, int]:
699
- disk_keys = sorted(
700
- key
701
- for key in cfg.keys()
702
- if (
703
- key.startswith("ide")
704
- or key.startswith("sata")
705
- or key.startswith("scsi")
706
- or key.startswith("virtio")
707
- or key.startswith("efidisk")
708
- or key.startswith("raid")
709
- )
710
- and cfg.get(key)
711
- )
712
- for key in disk_keys:
713
- storage_name, size_gib = _parse_disk_reference(cfg.get(key))
714
- if storage_name or size_gib:
715
- return storage_name, size_gib
716
- return None, 0
717
-
718
-
719
- def _storage_matches_default(storage_name: Any, default_storage: str | None) -> bool:
720
- if not default_storage:
721
- return False
722
- storage = _extract_storage(storage_name)
723
- default = str(default_storage).strip()
724
- return storage.lower() == default.lower()
725
-
726
-
727
- def _build_managed_containers_summary(
728
- records: List[Dict[str, Any]],
729
- unmanaged_records: List[Dict[str, Any]],
730
- node_status: Dict[str, Any] | None,
731
- storage_snapshot: Dict[str, Any] | None,
732
- default_storage: str | None,
733
- ) -> Dict[str, Any]:
734
- managed_containers: List[Dict[str, Any]] = []
596
+ def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str, Any]:
735
597
  total_ram = 0
736
598
  total_disk = 0
737
599
  total_cpu_share = 0.0
600
+ containers: List[Dict[str, Any]] = []
738
601
 
739
- for record in sorted(records, key=lambda entry: _to_int(entry.get("vmid"))):
740
- ram_mib = _to_int(record.get("ram_mib"))
741
- disk_gib = _to_int(record.get("disk_gib"))
742
- cpu_share = _to_float(record.get("cpus"))
602
+ def _as_int(value: Any) -> int:
603
+ try:
604
+ return int(value)
605
+ except (TypeError, ValueError):
606
+ return 0
607
+
608
+ def _as_float(value: Any) -> float:
609
+ try:
610
+ return float(value)
611
+ except (TypeError, ValueError):
612
+ return 0.0
613
+
614
+ for record in sorted(records, key=lambda entry: _as_int(entry.get("vmid"))):
615
+ ram_mib = _as_int(record.get("ram_mib"))
616
+ disk_gib = _as_int(record.get("disk_gib"))
617
+ cpu_share = _as_float(record.get("cpus"))
743
618
  total_ram += ram_mib
744
619
  total_disk += disk_gib
745
620
  total_cpu_share += cpu_share
746
621
  status = (record.get("status") or "unknown").lower()
747
- managed_containers.append(
622
+ containers.append(
748
623
  {
749
- "vmid": str(_to_int(record.get("vmid"))) if record.get("vmid") is not None else None,
624
+ "vmid": str(_as_int(record.get("vmid"))) if record.get("vmid") is not None else None,
750
625
  "device_id": record.get("device_id"),
751
626
  "hostname": record.get("hostname"),
752
627
  "template": record.get("template"),
@@ -756,134 +631,177 @@ def _build_managed_containers_summary(
756
631
  "cpu_share": cpu_share,
757
632
  "created_at": record.get("created_at"),
758
633
  "status": status,
759
- "managed": True,
760
- "matches_default_storage": _storage_matches_default(record.get("storage"), default_storage),
761
634
  }
762
635
  )
763
636
 
764
- unmanaged_total_ram = sum(
765
- _to_int(entry.get("ram_mib"))
766
- for entry in unmanaged_records
767
- if entry.get("reserve_on_boot")
768
- )
769
- unmanaged_total_disk = sum(
770
- _to_int(entry.get("disk_gib"))
771
- for entry in unmanaged_records
772
- if entry.get("matches_default_storage")
773
- )
774
- unmanaged_total_cpu = sum(
775
- _to_float(entry.get("cpu_share"))
776
- for entry in unmanaged_records
777
- if entry.get("reserve_on_boot")
778
- )
779
-
780
- allocated_ram = total_ram + unmanaged_total_ram
781
- allocated_disk = total_disk + unmanaged_total_disk
782
- allocated_cpu = total_cpu_share + unmanaged_total_cpu
783
-
784
- host_ram_node, host_disk_node, host_cpu_node = _extract_host_totals(node_status)
785
- storage_host_disk = storage_snapshot.get("total_gib") if storage_snapshot else None
786
- host_ram = host_ram_node
787
- host_disk = storage_host_disk if storage_host_disk is not None else host_disk_node
788
- host_cpu = host_cpu_node
789
-
790
-
791
637
  return {
792
638
  "updated_at": datetime.utcnow().isoformat() + "Z",
793
- "count": len(managed_containers),
639
+ "count": len(containers),
794
640
  "total_ram_mib": total_ram,
795
641
  "total_disk_gib": total_disk,
796
642
  "total_cpu_share": round(total_cpu_share, 2),
797
- "containers": managed_containers,
798
- "unmanaged_containers": unmanaged_records,
799
- "unmanaged_count": len(unmanaged_records),
800
- "allocated_ram_mib": allocated_ram,
801
- "allocated_disk_gib": allocated_disk,
802
- "allocated_cpu_share": round(allocated_cpu, 2),
803
- "available_ram_mib": _calculate_available(host_ram, allocated_ram) if host_ram is not None else None,
804
- "available_disk_gib": _calculate_available(host_disk, allocated_disk) if host_disk is not None else None,
805
- "available_cpu_share": _calculate_available(host_cpu, allocated_cpu) if host_cpu is not None else None,
806
- "host_total_ram_mib": host_ram,
807
- "host_total_disk_gib": host_disk,
808
- "host_total_cpu_cores": host_cpu,
809
- "default_storage": default_storage,
810
- "default_storage_snapshot": storage_snapshot,
643
+ "containers": containers,
811
644
  }
812
645
 
813
646
 
647
+ def _build_full_container_summary(records: List[Dict[str, Any]], config: Dict[str, Any]) -> Dict[str, Any]:
648
+ base_summary = _build_managed_containers_summary(records)
649
+ if not config or not config.get("token_value"):
650
+ return base_summary
651
+
652
+ try:
653
+ proxmox = _connect_proxmox(config)
654
+ node = _get_node_from_config(config)
655
+ except Exception as exc: # pragma: no cover - best effort
656
+ logger.debug("Unable to extend container summary with Proxmox data: %s", exc)
657
+ return base_summary
658
+
659
+ default_storage = (config.get("default_storage") or "").strip()
660
+ record_map: Dict[str, Dict[str, Any]] = {}
661
+ for record in records:
662
+ vmid = record.get("vmid")
663
+ if vmid is None:
664
+ continue
665
+ try:
666
+ vmid_key = str(int(vmid))
667
+ except (ValueError, TypeError):
668
+ continue
669
+ record_map[vmid_key] = record
670
+
671
+ managed_entries: List[Dict[str, Any]] = []
672
+ unmanaged_entries: List[Dict[str, Any]] = []
673
+ allocated_ram = 0.0
674
+ allocated_disk = 0.0
675
+ allocated_cpu = 0.0
676
+
677
+ def _process_entries(kind: str, getter: str) -> None:
678
+ nonlocal allocated_ram, allocated_disk, allocated_cpu
679
+ entries = getattr(proxmox.nodes(node), getter).get()
680
+ for entry in entries:
681
+ vmid = entry.get("vmid")
682
+ if vmid is None:
683
+ continue
684
+ vmid_str = str(vmid)
685
+ cfg: Dict[str, Any] = {}
686
+ try:
687
+ cfg = getattr(proxmox.nodes(node), getter)(vmid_str).config.get() or {}
688
+ except Exception as exc: # pragma: no cover - best effort
689
+ logger.debug("Failed to load %s config for %s: %s", kind, vmid_str, exc)
690
+ cfg = {}
691
+
692
+ record = record_map.get(vmid_str)
693
+ description = cfg.get("description") or ""
694
+ managed = bool(record) or MANAGED_MARKER in description
695
+ hostname = entry.get("name") or cfg.get("hostname") or (record.get("hostname") if record else None)
696
+ storage = _pick_container_storage(kind, cfg, entry)
697
+ disk_gib = _pick_container_disk_gib(kind, cfg, entry)
698
+ ram_mib = _pick_container_ram_mib(kind, cfg, entry)
699
+ cpu_share = _pick_container_cpu_share(kind, cfg, entry)
700
+ reserve_on_boot = _parse_onboot_flag(cfg.get("onboot"))
701
+ matches_default_storage = bool(default_storage and storage and storage.lower() == default_storage.lower())
702
+
703
+ base_entry = {
704
+ "type": kind,
705
+ "vmid": vmid_str,
706
+ "hostname": hostname,
707
+ "status": (entry.get("status") or "unknown").lower(),
708
+ "storage": storage,
709
+ "disk_gib": disk_gib,
710
+ "ram_mib": ram_mib,
711
+ "cpu_share": cpu_share,
712
+ "reserve_on_boot": reserve_on_boot,
713
+ "matches_default_storage": matches_default_storage,
714
+ "managed": managed,
715
+ }
716
+
717
+ if managed:
718
+ merged = base_entry | {
719
+ "device_id": record.get("device_id") if record else None,
720
+ "template": record.get("template") if record else None,
721
+ "created_at": record.get("created_at") if record else None,
722
+ }
723
+ managed_entries.append(merged)
724
+ else:
725
+ unmanaged_entries.append(base_entry)
726
+
727
+ if managed or reserve_on_boot:
728
+ allocated_ram += ram_mib
729
+ allocated_cpu += cpu_share
730
+ if managed or matches_default_storage:
731
+ allocated_disk += disk_gib
732
+
733
+ _process_entries("lxc", "lxc")
734
+ _process_entries("qemu", "qemu")
735
+
736
+ memory_info = {}
737
+ cpu_info = {}
738
+ try:
739
+ node_status = proxmox.nodes(node).status.get()
740
+ memory_info = node_status.get("memory") or {}
741
+ cpu_info = node_status.get("cpuinfo") or {}
742
+ except Exception as exc: # pragma: no cover - best effort
743
+ logger.debug("Unable to read node status for resource totals: %s", exc)
744
+
745
+ host_total_ram_mib = _bytes_to_mib(memory_info.get("total"))
746
+ used_ram_mib = _bytes_to_mib(memory_info.get("used"))
747
+ available_ram_mib = max(host_total_ram_mib - used_ram_mib, 0.0) if host_total_ram_mib else None
748
+ host_total_cpu_cores = _safe_float(cpu_info.get("cores"))
749
+ available_cpu_share = max(host_total_cpu_cores - allocated_cpu, 0.0) if host_total_cpu_cores else None
750
+
751
+ host_total_disk_gib = None
752
+ available_disk_gib = None
753
+ if default_storage:
754
+ try:
755
+ storage_status = proxmox.nodes(node).storage(default_storage).status.get()
756
+ host_total_disk_gib = _bytes_to_gib(storage_status.get("total"))
757
+ available_disk_gib = _bytes_to_gib(storage_status.get("avail"))
758
+ except Exception as exc: # pragma: no cover - best effort
759
+ logger.debug("Unable to read storage status for %s: %s", default_storage, exc)
760
+
761
+ summary = base_summary.copy()
762
+ summary["containers"] = managed_entries
763
+ summary["count"] = len(managed_entries)
764
+ summary["total_ram_mib"] = int(sum(entry.get("ram_mib") or 0 for entry in managed_entries))
765
+ summary["total_disk_gib"] = int(sum(entry.get("disk_gib") or 0 for entry in managed_entries))
766
+ summary["total_cpu_share"] = round(sum(entry.get("cpu_share") or 0 for entry in managed_entries), 2)
767
+ summary["unmanaged_containers"] = unmanaged_entries
768
+ summary["allocated_ram_mib"] = round(allocated_ram, 2)
769
+ summary["allocated_disk_gib"] = round(allocated_disk, 2)
770
+ summary["allocated_cpu_share"] = round(allocated_cpu, 2)
771
+ summary["host_total_ram_mib"] = int(host_total_ram_mib) if host_total_ram_mib else None
772
+ summary["host_total_disk_gib"] = host_total_disk_gib
773
+ summary["host_total_cpu_cores"] = host_total_cpu_cores if host_total_cpu_cores else None
774
+ summary["available_ram_mib"] = int(available_ram_mib) if available_ram_mib is not None else None
775
+ summary["available_disk_gib"] = available_disk_gib
776
+ summary["available_cpu_share"] = available_cpu_share if available_cpu_share is not None else None
777
+ return summary
778
+
779
+
814
780
  def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
815
- def _refresh_container_statuses(
816
- records: List[Dict[str, Any]],
817
- config: Dict[str, Any] | None,
818
- managed_vmids: Set[str],
819
- default_storage: str | None,
820
- ) -> Tuple[Dict[str, str], List[Dict[str, Any]], Dict[str, Any] | None, Dict[str, Any] | None]:
821
- statuses: Dict[str, str] = {}
822
- unmanaged: List[Dict[str, Any]] = []
823
- node_status: Dict[str, Any] | None = None
824
- if not config:
825
- return statuses, unmanaged, node_status, None
826
- proxmox = None
827
- node = None
781
+ def _refresh_container_statuses(records: List[Dict[str, Any]], config: Dict[str, Any] | None) -> None:
782
+ if not records or not config:
783
+ return
828
784
  try:
829
785
  proxmox = _connect_proxmox(config)
830
786
  node = _get_node_from_config(config)
831
- node_status = proxmox.nodes(node).status.get()
832
- for ct in proxmox.nodes(node).lxc.get():
833
- vmid_val = ct.get("vmid")
834
- if vmid_val is None:
835
- continue
836
- vmid_key = str(_to_int(vmid_val))
837
- statuses[vmid_key] = (ct.get("status") or "unknown").lower()
838
- if vmid_key in managed_vmids:
839
- continue
840
- cfg: Dict[str, Any] = {}
841
- try:
842
- cfg = proxmox.nodes(node).lxc(vmid_key).config.get() or {}
843
- except Exception as exc: # pragma: no cover - best effort
844
- logger.debug("Failed to read config for container %s: %s", vmid_key, exc)
845
- description = (cfg.get("description") or "")
846
- if MANAGED_MARKER in description:
847
- continue
848
- unmanaged.append(
849
- _build_unmanaged_container_entry(ct, cfg, vmid_key, default_storage, entry_type="lxc")
850
- )
851
- for vm in proxmox.nodes(node).qemu.get():
852
- vmid_val = vm.get("vmid")
853
- if vmid_val is None:
854
- continue
855
- vmid_key = str(_to_int(vmid_val))
856
- statuses[vmid_key] = (vm.get("status") or "unknown").lower()
857
- if vmid_key in managed_vmids:
858
- continue
859
- cfg: Dict[str, Any] = {}
860
- try:
861
- cfg = proxmox.nodes(node).qemu(vmid_key).config.get() or {}
862
- except Exception as exc:
863
- logger.debug("Failed to read config for VM %s: %s", vmid_key, exc)
864
- description = (cfg.get("description") or "")
865
- if MANAGED_MARKER in description:
866
- continue
867
- storage_name, disk_gib_override = _extract_qemu_disk_info(cfg)
868
- unmanaged.append(
869
- _build_unmanaged_container_entry(
870
- vm,
871
- cfg,
872
- vmid_key,
873
- default_storage,
874
- disk_gib_override=disk_gib_override,
875
- storage_override=storage_name,
876
- entry_type="qemu",
877
- )
878
- )
787
+ statuses = {
788
+ str(ct.get("vmid")): (ct.get("status") or "unknown").lower()
789
+ for ct in proxmox.nodes(node).lxc.get()
790
+ }
879
791
  except Exception as exc: # pragma: no cover - best effort
880
792
  logger.debug("Failed to refresh container statuses: %s", exc)
881
- storage_snapshot = (
882
- _get_storage_snapshot(proxmox, node, default_storage)
883
- if proxmox and node
884
- else None
885
- )
886
- return statuses, unmanaged, node_status, storage_snapshot
793
+ return
794
+ for record in records:
795
+ vmid = record.get("vmid")
796
+ if vmid is None:
797
+ continue
798
+ try:
799
+ vmid_key = str(int(vmid))
800
+ except (ValueError, TypeError):
801
+ continue
802
+ status = statuses.get(vmid_key)
803
+ if status:
804
+ record["status"] = status
887
805
 
888
806
  now = time.monotonic()
889
807
  with _MANAGED_CONTAINERS_CACHE_LOCK:
@@ -893,22 +811,8 @@ def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
893
811
  return cached
894
812
  config = _load_config()
895
813
  records = _load_managed_container_records()
896
- managed_vmids: Set[str] = {
897
- str(_to_int(record.get("vmid"))) for record in records if record.get("vmid") is not None
898
- }
899
- default_storage = config.get("default_storage") if config else None
900
- statuses, unmanaged, node_status, storage_snapshot = _refresh_container_statuses(
901
- records, config, managed_vmids, default_storage
902
- )
903
- for record in records:
904
- vmid = record.get("vmid")
905
- if vmid is None:
906
- continue
907
- vmid_key = str(_to_int(vmid))
908
- status = statuses.get(vmid_key)
909
- if status:
910
- record["status"] = status
911
- summary = _build_managed_containers_summary(records, unmanaged, node_status, storage_snapshot, default_storage)
814
+ _refresh_container_statuses(records, config)
815
+ summary = _build_full_container_summary(records, config)
912
816
  with _MANAGED_CONTAINERS_CACHE_LOCK:
913
817
  _MANAGED_CONTAINERS_CACHE["timestamp"] = now
914
818
  _MANAGED_CONTAINERS_CACHE["summary"] = summary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.16.dev9
3
+ Version: 1.4.16.dev11
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -1,7 +1,7 @@
1
1
  portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
2
2
  portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
3
3
  portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
4
- portacode/_version.py,sha256=MiVkLaI5JqUCo1IS8Z-ZuJxS2iDsMOn2RV8qQYxQ5kE,719
4
+ portacode/_version.py,sha256=ZsUlJUljVebwmjOdkxbUPcwi8iyVjIuyBU9g68awYpA,721
5
5
  portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
6
6
  portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
7
7
  portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
@@ -22,7 +22,7 @@ portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZb
22
22
  portacode/connection/handlers/file_handlers.py,sha256=nAJH8nXnX07xxD28ngLpgIUzcTuRwZBNpEGEKdRqohw,39507
23
23
  portacode/connection/handlers/project_aware_file_handlers.py,sha256=AqgMnDqX2893T2NsrvUSCwjN5VKj4Pb2TN0S_SuboOE,9803
24
24
  portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
25
- portacode/connection/handlers/proxmox_infra.py,sha256=CcPTFTM5TMJ9uX4-Ndrfao6Xx_8zdMMPlvze08SHWgU,88344
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=-zAmaOTCTJaic8sclb5Z0DVCl_pU7XePo86NKohi3gc,85295
26
26
  portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
27
27
  portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
28
28
  portacode/connection/handlers/system_handlers.py,sha256=fr12QpOr_Z8KYGUU-AYrTQwRPAcrLK85hvj3SEq1Kw8,14757
@@ -65,7 +65,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
65
65
  portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
66
66
  portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
67
67
  portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
68
- portacode-1.4.16.dev9.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
+ portacode-1.4.16.dev11.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
69
69
  test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
70
70
  test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
71
71
  test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
@@ -91,8 +91,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
91
91
  testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
92
92
  testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
93
93
  testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
94
- portacode-1.4.16.dev9.dist-info/METADATA,sha256=IwtVePxyAtqCsSSOTisxusWv8gBIvL1DSCoT96cNDj0,13051
95
- portacode-1.4.16.dev9.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
- portacode-1.4.16.dev9.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
- portacode-1.4.16.dev9.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
- portacode-1.4.16.dev9.dist-info/RECORD,,
94
+ portacode-1.4.16.dev11.dist-info/METADATA,sha256=nEvtzLLbq9J8jG-kubMsNMRQ07YdtEczInIarfWpGhY,13052
95
+ portacode-1.4.16.dev11.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
+ portacode-1.4.16.dev11.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
+ portacode-1.4.16.dev11.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
+ portacode-1.4.16.dev11.dist-info/RECORD,,