ots-shared 0.2.0__tar.gz

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.
@@ -0,0 +1,30 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ *.egg
11
+
12
+ # Virtual environments
13
+ .venv/
14
+ venv/
15
+ ENV/
16
+
17
+ # IDE
18
+ .idea/
19
+ .vscode/
20
+ *.swp
21
+ *.swo
22
+ *.txt
23
+
24
+ # OS
25
+ .DS_Store
26
+ Thumbs.db
27
+
28
+ .claude
29
+ .serena
30
+ .coverage
@@ -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,52 @@
1
+ [project]
2
+ name = "ots-shared"
3
+ version = "0.2.0"
4
+ description = "Shared constants and utilities for OTS operations tools"
5
+ readme = {text = "Shared constants and utilities for OTS operations tools.", content-type = "text/plain"}
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ authors = [{ name = "Onetime" }]
9
+ keywords = ["cli", "infrastructure", "shared"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Environment :: Console",
13
+ "Intended Audience :: System Administrators",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: System :: Systems Administration",
19
+ ]
20
+ dependencies = ["cyclopts>=3.9"]
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/onetimesecret/rots"
24
+
25
+ [project.optional-dependencies]
26
+ ssh = ["paramiko>=3.4"]
27
+ dev = ["pyright>=1.1.390"]
28
+ test = ["pytest>=8.0", "pytest-mock>=3.14"]
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/ots_shared"]
36
+
37
+ [tool.pyright]
38
+ pythonVersion = "3.11"
39
+ typeCheckingMode = "basic"
40
+ include = ["src"]
41
+
42
+ [tool.pytest.ini_options]
43
+ pythonpath = ["src"]
44
+ testpaths = ["tests"]
45
+
46
+ [tool.ruff]
47
+ target-version = "py311"
48
+ src = ["src"]
49
+ line-length = 100
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "I", "N", "W", "UP"]
@@ -0,0 +1 @@
1
+ """Shared constants and utilities for OTS operations tools."""
@@ -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]
@@ -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