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 +1 -0
- ots_shared/cli.py +70 -0
- ots_shared/exit_codes.py +29 -0
- ots_shared/ssh/__init__.py +51 -0
- ots_shared/ssh/_pty.py +172 -0
- ots_shared/ssh/connection.py +152 -0
- ots_shared/ssh/env.py +191 -0
- ots_shared/ssh/executor.py +483 -0
- ots_shared/taxonomy.py +100 -0
- ots_shared-0.2.0.dist-info/METADATA +28 -0
- ots_shared-0.2.0.dist-info/RECORD +12 -0
- ots_shared-0.2.0.dist-info/WHEEL +4 -0
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
|
+
]
|
ots_shared/exit_codes.py
ADDED
|
@@ -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,,
|