rapidctl 0.1.0__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.
rapidctl/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,104 @@
1
+ from typing import Optional, Dict, Any
2
+ import re
3
+ from urllib.parse import urlparse
4
+ import os.path
5
+ import rapidctl.cli.tasks
6
+ from pathlib import Path
7
+ import json
8
+ import time
9
+
10
+ from rapidctl.bootstrap.state import StateManager
11
+
12
+ class CtlClient:
13
+ """
14
+ Class that defines and validates the required data that requires definition
15
+ on the CTL client side of the stack.
16
+
17
+ Once actions using this class are complete the CTL client should be ready
18
+ to connect to the container layer and start command provisioning.
19
+ """
20
+ def __init__(self, state_manager: Optional[StateManager] = None):
21
+ self.container_repo: Optional[str] = None
22
+ self.baseline_version: str = "1.0.0"
23
+ self.client_version: str = "0.0.1"
24
+ self.image_id: Optional[str] = None
25
+ self.command_path: str = "/opt/rapidctl/cmd/"
26
+ self.cli: Optional[Any] = None
27
+
28
+ # Pluggable state manager
29
+ self.state_manager = state_manager or StateManager()
30
+
31
+ # Load persisted version state if available
32
+ self._load_persisted_version()
33
+
34
+ def check_for_updates(self) -> Optional[str]:
35
+ """Check if a newer container version exists locally."""
36
+ import rapidctl.cli.actions as actions
37
+
38
+ if not self.cli:
39
+ self.connect()
40
+
41
+ try:
42
+ newer = actions.find_newer_version(self.cli, self.container_repo, self.baseline_version)
43
+ return newer
44
+ except Exception:
45
+ return None
46
+
47
+
48
+
49
+ def _load_persisted_version(self) -> None:
50
+ """Attempt to load a pinned version from disk."""
51
+ if self.container_repo:
52
+ pinned = self.state_manager.get_state(f"version_{self.container_repo}")
53
+ if pinned:
54
+ self.baseline_version = pinned
55
+
56
+ @property
57
+ def container_version(self):
58
+ """
59
+ Function to provide an view of the container image tag
60
+
61
+ Returns:
62
+ str: Aggregrated version of the container repo path and version
63
+ """
64
+ return self._container_validator("%s:%s" % (self.container_repo, self.baseline_version))
65
+
66
+ def set_version(self, version: str) -> None:
67
+ """Update the baseline version with validation."""
68
+ # Validate that the version string is safe
69
+ safe_version = re.sub(r'[^a-zA-Z0-9._-]', '', version)
70
+ if safe_version:
71
+ self.baseline_version = safe_version
72
+
73
+ def get_version(self) -> str:
74
+ """Return the current baseline version."""
75
+ return self.baseline_version
76
+
77
+ def persist_version(self) -> None:
78
+ """Persist the current baseline version to disk."""
79
+ if self.container_repo:
80
+ self.state_manager.set_state(f"version_{self.container_repo}", self.baseline_version)
81
+
82
+ def connect(self):
83
+ """
84
+ Connect to Podman and return the active session.
85
+ """
86
+ if self.cli is None:
87
+ from rapidctl.cli import PodmanCLI
88
+ self.cli = PodmanCLI()
89
+ self.cli._connect_to_podman()
90
+ return self.cli
91
+
92
+ def _container_validator(self, container_image):
93
+ """
94
+ Sanitize a container image URL/name to prevent command injection and ensure valid format.
95
+
96
+ Args:
97
+ container_image (str): The container image URL or name to sanitize
98
+
99
+ Returns:
100
+ str: Sanitized container image string or None if invalid
101
+ """
102
+ import rapidctl.cli.tasks as tasks
103
+ return tasks.sanitize_container_image(container_image)
104
+
@@ -0,0 +1,46 @@
1
+ """
2
+ Connectors module for platform-specific container runtime connections.
3
+
4
+ This module provides platform-specific connectors for different operating systems
5
+ to detect and connect to container runtimes like Podman.
6
+ """
7
+
8
+ import os
9
+ import platform
10
+ from typing import Optional
11
+
12
+
13
+ def get_connector():
14
+ """
15
+ Get the appropriate connector for the current platform.
16
+
17
+ Returns:
18
+ Connector instance for the current platform
19
+
20
+ Raises:
21
+ NotImplementedError: If the current platform is not supported
22
+ """
23
+ system = platform.system()
24
+
25
+ if system == "Darwin":
26
+ from rapidctl.bootstrap.connectors.osx import get_connector as get_osx_connector
27
+ return get_osx_connector()
28
+ elif system == "Linux":
29
+ from rapidctl.bootstrap.connectors.linux import get_connector as get_linux_connector
30
+ return get_linux_connector()
31
+ elif system == "Windows":
32
+ # TODO: Implement Windows connector
33
+ raise NotImplementedError(f"Windows connector not yet implemented")
34
+ else:
35
+ raise NotImplementedError(f"Unsupported platform: {system}")
36
+
37
+
38
+ def detect_socket() -> Optional[str]:
39
+ """
40
+ Detect the container runtime socket for the current platform.
41
+
42
+ Returns:
43
+ str: Socket URI or None if not found
44
+ """
45
+ connector = get_connector()
46
+ return connector.detect_socket()
@@ -0,0 +1,72 @@
1
+ import os
2
+ import shutil
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+
8
+ class BaseConnector(ABC):
9
+ """
10
+ Abstract base class for platform-specific Podman connectors.
11
+ Defines the standard interface and common functionality shared
12
+ across the operating systems.
13
+ """
14
+
15
+ def __init__(self):
16
+ self.socket_path: Optional[str] = None
17
+
18
+ @abstractmethod
19
+ def detect_socket(self) -> Optional[str]:
20
+ """
21
+ Detect the Podman socket location for the current platform.
22
+
23
+ Returns:
24
+ str: URI to the Podman socket or None if not found
25
+ """
26
+ pass
27
+
28
+ @abstractmethod
29
+ def setup(self) -> bool:
30
+ """
31
+ Platform-specific setup routine, checking requirements and
32
+ providing guidance or auto-starting Podman if needed.
33
+
34
+ Returns:
35
+ bool: True if Podman is ready and socket is found, False otherwise
36
+ """
37
+ pass
38
+
39
+ def is_podman_installed(self) -> bool:
40
+ """
41
+ Check if Podman is installed on the system.
42
+
43
+ Returns:
44
+ bool: True if Podman executable is found in PATH, False otherwise
45
+ """
46
+ return shutil.which("podman") is not None
47
+
48
+ def _validate_socket(self, socket_uri: str) -> bool:
49
+ """
50
+ Validate that a socket path exists and is accessible.
51
+
52
+ Args:
53
+ socket_uri: Socket URI (e.g., 'unix:///path/to/socket')
54
+
55
+ Returns:
56
+ bool: True if socket is valid and accessible
57
+ """
58
+ if socket_uri.startswith("unix://"):
59
+ socket_path = socket_uri[7:]
60
+ else:
61
+ socket_path = socket_uri
62
+
63
+ path = Path(socket_path)
64
+
65
+ if not path.exists():
66
+ return False
67
+
68
+ # Standard check: must be a socket and accessible
69
+ if path.is_socket():
70
+ return os.access(path, os.R_OK | os.W_OK)
71
+
72
+ return False
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Linux Connector for Podman
4
+
5
+ Handles Linux-specific requirements for connecting to Podman:
6
+ - Detects the Podman socket location (rootless and rootful)
7
+ - Validates socket accessibility
8
+ """
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from rapidctl.bootstrap.connectors.base import BaseConnector
15
+
16
+
17
+ class LinuxConnector(BaseConnector):
18
+ """Connector for Podman on Linux systems."""
19
+
20
+ def __init__(self):
21
+ super().__init__()
22
+
23
+ def detect_socket(self) -> Optional[str]:
24
+ """
25
+ Detect the Podman socket location on Linux.
26
+
27
+ Checks:
28
+ 1. Environment variable PODMAN_SOCKET
29
+ 2. Rootless socket (XDG_RUNTIME_DIR/podman/podman.sock)
30
+ 3. Rootful socket (/run/podman/podman.sock)
31
+ 4. User-specific socket fallback (/run/user/UID/podman/podman.sock)
32
+ """
33
+ try:
34
+ # Fast path: return existing valid socket
35
+ if self.socket_path and self._validate_socket(self.socket_path):
36
+ return self.socket_path
37
+
38
+ # 1. Check environment variable
39
+ env_socket = os.environ.get("PODMAN_SOCKET")
40
+ if env_socket:
41
+ if self._validate_socket(env_socket):
42
+ self.socket_path = env_socket
43
+ return env_socket
44
+
45
+ # 2. Check XDG_RUNTIME_DIR (Standard for rootless)
46
+ xdg_runtime = os.environ.get("XDG_RUNTIME_DIR")
47
+ if xdg_runtime:
48
+ path = Path(xdg_runtime) / "podman/podman.sock"
49
+ try:
50
+ if self._validate_socket(f"unix://{path}"):
51
+ self.socket_path = f"unix://{path}"
52
+ return self.socket_path
53
+ except PermissionError:
54
+ pass
55
+
56
+ # 3. Check for specific UID-based path as fallback if XDG_RUNTIME_DIR is not set
57
+ try:
58
+ uid = os.getuid()
59
+ uid_path = Path(f"/run/user/{uid}/podman/podman.sock")
60
+ if uid_path.exists():
61
+ if self._validate_socket(f"unix://{uid_path}"):
62
+ self.socket_path = f"unix://{uid_path}"
63
+ return self.socket_path
64
+ except PermissionError:
65
+ pass
66
+
67
+ # 4. Check rootful socket (requires permissions)
68
+ try:
69
+ rootful_path = Path("/run/podman/podman.sock")
70
+ if rootful_path.exists():
71
+ if self._validate_socket(f"unix://{rootful_path}"):
72
+ self.socket_path = f"unix://{rootful_path}"
73
+ return self.socket_path
74
+ except PermissionError:
75
+ pass
76
+
77
+ except Exception:
78
+ # Catch-all for any other unexpected issues during detection
79
+ pass
80
+
81
+ return None
82
+
83
+ def _validate_socket(self, socket_uri: str) -> bool:
84
+ """Validate that a socket path exists and is accessible."""
85
+ return super()._validate_socket(socket_uri)
86
+
87
+ def setup(self) -> bool:
88
+ """Basic setup for Linux."""
89
+ if not self.is_podman_installed():
90
+ return False
91
+
92
+ return self.detect_socket() is not None
93
+
94
+
95
+ def get_connector() -> LinuxConnector:
96
+ """Factory function."""
97
+ return LinuxConnector()
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ OSX Connector for Podman
4
+
5
+ This connector handles macOS-specific requirements for connecting to Podman:
6
+ - Detects the Podman socket location
7
+ - Validates socket accessibility
8
+ - Sets up any OS-specific requirements
9
+ """
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from rapidctl.bootstrap.connectors.base import BaseConnector
19
+
20
+
21
+ class OSXConnector(BaseConnector):
22
+ """Connector for Podman on macOS systems."""
23
+
24
+ def __init__(self):
25
+ super().__init__()
26
+ self.podman_machine_name: Optional[str] = None
27
+
28
+ def detect_socket(self) -> Optional[str]:
29
+ """
30
+ Detect the Podman socket location on macOS.
31
+
32
+ Checks multiple common locations in order of preference:
33
+ 1. Environment variable PODMAN_SOCKET
34
+ 2. User's podman machine socket (~/.local/share/containers/podman/machine/podman.sock)
35
+ 3. System docker.sock (often symlinked to podman on macOS)
36
+ 4. Podman machine-specific sockets
37
+
38
+ Returns:
39
+ str: URI to the Podman socket (e.g., 'unix:///path/to/socket') or None if not found
40
+ """
41
+ # Fast path: return existing valid socket
42
+ if self.socket_path and self._validate_socket(self.socket_path):
43
+ return self.socket_path
44
+ # Check environment variable first
45
+ env_socket = os.environ.get("PODMAN_SOCKET")
46
+ if env_socket:
47
+ if self._validate_socket(env_socket):
48
+ self.socket_path = env_socket
49
+ return env_socket
50
+
51
+ # Common socket locations on macOS
52
+ socket_candidates = [
53
+ # Podman machine default socket (symlink)
54
+ Path.home() / ".local/share/containers/podman/machine/podman.sock",
55
+ # System docker.sock (may be symlinked to podman)
56
+ Path("/var/run/docker.sock"),
57
+ # Podman Desktop socket
58
+ Path.home() / ".local/share/containers/podman/podman.sock",
59
+ ]
60
+
61
+ # Check each candidate
62
+ for socket_path in socket_candidates:
63
+ if socket_path.exists():
64
+ socket_uri = f"unix://{socket_path}"
65
+ if self._validate_socket(socket_uri):
66
+ self.socket_path = socket_uri
67
+ return socket_uri
68
+
69
+ # Try to find machine-specific sockets
70
+ machine_dir = Path.home() / ".local/share/containers/podman/machine"
71
+ if machine_dir.exists():
72
+ for machine_path in machine_dir.iterdir():
73
+ if machine_path.is_dir():
74
+ socket_path = machine_path / "podman.sock"
75
+ if socket_path.exists():
76
+ socket_uri = f"unix://{socket_path}"
77
+ if self._validate_socket(socket_uri):
78
+ self.podman_machine_name = machine_path.name
79
+ self.socket_path = socket_uri
80
+ return socket_uri
81
+
82
+ return None
83
+
84
+ def _validate_socket(self, socket_uri: str) -> bool:
85
+ """
86
+ Validate that a socket path exists and is accessible.
87
+
88
+ Args:
89
+ socket_uri: Socket URI (e.g., 'unix:///path/to/socket')
90
+
91
+ Returns:
92
+ bool: True if socket is valid and accessible
93
+ """
94
+ # Call the base implementation first
95
+ if super()._validate_socket(socket_uri):
96
+ return True
97
+
98
+ # The base implementation does not allow symlinks.
99
+ # Check if the fallback is a valid symlink to a socket.
100
+ if socket_uri.startswith("unix://"):
101
+ socket_path = socket_uri[7:]
102
+ else:
103
+ socket_path = socket_uri
104
+
105
+ path = Path(socket_path)
106
+ if path.exists() and path.is_symlink():
107
+ return os.access(path, os.R_OK | os.W_OK)
108
+
109
+ return False
110
+
111
+ def ensure_podman_running(self) -> bool:
112
+ """
113
+ Check if Podman machine is running, and provide guidance if not.
114
+
115
+ Returns:
116
+ bool: True if Podman is running, False otherwise
117
+ """
118
+ # Fast path: If socket is valid and accessible, Podman is effectively running
119
+ if self.socket_path and self._validate_socket(self.socket_path):
120
+ return True
121
+
122
+ try:
123
+ # Check if podman command is available
124
+ result = subprocess.run(
125
+ ["podman", "machine", "list", "--format", "json"],
126
+ capture_output=True,
127
+ text=True,
128
+ timeout=5
129
+ )
130
+
131
+ if result.returncode == 0:
132
+ # Parse output to check if any machine is running
133
+ import json
134
+ machines = json.loads(result.stdout)
135
+ running_machines = [m for m in machines if m.get("Running", False)]
136
+
137
+ if running_machines:
138
+ self.podman_machine_name = running_machines[0].get("Name")
139
+ return True
140
+
141
+ except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
142
+ pass
143
+
144
+ return False
145
+
146
+ def get_connection_info(self) -> dict:
147
+ """
148
+ Get connection information for Podman on macOS.
149
+
150
+ Returns:
151
+ dict: Connection information including socket path and machine name
152
+ """
153
+ if not self.socket_path:
154
+ self.detect_socket()
155
+
156
+ return {
157
+ "socket_path": self.socket_path,
158
+ "machine_name": self.podman_machine_name,
159
+ "platform": "darwin",
160
+ "connector": "osx"
161
+ }
162
+
163
+ def setup(self) -> bool:
164
+ """
165
+ Set up the OSX connector and validate Podman availability.
166
+
167
+ This method:
168
+ 1. Checks if Podman is installed
169
+ 2. Validates Podman is running and prompts to start if not
170
+ 3. Detects the Podman socket
171
+
172
+ Returns:
173
+ bool: True if setup successful, False otherwise
174
+ """
175
+ # 1. Check if podman is installed
176
+ if not self.is_podman_installed():
177
+ print("Podman is not installed. Please install it (e.g., using 'brew install podman').")
178
+ return False
179
+
180
+ # 2. Check if Podman is running
181
+ if not self.ensure_podman_running():
182
+ # Prompt the user to start the machine
183
+ try:
184
+ import sys
185
+ sys.stdout.flush()
186
+ response = input("Podman machine is not running. Would you like to start it? [Y/n] ")
187
+ if response.lower() in ('', 'y', 'yes'):
188
+ print("Starting Podman machine (this may take a moment)...")
189
+ try:
190
+ # Call podman machine start
191
+ subprocess.run(
192
+ ["podman", "machine", "start"],
193
+ check=True,
194
+ capture_output=True,
195
+ text=True
196
+ )
197
+ # Wait a moment for the socket to be fully ready
198
+ time.sleep(2)
199
+
200
+ # Re-verify it's running after start
201
+ if not self.ensure_podman_running():
202
+ print("Warning: Podman machine started but status could not be verified.")
203
+ except subprocess.CalledProcessError as e:
204
+ print(f"Failed to start Podman machine: {e.stderr or e.output}")
205
+ return False
206
+ else:
207
+ print("Podman machine must be running to use this tool.")
208
+ return False
209
+ except (KeyboardInterrupt, EOFError):
210
+ print("\nOperation cancelled.")
211
+ return False
212
+
213
+ # 3. Detect socket
214
+ socket = self.detect_socket()
215
+ if not socket:
216
+ print("Could not detect Podman socket automatically.")
217
+ return False
218
+
219
+ return True
220
+
221
+
222
+ def get_connector() -> OSXConnector:
223
+ """
224
+ Factory function to get an OSX connector instance.
225
+
226
+ Returns:
227
+ OSXConnector: Configured OSX connector
228
+ """
229
+ connector = OSXConnector()
230
+ return connector
@@ -0,0 +1,107 @@
1
+ import json
2
+ import logging
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class StateManager:
10
+ """
11
+ Manages persistent state and cache data for rapidctl.
12
+ Pluggable by design: defaults to ~/.rapidctl/state.json, but can be
13
+ overridden for testing or multiple profiles.
14
+ """
15
+
16
+ def __init__(self, state_file: Optional[Path] = None):
17
+ """
18
+ Initialize the state manager.
19
+
20
+ Args:
21
+ state_file: Path to the JSON state file. Defaults to ~/.rapidctl/state.json
22
+ """
23
+ self.state_file: Path = state_file or Path.home() / ".rapidctl" / "state.json"
24
+
25
+ def get_state(self, key: str) -> Any:
26
+ """
27
+ Get a value from the state file.
28
+
29
+ Args:
30
+ key: The state key
31
+
32
+ Returns:
33
+ Any: The stored value, or None if not found or on error
34
+ """
35
+ if not self.state_file.exists():
36
+ return None
37
+
38
+ try:
39
+ with open(self.state_file, 'r') as f:
40
+ state = json.load(f)
41
+ return state.get(key)
42
+ except (json.JSONDecodeError, OSError) as e:
43
+ logger.warning(f"Failed to read state from {self.state_file}: {e}")
44
+ return None
45
+
46
+ def set_state(self, key: str, value: Any) -> None:
47
+ """
48
+ Set a value in the state file.
49
+
50
+ Args:
51
+ key: The state key
52
+ value: The data to store (must be JSON serializable)
53
+ """
54
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
55
+ state = {}
56
+
57
+ if self.state_file.exists():
58
+ try:
59
+ with open(self.state_file, 'r') as f:
60
+ state = json.load(f)
61
+ except (json.JSONDecodeError, OSError) as e:
62
+ logger.warning(f"Failed to load existing state file, overwriting: {e}")
63
+ # We intentionally don't fail here and just overwrite with empty state dict
64
+ pass
65
+
66
+ state[key] = value
67
+
68
+ try:
69
+ with open(self.state_file, 'w') as f:
70
+ json.dump(state, f, indent=4)
71
+ except OSError as e:
72
+ logger.warning(f"Failed to write state to {self.state_file}: {e}")
73
+
74
+ def get_cache(self, key: str) -> Any:
75
+ """
76
+ Get a cached value if it has not expired.
77
+
78
+ Args:
79
+ key: The cache key
80
+
81
+ Returns:
82
+ Any: The cached data, or None if missing or expired
83
+ """
84
+ cache_data = self.get_state(f"cache_{key}")
85
+ if cache_data and isinstance(cache_data, dict):
86
+ timestamp = cache_data.get("timestamp", 0)
87
+ ttl = cache_data.get("ttl", 300) # Default 5 mins
88
+
89
+ if time.time() - timestamp < ttl:
90
+ return cache_data.get("data")
91
+ return None
92
+
93
+ def set_cache(self, key: str, data: Any, ttl: int = 300) -> None:
94
+ """
95
+ Set a cached value with a time-to-live.
96
+
97
+ Args:
98
+ key: The cache key
99
+ data: The data to cache
100
+ ttl: Time to live in seconds (default: 300)
101
+ """
102
+ cache_data = {
103
+ "timestamp": time.time(),
104
+ "ttl": ttl,
105
+ "data": data
106
+ }
107
+ self.set_state(f"cache_{key}", cache_data)