portacode 0.3.4.dev0__py3-none-any.whl → 1.4.11.dev0__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.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +16 -3
- portacode/cli.py +155 -19
- portacode/connection/client.py +152 -12
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
- portacode/connection/handlers/__init__.py +43 -1
- portacode/connection/handlers/base.py +122 -18
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -0
- portacode/connection/handlers/proxmox_infra.py +307 -0
- portacode/connection/handlers/registry.py +53 -10
- portacode/connection/handlers/session.py +705 -53
- portacode/connection/handlers/system_handlers.py +142 -8
- portacode/connection/handlers/tab_factory.py +389 -0
- portacode/connection/handlers/terminal_handlers.py +150 -11
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +695 -28
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/service.py +6 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev0.dist-info/METADATA +298 -0
- portacode-1.4.11.dev0.dist-info/RECORD +97 -0
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.4.dev0.dist-info/METADATA +0 -236
- portacode-0.3.4.dev0.dist-info/RECORD +0 -27
- portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Data models for project state management.
|
|
2
|
+
|
|
3
|
+
This module contains all the dataclasses and models used throughout the project
|
|
4
|
+
state management system, including TabInfo, FileItem, GitFileChange,
|
|
5
|
+
GitDetailedStatus, ProjectState, and MonitoredFolder.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, asdict
|
|
9
|
+
from typing import Any, Dict, List, Optional, Set, Union
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class TabInfo:
|
|
14
|
+
"""Represents an editor tab with content and metadata."""
|
|
15
|
+
tab_id: str # Unique identifier for the tab
|
|
16
|
+
tab_type: str # 'file', 'diff', 'untitled', 'image', 'audio', 'video'
|
|
17
|
+
title: str # Display title for the tab
|
|
18
|
+
file_path: Optional[str] = None # Path for file-based tabs
|
|
19
|
+
content: Optional[str] = None # Text content or base64 for media
|
|
20
|
+
original_content: Optional[str] = None # For diff view
|
|
21
|
+
modified_content: Optional[str] = None # For diff view
|
|
22
|
+
|
|
23
|
+
# Content hash fields for caching optimization
|
|
24
|
+
content_hash: Optional[str] = None # SHA-256 hash of content
|
|
25
|
+
original_content_hash: Optional[str] = None # SHA-256 hash of original_content for diffs
|
|
26
|
+
modified_content_hash: Optional[str] = None # SHA-256 hash of modified_content for diffs
|
|
27
|
+
html_diff_hash: Optional[str] = None # SHA-256 hash of html_diff_versions JSON
|
|
28
|
+
|
|
29
|
+
is_dirty: bool = False # Has unsaved changes
|
|
30
|
+
mime_type: Optional[str] = None # For media files
|
|
31
|
+
encoding: Optional[str] = None # Content encoding (base64, utf-8, etc.)
|
|
32
|
+
metadata: Optional[Dict[str, Any]] = None # Additional metadata
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class MonitoredFolder:
|
|
37
|
+
"""Represents a folder that is being monitored for changes."""
|
|
38
|
+
folder_path: str
|
|
39
|
+
is_expanded: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class FileItem:
|
|
44
|
+
"""Represents a file or directory item with metadata."""
|
|
45
|
+
name: str
|
|
46
|
+
path: str
|
|
47
|
+
is_directory: bool
|
|
48
|
+
parent_path: str
|
|
49
|
+
size: Optional[int] = None
|
|
50
|
+
modified_time: Optional[float] = None
|
|
51
|
+
is_git_tracked: Optional[bool] = None
|
|
52
|
+
git_status: Optional[str] = None
|
|
53
|
+
is_staged: Optional[Union[bool, str]] = None # True, False, or "mixed"
|
|
54
|
+
is_hidden: bool = False
|
|
55
|
+
is_ignored: bool = False
|
|
56
|
+
children: Optional[List['FileItem']] = None
|
|
57
|
+
is_expanded: bool = False
|
|
58
|
+
is_loaded: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class GitFileChange:
|
|
63
|
+
"""Represents a single file change in git."""
|
|
64
|
+
file_repo_path: str # Relative path from repository root
|
|
65
|
+
file_name: str # Just the filename (basename)
|
|
66
|
+
file_abs_path: str # Absolute path to the file
|
|
67
|
+
change_type: str # 'added', 'modified', 'deleted', 'untracked' - follows git's native types
|
|
68
|
+
content_hash: Optional[str] = None # SHA256 hash of current file content
|
|
69
|
+
is_staged: bool = False # Whether this change is staged
|
|
70
|
+
diff_details: Optional[Dict[str, Any]] = None # Per-character diff information using diff-match-patch
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class GitDetailedStatus:
|
|
75
|
+
"""Represents detailed git status with file hashes."""
|
|
76
|
+
head_commit_hash: Optional[str] = None # Hash of HEAD commit
|
|
77
|
+
staged_changes: List[GitFileChange] = None # Changes in the staging area
|
|
78
|
+
unstaged_changes: List[GitFileChange] = None # Changes in working directory
|
|
79
|
+
untracked_files: List[GitFileChange] = None # Untracked files
|
|
80
|
+
|
|
81
|
+
def __post_init__(self):
|
|
82
|
+
if self.staged_changes is None:
|
|
83
|
+
self.staged_changes = []
|
|
84
|
+
if self.unstaged_changes is None:
|
|
85
|
+
self.unstaged_changes = []
|
|
86
|
+
if self.untracked_files is None:
|
|
87
|
+
self.untracked_files = []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class ProjectState:
|
|
92
|
+
"""Represents the complete state of a project."""
|
|
93
|
+
client_session_id: str # The client session ID - one project per client session
|
|
94
|
+
project_folder_path: str
|
|
95
|
+
items: List[FileItem]
|
|
96
|
+
monitored_folders: List[MonitoredFolder] = None
|
|
97
|
+
is_git_repo: bool = False
|
|
98
|
+
git_branch: Optional[str] = None
|
|
99
|
+
git_status_summary: Optional[Dict[str, int]] = None # Kept for backward compatibility
|
|
100
|
+
git_detailed_status: Optional[GitDetailedStatus] = None # New detailed git state
|
|
101
|
+
open_tabs: Dict[str, 'TabInfo'] = None # Changed from List to Dict with unique keys
|
|
102
|
+
active_tab: Optional['TabInfo'] = None
|
|
103
|
+
|
|
104
|
+
def __post_init__(self):
|
|
105
|
+
if self.open_tabs is None:
|
|
106
|
+
self.open_tabs = {}
|
|
107
|
+
if self.monitored_folders is None:
|
|
108
|
+
self.monitored_folders = []
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Utility functions for project state management.
|
|
2
|
+
|
|
3
|
+
This module contains shared utility functions used across the project state
|
|
4
|
+
management system, including tab key generation and other helper functions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_tab_key(tab_type: str, file_path: str, **kwargs) -> str:
|
|
12
|
+
"""Generate a unique key for a tab.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
tab_type: Type of tab ('file', 'diff', 'untitled', etc.)
|
|
16
|
+
file_path: Path to the file
|
|
17
|
+
**kwargs: Additional parameters for diff tabs (from_ref, to_ref, from_hash, to_hash)
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Unique string key for the tab
|
|
21
|
+
"""
|
|
22
|
+
if tab_type == 'file':
|
|
23
|
+
return file_path
|
|
24
|
+
elif tab_type == 'diff':
|
|
25
|
+
from_ref = kwargs.get('from_ref', '')
|
|
26
|
+
to_ref = kwargs.get('to_ref', '')
|
|
27
|
+
from_hash = kwargs.get('from_hash', '')
|
|
28
|
+
to_hash = kwargs.get('to_hash', '')
|
|
29
|
+
return f"diff:{file_path}:{from_ref}:{to_ref}:{from_hash}:{to_hash}"
|
|
30
|
+
elif tab_type == 'untitled':
|
|
31
|
+
# For untitled tabs, use the tab_id as the key since they don't have a file path
|
|
32
|
+
return kwargs.get('tab_id', str(uuid.uuid4()))
|
|
33
|
+
else:
|
|
34
|
+
# For other tab types, use file_path if available, otherwise tab_id
|
|
35
|
+
return file_path if file_path else kwargs.get('tab_id', str(uuid.uuid4()))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def generate_content_hash(content: str) -> str:
|
|
39
|
+
"""Generate SHA-256 hash of content for caching.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
content: The string content to hash
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
SHA-256 hash prefixed with 'sha256:'
|
|
46
|
+
"""
|
|
47
|
+
if content is None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
return "sha256:" + hashlib.sha256(content.encode('utf-8')).hexdigest()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Project state handlers - modular architecture.
|
|
2
|
+
|
|
3
|
+
This module serves as a compatibility layer that imports all the project state
|
|
4
|
+
handlers from the new modular structure. This ensures existing code continues
|
|
5
|
+
to work while providing access to the new architecture.
|
|
6
|
+
|
|
7
|
+
The original monolithic file has been broken down into a modular structure
|
|
8
|
+
located in the project_state/ subdirectory. All functionality, logging, and
|
|
9
|
+
documentation has been preserved while improving maintainability.
|
|
10
|
+
|
|
11
|
+
For detailed information about the new structure, see:
|
|
12
|
+
project_state/README.md
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# Import everything from the modular structure for backward compatibility
|
|
16
|
+
from .project_state import *
|
|
17
|
+
|
|
18
|
+
# Ensure all handlers are available at module level for existing imports
|
|
19
|
+
from .project_state.handlers import (
|
|
20
|
+
ProjectStateFolderExpandHandler,
|
|
21
|
+
ProjectStateFolderCollapseHandler,
|
|
22
|
+
ProjectStateFileOpenHandler,
|
|
23
|
+
ProjectStateTabCloseHandler,
|
|
24
|
+
ProjectStateSetActiveTabHandler,
|
|
25
|
+
ProjectStateDiffOpenHandler,
|
|
26
|
+
ProjectStateDiffContentHandler,
|
|
27
|
+
ProjectStateGitStageHandler,
|
|
28
|
+
ProjectStateGitUnstageHandler,
|
|
29
|
+
ProjectStateGitRevertHandler,
|
|
30
|
+
ProjectStateGitCommitHandler,
|
|
31
|
+
handle_client_session_cleanup
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from .project_state.manager import (
|
|
35
|
+
get_or_create_project_state_manager,
|
|
36
|
+
reset_global_project_state_manager,
|
|
37
|
+
debug_global_manager_state
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from .project_state.utils import generate_tab_key
|
|
41
|
+
|
|
42
|
+
# Re-export with the old private function names for backward compatibility
|
|
43
|
+
_get_or_create_project_state_manager = get_or_create_project_state_manager
|
|
44
|
+
_reset_global_project_state_manager = reset_global_project_state_manager
|
|
45
|
+
_debug_global_manager_state = debug_global_manager_state
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Proxmox infrastructure configuration handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, Iterable, List, Tuple
|
|
15
|
+
|
|
16
|
+
import platformdirs
|
|
17
|
+
|
|
18
|
+
from .base import SyncHandler
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
CONFIG_DIR = Path(platformdirs.user_config_dir("portacode"))
|
|
23
|
+
CONFIG_PATH = CONFIG_DIR / "proxmox_infra.json"
|
|
24
|
+
|
|
25
|
+
DEFAULT_HOST = "localhost"
|
|
26
|
+
DEFAULT_NODE_NAME = os.uname().nodename.split(".", 1)[0]
|
|
27
|
+
DEFAULT_BRIDGE = "vmbr1"
|
|
28
|
+
SUBNET_CIDR = "10.10.0.1/24"
|
|
29
|
+
BRIDGE_IP = SUBNET_CIDR.split("/", 1)[0]
|
|
30
|
+
DHCP_START = "10.10.0.100"
|
|
31
|
+
DHCP_END = "10.10.0.200"
|
|
32
|
+
DNS_SERVER = "1.1.1.1"
|
|
33
|
+
IFACES_PATH = Path("/etc/network/interfaces")
|
|
34
|
+
SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
|
|
35
|
+
UNIT_DIR = Path("/etc/systemd/system")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _call_subprocess(cmd: List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
|
39
|
+
env = os.environ.copy()
|
|
40
|
+
env.setdefault("DEBIAN_FRONTEND", "noninteractive")
|
|
41
|
+
return subprocess.run(cmd, env=env, text=True, capture_output=True, **kwargs)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ensure_proxmoxer() -> Any:
|
|
45
|
+
try:
|
|
46
|
+
from proxmoxer import ProxmoxAPI # noqa: F401
|
|
47
|
+
except ModuleNotFoundError as exc:
|
|
48
|
+
python = sys.executable
|
|
49
|
+
logger.info("Proxmoxer missing; installing via pip")
|
|
50
|
+
try:
|
|
51
|
+
_call_subprocess([python, "-m", "pip", "install", "proxmoxer"], check=True)
|
|
52
|
+
except subprocess.CalledProcessError as pip_exc:
|
|
53
|
+
msg = pip_exc.stderr or pip_exc.stdout or str(pip_exc)
|
|
54
|
+
raise RuntimeError(f"Failed to install proxmoxer: {msg}") from pip_exc
|
|
55
|
+
from proxmoxer import ProxmoxAPI # noqa: F401
|
|
56
|
+
from proxmoxer import ProxmoxAPI
|
|
57
|
+
return ProxmoxAPI
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_token(token_identifier: str) -> Tuple[str, str]:
|
|
61
|
+
identifier = token_identifier.strip()
|
|
62
|
+
if "!" not in identifier or "@" not in identifier:
|
|
63
|
+
raise ValueError("Expected API token in the form user@realm!tokenid")
|
|
64
|
+
user_part, token_name = identifier.split("!", 1)
|
|
65
|
+
user = user_part.strip()
|
|
66
|
+
token_name = token_name.strip()
|
|
67
|
+
if "@" not in user:
|
|
68
|
+
raise ValueError("API token missing user realm (user@realm)")
|
|
69
|
+
if not token_name:
|
|
70
|
+
raise ValueError("Token identifier missing token name")
|
|
71
|
+
return user, token_name
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _save_config(data: Dict[str, Any]) -> None:
|
|
75
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
tmp_path = CONFIG_PATH.with_suffix(".tmp")
|
|
77
|
+
tmp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
78
|
+
os.replace(tmp_path, CONFIG_PATH)
|
|
79
|
+
os.chmod(CONFIG_PATH, stat.S_IRUSR | stat.S_IWUSR)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_config() -> Dict[str, Any]:
|
|
83
|
+
if not CONFIG_PATH.exists():
|
|
84
|
+
return {}
|
|
85
|
+
try:
|
|
86
|
+
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
87
|
+
except json.JSONDecodeError as exc:
|
|
88
|
+
logger.warning("Failed to parse Proxmox infra config: %s", exc)
|
|
89
|
+
return {}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _pick_node(client: Any) -> str:
|
|
93
|
+
nodes = client.nodes().get()
|
|
94
|
+
for node in nodes:
|
|
95
|
+
if node.get("node") == DEFAULT_NODE_NAME:
|
|
96
|
+
return DEFAULT_NODE_NAME
|
|
97
|
+
return nodes[0].get("node") if nodes else DEFAULT_NODE_NAME
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _list_templates(client: Any, node: str, storages: Iterable[Dict[str, Any]]) -> List[str]:
|
|
101
|
+
templates: List[str] = []
|
|
102
|
+
for storage in storages:
|
|
103
|
+
storage_name = storage.get("storage")
|
|
104
|
+
if not storage_name:
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
items = client.nodes(node).storage(storage_name).content.get()
|
|
108
|
+
except Exception:
|
|
109
|
+
continue
|
|
110
|
+
for item in items:
|
|
111
|
+
if item.get("content") == "vztmpl" and item.get("volid"):
|
|
112
|
+
templates.append(item["volid"])
|
|
113
|
+
return templates
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
|
|
117
|
+
candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
|
|
118
|
+
if not candidates:
|
|
119
|
+
candidates = [s for s in storages if "rootdir" in s.get("content", "")]
|
|
120
|
+
if not candidates:
|
|
121
|
+
return ""
|
|
122
|
+
candidates.sort(key=lambda entry: entry.get("avail", 0), reverse=True)
|
|
123
|
+
return candidates[0].get("storage", "")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _write_bridge_config(bridge: str) -> None:
|
|
127
|
+
begin = f"# Portacode INFRA BEGIN {bridge}"
|
|
128
|
+
end = f"# Portacode INFRA END {bridge}"
|
|
129
|
+
current = IFACES_PATH.read_text(encoding="utf-8") if IFACES_PATH.exists() else ""
|
|
130
|
+
if begin in current:
|
|
131
|
+
return
|
|
132
|
+
block = f"""
|
|
133
|
+
{begin}
|
|
134
|
+
auto {bridge}
|
|
135
|
+
iface {bridge} inet static
|
|
136
|
+
address {SUBNET_CIDR}
|
|
137
|
+
bridge-ports none
|
|
138
|
+
bridge-stp off
|
|
139
|
+
bridge-fd 0
|
|
140
|
+
{end}
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
mode = "a" if IFACES_PATH.exists() else "w"
|
|
144
|
+
with open(IFACES_PATH, mode, encoding="utf-8") as fh:
|
|
145
|
+
if current and not current.endswith("\n"):
|
|
146
|
+
fh.write("\n")
|
|
147
|
+
fh.write(block)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _ensure_sysctl() -> None:
|
|
151
|
+
SYSCTL_PATH.write_text("net.ipv4.ip_forward=1\n", encoding="utf-8")
|
|
152
|
+
_call_subprocess(["/sbin/sysctl", "-w", "net.ipv4.ip_forward=1"], check=True)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _write_units(bridge: str) -> None:
|
|
156
|
+
nat_name = f"portacode-{bridge}-nat.service"
|
|
157
|
+
dns_name = f"portacode-{bridge}-dnsmasq.service"
|
|
158
|
+
nat = UNIT_DIR / nat_name
|
|
159
|
+
dns = UNIT_DIR / dns_name
|
|
160
|
+
nat.write_text(f"""[Unit]
|
|
161
|
+
Description=Portacode NAT for {bridge}
|
|
162
|
+
After=network-online.target
|
|
163
|
+
Wants=network-online.target
|
|
164
|
+
|
|
165
|
+
[Service]
|
|
166
|
+
Type=oneshot
|
|
167
|
+
RemainAfterExit=yes
|
|
168
|
+
ExecStart=/usr/sbin/iptables -t nat -A POSTROUTING -s {BRIDGE_IP}/24 -o vmbr0 -j MASQUERADE
|
|
169
|
+
ExecStart=/usr/sbin/iptables -A FORWARD -i {bridge} -o vmbr0 -j ACCEPT
|
|
170
|
+
ExecStart=/usr/sbin/iptables -A FORWARD -i vmbr0 -o {bridge} -m state --state RELATED,ESTABLISHED -j ACCEPT
|
|
171
|
+
ExecStop=/usr/sbin/iptables -t nat -D POSTROUTING -s {BRIDGE_IP}/24 -o vmbr0 -j MASQUERADE
|
|
172
|
+
ExecStop=/usr/sbin/iptables -D FORWARD -i {bridge} -o vmbr0 -j ACCEPT
|
|
173
|
+
ExecStop=/usr/sbin/iptables -D FORWARD -i vmbr0 -o {bridge} -m state --state RELATED,ESTABLISHED -j ACCEPT
|
|
174
|
+
|
|
175
|
+
[Install]
|
|
176
|
+
WantedBy=multi-user.target
|
|
177
|
+
""", encoding="utf-8")
|
|
178
|
+
dns.write_text(f"""[Unit]
|
|
179
|
+
Description=Portacode dnsmasq for {bridge}
|
|
180
|
+
After=network-online.target
|
|
181
|
+
Wants=network-online.target
|
|
182
|
+
|
|
183
|
+
[Service]
|
|
184
|
+
Type=simple
|
|
185
|
+
ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --interface={bridge} --bind-interfaces --listen-address={BRIDGE_IP} \
|
|
186
|
+
--port=0 --dhcp-range={DHCP_START},{DHCP_END},12h \
|
|
187
|
+
--dhcp-option=option:router,{BRIDGE_IP} \
|
|
188
|
+
--dhcp-option=option:dns-server,{DNS_SERVER} \
|
|
189
|
+
--conf-file=/dev/null --pid-file=/run/portacode_dnsmasq.pid --dhcp-leasefile=/var/lib/misc/portacode_dnsmasq.leases
|
|
190
|
+
Restart=always
|
|
191
|
+
|
|
192
|
+
[Install]
|
|
193
|
+
WantedBy=multi-user.target
|
|
194
|
+
""", encoding="utf-8")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
|
|
198
|
+
if os.geteuid() != 0:
|
|
199
|
+
raise PermissionError("Bridge setup requires root privileges")
|
|
200
|
+
if not shutil.which("dnsmasq"):
|
|
201
|
+
apt = shutil.which("apt-get")
|
|
202
|
+
if not apt:
|
|
203
|
+
raise RuntimeError("dnsmasq is missing and apt-get unavailable to install it")
|
|
204
|
+
_call_subprocess([apt, "update"], check=True)
|
|
205
|
+
_call_subprocess([apt, "install", "-y", "dnsmasq"], check=True)
|
|
206
|
+
_write_bridge_config(bridge)
|
|
207
|
+
_ensure_sysctl()
|
|
208
|
+
_write_units(bridge)
|
|
209
|
+
_call_subprocess(["/bin/systemctl", "daemon-reload"], check=True)
|
|
210
|
+
nat_service = f"portacode-{bridge}-nat.service"
|
|
211
|
+
dns_service = f"portacode-{bridge}-dnsmasq.service"
|
|
212
|
+
_call_subprocess(["/bin/systemctl", "enable", "--now", nat_service, dns_service], check=True)
|
|
213
|
+
_call_subprocess(["/sbin/ifup", bridge], check=False)
|
|
214
|
+
return {"applied": True, "bridge": bridge, "message": f"Bridge {bridge} configured"}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
218
|
+
if not config:
|
|
219
|
+
return {"configured": False}
|
|
220
|
+
network = config.get("network", {})
|
|
221
|
+
return {
|
|
222
|
+
"configured": True,
|
|
223
|
+
"host": config.get("host"),
|
|
224
|
+
"node": config.get("node"),
|
|
225
|
+
"user": config.get("user"),
|
|
226
|
+
"token_name": config.get("token_name"),
|
|
227
|
+
"default_storage": config.get("default_storage"),
|
|
228
|
+
"templates": config.get("templates") or [],
|
|
229
|
+
"last_verified": config.get("last_verified"),
|
|
230
|
+
"network": {
|
|
231
|
+
"applied": network.get("applied", False),
|
|
232
|
+
"message": network.get("message"),
|
|
233
|
+
"bridge": network.get("bridge", DEFAULT_BRIDGE),
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl: bool = False) -> Dict[str, Any]:
|
|
239
|
+
ProxmoxAPI = _ensure_proxmoxer()
|
|
240
|
+
user, token_name = _parse_token(token_identifier)
|
|
241
|
+
client = ProxmoxAPI(
|
|
242
|
+
DEFAULT_HOST,
|
|
243
|
+
user=user,
|
|
244
|
+
token_name=token_name,
|
|
245
|
+
token_value=token_value,
|
|
246
|
+
verify_ssl=verify_ssl,
|
|
247
|
+
timeout=30,
|
|
248
|
+
)
|
|
249
|
+
node = _pick_node(client)
|
|
250
|
+
status = client.nodes(node).status.get()
|
|
251
|
+
storages = client.nodes(node).storage.get()
|
|
252
|
+
default_storage = _pick_storage(storages)
|
|
253
|
+
templates = _list_templates(client, node, storages)
|
|
254
|
+
network: Dict[str, Any] = {}
|
|
255
|
+
try:
|
|
256
|
+
network = _ensure_bridge()
|
|
257
|
+
except PermissionError as exc:
|
|
258
|
+
network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
|
|
259
|
+
logger.warning("Bridge setup skipped: %s", exc)
|
|
260
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
261
|
+
network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
|
|
262
|
+
logger.warning("Bridge setup failed: %s", exc)
|
|
263
|
+
config = {
|
|
264
|
+
"host": DEFAULT_HOST,
|
|
265
|
+
"node": node,
|
|
266
|
+
"user": user,
|
|
267
|
+
"token_name": token_name,
|
|
268
|
+
"token_value": token_value,
|
|
269
|
+
"verify_ssl": verify_ssl,
|
|
270
|
+
"default_storage": default_storage,
|
|
271
|
+
"templates": templates,
|
|
272
|
+
"last_verified": datetime.utcnow().isoformat() + "Z",
|
|
273
|
+
"network": network,
|
|
274
|
+
"node_status": status,
|
|
275
|
+
}
|
|
276
|
+
_save_config(config)
|
|
277
|
+
snapshot = build_snapshot(config)
|
|
278
|
+
snapshot["node_status"] = status
|
|
279
|
+
return snapshot
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def get_infra_snapshot() -> Dict[str, Any]:
|
|
283
|
+
config = _load_config()
|
|
284
|
+
snapshot = build_snapshot(config)
|
|
285
|
+
if config.get("node_status"):
|
|
286
|
+
snapshot["node_status"] = config["node_status"]
|
|
287
|
+
return snapshot
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class ConfigureProxmoxInfraHandler(SyncHandler):
|
|
291
|
+
@property
|
|
292
|
+
def command_name(self) -> str:
|
|
293
|
+
return "setup_proxmox_infra"
|
|
294
|
+
|
|
295
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
296
|
+
token_identifier = message.get("token_identifier")
|
|
297
|
+
token_value = message.get("token_value")
|
|
298
|
+
verify_ssl = bool(message.get("verify_ssl"))
|
|
299
|
+
if not token_identifier or not token_value:
|
|
300
|
+
raise ValueError("token_identifier and token_value are required")
|
|
301
|
+
snapshot = configure_infrastructure(token_identifier, token_value, verify_ssl=verify_ssl)
|
|
302
|
+
return {
|
|
303
|
+
"event": "proxmox_infra_configured",
|
|
304
|
+
"success": True,
|
|
305
|
+
"message": "Proxmox infrastructure configured",
|
|
306
|
+
"infra": snapshot,
|
|
307
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
from typing import Dict, Type, Any, Optional, List, TYPE_CHECKING
|
|
5
|
+
from portacode.utils.ntp_clock import ntp_clock
|
|
5
6
|
|
|
6
7
|
if TYPE_CHECKING:
|
|
7
8
|
from ..multiplex import Channel
|
|
@@ -72,32 +73,74 @@ class CommandRegistry:
|
|
|
72
73
|
|
|
73
74
|
async def dispatch(self, command_name: str, message: Dict[str, Any], reply_channel: Optional[str] = None) -> bool:
|
|
74
75
|
"""Dispatch a command to its handler.
|
|
75
|
-
|
|
76
|
+
|
|
76
77
|
Args:
|
|
77
78
|
command_name: The command name
|
|
78
79
|
message: The command message
|
|
79
80
|
reply_channel: Optional reply channel
|
|
80
|
-
|
|
81
|
+
|
|
81
82
|
Returns:
|
|
82
83
|
True if handler was found and executed, False otherwise
|
|
83
84
|
"""
|
|
85
|
+
logger.info("registry: Dispatching command '%s' with reply_channel=%s", command_name, reply_channel)
|
|
86
|
+
|
|
87
|
+
# Add handler_receive timestamp if trace present
|
|
88
|
+
if "trace" in message and "request_id" in message:
|
|
89
|
+
handler_receive_time = ntp_clock.now_ms()
|
|
90
|
+
if handler_receive_time is not None:
|
|
91
|
+
message["trace"]["handler_receive"] = handler_receive_time
|
|
92
|
+
# Update ping to show total time from client_send
|
|
93
|
+
if "client_send" in message["trace"]:
|
|
94
|
+
message["trace"]["ping"] = handler_receive_time - message["trace"]["client_send"]
|
|
95
|
+
logger.info(f"🎯 Handler received traced message: {message['request_id']}")
|
|
96
|
+
|
|
84
97
|
handler = self.get_handler(command_name)
|
|
85
98
|
if handler is None:
|
|
86
|
-
logger.warning("No handler found for command: %s", command_name)
|
|
99
|
+
logger.warning("registry: No handler found for command: %s", command_name)
|
|
87
100
|
return False
|
|
88
|
-
|
|
101
|
+
|
|
89
102
|
try:
|
|
90
103
|
await handler.handle(message, reply_channel)
|
|
104
|
+
logger.info("registry: Successfully dispatched command '%s'", command_name)
|
|
91
105
|
return True
|
|
92
106
|
except Exception as exc:
|
|
93
|
-
logger.exception("Error dispatching command %s: %s", command_name, exc)
|
|
94
|
-
# Send error response
|
|
95
|
-
|
|
96
|
-
if reply_channel:
|
|
97
|
-
error_payload["reply_channel"] = reply_channel
|
|
98
|
-
await self.control_channel.send(error_payload)
|
|
107
|
+
logger.exception("registry: Error dispatching command %s: %s", command_name, exc)
|
|
108
|
+
# Send session-aware error response
|
|
109
|
+
await self._send_session_aware_error(str(exc), reply_channel, message.get("project_id"))
|
|
99
110
|
return False
|
|
100
111
|
|
|
112
|
+
async def _send_session_aware_error(self, message: str, reply_channel: Optional[str] = None, project_id: str = None) -> None:
|
|
113
|
+
"""Send an error response with client session awareness."""
|
|
114
|
+
error_payload = {"event": "error", "message": message}
|
|
115
|
+
|
|
116
|
+
# Get client session manager from context
|
|
117
|
+
client_session_manager = self.context.get("client_session_manager")
|
|
118
|
+
|
|
119
|
+
if client_session_manager and client_session_manager.has_interested_clients():
|
|
120
|
+
# Get target sessions
|
|
121
|
+
target_sessions = client_session_manager.get_target_sessions(project_id)
|
|
122
|
+
if not target_sessions:
|
|
123
|
+
logger.debug("registry: No target sessions found, skipping error send")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
# Add session targeting information
|
|
127
|
+
error_payload["client_sessions"] = target_sessions
|
|
128
|
+
|
|
129
|
+
# Add backward compatibility reply_channel (first session if not provided)
|
|
130
|
+
if not reply_channel:
|
|
131
|
+
reply_channel = client_session_manager.get_reply_channel_for_compatibility()
|
|
132
|
+
if reply_channel:
|
|
133
|
+
error_payload["reply_channel"] = reply_channel
|
|
134
|
+
|
|
135
|
+
logger.debug("registry: Sending error to %d client sessions: %s",
|
|
136
|
+
len(target_sessions), target_sessions)
|
|
137
|
+
else:
|
|
138
|
+
# Fallback to original behavior
|
|
139
|
+
if reply_channel:
|
|
140
|
+
error_payload["reply_channel"] = reply_channel
|
|
141
|
+
|
|
142
|
+
await self.control_channel.send(error_payload)
|
|
143
|
+
|
|
101
144
|
def update_context(self, context: Dict[str, Any]) -> None:
|
|
102
145
|
"""Update the shared context for all handlers.
|
|
103
146
|
|