ots-shared 0.2.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.
ots_shared/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Shared constants and utilities for OTS operations tools."""
ots_shared/cli.py ADDED
@@ -0,0 +1,70 @@
1
+ """Shared cyclopts type aliases for consistent CLI flags across OTS tools.
2
+
3
+ All common flags use long+short forms for consistency:
4
+ --quiet, -q
5
+ --dry-run, -n
6
+ --yes, -y
7
+ --follow, -f
8
+ --lines, -l
9
+ --json, -j
10
+ """
11
+
12
+ from typing import Annotated
13
+
14
+ import cyclopts
15
+
16
+ # Output control
17
+ Quiet = Annotated[
18
+ bool,
19
+ cyclopts.Parameter(
20
+ name=["--quiet", "-q"],
21
+ help="Suppress output",
22
+ ),
23
+ ]
24
+
25
+ DryRun = Annotated[
26
+ bool,
27
+ cyclopts.Parameter(
28
+ name=["--dry-run", "-n"],
29
+ help="Show what would be done without doing it",
30
+ negative=[], # Disable --no-dry-run generation
31
+ ),
32
+ ]
33
+
34
+
35
+ # Confirmation
36
+ Yes = Annotated[
37
+ bool,
38
+ cyclopts.Parameter(
39
+ name=["--yes", "-y"],
40
+ help="Skip confirmation prompts",
41
+ ),
42
+ ]
43
+
44
+
45
+ # Log viewing
46
+ Follow = Annotated[
47
+ bool,
48
+ cyclopts.Parameter(
49
+ name=["--follow", "-f"],
50
+ help="Follow log output",
51
+ ),
52
+ ]
53
+
54
+ Lines = Annotated[
55
+ int,
56
+ cyclopts.Parameter(
57
+ name=["--lines", "-l"],
58
+ help="Number of lines to show",
59
+ ),
60
+ ]
61
+
62
+
63
+ # JSON output
64
+ JsonOutput = Annotated[
65
+ bool,
66
+ cyclopts.Parameter(
67
+ name=["--json", "-j"],
68
+ help="Output as JSON",
69
+ ),
70
+ ]
@@ -0,0 +1,29 @@
1
+ """Standardised exit codes for all OTS CLI tools.
2
+
3
+ All commands across ots-containers, hcloud-manager, otsinfra, and
4
+ ots-cloudinit use this scheme so that CI pipelines and shell scripts
5
+ can distinguish between different failure modes.
6
+
7
+ Usage::
8
+
9
+ from ots_shared.exit_codes import EXIT_SUCCESS, EXIT_FAILURE, EXIT_PARTIAL, EXIT_PRECOND
10
+
11
+ raise SystemExit(EXIT_PARTIAL)
12
+ """
13
+
14
+ EXIT_SUCCESS: int = 0
15
+ """Command completed successfully; all operations applied."""
16
+
17
+ EXIT_FAILURE: int = 1
18
+ """General command failure (unexpected error, bad arguments, etc.)."""
19
+
20
+ EXIT_PARTIAL: int = 2
21
+ """Partial success: at least one operation succeeded and at least one failed.
22
+
23
+ Check the command output for details on which operations failed."""
24
+
25
+ EXIT_PRECOND: int = 3
26
+ """Precondition not met: required configuration is absent.
27
+
28
+ Examples: missing env file, missing secrets, image not pulled.
29
+ No destructive action was attempted."""
@@ -0,0 +1,51 @@
1
+ """SSH remote execution support for OTS operations tools.
2
+
3
+ Public API:
4
+ - Environment: find_env_file, load_env_file, resolve_config_dir, resolve_host
5
+ - Executor: Result, CommandError, Executor, LocalExecutor, SSHExecutor, is_remote
6
+ - Connection: ssh_connect
7
+ """
8
+
9
+ from .env import (
10
+ find_env_file,
11
+ generate_env_template,
12
+ load_env_file,
13
+ resolve_config_dir,
14
+ resolve_host,
15
+ )
16
+ from .executor import (
17
+ SSH_DEFAULT_TIMEOUT,
18
+ CommandError,
19
+ Executor,
20
+ LocalExecutor,
21
+ Result,
22
+ SSHExecutor,
23
+ is_remote,
24
+ )
25
+
26
+ __all__ = [
27
+ "find_env_file",
28
+ "generate_env_template",
29
+ "load_env_file",
30
+ "resolve_config_dir",
31
+ "resolve_host",
32
+ "SSH_DEFAULT_TIMEOUT",
33
+ "CommandError",
34
+ "Executor",
35
+ "LocalExecutor",
36
+ "Result",
37
+ "SSHExecutor",
38
+ "is_remote",
39
+ "ssh_connect",
40
+ ]
41
+
42
+
43
+ def ssh_connect(
44
+ hostname: str,
45
+ ssh_config_path: object | None = None,
46
+ timeout: int = 15,
47
+ ) -> object:
48
+ """Open an SSH connection. Deferred import to avoid requiring paramiko."""
49
+ from .connection import ssh_connect as _ssh_connect
50
+
51
+ return _ssh_connect(hostname, ssh_config_path=ssh_config_path, timeout=timeout) # type: ignore[arg-type]
ots_shared/ssh/_pty.py ADDED
@@ -0,0 +1,172 @@
1
+ """PTY helper for interactive SSH sessions.
2
+
3
+ Internal module — not exported from ots_shared.ssh.__init__.
4
+ Provides terminal save/restore, raw mode, SIGWINCH propagation,
5
+ and a bidirectional select loop for full-PTY SSH channels.
6
+
7
+ All termios/tty/signal usage is guarded for portability (Windows
8
+ lacks these modules). Functions raise RuntimeError if called on
9
+ a platform without PTY support.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import select
17
+ import shutil
18
+ import sys
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Guarded imports — these are Unix-only.
23
+ try:
24
+ import signal
25
+ import termios
26
+ import tty
27
+
28
+ _HAS_PTY_SUPPORT = True
29
+ except ImportError:
30
+ _HAS_PTY_SUPPORT = False
31
+
32
+
33
+ def _require_pty_support() -> None:
34
+ """Raise RuntimeError if termios/tty are unavailable."""
35
+ if not _HAS_PTY_SUPPORT:
36
+ raise RuntimeError(
37
+ "PTY support requires termios/tty (Unix-only). "
38
+ "Interactive SSH sessions are not available on this platform."
39
+ )
40
+
41
+
42
+ def get_terminal_size() -> tuple[int, int]:
43
+ """Return (columns, rows) of the current terminal.
44
+
45
+ Falls back to (80, 24) if detection fails.
46
+ """
47
+ size = shutil.get_terminal_size(fallback=(80, 24))
48
+ return (size.columns, size.lines)
49
+
50
+
51
+ def set_raw(fd: int) -> list:
52
+ """Save terminal attributes and switch *fd* to raw mode.
53
+
54
+ Returns the saved attributes (pass to :func:`restore`).
55
+ Raises RuntimeError on platforms without termios.
56
+ """
57
+ _require_pty_support()
58
+ old_attrs = termios.tcgetattr(fd)
59
+ tty.setraw(fd)
60
+ tty.setcbreak(fd)
61
+ return old_attrs
62
+
63
+
64
+ def restore(fd: int, attrs: list) -> None:
65
+ """Restore terminal attributes previously saved by :func:`set_raw`.
66
+
67
+ Uses TCSADRAIN so queued output finishes before the change.
68
+ Raises RuntimeError on platforms without termios.
69
+ """
70
+ _require_pty_support()
71
+ termios.tcsetattr(fd, termios.TCSADRAIN, attrs)
72
+
73
+
74
+ def install_sigwinch_handler(channel: object) -> object | None:
75
+ """Install a SIGWINCH handler that resizes *channel*'s PTY.
76
+
77
+ Returns the previous signal handler so callers can restore it.
78
+ On platforms without SIGWINCH this is a no-op and returns None.
79
+ """
80
+ if not _HAS_PTY_SUPPORT:
81
+ return None
82
+
83
+ if not hasattr(signal, "SIGWINCH"):
84
+ return None
85
+
86
+ def _handler(signum: int, frame: object) -> None:
87
+ try:
88
+ cols, rows = get_terminal_size()
89
+ channel.resize_pty(width=cols, height=rows) # type: ignore[union-attr]
90
+ except OSError:
91
+ pass
92
+
93
+ old_handler = signal.signal(signal.SIGWINCH, _handler)
94
+ return old_handler
95
+
96
+
97
+ def restore_sigwinch_handler(old_handler: object | None) -> None:
98
+ """Restore the SIGWINCH handler saved by :func:`install_sigwinch_handler`."""
99
+ if old_handler is None:
100
+ return
101
+ if not _HAS_PTY_SUPPORT:
102
+ return
103
+ if not hasattr(signal, "SIGWINCH"):
104
+ return
105
+ signal.signal(signal.SIGWINCH, old_handler) # type: ignore[arg-type]
106
+
107
+
108
+ def interactive_loop(
109
+ channel: object,
110
+ stdin_fd: int,
111
+ stdout_buffer: object | None = None,
112
+ ) -> int:
113
+ """Bidirectional select loop between *channel* and *stdin_fd*.
114
+
115
+ Reads from the Paramiko channel and writes to *stdout_buffer*
116
+ (defaults to ``sys.stdout.buffer``). Reads from *stdin_fd* and
117
+ sends to the channel. Runs until the channel exits.
118
+
119
+ Returns the remote process exit code.
120
+ """
121
+ if stdout_buffer is None:
122
+ stdout_buffer = sys.stdout.buffer
123
+
124
+ while True:
125
+ readable, _, _ = select.select([channel, stdin_fd], [], [], 0.1)
126
+
127
+ if channel in readable:
128
+ if channel.recv_ready(): # type: ignore[union-attr]
129
+ data = channel.recv(4096) # type: ignore[union-attr]
130
+ if data:
131
+ stdout_buffer.write(data) # type: ignore[union-attr]
132
+ stdout_buffer.flush() # type: ignore[union-attr]
133
+ elif channel.exit_status_ready(): # type: ignore[union-attr]
134
+ break
135
+ elif channel.exit_status_ready(): # type: ignore[union-attr]
136
+ break
137
+
138
+ if stdin_fd in readable:
139
+ user_input = os.read(stdin_fd, 1024)
140
+ if user_input:
141
+ channel.sendall(user_input) # type: ignore[union-attr]
142
+
143
+ if channel.exit_status_ready() and not channel.recv_ready(): # type: ignore[union-attr]
144
+ break
145
+
146
+ return channel.recv_exit_status() # type: ignore[union-attr]
147
+
148
+
149
+ def run_pty_session(channel: object) -> int:
150
+ """High-level PTY session driver for SSHExecutor.run_interactive().
151
+
152
+ Saves terminal state, sets raw mode, installs SIGWINCH handler,
153
+ runs the bidirectional loop, and restores everything in ``finally``.
154
+
155
+ Returns the remote process exit code.
156
+ """
157
+ _require_pty_support()
158
+
159
+ stdin_fd = sys.stdin.fileno()
160
+ old_attrs = termios.tcgetattr(stdin_fd)
161
+ old_sigwinch = None
162
+
163
+ try:
164
+ old_sigwinch = install_sigwinch_handler(channel)
165
+ tty.setraw(stdin_fd)
166
+ tty.setcbreak(stdin_fd)
167
+ exit_code = interactive_loop(channel, stdin_fd)
168
+ finally:
169
+ termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
170
+ restore_sigwinch_handler(old_sigwinch)
171
+
172
+ return exit_code
@@ -0,0 +1,152 @@
1
+ """Paramiko SSHClient factory using ~/.ssh/config.
2
+
3
+ Creates SSH connections that honour the user's SSH config for Host,
4
+ User, Port, IdentityFile, and ProxyCommand settings.
5
+
6
+ Paramiko does not process ``Include`` directives, so we resolve them
7
+ recursively before parsing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ import logging
14
+ from glob import glob
15
+ from pathlib import Path
16
+
17
+ import paramiko
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Connection timeout in seconds
22
+ DEFAULT_TIMEOUT = 15
23
+
24
+
25
+ def _resolve_includes(config_path: Path, _seen: set[Path] | None = None) -> str:
26
+ """Recursively expand ``Include`` directives in an SSH config file.
27
+
28
+ Paramiko's SSHConfig parser silently ignores Include directives.
29
+ This function reads the config, expands any Include lines (handling
30
+ ``~`` expansion, relative paths, and glob patterns), and returns a
31
+ single merged string suitable for ``SSHConfig.parse()``.
32
+ """
33
+ if _seen is None:
34
+ _seen = set()
35
+
36
+ resolved = config_path.resolve()
37
+ if resolved in _seen:
38
+ return ""
39
+ _seen.add(resolved)
40
+
41
+ if not config_path.is_file():
42
+ return ""
43
+
44
+ lines: list[str] = []
45
+ for line in config_path.read_text().splitlines():
46
+ stripped = line.strip()
47
+ if stripped.lower().startswith("include "):
48
+ pattern = stripped.split(None, 1)[1].strip()
49
+ # Strip surrounding quotes
50
+ if len(pattern) >= 2 and pattern[0] == pattern[-1] and pattern[0] in ('"', "'"):
51
+ pattern = pattern[1:-1]
52
+ # Expand ~ and resolve relative paths against config dir
53
+ pattern = str(Path(pattern).expanduser())
54
+ if not Path(pattern).is_absolute():
55
+ pattern = str(config_path.parent / pattern)
56
+ for included_path in sorted(glob(pattern)):
57
+ included = Path(included_path)
58
+ if included.is_file():
59
+ lines.append(_resolve_includes(included, _seen))
60
+ else:
61
+ lines.append(line)
62
+
63
+ return "\n".join(lines)
64
+
65
+
66
+ def ssh_connect(
67
+ hostname: str,
68
+ ssh_config_path: Path | None = None,
69
+ timeout: int = DEFAULT_TIMEOUT,
70
+ ) -> paramiko.SSHClient:
71
+ """Open an SSH connection to *hostname* using SSH config.
72
+
73
+ Uses paramiko with RejectPolicy — the host must already be in
74
+ known_hosts. Returns a connected paramiko.SSHClient.
75
+
76
+ Raises:
77
+ paramiko.SSHException: If connection or authentication fails.
78
+ """
79
+ config_path = ssh_config_path or Path.home() / ".ssh" / "config"
80
+
81
+ # Parse SSH config with Include directives resolved
82
+ ssh_config = paramiko.SSHConfig()
83
+ if config_path.exists():
84
+ merged = _resolve_includes(config_path)
85
+ ssh_config.parse(io.StringIO(merged))
86
+
87
+ host_config = ssh_config.lookup(hostname)
88
+
89
+ # Build connection kwargs from SSH config
90
+ connect_kwargs: dict = {
91
+ "hostname": host_config.get("hostname", hostname),
92
+ "timeout": timeout,
93
+ }
94
+
95
+ if "port" in host_config:
96
+ connect_kwargs["port"] = int(host_config["port"])
97
+
98
+ if "user" in host_config:
99
+ connect_kwargs["username"] = host_config["user"]
100
+
101
+ if "identityfile" in host_config:
102
+ # SSH config may list multiple identity files; pass all that exist
103
+ key_files = [
104
+ str(Path(kf).expanduser())
105
+ for kf in host_config["identityfile"]
106
+ if Path(kf).expanduser().exists()
107
+ ]
108
+ if key_files:
109
+ connect_kwargs["key_filename"] = key_files
110
+
111
+ # ProxyCommand support
112
+ proxy_cmd = host_config.get("proxycommand")
113
+ if proxy_cmd:
114
+ connect_kwargs["sock"] = paramiko.ProxyCommand(proxy_cmd)
115
+
116
+ # Create client with RejectPolicy (security requirement)
117
+ client = paramiko.SSHClient()
118
+ client.set_missing_host_key_policy(paramiko.RejectPolicy())
119
+
120
+ # Load known hosts
121
+ known_hosts = Path.home() / ".ssh" / "known_hosts"
122
+ if known_hosts.exists():
123
+ client.load_host_keys(str(known_hosts))
124
+ client.load_system_host_keys()
125
+
126
+ # Paramiko looks up non-standard ports as [host]:port in known_hosts,
127
+ # but OpenSSH also accepts bare host entries as a fallback. Mirror
128
+ # that behaviour so existing known_hosts files work without needing
129
+ # port-specific entries.
130
+ port = connect_kwargs.get("port", 22)
131
+ actual_host = connect_kwargs["hostname"]
132
+ if port != 22:
133
+ bracketed = f"[{actual_host}]:{port}"
134
+ host_keys = client.get_host_keys()
135
+ if bracketed not in host_keys and actual_host in host_keys:
136
+ for key_type in host_keys[actual_host]:
137
+ host_keys.add(bracketed, key_type, host_keys[actual_host][key_type])
138
+
139
+ logger.info("SSH connecting to %s", connect_kwargs.get("hostname", hostname))
140
+ try:
141
+ client.connect(**connect_kwargs)
142
+ except paramiko.PasswordRequiredException:
143
+ # Key file is encrypted and no passphrase was provided. Drop
144
+ # key_filename so paramiko falls through to the SSH agent, which
145
+ # typically has the decrypted key loaded (via AddKeysToAgent/UseKeychain).
146
+ if "key_filename" in connect_kwargs:
147
+ logger.debug("Encrypted key file, falling back to SSH agent")
148
+ del connect_kwargs["key_filename"]
149
+ client.connect(**connect_kwargs)
150
+ else:
151
+ raise
152
+ return client
ots_shared/ssh/env.py ADDED
@@ -0,0 +1,191 @@
1
+ """.otsinfra.env discovery, parsing, and host resolution.
2
+
3
+ The .otsinfra.env file provides targeting context for remote execution.
4
+ It is discovered by walking up from the current directory, stopping at
5
+ the repository root (.git) or the user's home directory.
6
+
7
+ Standard variables:
8
+ OTS_HOST Target host (SSH hostname or alias)
9
+ OTS_REPOSITORY Container image repository
10
+ OTS_TAG Release version tag (e.g. v0.24)
11
+ OTS_IMAGE Container image override (optional)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import re
19
+ from pathlib import Path
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ ENV_FILENAME = ".otsinfra.env"
24
+ _CONFIG_DIR_PREFIX = "config-v"
25
+ _CONFIG_SYMLINK = "config"
26
+
27
+
28
+ def find_env_file(start: Path | None = None) -> Path | None:
29
+ """Walk up from *start* looking for a .otsinfra.env file.
30
+
31
+ Stops at the first directory containing .git or at the user's home
32
+ directory — whichever is reached first. Returns None if not found.
33
+ """
34
+ current = (start or Path.cwd()).resolve()
35
+ home = Path.home().resolve()
36
+
37
+ while True:
38
+ candidate = current / ENV_FILENAME
39
+ if candidate.is_file():
40
+ return candidate
41
+
42
+ # Stop at .git boundary
43
+ if (current / ".git").exists():
44
+ return None
45
+
46
+ # Stop at home directory ceiling
47
+ if current == home:
48
+ return None
49
+
50
+ parent = current.parent
51
+ # Filesystem root — stop
52
+ if parent == current:
53
+ return None
54
+
55
+ current = parent
56
+
57
+
58
+ def load_env_file(path: Path) -> dict[str, str]:
59
+ """Parse a .otsinfra.env file into a dict.
60
+
61
+ Format: KEY=VALUE lines. Blank lines and lines starting with # are
62
+ ignored. Values are stripped of surrounding whitespace. Quoted values
63
+ (single or double) are unquoted.
64
+ """
65
+ result: dict[str, str] = {}
66
+ for line in path.read_text().splitlines():
67
+ line = line.strip()
68
+ if not line or line.startswith("#"):
69
+ continue
70
+ if "=" not in line:
71
+ continue
72
+ key, _, value = line.partition("=")
73
+ key = key.strip()
74
+ value = value.strip()
75
+ # Strip matching quotes
76
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
77
+ value = value[1:-1]
78
+ result[key] = value
79
+ return result
80
+
81
+
82
+ def resolve_host(host_flag: str | None = None) -> str | None:
83
+ """Determine the target host using the resolution priority chain.
84
+
85
+ Priority:
86
+ 1. Explicit --host flag value
87
+ 2. OTS_HOST environment variable
88
+ 3. OTS_HOST from .otsinfra.env (walk-up discovery)
89
+ 4. None (local execution)
90
+ """
91
+ # 1. Explicit flag
92
+ if host_flag:
93
+ logger.debug("Host from --host flag: %s", host_flag)
94
+ return host_flag
95
+
96
+ # 2. Environment variable
97
+ env_host = os.environ.get("OTS_HOST")
98
+ if env_host:
99
+ logger.debug("Host from OTS_HOST env var: %s", env_host)
100
+ return env_host
101
+
102
+ # 3. Walk-up .otsinfra.env
103
+ env_path = find_env_file()
104
+ if env_path:
105
+ env_vars = load_env_file(env_path)
106
+ file_host = env_vars.get("OTS_HOST")
107
+ if file_host:
108
+ logger.info("Host from %s: %s", env_path, file_host)
109
+ return file_host
110
+
111
+ # 4. Local
112
+ return None
113
+
114
+
115
+ def resolve_config_dir(start: Path | None = None) -> Path | None:
116
+ """Resolve the current config directory for a jurisdiction.
117
+
118
+ Resolution order:
119
+ 1. A ``config`` symlink or directory sibling to the .otsinfra.env file
120
+ (stable pointer managed by the operator).
121
+ 2. OTS_TAG from .otsinfra.env → versioned directory name
122
+ (e.g. ``v0.24`` → ``config-v0.24``).
123
+
124
+ The config directory is expected to be a sibling of the env file.
125
+ Returns the directory path if it exists, None otherwise.
126
+ """
127
+ env_path = find_env_file(start)
128
+ if env_path is None:
129
+ return None
130
+
131
+ parent = env_path.parent
132
+
133
+ # 1. Stable symlink convention: config -> config-v0.24
134
+ symlink = parent / _CONFIG_SYMLINK
135
+ if symlink.is_dir():
136
+ logger.debug("Config dir from symlink %s: %s", symlink, symlink.resolve())
137
+ return symlink
138
+
139
+ # 2. Derive from OTS_TAG
140
+ env_vars = load_env_file(env_path)
141
+ tag = env_vars.get("OTS_TAG")
142
+ if not tag:
143
+ logger.debug("No OTS_TAG in %s", env_path)
144
+ return None
145
+
146
+ version = _tag_to_version(tag)
147
+ if version is None:
148
+ logger.warning("Cannot parse version from OTS_TAG=%s in %s", tag, env_path)
149
+ return None
150
+
151
+ config_dir = parent / f"{_CONFIG_DIR_PREFIX}{version}"
152
+ if config_dir.is_dir():
153
+ logger.debug("Config dir from %s: %s", env_path, config_dir)
154
+ return config_dir
155
+
156
+ logger.debug("Config dir does not exist: %s", config_dir)
157
+ return None
158
+
159
+
160
+ def generate_env_template(
161
+ host: str = "",
162
+ tag: str = "",
163
+ repository: str = "",
164
+ ) -> str:
165
+ """Generate a .otsinfra.env template with optional pre-filled values.
166
+
167
+ Returns the file content as a string.
168
+ """
169
+ lines = [
170
+ "# .otsinfra.env — targeting context for OTS remote operations",
171
+ "#",
172
+ "# Walk-up discovery: ots-containers commands search for this file",
173
+ "# starting from the current directory upward to the repo root.",
174
+ "",
175
+ f"OTS_HOST={host}",
176
+ f"OTS_TAG={tag}",
177
+ ]
178
+ if repository:
179
+ lines.append(f"OTS_REPOSITORY={repository}")
180
+ lines.append("")
181
+ return "\n".join(lines)
182
+
183
+
184
+ def _tag_to_version(tag: str) -> str | None:
185
+ """Extract major.minor version from a tag string.
186
+
187
+ Accepts formats like ``v0.24``, ``v0.24.1``, ``0.24``, ``0.24.1``.
188
+ Returns ``"0.24"`` (major.minor only) or None if unparseable.
189
+ """
190
+ m = re.match(r"v?(\d+\.\d+)", tag)
191
+ return m.group(1) if m else None
@@ -0,0 +1,483 @@
1
+ """Command execution abstraction for local and remote (SSH) targets.
2
+
3
+ Provides a Protocol-based executor pattern so callers can run shell commands
4
+ without knowing whether they execute locally or over SSH. Also supports
5
+ individual file transfers via ``put_file`` / ``get_file`` (SFTP for SSH,
6
+ local filesystem for local). For bulk file operations, use rsync
7
+ (see ``ots_containers.commands.host._rsync``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import select
15
+ import shlex
16
+ import subprocess
17
+ import sys
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Protocol, runtime_checkable
21
+
22
+ # Default timeout (in seconds) for SSH command execution.
23
+ # Prevents hung remote processes from running indefinitely.
24
+ # Individual call sites can override with an explicit timeout= kwarg.
25
+ # Use None for truly open-ended operations (run_interactive handles this).
26
+ SSH_DEFAULT_TIMEOUT: int = 120
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ REDACTED = "***"
31
+
32
+
33
+ def _redact_cmd(cmd: list[str], sensitive: set[str] | None) -> list[str]:
34
+ """Return a copy of *cmd* with sensitive argument values replaced."""
35
+ if not sensitive:
36
+ return cmd
37
+ return [REDACTED if c in sensitive else c for c in cmd]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Result:
42
+ """Outcome of a command execution."""
43
+
44
+ command: str
45
+ returncode: int
46
+ stdout: str
47
+ stderr: str
48
+
49
+ @property
50
+ def ok(self) -> bool:
51
+ return self.returncode == 0
52
+
53
+ def check(self) -> None:
54
+ """Raise CommandError if the command failed."""
55
+ if not self.ok:
56
+ raise CommandError(self)
57
+
58
+
59
+ class CommandError(Exception):
60
+ """A command returned a non-zero exit code."""
61
+
62
+ def __init__(self, result: Result) -> None:
63
+ self.result = result
64
+ super().__init__(f"Command failed (exit {result.returncode}): {result.command}")
65
+
66
+
67
+ @runtime_checkable
68
+ class Executor(Protocol):
69
+ """Interface for running shell commands on a target host."""
70
+
71
+ def run(
72
+ self,
73
+ cmd: list[str],
74
+ *,
75
+ sudo: bool = False,
76
+ timeout: int | None = None,
77
+ check: bool = False,
78
+ input: str | None = None,
79
+ sensitive_args: set[str] | None = None,
80
+ ) -> Result: ...
81
+
82
+ def run_stream(
83
+ self,
84
+ cmd: list[str],
85
+ *,
86
+ sudo: bool = False,
87
+ timeout: int | None = None,
88
+ sensitive_args: set[str] | None = None,
89
+ ) -> int:
90
+ """Stream stdout/stderr to the caller's terminal in real time.
91
+
92
+ Returns the process exit code. Does not capture output — it flows
93
+ directly to the current terminal.
94
+ """
95
+ ...
96
+
97
+ def run_interactive(
98
+ self,
99
+ cmd: list[str],
100
+ *,
101
+ sudo: bool = False,
102
+ sensitive_args: set[str] | None = None,
103
+ ) -> int:
104
+ """Run with full PTY: bidirectional stdin/stdout, SIGWINCH propagation.
105
+
106
+ For interactive sessions (shells, exec -it) — no timeout since the
107
+ session is open-ended. Returns the process exit code.
108
+ """
109
+ ...
110
+
111
+ def put_file(
112
+ self,
113
+ local_path: str | Path,
114
+ remote_path: str | Path,
115
+ *,
116
+ permissions: int | None = None,
117
+ ) -> None:
118
+ """Transfer a file from the local machine to the target host.
119
+
120
+ Args:
121
+ local_path: Path to the file on the local machine.
122
+ remote_path: Destination path on the target host.
123
+ permissions: Optional octal permissions (e.g. 0o644) to set
124
+ on the destination file.
125
+ """
126
+ ...
127
+
128
+ def get_file(
129
+ self,
130
+ remote_path: str | Path,
131
+ local_path: str | Path,
132
+ ) -> None:
133
+ """Transfer a file from the target host to the local machine.
134
+
135
+ Args:
136
+ remote_path: Path to the file on the target host.
137
+ local_path: Destination path on the local machine.
138
+ """
139
+ ...
140
+
141
+ def close(self) -> None: ...
142
+
143
+
144
+ def _require_list(cmd: object, method: str) -> None:
145
+ """Raise TypeError if *cmd* is a str instead of list[str].
146
+
147
+ shlex.quote iterates a str character-by-character, producing a broken
148
+ (though not injectable) command. Catching this early prevents subtle bugs.
149
+ """
150
+ if isinstance(cmd, str):
151
+ raise TypeError(
152
+ f"{method} requires cmd as list[str], got str. "
153
+ f"Use shlex.split() or pass a list: {cmd!r}"
154
+ )
155
+
156
+
157
+ def is_remote(executor: Executor | None) -> bool:
158
+ """Return True if *executor* dispatches commands to a remote host.
159
+
160
+ Returns False for ``None`` (no executor) or a :class:`LocalExecutor`.
161
+ """
162
+ if executor is None:
163
+ return False
164
+ return not isinstance(executor, LocalExecutor)
165
+
166
+
167
+ class LocalExecutor:
168
+ """Execute commands on the local machine via subprocess.
169
+
170
+ File transfers operate directly on the local filesystem.
171
+ """
172
+
173
+ def run(
174
+ self,
175
+ cmd: list[str],
176
+ *,
177
+ sudo: bool = False,
178
+ timeout: int | None = None,
179
+ check: bool = False,
180
+ input: str | None = None,
181
+ sensitive_args: set[str] | None = None,
182
+ ) -> Result:
183
+ _require_list(cmd, "LocalExecutor.run")
184
+ full_cmd = ["sudo", "--"] + cmd if sudo else cmd
185
+ safe_cmd = _redact_cmd(full_cmd, sensitive_args)
186
+ logger.debug("local: %s", " ".join(shlex.quote(c) for c in safe_cmd))
187
+ try:
188
+ kwargs: dict = dict(
189
+ capture_output=True,
190
+ text=True,
191
+ timeout=timeout,
192
+ )
193
+ if input is not None:
194
+ kwargs["input"] = input
195
+ proc = subprocess.run(full_cmd, **kwargs)
196
+ except subprocess.TimeoutExpired as exc:
197
+ return Result(
198
+ command=" ".join(shlex.quote(c) for c in safe_cmd),
199
+ returncode=124,
200
+ stdout=exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or ""),
201
+ stderr=exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or ""),
202
+ )
203
+ result = Result(
204
+ command=" ".join(shlex.quote(c) for c in safe_cmd),
205
+ returncode=proc.returncode,
206
+ stdout=proc.stdout,
207
+ stderr=proc.stderr,
208
+ )
209
+ if check:
210
+ result.check()
211
+ return result
212
+
213
+ def run_stream(
214
+ self,
215
+ cmd: list[str],
216
+ *,
217
+ sudo: bool = False,
218
+ timeout: int | None = None,
219
+ sensitive_args: set[str] | None = None,
220
+ ) -> int:
221
+ _require_list(cmd, "LocalExecutor.run_stream")
222
+ full_cmd = ["sudo", "--"] + cmd if sudo else cmd
223
+ safe_cmd = _redact_cmd(full_cmd, sensitive_args)
224
+ logger.debug("local stream: %s", " ".join(shlex.quote(c) for c in safe_cmd))
225
+ try:
226
+ proc = subprocess.run(full_cmd, timeout=timeout)
227
+ except subprocess.TimeoutExpired:
228
+ return 124
229
+ return proc.returncode
230
+
231
+ def run_interactive(
232
+ self,
233
+ cmd: list[str],
234
+ *,
235
+ sudo: bool = False,
236
+ sensitive_args: set[str] | None = None,
237
+ ) -> int:
238
+ _require_list(cmd, "LocalExecutor.run_interactive")
239
+ full_cmd = ["sudo", "--"] + cmd if sudo else cmd
240
+ safe_cmd = _redact_cmd(full_cmd, sensitive_args)
241
+ logger.debug("local interactive: %s", " ".join(shlex.quote(c) for c in safe_cmd))
242
+ try:
243
+ proc = subprocess.run(full_cmd)
244
+ return proc.returncode
245
+ except KeyboardInterrupt:
246
+ return 130
247
+
248
+ def put_file(
249
+ self,
250
+ local_path: str | Path,
251
+ remote_path: str | Path,
252
+ *,
253
+ permissions: int | None = None,
254
+ ) -> None:
255
+ src = Path(local_path)
256
+ dst = Path(remote_path)
257
+ logger.debug("local put_file: %s -> %s", src, dst)
258
+ dst.parent.mkdir(parents=True, exist_ok=True)
259
+ dst.write_bytes(src.read_bytes())
260
+ if permissions is not None:
261
+ dst.chmod(permissions)
262
+
263
+ def get_file(
264
+ self,
265
+ remote_path: str | Path,
266
+ local_path: str | Path,
267
+ ) -> None:
268
+ src = Path(remote_path)
269
+ dst = Path(local_path)
270
+ logger.debug("local get_file: %s -> %s", src, dst)
271
+ dst.parent.mkdir(parents=True, exist_ok=True)
272
+ dst.write_bytes(src.read_bytes())
273
+
274
+ def close(self) -> None:
275
+ pass
276
+
277
+
278
+ class SSHExecutor:
279
+ """Execute commands on a remote host over an SSH connection.
280
+
281
+ Requires paramiko. Import is deferred so the module can be loaded
282
+ without paramiko installed — only construction raises ImportError.
283
+ """
284
+
285
+ def __init__(self, client: object) -> None:
286
+ try:
287
+ import paramiko
288
+ except ImportError:
289
+ raise ImportError(
290
+ "paramiko is required for SSH execution. "
291
+ "Install it with: pip install ots-shared[ssh]"
292
+ ) from None
293
+ if not isinstance(client, paramiko.SSHClient):
294
+ raise TypeError(f"Expected paramiko.SSHClient, got {type(client).__name__}")
295
+ self._client = client
296
+
297
+ # Sentinel to distinguish "caller did not pass timeout" from "caller passed None".
298
+ _UNSET = object()
299
+
300
+ def run(
301
+ self,
302
+ cmd: list[str],
303
+ *,
304
+ sudo: bool = False,
305
+ timeout: int | None | object = _UNSET,
306
+ check: bool = False,
307
+ input: str | None = None,
308
+ sensitive_args: set[str] | None = None,
309
+ ) -> Result:
310
+ _require_list(cmd, "SSHExecutor.run")
311
+
312
+ # Apply default timeout when caller does not specify one.
313
+ # Pass timeout=None explicitly to disable the timeout.
314
+ effective_timeout: int | None
315
+ if timeout is self._UNSET:
316
+ effective_timeout = SSH_DEFAULT_TIMEOUT
317
+ else:
318
+ effective_timeout = timeout # type: ignore[assignment]
319
+
320
+ # Security: shlex.quote every argument before joining
321
+ shell_cmd = " ".join(shlex.quote(c) for c in cmd)
322
+ safe_cmd = " ".join(shlex.quote(c) for c in _redact_cmd(cmd, sensitive_args))
323
+ if sudo:
324
+ shell_cmd = f"sudo -- {shell_cmd}"
325
+ safe_cmd = f"sudo -- {safe_cmd}"
326
+ logger.debug("ssh: %s", safe_cmd)
327
+ stdin_ch, stdout, stderr = self._client.exec_command(shell_cmd, timeout=effective_timeout)
328
+ if input is not None:
329
+ stdin_ch.write(input)
330
+ stdin_ch.channel.shutdown_write()
331
+ exit_code = stdout.channel.recv_exit_status()
332
+ result = Result(
333
+ command=safe_cmd,
334
+ returncode=exit_code,
335
+ stdout=stdout.read().decode("utf-8", errors="replace"),
336
+ stderr=stderr.read().decode("utf-8", errors="replace"),
337
+ )
338
+ if check:
339
+ result.check()
340
+ return result
341
+
342
+ def run_stream(
343
+ self,
344
+ cmd: list[str],
345
+ *,
346
+ sudo: bool = False,
347
+ timeout: int | None = None,
348
+ sensitive_args: set[str] | None = None,
349
+ ) -> int:
350
+ """Stream remote command output to local terminal via select loop.
351
+
352
+ When *timeout* is provided, enforces an overall deadline on the
353
+ streaming session. If the deadline is exceeded, the channel is
354
+ closed and exit code 124 (matching GNU timeout convention) is returned.
355
+ """
356
+ import time
357
+
358
+ _require_list(cmd, "SSHExecutor.run_stream")
359
+ shell_cmd = " ".join(shlex.quote(c) for c in cmd)
360
+ safe_cmd = " ".join(shlex.quote(c) for c in _redact_cmd(cmd, sensitive_args))
361
+ if sudo:
362
+ shell_cmd = f"sudo -- {shell_cmd}"
363
+ safe_cmd = f"sudo -- {safe_cmd}"
364
+ logger.debug("ssh stream: %s", safe_cmd)
365
+ transport = self._client.get_transport()
366
+ channel = transport.open_session()
367
+ channel.setblocking(0)
368
+ if timeout is not None:
369
+ channel.settimeout(float(timeout))
370
+ channel.exec_command(shell_cmd)
371
+
372
+ # Overall deadline for the select loop
373
+ deadline = time.monotonic() + timeout if timeout is not None else None
374
+
375
+ # Stream output until channel is closed
376
+ while (
377
+ not channel.exit_status_ready() or channel.recv_ready() or channel.recv_stderr_ready()
378
+ ):
379
+ # Check deadline
380
+ if deadline is not None and time.monotonic() > deadline:
381
+ logger.warning("ssh stream timeout after %ds: %s", timeout, safe_cmd)
382
+ channel.close()
383
+ return 124
384
+
385
+ readable, _, _ = select.select([channel], [], [], 0.1)
386
+ if readable:
387
+ if channel.recv_ready():
388
+ data = channel.recv(4096)
389
+ if data:
390
+ sys.stdout.buffer.write(data)
391
+ sys.stdout.buffer.flush()
392
+ if channel.recv_stderr_ready():
393
+ data = channel.recv_stderr(4096)
394
+ if data:
395
+ sys.stderr.buffer.write(data)
396
+ sys.stderr.buffer.flush()
397
+ # Drain any remaining data after exit
398
+ while channel.recv_ready():
399
+ data = channel.recv(4096)
400
+ if data:
401
+ sys.stdout.buffer.write(data)
402
+ sys.stdout.buffer.flush()
403
+ while channel.recv_stderr_ready():
404
+ data = channel.recv_stderr(4096)
405
+ if data:
406
+ sys.stderr.buffer.write(data)
407
+ sys.stderr.buffer.flush()
408
+ exit_code = channel.recv_exit_status()
409
+ channel.close()
410
+ return exit_code
411
+
412
+ def run_interactive(
413
+ self,
414
+ cmd: list[str],
415
+ *,
416
+ sudo: bool = False,
417
+ sensitive_args: set[str] | None = None,
418
+ ) -> int:
419
+ """Run with full PTY over SSH: bidirectional stdin/stdout, SIGWINCH.
420
+
421
+ Delegates terminal handling to :mod:`ots_shared.ssh._pty`.
422
+ """
423
+ _require_list(cmd, "SSHExecutor.run_interactive")
424
+ from ots_shared.ssh import _pty
425
+
426
+ shell_cmd = " ".join(shlex.quote(c) for c in cmd)
427
+ safe_cmd = " ".join(shlex.quote(c) for c in _redact_cmd(cmd, sensitive_args))
428
+ if sudo:
429
+ shell_cmd = f"sudo -- {shell_cmd}"
430
+ safe_cmd = f"sudo -- {safe_cmd}"
431
+ logger.debug("ssh interactive: %s", safe_cmd)
432
+
433
+ transport = self._client.get_transport()
434
+ channel = transport.open_session()
435
+
436
+ # Request PTY with current terminal dimensions
437
+ cols, rows = _pty.get_terminal_size()
438
+ term = os.environ.get("TERM", "xterm-256color")
439
+ channel.get_pty(term=term, width=cols, height=rows)
440
+ channel.exec_command(shell_cmd)
441
+ channel.setblocking(0)
442
+
443
+ try:
444
+ exit_code = _pty.run_pty_session(channel)
445
+ finally:
446
+ channel.close()
447
+ return exit_code
448
+
449
+ def put_file(
450
+ self,
451
+ local_path: str | Path,
452
+ remote_path: str | Path,
453
+ *,
454
+ permissions: int | None = None,
455
+ ) -> None:
456
+ src = Path(local_path)
457
+ dst_str = str(remote_path)
458
+ logger.debug("ssh put_file: %s -> %s", src, dst_str)
459
+ sftp = self._client.open_sftp()
460
+ try:
461
+ sftp.put(str(src), dst_str)
462
+ if permissions is not None:
463
+ sftp.chmod(dst_str, permissions)
464
+ finally:
465
+ sftp.close()
466
+
467
+ def get_file(
468
+ self,
469
+ remote_path: str | Path,
470
+ local_path: str | Path,
471
+ ) -> None:
472
+ src_str = str(remote_path)
473
+ dst = Path(local_path)
474
+ logger.debug("ssh get_file: %s -> %s", src_str, dst)
475
+ dst.parent.mkdir(parents=True, exist_ok=True)
476
+ sftp = self._client.open_sftp()
477
+ try:
478
+ sftp.get(src_str, str(dst))
479
+ finally:
480
+ sftp.close()
481
+
482
+ def close(self) -> None:
483
+ self._client.close()
ots_shared/taxonomy.py ADDED
@@ -0,0 +1,100 @@
1
+ """Canonical jurisdiction and environment vocabularies for OTS infrastructure.
2
+
3
+ These vocabularies are the single source of truth for taxonomy inference
4
+ across all OTS CLI tools (otsinfra, ots-cloudinit, hcloud-manager, etc.).
5
+
6
+ Jurisdictions are ISO 3166-1 alpha-2 country codes used in hostname
7
+ segments, directory structures, and inventory records.
8
+
9
+ Environments map common aliases to canonical short names, allowing
10
+ hostname segments like "stg" or "production" to resolve consistently.
11
+ """
12
+
13
+ KNOWN_JURISDICTIONS: frozenset[str] = frozenset(
14
+ {
15
+ "ar",
16
+ "at",
17
+ "au",
18
+ "be",
19
+ "br",
20
+ "ca",
21
+ "ch",
22
+ "cl",
23
+ "co",
24
+ "cz",
25
+ "de",
26
+ "dk",
27
+ "es",
28
+ "eu",
29
+ "fi",
30
+ "fr",
31
+ "gb",
32
+ "hk",
33
+ "hu",
34
+ "ie",
35
+ "in",
36
+ "it",
37
+ "jp",
38
+ "kr",
39
+ "mx",
40
+ "nl",
41
+ "no",
42
+ "nz",
43
+ "pe",
44
+ "pl",
45
+ "pt",
46
+ "ro",
47
+ "se",
48
+ "sg",
49
+ "tw",
50
+ "uk",
51
+ "us",
52
+ "za",
53
+ }
54
+ )
55
+ """ISO 3166-1 alpha-2 country codes used as jurisdiction identifiers.
56
+
57
+ Checked against hostname segments split on '-' for taxonomy inference.
58
+ """
59
+
60
+ KNOWN_ROLES: frozenset[str] = frozenset(
61
+ {
62
+ "web",
63
+ "db",
64
+ "redis",
65
+ "jump",
66
+ "bastion",
67
+ "worker",
68
+ "monitor",
69
+ "proxy",
70
+ "mail",
71
+ "dns",
72
+ }
73
+ )
74
+ """Common host roles in OTS infrastructure.
75
+
76
+ Used for validation warnings in otsinfra host add/edit. Not an exhaustive
77
+ list -- unknown roles produce a warning, not an error.
78
+ """
79
+
80
+ KNOWN_ENVIRONMENTS: dict[str, str] = {
81
+ "prod": "prod",
82
+ "production": "prod",
83
+ "staging": "staging",
84
+ "stg": "staging",
85
+ "stage": "staging",
86
+ "dev": "dev",
87
+ "development": "dev",
88
+ "test": "test",
89
+ "testing": "test",
90
+ "qa": "qa",
91
+ "uat": "uat",
92
+ "demo": "demo",
93
+ "sandbox": "sandbox",
94
+ "lab": "lab",
95
+ }
96
+ """Map of environment aliases to canonical short names.
97
+
98
+ Keys are the recognized input strings (from hostnames, CLI flags, etc.).
99
+ Values are the canonical names stored in inventory records.
100
+ """
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: ots-shared
3
+ Version: 0.2.0
4
+ Summary: Shared constants and utilities for OTS operations tools
5
+ Project-URL: Repository, https://github.com/onetimesecret/rots
6
+ Author: Onetime
7
+ License-Expression: MIT
8
+ Keywords: cli,infrastructure,shared
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: System :: Systems Administration
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: cyclopts>=3.9
19
+ Provides-Extra: dev
20
+ Requires-Dist: pyright>=1.1.390; extra == 'dev'
21
+ Provides-Extra: ssh
22
+ Requires-Dist: paramiko>=3.4; extra == 'ssh'
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest-mock>=3.14; extra == 'test'
25
+ Requires-Dist: pytest>=8.0; extra == 'test'
26
+ Description-Content-Type: text/plain
27
+
28
+ Shared constants and utilities for OTS operations tools.
@@ -0,0 +1,12 @@
1
+ ots_shared/__init__.py,sha256=edoVgdqUhKcuLgCqd5yHzGnnciTt0a3wiPJCJinYkxo,63
2
+ ots_shared/cli.py,sha256=mtA_pEexV43Q4fg36C0oNua-Npk9-r0x0Mcs2iOu1HI,1195
3
+ ots_shared/exit_codes.py,sha256=0pVZMECJhxgPIo9Ad3Z6B0nTSJx5gVhiX0qKKAQxDJ0,913
4
+ ots_shared/taxonomy.py,sha256=tCWa2NIFlaERJDzD6j0PIyYBDfFcTZ5HJRW1RarV42A,2174
5
+ ots_shared/ssh/__init__.py,sha256=7rPxMwgTrypVD5mdf3i6be6LYWoPuYPoc8YOXCN6Vvc,1196
6
+ ots_shared/ssh/_pty.py,sha256=wfIYQTNCQkqV0MJtCDfqMefwI9wyfqRLcjHT8APwdqM,5265
7
+ ots_shared/ssh/connection.py,sha256=pkFUnCMtz11po_cEgk9LUuN4mPQhjagYdYz4yrXSogM,5326
8
+ ots_shared/ssh/env.py,sha256=rBpbunOam8LbBYW_0RWIPTzk8J8Hg8hPq_sDNZutrGo,5791
9
+ ots_shared/ssh/executor.py,sha256=o7aP-kyCS53yYnrfhIWuqjmSLFpbFh71Y3zzB7uK0mg,15711
10
+ ots_shared-0.2.0.dist-info/METADATA,sha256=4n84hXjQ2Q_tjqNHGiWyM5WGOXnUvDdpLKODW_ar6k0,1045
11
+ ots_shared-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ ots_shared-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any