portacode 1.4.15.dev10__py3-none-any.whl → 1.4.15.dev15__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.15.dev10'
32
- __version_tuple__ = version_tuple = (1, 4, 15, 'dev10')
31
+ __version__ = version = '1.4.15.dev15'
32
+ __version_tuple__ = version_tuple = (1, 4, 15, 'dev15')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -16,7 +16,7 @@ import sys
16
16
  import tempfile
17
17
  import time
18
18
  import threading
19
- from datetime import datetime
19
+ from datetime import datetime, timezone
20
20
  from pathlib import Path
21
21
  from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
22
22
 
@@ -47,7 +47,7 @@ UNIT_DIR = Path("/etc/systemd/system")
47
47
  _MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
48
48
  _MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
49
49
  _MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
50
- _STARTUP_TEMPLATES_REFRESHED = False
50
+ TEMPLATES_REFRESH_INTERVAL_S = 300
51
51
 
52
52
  ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
53
53
 
@@ -197,10 +197,33 @@ def _build_proxmox_client_from_config(config: Dict[str, Any]):
197
197
  )
198
198
 
199
199
 
200
+ def _current_time_iso() -> str:
201
+ return datetime.now(timezone.utc).isoformat()
202
+
203
+
204
+ def _parse_iso_timestamp(value: str) -> Optional[datetime]:
205
+ if not value:
206
+ return None
207
+ text = value
208
+ if text.endswith("Z"):
209
+ text = text[:-1] + "+00:00"
210
+ try:
211
+ return datetime.fromisoformat(text)
212
+ except ValueError:
213
+ return None
214
+
215
+
216
+ def _templates_need_refresh(config: Dict[str, Any]) -> bool:
217
+ if not config or not config.get("token_value"):
218
+ return False
219
+ last = _parse_iso_timestamp(config.get("templates_last_refreshed") or "")
220
+ if not last:
221
+ return True
222
+ return (datetime.now(timezone.utc) - last).total_seconds() >= TEMPLATES_REFRESH_INTERVAL_S
223
+
224
+
200
225
  def _ensure_templates_refreshed_on_startup(config: Dict[str, Any]) -> None:
201
- global _STARTUP_TEMPLATES_REFRESHED
202
- if _STARTUP_TEMPLATES_REFRESHED or not config or not config.get("token_value"):
203
- _STARTUP_TEMPLATES_REFRESHED = True
226
+ if not _templates_need_refresh(config):
204
227
  return
205
228
  try:
206
229
  client = _build_proxmox_client_from_config(config)
@@ -209,10 +232,10 @@ def _ensure_templates_refreshed_on_startup(config: Dict[str, Any]) -> None:
209
232
  templates = _list_templates(client, node, storages)
210
233
  if templates:
211
234
  config["templates"] = templates
235
+ config["templates_last_refreshed"] = _current_time_iso()
236
+ _save_config(config)
212
237
  except Exception as exc:
213
238
  logger.warning("Unable to refresh Proxmox templates on startup: %s", exc)
214
- finally:
215
- _STARTUP_TEMPLATES_REFRESHED = True
216
239
 
217
240
 
218
241
  def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
@@ -473,48 +496,119 @@ def _friendly_step_label(step_name: str) -> str:
473
496
  return normalized.capitalize()
474
497
 
475
498
 
499
+ _PACKAGE_MANAGER_PROFILES: Dict[str, Dict[str, Any]] = {
500
+ "apt": {
501
+ "update_cmd": "apt-get update -y",
502
+ "update_step_name": "apt_update",
503
+ "install_cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
504
+ "install_step_name": "install_deps",
505
+ "update_retries": 4,
506
+ "install_retries": 5,
507
+ },
508
+ "dnf": {
509
+ "update_cmd": "dnf check-update || true",
510
+ "update_step_name": "dnf_update",
511
+ "install_cmd": "dnf install -y python3 python3-pip sudo",
512
+ "install_step_name": "install_deps",
513
+ "update_retries": 3,
514
+ "install_retries": 5,
515
+ },
516
+ "yum": {
517
+ "update_cmd": "yum makecache",
518
+ "update_step_name": "yum_update",
519
+ "install_cmd": "yum install -y python3 python3-pip sudo",
520
+ "install_step_name": "install_deps",
521
+ "update_retries": 3,
522
+ "install_retries": 5,
523
+ },
524
+ "apk": {
525
+ "update_cmd": "apk update",
526
+ "update_step_name": "apk_update",
527
+ "install_cmd": "apk add --no-cache python3 py3-pip sudo",
528
+ "install_step_name": "install_deps",
529
+ "update_retries": 3,
530
+ "install_retries": 5,
531
+ },
532
+ "pacman": {
533
+ "update_cmd": "pacman -Sy --noconfirm",
534
+ "update_step_name": "pacman_update",
535
+ "install_cmd": "pacman -S --noconfirm python python-pip sudo",
536
+ "install_step_name": "install_deps",
537
+ "update_retries": 3,
538
+ "install_retries": 5,
539
+ },
540
+ "zypper": {
541
+ "update_cmd": "zypper refresh",
542
+ "update_step_name": "zypper_update",
543
+ "install_cmd": "zypper install -y python3 python3-pip sudo",
544
+ "install_step_name": "install_deps",
545
+ "update_retries": 3,
546
+ "install_retries": 5,
547
+ },
548
+ }
549
+
550
+ _UPDATE_RETRY_ON = [
551
+ "Temporary failure resolving",
552
+ "Could not resolve",
553
+ "Failed to fetch",
554
+ ]
555
+
556
+ _INSTALL_RETRY_ON = [
557
+ "lock-frontend",
558
+ "Unable to acquire the dpkg frontend lock",
559
+ "Temporary failure resolving",
560
+ "Could not resolve",
561
+ "Failed to fetch",
562
+ ]
563
+
564
+
476
565
  def _build_bootstrap_steps(
477
566
  user: str,
478
567
  password: str,
479
568
  ssh_key: str,
480
569
  include_portacode_connect: bool = True,
570
+ package_manager: str = "apt",
481
571
  ) -> List[Dict[str, Any]]:
482
- steps = [
483
- {
484
- "name": "apt_update",
485
- "cmd": "apt-get update -y",
486
- "retries": 4,
487
- "retry_delay_s": 5,
488
- "retry_on": [
489
- "Temporary failure resolving",
490
- "Could not resolve",
491
- "Failed to fetch",
492
- ],
493
- },
494
- {
495
- "name": "install_deps",
496
- "cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
497
- "retries": 5,
498
- "retry_delay_s": 5,
499
- "retry_on": [
500
- "lock-frontend",
501
- "Unable to acquire the dpkg frontend lock",
502
- "Temporary failure resolving",
503
- "Could not resolve",
504
- "Failed to fetch",
505
- ],
506
- },
507
- {"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
508
- {"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
509
- ]
572
+ profile = _PACKAGE_MANAGER_PROFILES.get(package_manager, _PACKAGE_MANAGER_PROFILES["apt"])
573
+ steps: List[Dict[str, Any]] = []
574
+ update_cmd = profile.get("update_cmd")
575
+ if update_cmd:
576
+ steps.append(
577
+ {
578
+ "name": profile.get("update_step_name", "package_update"),
579
+ "cmd": update_cmd,
580
+ "retries": profile.get("update_retries", 3),
581
+ "retry_delay_s": 5,
582
+ "retry_on": _UPDATE_RETRY_ON,
583
+ }
584
+ )
585
+ install_cmd = profile.get("install_cmd")
586
+ if install_cmd:
587
+ steps.append(
588
+ {
589
+ "name": profile.get("install_step_name", "install_deps"),
590
+ "cmd": install_cmd,
591
+ "retries": profile.get("install_retries", 5),
592
+ "retry_delay_s": 5,
593
+ "retry_on": _INSTALL_RETRY_ON,
594
+ }
595
+ )
596
+ steps.extend(
597
+ [
598
+ {"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
599
+ {"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
600
+ ]
601
+ )
510
602
  if password:
511
603
  steps.append({"name": "set_password", "cmd": f"echo '{user}:{password}' | chpasswd", "retries": 0})
512
604
  if ssh_key:
513
- steps.append({
514
- "name": "add_ssh_key",
515
- "cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
516
- "retries": 0,
517
- })
605
+ steps.append(
606
+ {
607
+ "name": "add_ssh_key",
608
+ "cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
609
+ "retries": 0,
610
+ }
611
+ )
518
612
  steps.extend(
519
613
  [
520
614
  {"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
@@ -526,6 +620,45 @@ def _build_bootstrap_steps(
526
620
  return steps
527
621
 
528
622
 
623
+ def _guess_package_manager_from_template(template: str) -> str:
624
+ normalized = (template or "").lower()
625
+ if "alpine" in normalized:
626
+ return "apk"
627
+ if "archlinux" in normalized:
628
+ return "pacman"
629
+ if "centos-7" in normalized:
630
+ return "yum"
631
+ if any(keyword in normalized for keyword in ("centos-8", "centos-9", "centos-9-stream", "centos-8-stream")):
632
+ return "dnf"
633
+ if any(keyword in normalized for keyword in ("rockylinux", "almalinux", "fedora")):
634
+ return "dnf"
635
+ if "opensuse" in normalized or "suse" in normalized:
636
+ return "zypper"
637
+ if any(keyword in normalized for keyword in ("debian", "ubuntu", "devuan", "turnkeylinux")):
638
+ return "apt"
639
+ if normalized.startswith("system/") and "linux" in normalized:
640
+ return "apt"
641
+ return "apt"
642
+
643
+
644
+ def _detect_package_manager(vmid: int) -> str:
645
+ candidates = [
646
+ ("apt", "apt-get"),
647
+ ("dnf", "dnf"),
648
+ ("yum", "yum"),
649
+ ("apk", "apk"),
650
+ ("pacman", "pacman"),
651
+ ("zypper", "zypper"),
652
+ ]
653
+ for name, binary in candidates:
654
+ res = _run_pct(vmid, f"command -v {binary} >/dev/null 2>&1")
655
+ if res.get("returncode") == 0:
656
+ logger.debug("Detected package manager %s inside container %s", name, vmid)
657
+ return name
658
+ logger.warning("Unable to detect package manager inside container %s; defaulting to apt", vmid)
659
+ return "apt"
660
+
661
+
529
662
  def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
530
663
  for entry in storages:
531
664
  if entry.get("storage") == storage_name:
@@ -1003,7 +1136,16 @@ def _bootstrap_portacode(
1003
1136
  total_steps: Optional[int] = None,
1004
1137
  default_public_key: Optional[str] = None,
1005
1138
  ) -> Tuple[str, List[Dict[str, Any]]]:
1006
- actual_steps = steps if steps is not None else _build_bootstrap_steps(user, password, ssh_key)
1139
+ if steps is not None:
1140
+ actual_steps = steps
1141
+ else:
1142
+ detected_manager = _detect_package_manager(vmid)
1143
+ actual_steps = _build_bootstrap_steps(
1144
+ user,
1145
+ password,
1146
+ ssh_key,
1147
+ package_manager=detected_manager,
1148
+ )
1007
1149
  results, ok = _run_setup_steps(
1008
1150
  vmid,
1009
1151
  actual_steps,
@@ -1027,6 +1169,15 @@ def _bootstrap_portacode(
1027
1169
  else:
1028
1170
  command_text = str(command)
1029
1171
  command_suffix = f" command={command_text}" if command_text else ""
1172
+ stdout = details.get("stdout")
1173
+ stderr = details.get("stderr")
1174
+ if stdout or stderr:
1175
+ logger.debug(
1176
+ "Bootstrap command output%s%s%s",
1177
+ f" stdout={stdout!r}" if stdout else "",
1178
+ " " if stdout and stderr else "",
1179
+ f"stderr={stderr!r}" if stderr else "",
1180
+ )
1030
1181
  if summary:
1031
1182
  logger.warning(
1032
1183
  "Portacode bootstrap failure summary=%s%s%s",
@@ -1034,10 +1185,15 @@ def _bootstrap_portacode(
1034
1185
  f" history_len={len(history)}" if history else "",
1035
1186
  f" command={command_text}" if command_text else "",
1036
1187
  )
1037
- raise RuntimeError(
1038
- f"Portacode bootstrap steps failed: {summary}{history_snippet}{command_suffix}"
1039
- )
1040
- raise RuntimeError("Portacode bootstrap steps failed.")
1188
+ logger.error(
1189
+ "Portacode bootstrap command failed%s%s%s",
1190
+ f" command={command_text}" if command_text else "",
1191
+ f" stdout={stdout!r}" if stdout else "",
1192
+ f" stderr={stderr!r}" if stderr else "",
1193
+ )
1194
+ raise RuntimeError(
1195
+ f"Portacode bootstrap steps failed: {summary}{history_snippet}{command_suffix}"
1196
+ )
1041
1197
  key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
1042
1198
  public_key = key_step.get("public_key") if key_step else default_public_key
1043
1199
  if not public_key:
@@ -1104,8 +1260,9 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
1104
1260
  "token_value": token_value,
1105
1261
  "verify_ssl": verify_ssl,
1106
1262
  "default_storage": default_storage,
1107
- "templates": templates,
1108
1263
  "last_verified": datetime.utcnow().isoformat() + "Z",
1264
+ "templates": templates,
1265
+ "templates_last_refreshed": _current_time_iso(),
1109
1266
  "network": network,
1110
1267
  "node_status": status,
1111
1268
  }
@@ -1189,12 +1346,17 @@ class CreateProxmoxContainerHandler(SyncHandler):
1189
1346
  device_public_key = (message.get("device_public_key") or "").strip()
1190
1347
  device_private_key = (message.get("device_private_key") or "").strip()
1191
1348
  has_device_keypair = bool(device_public_key and device_private_key)
1349
+ config_guess = _load_config()
1350
+ template_candidates = config_guess.get("templates") or []
1351
+ template_hint = (message.get("template") or (template_candidates[0] if template_candidates else "")).strip()
1352
+ package_manager = _guess_package_manager_from_template(template_hint)
1192
1353
  bootstrap_user, bootstrap_password, bootstrap_ssh_key = _get_provisioning_user_info(message)
1193
1354
  bootstrap_steps = _build_bootstrap_steps(
1194
1355
  bootstrap_user,
1195
1356
  bootstrap_password,
1196
1357
  bootstrap_ssh_key,
1197
1358
  include_portacode_connect=not has_device_keypair,
1359
+ package_manager=package_manager,
1198
1360
  )
1199
1361
  total_steps = 3 + len(bootstrap_steps) + 2
1200
1362
  current_step_index = 1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.15.dev10
3
+ Version: 1.4.15.dev15
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=OsU3LmSVwSb16YuRZZoOGhx4fmAppA2W-vOQo60j6hQ,721
4
+ portacode/_version.py,sha256=0X-Wp9VmwXeZPPl4KjWDu4REXY5P2jnbevaNhxIBWL0,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=5DAHhzbegCI0fKdekP8Or6jrGZptmthLdq-RMtOPZDc,63843
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=MIkDCRkc-Tkxm4FdvCBbiGcXKYKp6OUwtLtKHoOPF-I,69295
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.15.dev10.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
+ portacode-1.4.15.dev15.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.15.dev10.dist-info/METADATA,sha256=FZkeltpJrx27P2fnujDDrYsinIFjnm1VsnnqTSVBk1g,13052
95
- portacode-1.4.15.dev10.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
- portacode-1.4.15.dev10.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
- portacode-1.4.15.dev10.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
- portacode-1.4.15.dev10.dist-info/RECORD,,
94
+ portacode-1.4.15.dev15.dist-info/METADATA,sha256=XJ2f3lsNMucCNlgkAJLKlnu8TYX1_eaSzKWCJnB-2Ik,13052
95
+ portacode-1.4.15.dev15.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
+ portacode-1.4.15.dev15.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
+ portacode-1.4.15.dev15.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
+ portacode-1.4.15.dev15.dist-info/RECORD,,